扫 3600 个文件实测:手写 os.scandir 3.58 ms 比 os.walk 快,pathlib.rglob 慢 3

举报
yd_232353574 发表于 2026/11/07 08:58:31 2026/11/07
【摘要】 实测五种遍历写法,手写 os.scandir 比 os.walk 少用约 20% 时间,pathlib.rglob 慢 3.2 倍;并纠正一个流传很广的说法:Windows 上 scandir 快照跟 d_type 无关。

遍历目录该用哪个

有个程序要扫一遍目录树收集文件。常见选择就那几个:os.walk、pathlib.Path.rglob、glob.glob(recursive=True)、手写 os.scandir 递归。

社区流传最广的经验是「os.walk 最快,pathlib 最慢,差三到四倍」。我在本机实测了一遍,排名跟这条经验不一样。

怎么测的

环境:Windows Server 2022、NTFS、Python 3.13.14。测试树是 30 个子目录、每个 120 个文件,共 3600 个 .txt,每个 100 字节。

每个写法先跑 2 轮热身,再把 5 个写法交叉着跑 25 轮取中位数,另外单独校验总数,五种写法都得数出 3600 才算数。

顺带说一句,我最早那版稿子是 10 月 30 日跑的,测出「os.scandir 9.99 ms,比 os.walk 的 4.39 ms 还慢」。回头查代码才发现写错了:那个「scandir + is_file()」是在 os.walk 的结果上又对每个目录 scandir 了一遍,等于把树走了两遍;而那个「os.listdir 递归」的实现其实是 os.walk 本身。现在换成真正的单遍递归 scandir,才是 3.58 ms,排名反过来的原因是写法改了,不是加了热身。

成绩

  • os.scandir + is_file() 递归 → 3.58 ms
  • os.walk → 4.47 ms
  • pathlib.rglob('*.txt') → 14.28 ms
  • glob.iglob(recursive=True) → 15.15 ms
  • os.listdir + os.path.isdir() 递归 → 82.49 ms

手写 scandir 比 os.walk 少用约 20% 时间,pathlib.rglob 比 os.walk 慢 3.2 倍,glob 慢 3.4 倍,listdir 加 isdir 慢了 18.5 倍,五个里最慢。

listdir 这一栏要留个心眼:它的耗时对文件系统元数据缓存状态敏感,我多次独立运行测到的倍数在 17 倍到 29 倍之间跳(实测到过 16.9、18.5、27.6、28.6 倍),差异主要来自缓存冷热,不是代码行为变了。其余四栏都在 10% 出头,明显稳得多。

os.walk 并不是最快的

按理说 os.walk 该赢,它是标准库写好的。翻 CPython 源码才知道,os.walk 内部就是调 os.scandir(top),拿到的也是同一套 DirEntry 元信息(只是它不往外抛,yield 出去的是三元组:一个路径字符串加两个名字列表)。元信息一样,差别在它多做的事:

  • 默认自顶向下模式下,每进一个子目录前多做一次 os.path.islink()。这是 bpo-23605 那轮改造定下的取舍,源码注释写明:调用方可能在 yield 期间替换目录项,只能重新查,不能复用循环里缓存的 entry.is_symlink()
  • 生成器要产出三元组,还要维护 dirs、nondirs、walk_dirs 三个列表

我另做一组对照实验,把这些开销加回手写版本:只有 is_dir() 时 3.92 ms,加 is_symlink() 4.02 ms,再换成每目录一次 os.path.islink() 4.43 ms,中间还有一步加 sys.audit()(4.40 ms,测出来落在噪声里,因为真实 os.walk 每次调用只 audit 一次,不是每目录一次),最后加三元组 4.59 ms,而 os.walk 是 4.90 ms。这组跑在另一棵同构树、15 轮,绝对值跟主表不通用,要看的是逐项差值;最大一块是每目录一次的 islink。

补一句,topdown=False 走另一条路,改用 entry.is_symlink() 且只对目录条目调用,该分支下 os.path.islink() 一次都不执行。

手写 scandir 少用的那 20% 时间里,最大的一块是每目录一次的 islink,那正是符号链接保护的开销;剩下的差额 os.walk 花在生成器、两个名字列表和异常处理上。

os.listdir 才是真的慢

很多人觉得 os.listdir 轻便、os.path.isdir() 便宜,加起来应该不慢。实测是最慢的。

原因很直白:os.path.isdir() 是一次独立的文件系统查询,每个条目都要查一次(Windows 上是轻量的属性查询,不走 os.stat)。我单独测了 3600 个路径逐个查,花了 67.96 ms,折合每条 18.88 微秒,光这一项就占掉 82 ms 的大头。

scandir 快在它顺带把元信息带回来了,is_file() 直接读枚举时取回的快照(符号链接这类重解析点仍会回落到真正的查询)。

pathlib 和 glob 慢在哪

两者耗时接近,pathlib 略快,因为都要做模式匹配:glob 把通配符翻译成正则靠 fnmatch._translate,pathlib 的 rglob 转手交给 glob 模块里的 _Globber,最终也落到同一个叶子函数上。翻译占多少我没单独拆开测。pathlib 还要为每个结果造 Path 对象并做路径解析,写起来干净、能自由组合,相对 os.walk 慢 3.2 倍,相对 glob 反而快 6%。

scandir 的一个陷阱

os.scandir 是 os.walk 快的另一个原因:一次返回目录项的元信息,不用再单独查。

但它有个坑:entry.is_file() 读的是目录项的快照,文件被删除后仍然返回 True。

with os.scandir(d) as it:
    entry = next(iter(it))
    before = entry.is_file()   # True
    os.remove(entry.path)
    after = entry.is_file()    # 仍然是 True

before 是 True,after 还是 True,尽管文件已经被删了。

这里要纠正一个流传很广的说法:有人会说这是因为 scandir 直接读目录项里的 d_type 字段、文件删了那个字段不变。在 Windows 上根本不成立,os.DirEntry 连 d_type 属性都没有。快照来自目录枚举时一次性取回的那份文件属性。

我做了个更狠的验证:文件不删,只是原地从 10 字节写到 500 字节,entry.stat().st_size 仍然返回 10,说明快照确实来自枚举那一刻,不是事后查的。

要留意这是 Windows 的行为。这台机器只有 Windows,Linux 上那句是照 CPython 源码推的,没实测过:按那个实现,文件已删时 entry.stat() 会抛 FileNotFoundError。

绕开快照、重新查路径才会报错:os.stat(entry.path) 和 open(entry.path) 都会抛 FileNotFoundError(errno=2)。所以遍历期间有别的进程删文件时,is_file() 拦不住你,open() 才会抛异常,稳妥的写法是捕获 FileNotFoundError 后 continue。

实用做法

  • 只需要分清哪些是文件 → 手写 scandir 递归,最快
  • 需要标准的符号链接语义 → os.walk,少用的那 20% 时间买的是它的正确性
  • 需要当前的文件大小、修改时间 → 用 os.stat(entry.path),别用 entry.stat(),后者在 Windows 上是枚举期快照,会拿到过期值
  • 路径处理逻辑复杂 → pathlib.rglob,写得清爽,比 os.walk 慢 3.2 倍

要避开的是 listdir 加 isdir 的循环,慢十几倍到近 30 倍,是五个写法里唯一的重灾区。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。