PEP 471 – os.scandir() 函数 – 一个更好、更快的目录迭代器
- 作者:
- Ben Hoyt <benhoyt at gmail.com>
- BDFL 委托:
- Victor Stinner <vstinner at python.org>
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 创建日期:
- 2014年5月30日
- Python 版本:
- 3.5
- 发布历史:
- 2014年6月27日, 2014年7月8日, 2014年7月14日
摘要
本 PEP 提议在标准库中包含一个新的目录迭代函数 os.scandir()。这个新函数增加了有用的功能,并通过在大多数情况下避免调用 os.stat(),将 os.walk() 的速度提高了 2-20 倍(取决于平台和文件系统)。
基本原理
Python 内置的 os.walk() 比它需要的慢得多,因为除了对每个目录调用 os.listdir() 之外,它还会对每个文件执行 stat() 系统调用或 GetFileAttributes() 以确定该条目是否为目录。
但底层系统调用——Windows 上的 FindFirstFile / FindNextFile 和 POSIX 系统上的 readdir——已经告诉您返回的文件是否为目录,因此不需要进一步的系统调用。此外,Windows 系统调用会返回目录条目的所有信息,例如文件大小和上次修改时间,用于创建 stat_result 对象。
简而言之,您可以将 os.walk() 等树状函数所需的系统调用次数从大约 2N 减少到 N,其中 N 是树中文件和目录的总数。(而且由于目录树通常比它们深,所以通常情况会好得多。)
实际上,移除所有这些额外的系统调用使 os.walk() 在 Windows 上快了约 8-9 倍,在 POSIX 系统上快了约 2-3 倍。所以我们谈论的不是微优化。在此处查看更多基准测试。
与此有点相关的是,许多人(参见 Python 问题 11406)也热衷于一个 os.listdir() 版本,它在迭代时生成文件名而不是返回一个大列表。这提高了迭代非常大目录时的内存效率。
因此,除了提供一个用于直接调用的 scandir() 迭代器函数之外,Python 现有的 os.walk() 函数的速度也可以大大提高。
实施
本提案的实现由 Ben Hoyt(初始版本)和 Tim Golden(他在 C 扩展模块方面提供了很大帮助)完成。它位于 GitHub 上的 benhoyt/scandir。(实现可能稍微落后于本 PEP 的更新。)
请注意,此模块已被使用和测试(参见本 PEP 中的“实际应用”部分),因此它不仅仅是一个概念验证。但是,它被标记为测试版软件,并未经过广泛的实战测试。在进入标准库之前,它还需要一些清理和更彻底的测试,以及集成到 posixmodule.c 中。
提案具体内容
os.scandir()
具体来说,本 PEP 提议在标准库的 os 模块中添加一个单独的函数 scandir,它接受一个可选的字符串作为参数。
scandir(path='.') -> generator of DirEntry objects
与 listdir 类似,scandir 调用操作系统的目录迭代系统调用以获取给定 path 中文件的名称,但它与 listdir 在两个方面有所不同:
- 它不返回纯文件名字符串,而是返回轻量级的
DirEntry对象,该对象包含文件名字符串并提供简单的方法来访问操作系统可能已返回的其他数据。 - 它返回一个生成器而不是一个列表,因此
scandir充当一个真正的迭代器,而不是立即返回完整列表。
scandir() 为 path 中的每个文件和子目录生成一个 DirEntry 对象。就像 listdir 一样,跳过 '.' 和 '..' 伪目录,并且条目以系统依赖的顺序生成。每个 DirEntry 对象具有以下属性和方法:
name:条目的文件名,相对于 scandirpath参数(对应于os.listdir的返回值)path:条目的完整路径名(不一定是绝对路径)——相当于os.path.join(scandir_path, entry.name)inode():返回条目的 inode 号。结果缓存在DirEntry对象上,使用os.stat(entry.path, follow_symlinks=False).st_ino获取最新信息。在 Unix 上,不需要系统调用。is_dir(*, follow_symlinks=True):类似于pathlib.Path.is_dir(),但返回值缓存在DirEntry对象上;在大多数情况下不需要系统调用;如果follow_symlinks为 False,则不跟随符号链接。is_file(*, follow_symlinks=True):类似于pathlib.Path.is_file(),但返回值缓存在DirEntry对象上;在大多数情况下不需要系统调用;如果follow_symlinks为 False,则不跟随符号链接。is_symlink():类似于pathlib.Path.is_symlink(),但返回值缓存在DirEntry对象上;在大多数情况下不需要系统调用。stat(*, follow_symlinks=True):类似于os.stat(),但返回值缓存在DirEntry对象上;在 Windows 上不需要系统调用(符号链接除外);如果follow_symlinks为 False,则不跟随符号链接(类似于os.lstat())。
所有 方法 在某些情况下都可能执行系统调用,因此可能会引发 OSError——有关更多详细信息,请参阅“关于异常处理的注意事项”部分。
为了一致性,DirEntry 的属性和方法名尽可能与新的 pathlib 模块中的名称相同。唯一的功能区别是 DirEntry 方法在第一次调用后将其值缓存到条目对象上。
与 os 模块中的其他函数一样,scandir() 接受字节或字符串对象作为 path 参数,并以与 path 相同的类型返回 DirEntry.name 和 DirEntry.path 属性。然而,强烈建议使用字符串类型,因为这确保了对 Unicode 文件名的跨平台支持。(在 Windows 上,自 Python 3.3 以来,字节文件名已被弃用)。
os.walk()
作为本提案的一部分,os.walk() 也将被修改为使用 scandir() 而不是 listdir() 和 os.path.isdir()。这将大大提高 os.walk() 的速度(如上所述,根据系统不同,可提高 2-20 倍)。
示例
首先,一个非常简单的 scandir() 示例,展示了 DirEntry.name 属性和 DirEntry.is_dir() 方法的使用:
def subdirs(path):
"""Yield directory names not starting with '.' under given path."""
for entry in os.scandir(path):
if not entry.name.startswith('.') and entry.is_dir():
yield entry.name
在 Windows 和 POSIX 系统上,使用 scandir 的 subdirs() 函数将比使用 os.listdir() 和 os.path.isdir() 显著更快,尤其是在中型或大型目录中。
或者,为了获取目录树中文件的总大小,展示了 DirEntry.stat() 方法和 DirEntry.path 属性的使用:
def get_tree_size(path):
"""Return total size of files in given path and subdirs."""
total = 0
for entry in os.scandir(path):
if entry.is_dir(follow_symlinks=False):
total += get_tree_size(entry.path)
else:
total += entry.stat(follow_symlinks=False).st_size
return total
这也展示了 is_dir() 的 follow_symlinks 参数的使用——在这样的递归函数中,我们可能不希望跟随链接。(要在此类递归函数中正确跟随链接,我们需要对跟随符号链接导致递归循环的情况进行特殊处理。)
请注意,get_tree_size() 在 Windows 上会获得巨大的速度提升,因为不需要额外的 stat 调用,但在 POSIX 系统上,目录迭代函数不返回大小信息,因此此函数在此处不会获得任何收益。
关于缓存的注意事项
DirEntry 对象相对简单——name 和 path 属性显然始终缓存,is_X 和 stat 方法缓存它们的值(在 Windows 上通过 FindNextFile 立即缓存,在 POSIX 系统上通过 stat 系统调用首次使用时缓存),并且从不从系统重新获取。
因此,DirEntry 对象旨在用于迭代后即丢弃,而不是存储在长期存在的数据结构中并反复调用方法。
如果开发人员需要“刷新”行为(例如,观察文件大小的变化),他们可以简单地使用 pathlib.Path 对象,或者调用常规的 os.stat() 或 os.path.getsize() 函数,这些函数每次调用都会从操作系统获取最新数据。
关于异常处理的注意事项
DirEntry.is_X() 和 DirEntry.stat() 明确地是方法而不是属性或特性,以明确它们可能不是廉价操作(尽管它们通常是),并且它们可能会进行系统调用。因此,这些方法可能会引发 OSError。
例如,DirEntry.stat() 在基于 POSIX 的系统上总是会进行系统调用,而 DirEntry.is_X() 方法在这种系统上会进行 stat() 系统调用,如果 readdir() 不支持 d_type 或返回值为 DT_UNKNOWN 的 d_type,这在某些条件或某些文件系统下可能会发生。
通常这并不重要——例如,标准库中定义的 os.walk() 只捕获 listdir() 调用周围的错误。
此外,由于 DirEntry.is_X 方法的异常抛出行为与 pathlib 的行为一致——只在权限或其他致命错误情况下抛出 OSError,但如果路径不存在或是一个损坏的符号链接则返回 False——所以通常不需要在 is_X() 调用周围捕获错误。
然而,当用户需要细粒度错误处理时,可能需要捕获所有方法调用周围的 OSError 并进行适当处理。
例如,下面是上面展示的 get_tree_size() 示例的一个版本,但添加了细粒度错误处理:
def get_tree_size(path):
"""Return total size of files in path and subdirs. If
is_dir() or stat() fails, print an error message to stderr
and assume zero size (for example, file has been deleted).
"""
total = 0
for entry in os.scandir(path):
try:
is_dir = entry.is_dir(follow_symlinks=False)
except OSError as error:
print('Error calling is_dir():', error, file=sys.stderr)
continue
if is_dir:
total += get_tree_size(entry.path)
else:
try:
total += entry.stat(follow_symlinks=False).st_size
except OSError as error:
print('Error calling stat():', error, file=sys.stderr)
return total
支持
GitHub 上的 scandir 模块已经被分叉和使用了不少(参见本 PEP 中的“实际应用”),但核心开发人员和 python-dev 以及 python-ideas 邮件列表上的其他人也对 scandir 类似的函数提供了不少直接支持。以下是一些示例:
- python-dev:在 2014 年 6 月的 python-dev 帖子中,scandir 和 PEP 471 获得了大量 +1,负面评论很少。
- Alyssa Coghlan,Python 核心开发人员:“我所在 Red Hat 发布工程团队曾对必须对网络挂载目录树中的每个文件进行 stat 操作,以获取 dirent 结构中已存在的信息表示不满,因此我绝对支持 os.scandir,只要它能提供这些信息。” [来源1]
- Tim Golden,Python 核心开发人员,对 scandir 的支持足以让他花费时间重构并显著改进 scandir 的 C 扩展模块。 [来源2]
- Christian Heimes,Python 核心开发人员:“+1 for something like yielddir()” [来源3] 和 “确实!我希望在 3.4 中看到这个功能,这样我就可以从我们的代码库中删除我自己的 hack。” [来源4]
- Gregory P. Smith,Python 核心开发人员:“由于 3.4beta1 今晚发布,这不会在 3.4 中实现,所以我将其推迟到 3.5。我非常喜欢上面概述的提议设计。” [来源5]
- Guido van Rossum 关于将 scandir 添加到 Python 3.5 的可能性(因为 3.4 为时已晚):“添加 scandir()(无论是添加到 os 还是 pathlib)的时机也已过去。务必进行实验并为 3.5 的考虑做好准备,但我不想将其添加到 3.4。” [来源6]
Alyssa (Nick) Coghlan 在 python-dev 上对本 PEP 本身(元支持?)表示支持:“一个审查所有这些内容并为 3.5 提出特定 os.scandir API 的 PEP 将是一件好事。” [来源7]
实际应用
迄今为止,scandir 实现无疑很有用,但已明确标记为“测试版”,因此在实际应用中有多少使用尚不确定。Ben Hoyt 收到了一些使用者的报告。例如:
- Chris F:“我正在处理一些相当大的目录,本来预计需要修改 getdents。所以谢谢你省了我的力气。” [通过个人电子邮件]
- bschollnick:“我想让你知道这一点,因为我正在使用 Scandir 作为此代码的构建块。这是一个很好的例子,说明 scandir 比 os.listdir 带来了显著的性能提升。” [来源8]
- Avram L:“我正在为我的一个项目测试我们的 scandir。看起来相当稳定,所以首先,只想说干得好!” [通过个人电子邮件]
- Matt Z:“我使用 scandir 在 15 秒内转储了一个网络目录的内容。13 个根目录,结构中有 60,000 个文件。这将取代嵌入在电子表格中的一些旧 VBA 代码,该代码需要 15-20 分钟才能完成完全相同的事情。” [通过个人电子邮件]
其他人 请求为其提供 PyPI 包,该包已创建。请参阅 PyPI 包。
GitHub 统计数据意义不大,但 scandir 确实有几个关注者、问题、分支等。以下是截至 2014 年 7 月 7 日的统计数据:
- 关注者:17
- 星标:57
- 分支:20
- 问题:4 个开放,26 个已关闭
此外,由于本 PEP 将显著提高 os.walk() 的速度,将有成千上万的开发人员和脚本以及大量的生产代码从中受益。例如,在 GitHub 上,os.walk 的使用次数(194,000)几乎与 os.mkdir 的使用次数(230,000)一样多。
被拒绝的想法
命名
此函数名称的唯一其他真正竞争者是 iterdir()。然而,Python 中的 iterX() 函数(主要在 Python 2 中找到)倾向于作为其非迭代器对应项的简单迭代器等价物。例如,dict.iterkeys() 只是 dict.keys() 的迭代器版本,但返回的对象是相同的。然而,在 scandir() 的情况下,返回值是截然不同的对象(DirEntry 对象与文件名字符串),因此这可能应该通过名称差异来反映——因此选择了 scandir()。
请参阅 python-dev 上的一些相关讨论。
通配符支持
Windows 上的 FindFirstFile/FindNextFile 支持传递 *.jpg 这样的“通配符”,所以最初人们(包括本 PEP 的作者)认为在 scandir 函数中包含一个 windows_wildcard 关键字参数,以便用户可以传入通配符会是一个好主意。
然而,经过进一步思考和讨论,决定这是一个坏主意,除非它能实现跨平台(一个 pattern 关键字参数或类似的东西)。这最初看起来很简单——只需在 Windows 上使用 OS 通配符支持,然后在基于 POSIX 的系统上使用 fnmatch 或 re 等。
不幸的是,微软并没有真正记录 Windows 通配符匹配规则,而且它们相当古怪(请参阅这篇博客文章),这意味着使用 fnmatch 或正则表达式来模拟它非常麻烦。
因此,共识是 Windows 通配符支持是个坏主意。如果以后有跨平台的方式实现,可以在以后添加,但初始版本不会包含。
在 2012 年 11 月的 python-ideas 帖子和 2014 年 6 月的 python-dev 关于 PEP 471 的帖子中阅读更多内容。
默认不跟随符号链接的方法
在 python-dev 上有过很多关于 DirEntry 方法是否应跟随符号链接的争论(当 is_X() 方法没有 follow_symlinks 参数时)。
最初它们没有(参见本 PEP 和 scandir.py 模块的早期版本),但 Victor Stinner 在 python-dev 上提出了一个相当令人信服的理由,认为默认跟随符号链接是一个更好的主意,因为:
- 跟随链接通常是你想要的(在标准库中,使用
os.listdir()和os.path.isdir()的函数在 92% 的情况下确实会跟随符号链接) - 这是
os.path.isdir()和pathlib.Path.is_dir()等类似函数设定的先例,否则会令人困惑 - 采用不跟随链接的方法,如果你想跟随链接,你必须写成类似
if (entry.is_symlink() and os.path.isdir(entry.path)) or entry.is_dir()的代码,这很笨拙
作为一个例子,说明不跟随符号链接的版本容易出错,本 PEP 的作者在他的 scandir.py 中 scandir.walk() 的初始实现中就曾因这个测试错误而导致了一个 bug(参见 此处的问题 #4)。
最终,关于方法是否应跟随符号链接并未达成完全一致,但在最积极参与者之间达成了基本共识,本 PEP 的作者认为上述情况足以支持默认跟随符号链接。
此外,如果需要其他行为,可以轻松地调用带有 follow_symlinks=False 的相关方法。
DirEntry 属性作为属性
在某些方面,如果 DirEntry 的 is_X() 和 stat() 是属性而不是方法,那会更好,以表明它们非常廉价或免费。然而,情况并非完全如此,因为 stat() 在基于 POSIX 的系统上需要进行 OS 调用,但在 Windows 上不需要。即使 is_dir() 和类似的方法在基于 POSIX 的系统上,如果 dirent.d_type 值为 DT_UNKNOWN(在某些文件系统上),也可能执行 OS 调用。
此外,人们会期望属性访问 entry.is_dir 只会引发 AttributeError,而不会在它在后台进行系统调用时引发 OSError。调用代码将不得不在看起来像简单属性访问的地方使用 try/except,因此最好将它们设置为 方法。
请参阅 2013 年 5 月的 python-dev 帖子,其中本 PEP 作者提出了这个案例,并得到了核心开发人员的同意。
DirEntry 字段为“静态”只读属性对象
在 2014 年 7 月的 python-dev 消息中,Paul Moore 提出了一个“对 OS 功能的轻量级封装”的解决方案,其中 DirEntry 对象只包含静态属性:name、path 和 is_X,而 st_X 属性只在 Windows 上存在。这个想法是使用这个更简单、更低级的函数作为高级函数的构建块。
起初,大家普遍认为以这种方式简化是件好事。然而,这种方法存在两个问题。首先,假设 is_dir 和类似属性在 POSIX 上总是存在,但事实并非如此(如果 d_type 不存在或为 DT_UNKNOWN)。其次,实际上它是一个更难使用的 API,因为即使是 is_dir 属性在 POSIX 上也并非总是存在,如果它们不存在,则需要使用 hasattr() 进行测试,然后调用 os.stat()。
请参阅本 PEP 作者在 2014 年 7 月的 python-dev 回复,其中详细说明了为什么此选项不是理想解决方案,以及 Paul Moore 随后的回复表示同意。
DirEntry 字段为静态并带有 ensure_lstat 选项
Alyssa Coghlan 在 2014 年 6 月的 python-dev 消息中提出了另一个看似更简单且吸引人的选项:将 DirEntry.is_X 和 DirEntry.lstat_result 设为属性,并在迭代时填充 DirEntry.lstat_result,但仅在 scandir() 调用中指定了新参数 ensure_lstat=True 的情况下。
这比上述方法有一个优点,就是如果您需要,可以轻松地从 scandir() 获取 stat 结果。然而,它有一个严重的缺点,就是细粒度错误处理很麻烦,因为 stat() 将在迭代期间被调用(因此可能引发 OSError),导致一个相当丑陋的手动迭代循环:
it = os.scandir(path)
while True:
try:
entry = next(it)
except OSError as error:
handle_error(path, error)
except StopIteration:
break
或者这意味着 scandir() 必须接受一个 onerror 参数——一个在迭代期间发生 stat() 错误时调用的函数。这在 PEP 作者看来,既不直接也不如在 DirEntry.stat() 调用周围使用 try/except 更具 Pythonic 风格。
另一个缺点是 os.scandir() 是为了让代码更快而编写的。在 POSIX 上总是调用 os.lstat() 不会带来任何速度提升。在大多数情况下,你不需要完整的 stat_result 对象——is_X() 方法就足够了,而且这些信息已经知道。
请参阅 Ben Hoyt 2014 年 7 月的回复,总结了这一点,并详细说明了他为什么认为最初的 PEP 471 提案最终是“正确的”。
返回值为 (name, stat_result) 两元组
最初,本 PEP 的作者将此概念提议为一个名为 iterdir_stat() 的函数,它生成 (name, stat_result) 的两元组。这确实有一个优点,即没有引入新的类型。然而,stat_result 在基于 POSIX 的系统上只部分填充(大多数字段设置为 None 和其他怪异之处),因此它们根本不是真正的 stat_result 对象,这必须彻底记录为与 os.stat() 不同。
此外,Python 对带有属性和方法的适当对象有很好的支持,这使得 API 比两元组更健全、更简单。这也使得 DirEntry 对象更具可扩展性和未来兼容性,因为操作系统会添加功能,我们希望将这些功能包含在 DirEntry 中。
另请参阅之前的一些讨论:
- 2013 年 5 月的 python-dev 帖子,其中 Alyssa Coghlan 首次提出了
DirEntry样式对象的概念。 - 2014 年 6 月的 python-dev 帖子,其中 Alyssa Coghlan 再次提出了反对两元组方法的有力论据。
返回值为重载的 stat_result 对象
另一个讨论过的替代方案是让返回值成为重载的 stat_result 对象,带有 name 和 path 属性。然而,除了这是一种奇怪(且牵强!)的重载之外,它还存在上述相同的问题——readdir() 在 POSIX 系统上不会获取大部分 stat_result 信息,只获取 st_mode 值的一部分。
返回值为 pathlib.Path 对象
Antoine Pitrou 的新标准库 pathlib 模块最初看起来是一个很好的主意,让 scandir() 返回 pathlib.Path 的实例。然而,pathlib.Path 的 is_X() 和 stat() 函数明确不缓存,而 scandir 由于设计原因必须缓存它们,因为它(通常)返回原始目录迭代系统调用的值。
如果 scandir 返回的 pathlib.Path 实例缓存了 stat 值,而普通的 pathlib.Path 对象明确不缓存,那会让人感到相当困惑。
Guido van Rossum 明确拒绝了 pathlib.Path 在 scandir 上缓存 stat 的提议,详见此处,这使得 pathlib.Path 对象成为 scandir 返回值的糟糕选择。
可能的改进
scandir 可以有很多可能的改进,但这里列出了一些本 PEP 作者想到的:
- scandir 可能会通过在每个
Py_BEGIN_ALLOW_THREADS块中调用readdir/FindNextFile约 50 次来进一步提高速度,这样它可以在 C 扩展模块中停留更长时间,结果可能会更快一些。这种方法尚未经过测试,但 Antoine Pitrou 在问题 11406 上提出了这个建议。 [来源9] - scandir 可以使用一个空闲列表来避免每次迭代的内存分配成本——一个包含 10 个甚至 1 个短的空闲列表可能会有所帮助。Victor Stinner 在 6 月 27 日的 python-dev 帖子中提出了这个建议。
之前的讨论
- 2012 年 11 月 Ben Hoyt 在 python-ideas 上发起的关于加速
os.walk()的原始帖子 - Python 问题 11406,其中包含了对 scandir 类似函数的最初提案
- 2013 年 5 月 Ben Hoyt 在 python-dev 上发起的进一步帖子,改进了
scandir()API,包括 Alyssa Coghlan 关于 scandir 生成DirEntry类似对象的建议 - 2013 年 11 月 Ben Hoyt 在 python-dev 上发起的帖子,讨论 scandir 与新
pathlib模块之间的交互 - 2014 年 6 月 Ben Hoyt 在 python-dev 上发起的帖子,讨论本 PEP 的第一个版本,其中对 API 进行了广泛讨论
- 2014 年 7 月 Ben Hoyt 在 python-dev 上发起的第一个帖子,讨论他对 PEP 471 的更新
- 2014 年 7 月 Ben Hoyt 在 python-dev 上发起的第二个帖子,讨论完成 PEP 471 所需的剩余决定,特别是
DirEntry方法是否应默认跟随符号链接。 - StackOverflow 上的问题,关于为什么
os.walk()很慢以及如何解决它(这在早期启发了本 PEP 的作者) - BetterWalk,本 PEP 作者之前的尝试,scandir 代码基于此
版权
本文档已置于公共领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0471.rst