Python标准库提供了多种文件遍历能力,但很多开发者写出来的搜索脚本在大目录下慢得离谱。一个包含几十万个小文件的目录树,用不恰当的方式遍历可能需要几分钟,而优化后往往几秒钟就能完成。差异主要来自三方面:目录读取API的选择、路径拼接方式,以及是否做了重复的系统调用。这篇文章会把这几个点逐一拆开讲清楚,并给出可以直接落地的代码。

一、从listdir到scandir:目录读取API的选择
早期大家遍历目录习惯用os.listdir,它返回目录下所有条目的名称列表。问题在于,如果你想知道某个条目是文件还是目录,还得再调用一次os.path.isdir或os.path.isfile,而这两个函数底层都会触发一次stat系统调用。在Linux上,目录项本身的dirent结构就带有类型信息(除了某些特殊文件系统),os.scandir能够直接读取这些信息,把类型判断的成本降到接近零。
看一组直观的对比。同样是统计一个包含30万个文件的目录树中所有文件的数量,os.listdir加os.path.isfile的组合需要做30万次额外的stat调用,而os.scandir配合entry.is_file()基本不需要额外调用。实测耗时差距能达到5到10倍,目录越深、文件越多,差距越明显。
import os
def count_files_listdir(root):
count = 0
for dirpath, dirnames, filenames in os.walk(root):
count += len(filenames)
return count
def count_files_scandir(root):
count = 0
stack = [root]
while stack:
current = stack.pop()
with os.scandir(current) as it:
for entry in it:
if entry.is_dir(follow_symlinks=False):
stack.append(entry.path)
elif entry.is_file(follow_symlinks=False):
count += 1
return count值得一提的是,Python 3.5之后的os.walk内部已经改用os.scandir实现了,所以直接用os.walk性能也不差。但如果你需要在遍历过程中拿到每个条目的详细信息(大小、修改时间等),os.scandir的entry.stat()结果在部分平台上会被缓存,比手动调os.stat(entry.path)更高效。
二、pathlib的易用性与性能取舍
pathlib是Python 3.4引入的面向对象路径库,写起来确实舒服:Path('/data').rglob('*.py')一行就能递归搜索所有Python文件,代码可读性远超字符串拼接。但在性能敏感的场景下,它有一定的代价。Path对象的每次除法运算(p / 'sub')都会创建新对象,rglob内部还会做额外的模式匹配处理,海量文件下的内存占用和CPU开销都比os.scandir方案高。
一个简单的原则:脚本工具、一次性任务,用pathlib享受开发效率;生产环境高频调用的搜索逻辑,用os.scandir或os.walk榨取运行效率。两者也可以混用,比如用pathlib处理入参解析,用os.scandir做核心遍历。
from pathlib import Path
def find_by_ext_easy(root, ext):
# 简洁写法,适合脚本和一次性任务
return [p for p in Path(root).rglob(f'*{ext}') if p.is_file()]
def find_by_ext_fast(root, ext):
# 性能优先写法,用scandir配合栈迭代
result = []
stack = [root]
while stack:
current = stack.pop()
try:
with os.scandir(current) as it:
for entry in it:
if entry.is_dir(follow_symlinks=False):
stack.append(entry.path)
elif entry.name.endswith(ext):
result.append(entry.path)
except PermissionError:
continue # 跳过无权限目录,避免整个搜索中断
return result注意上面代码里的PermissionError处理。真实环境中总有一些目录因权限问题无法读取,不做异常处理的话,一次权限问题就会让整个搜索任务失败,这在生产代码里是必须考虑的健壮性问题。
三、生成器、多线程与匹配策略的组合优化
搜索结果不一定要一次性全部返回。把函数写成生成器,遇到第一个匹配就立即产出,调用方可以随时中断,这对交互式工具尤其重要——用户找到想要的文件后就不必等全盘扫完。生成器写法还能显著降低内存占用,因为不需要维护一个巨大的结果列表。
import os
import fnmatch
def search_files(root, pattern):
"""生成器版本的文件搜索,支持通配符"""
stack = [root]
while stack:
current = stack.pop()
try:
with os.scandir(current) as it:
for entry in it:
if entry.is_dir(follow_symlinks=False):
stack.append(entry.path)
elif fnmatch.fnmatch(entry.name, pattern):
yield entry.path
except (PermissionError, OSError):
continue通配符匹配建议用fnmatch.fnmatch而不是自己写字符串处理,它支持*.log、test_*.py这类shell风格模式,跨平台行为一致。如果需要不区分大小写的匹配(比如在Windows上搜文件),换成fnmatch.fnmatchcase的反面——直接用fnmatch.fnmatch即可,它在Windows和macOS上默认就不区分大小写,Linux上区分,这个细节容易踩坑。
对于机械硬盘或网络文件系统(NFS、SMB)这类IO延迟高的场景,单线程遍历的瓶颈在于每次目录读取都要等待IO完成。此时可以用concurrent.futures.ThreadPoolExecutor并行处理多个子目录:主线程扫描顶层目录,把子目录分发给线程池去递归。由于GIL的存在,CPU计算不会并行,但IO等待可以重叠,网络盘上提速效果非常明显。本地SSD则收益有限,因为单线程已经能把IO队列打满,盲目加线程反而增加调度开销。
from concurrent.futures import ThreadPoolExecutor
def parallel_search(root, pattern, workers=8):
results = []
dirs = [root]
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = []
while dirs:
current = dirs.pop()
try:
with os.scandir(current) as it:
for entry in it:
if entry.is_dir(follow_symlinks=False):
dirs.append(entry.path)
elif fnmatch.fnmatch(entry.name, pattern):
results.append(entry.path)
except OSError:
continue
return results四、总结与实践建议
把前面的内容浓缩成几条可操作的建议。第一,优先使用os.scandir或新版os.walk,避免os.listdir加os.path.isfile的重复stat模式。第二,按场景选API:追求开发速度用pathlib,追求运行速度用os.scandir。第三,把搜索函数写成生成器,让调用方控制中断时机,降低内存峰值。第四,生产代码必须处理权限异常,跳过而非崩溃。第五,只有面对网络盘或机械盘时才考虑多线程并行,本地SSD上单线程已经足够快。
另外提一个容易被忽略的方向:如果搜索需求是长期反复进行的(比如日志分析系统每天扫描同样的目录),与其优化Python遍历速度,不如把文件路径信息建一次索引存进SQLite或内存缓存,后续搜索直接查库,耗时可以从秒级降到毫秒级。工具层面,Windows下的everything、Linux下的locate/updatedb都是这个思路的成熟实现,值得在设计自己的搜索方案前参考。
Python文件搜索os.walkpathlib修改时间:2026-09-05 20:56:53