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()
接受字节或 str 对象作为 path
参数,并使用与 path
相同的类型返回 DirEntry.name
和 DirEntry.path
属性。但是,**强烈建议**使用 str 类型,因为这确保了对 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
此 subdirs()
函数在 Windows 和 POSIX 系统上都将比 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
这还展示了如何使用follow_symlinks
参数传递给is_dir()
- 在像这样的递归函数中,我们可能不希望跟随链接。(为了正确地在像这样的递归函数中跟随链接,我们需要对跟随符号链接导致递归循环的情况进行特殊处理。)
请注意,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模块已经被fork并被广泛使用(请参阅此PEP中的“实际使用”),但核心开发者和其他人在python-dev和python-ideas邮件列表中也提供了相当多的对类似scandir函数的直接支持。示例如下
- python-dev:scandir和PEP 471在2014年6月的这个python-dev主题上获得了大量的+1和很少的负面评价。
- Alyssa Coghlan,一位核心Python开发者:“我让Red Hat本地发布工程团队表达了他们对不得不为存在于dirent结构中的信息而对网络挂载的目录树中的每个文件都进行stat的不满,因此我绝对支持os.scandir,只要它能提供这些信息。” [来源1]
- Tim Golden,一位核心Python开发者,足够支持scandir,以至于花费时间重构并显著改进了scandir的C扩展模块。 [来源2]
- Christian Heimes,一位核心Python开发者:“+1,支持类似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
实现肯定有用,但已被明确标记为“beta”,因此尚不确定它在实际中的使用情况。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确实有几个观察者、问题、fork等。以下是截至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的作者)认为将windows_wildcard
关键字参数包含到scandir
函数中以便用户可以传递它是一个好主意。
然而,经过进一步的思考和讨论,人们认为这是一个坏主意,除非它可以跨平台(一个pattern
关键字参数或类似的)。这乍一看似乎很容易 - 只需在Windows上使用OS通配符支持,然后在基于POSIX的系统上使用fnmatch
或re
。
不幸的是,Microsoft 并没有在任何地方真正记录精确的Windows通配符匹配规则,而且它们非常古怪(请参阅此博文),这意味着使用fnmatch
或正则表达式进行模拟非常成问题。
因此,大家一致认为Windows通配符支持是一个坏主意。如果以后有跨平台的方法可以实现它,则可以添加,但不会在初始版本中添加。
阅读更多内容,请参阅2012年11月的这个python-ideas主题和2014年6月关于PEP 471的这个python-dev主题。
方法默认不跟随符号链接
python-dev上关于DirEntry
方法是否应该跟随符号链接(当is_X()
方法没有follow_symlinks
参数时)进行了大量的讨论(请参阅此主题中的消息)。
最初它们没有(请参阅此PEP和scandir.py模块的先前版本),但Victor Stinner在python-dev上提出了一个很有说服力的论点,即默认情况下跟随符号链接是一个更好的主意,因为
- 跟随链接通常是您想要的(在标准库中92%的情况下,使用
os.listdir()
和os.path.isdir()
的函数确实跟随符号链接) - 这与类似函数
os.path.isdir()
和pathlib.Path.is_dir()
所设置的先例一致,因此采取其他方式会令人困惑。 - 使用不跟随链接的方法,如果你想跟随链接,则必须编写类似
if (entry.is_symlink() and os.path.isdir(entry.path)) or entry.is_dir()
的代码,这很笨拙。
作为一个例子,说明不跟随符号链接的版本容易出错,本 PEP 的作者在其最初的scandir.walk()
(位于 scandir.py 中)实现中,由于错误地使用了这种精确的测试而导致了一个错误(参见此处的 Issue #4)。
最终,并非所有人都完全同意这些方法应该跟随符号链接,但大多数参与者之间达成了基本共识,并且本 PEP 的作者认为上述案例足以证明默认情况下应该跟随符号链接。
此外,如果需要其他行为,可以直接使用follow_symlinks=False
调用相关方法。
DirEntry 属性为属性
在某些方面,如果DirEntry
的is_X()
和stat()
是属性而不是方法,会更简洁,表明它们非常廉价或免费。然而,情况并非完全如此,因为stat()
在基于 POSIX 的系统上需要进行系统调用,而在 Windows 上则不需要。即使is_dir()
及其相关方法,如果dirent.d_type
值为DT_UNKNOWN
(在某些文件系统上),也可能会在基于 POSIX 的系统上执行系统调用。
此外,人们期望属性访问entry.is_dir
只抛出AttributeError
,而不是在它在幕后进行系统调用时抛出OSError
。调用代码必须在看似简单的属性访问周围使用try
/except
,因此将其设为方法要好得多。
参见2013 年 5 月的 python-dev 线程,其中本 PEP 的作者提出了这个论点,并且核心开发者也表示同意。
DirEntry 字段为“静态”仅属性对象
在2014 年 7 月的 python-dev 消息中,Paul Moore 提出了一个“围绕操作系统功能的薄包装器”的解决方案,其中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
属性。但是,除了这是一种奇怪(且牵强!)的重载之外,它还存在上述相同的问题——大多数stat_result
信息不是由 POSIX 系统上的readdir()
获取的,只有(部分)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 在此处明确拒绝了在 scandir 上下文中缓存pathlib.Path
的 stat,这使得pathlib.Path
对象成为 scandir 返回值的糟糕选择。
可能的改进
可以对 scandir 进行许多可能的改进,但以下是本 PEP 作者想到的一些简短列表。
- scandir 可能可以通过在每个
Py_BEGIN_ALLOW_THREADS
块中调用readdir
/FindNextFile
大约 50 次来进一步加速,以便它在 C 扩展模块中停留更长时间,并且结果可能稍微快一些。这种方法尚未经过测试,但由 Antoine Pitrou 在 Issue 11406 中提出。[source9] - scandir 可以使用空闲列表来避免为每次迭代分配内存的开销——一个长度为 10 或甚至 1 的短空闲列表可能会有所帮助。由 Victor Stinner 在6 月 27 日的 python-dev 线程中提出。
之前的讨论
- Ben Hoyt 在 2012 年 11 月在 python-ideas 上启动的关于加速
os.walk()
的原始线程 - Python Issue 11406,其中包含类似 scandir 函数的原始提案
- Ben Hoyt 在 2013 年 5 月在 python-dev 上启动的进一步线程,该线程改进了
scandir()
API,包括 Alyssa Coghlan 提出的 scandir 生成DirEntry
风格对象的建议 - Ben Hoyt 在 2013 年 11 月于 python-dev 上发起的讨论线程,讨论 scandir 与新的
pathlib
模块之间的交互。 - Ben Hoyt 在 2014 年 6 月于 python-dev 上发起的讨论线程,讨论此 PEP 的第一个版本,并对 API 进行了广泛的讨论。
- Ben Hoyt 在 2014 年 7 月初于 python-dev 上发起的讨论线程,讨论他对 PEP 471 的更新。
- Ben Hoyt 在 2014 年 7 月中旬于 python-dev 上发起的讨论线程,讨论完成 PEP 471 所需的剩余决策,特别是
DirEntry
方法是否应该默认跟随符号链接。 - StackOverflow 上的问题,关于为什么
os.walk()
速度慢以及如何解决的提示(这在早期启发了此 PEP 的作者)。 - BetterWalk,此 PEP 作者之前对此的尝试,scandir 代码基于此。
版权
本文档已置于公共领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0471.rst
上次修改时间:2023-10-11 12:05:51 GMT