扫 3600 个文件实测:手写 os.scandir 3.58 ms 比 os.walk 快,pathlib.rglob 慢 3
遍历目录该用哪个
有个程序要扫一遍目录树收集文件。常见选择就那几个: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 msos.walk→ 4.47 mspathlib.rglob('*.txt')→ 14.28 msglob.iglob(recursive=True)→ 15.15 msos.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 倍,是五个写法里唯一的重灾区。
- 点赞
- 收藏
- 关注作者
评论(0)