Go 1.16 新特性:ReadDir 与 DirEntry
作为 Python 的 os.scandir 函数和 PEP 471(即 scandir 的最初提案)的主要作者,看到 Go 在即将于 2021 年 2 月底发布的 Go 1.16 中加入类似的功能,我感到非常高兴。
在 Go 中,这个功能将被命名为 os.ReadDir,于去年 9 月被提出。在经过 100 多条评论和数次设计调整后,由 Russ Cox 在 10 月提交。与文件系统无关的版本也已包含在新的 io/fs 包中,即 fs.ReadDir。
为什么需要 ReadDir?
简而言之:为了性能。
当你调用系统函数读取目录条目时,操作系统通常会一并返回文件名及其类型(而在 Windows 上,还会返回文件大小、最后修改时间等 stat 信息)。然而,Go 和 Python 最初的接口却丢弃了这些额外信息,迫使你对每个条目都要额外发起一次 stat 调用。系统调用本身就不便宜,而 stat 还可能需要从磁盘读取,至少也会访问磁盘缓存。
在递归遍历目录树时,你需要知道一个条目是文件还是目录,才能决定是否要递归进入。因此,即使是简单的目录树遍历,也需要先读取目录条目,再对每个条目执行 stat。但如果利用操作系统提供的类型信息,就可以省去这些 stat 调用,让目录遍历快上数倍(在网络文件系统上甚至能快上几十倍)。关于 Python 版本的一些基准测试可以参考这里。
遗憾的是,两种语言最初读取目录的设计都不够理想,都无法让你在不额外调用 stat 的情况下获取类型信息:Python 中的是 os.listdir,Go 中的则是 ioutil.ReadDir。
我在 2012 年首次提出了 Python 中 scandir 背后的想法,并为 2015 年发布的 Python 3.5 实现了它(可在此了解更多相关过程)。此后它也得到了持续的改进和增强:例如支持了 with 语句和文件描述符。
至于 Go,除了基于自己在 Python 版本上的经验提了几条 关于 改进的评论外,我并未参与提案或实现。
Python 与 Go 对比
我们来看看新的“读取目录”接口,特别是 Python 和 Go 中的接口有多么相似。
在 Python 中,你调用 os.scandir(path),它会返回一个由 os.DirEntry 对象组成的迭代器,其定义如下:
class DirEntry:
# This entry's filename.
name: str
# This entry's full path: os.path.join(scandir_path, entry.name).
path: str
# Return inode or file ID for this entry.
def inode(self) -> int: ...
# Return True if this entry is a directory.
def is_dir(self, follow_symlinks=True) -> bool: ...
# Return True if this entry is a regular file.
def is_file(self, follow_symlinks=True) -> bool: ...
# Return True if this entry is a symbolic link.
def is_symlink(self) -> bool: ...
# Return stat information for this entry.
def stat(self, follow_symlinks=True) -> stat_result: ...访问 name 和 path 属性永远不会抛出异常,但方法调用可能会抛出 OSError,具体取决于操作系统和文件系统,以及该条目是否为符号链接。例如,在 Linux 上,stat 总会执行一次系统调用,因此可能会抛出异常,而 is_X 系列方法通常不会。
在 Go 中,你调用 os.ReadDir(path),它会返回一个由 os.DirEntry 对象组成的切片,其定义如下:
type DirEntry interface {
// Returns the name of this entry's file (or subdirectory).
Name() string
// Reports whether the entry describes a directory.
IsDir() bool
// Returns the type bits for the entry (a subset of FileMode).
Type() FileMode
// Returns the FileInfo (stat information) for this entry.
Info() (FileInfo, error)
}你可以立刻看出两者之间的相似之处,不过秉承 Go 一贯的风格,Go 的版本要简洁一些。事实上,如果让我再做一次 Python 的 scandir,我可能会力推一个更简单的接口——特别是去掉 follow_symlinks 参数,并默认不跟随符号链接。
下面是一个使用 os.scandir 的示例——一个递归计算目录及其子目录中所有文件总大小的函数:
def get_tree_size(path):
total = 0
with os.scandir(path) as entries:
for entry in entries:
if entry.is_dir(follow_symlinks=False):
total += get_tree_size(entry.path)
else:
total += entry.stat(follow_symlinks=False).st_size
return total在 Go 中(等 1.16 发布后)则会是这样:
func GetTreeSize(path string) (int64, error) {
entries, err := os.ReadDir(path)
if err != nil {
return 0, err
}
var total int64
for _, entry := range entries {
if entry.IsDir() {
size, err := GetTreeSize(filepath.Join(path, entry.Name()))
if err != nil {
return 0, err
}
total += size
} else {
info, err := entry.Info()
if err != nil {
return 0, err
}
total += info.Size()
}
}
return total, nil
}两者的高层结构很相似,不过当然会有人说:“看,Go 的错误处理引入了多少样板代码!”这话没错——Python 的代码非常简洁。在一个小脚本里这样写完全没问题,而这正是 Python 的长处所在。
然而,在生产代码或健壮的命令行工具中,你会希望捕获 stat 调用周围的错误,或许还需要忽略权限错误或将其记录下来。Go 的代码明确体现了可能发生错误的事实,也让你可以轻松地加入日志记录或更友好的错误提示。
更高层的目录树遍历
此外,两种语言都有用于递归遍历目录树的高层函数。在 Python 中,这就是 os.walk。Python 中 scandir 的精妙之处在于,os.walk 的签名无需改动,因此所有现有的 os.walk 用户(数量众多)都能自动获得性能提升。
例如,使用 os.walk 打印目录树中所有非点号开头的文件路径:
def list_non_dot(path):
paths = []
for root, dirs, files in os.walk(path):
# Modify dirs to skip directories starting with '.'
dirs[:] = [d for d in dirs if not d.startswith('.')]
for f in files:
if f.startswith('.'):
continue
paths.append(os.path.join(root, f))
return sorted(paths)自 Python 3.5 起,os.walk 底层改用 scandir 而非 listdir,这段代码无需任何改动就能神奇地快上 1.5 到 20 倍,具体取决于操作系统和文件系统。
Go(1.16 之前)也有一个类似的函数 filepath.Walk,但遗憾的是,FileInfo 接口在设计时并未考虑让其各种方法调用能够报告错误。如我们所见,这些调用有时会执行系统调用——例如,像 Size 这样的 stat 信息在 Linux 上就总是需要一次系统调用。因此在 Go 中,这些方法需要返回错误(而在 Python 中则是抛出异常)。
曾有人想过暂时忽略错误处理,直接复用 FileInfo 接口,这样现有代码就能神奇地获得加速。事实上,议题 41188 就是 Russ Cox 提出的这样一个建议(还附带了一些数据来说明这个想法听起来并没有那么糟)。然而,stat 确实会返回错误,因此可能会出现出错时文件大小被返回为 0 等情况。结果,试图将其硬塞进现有 API 的做法遭到了强烈的反对,Russ 最终也承认未能达成共识,转而提出了 DirEntry 接口。
这意味着,要获得性能提升,需要将 filepath.Walk 调用改为 filepath.WalkDir——两者非常相似,只是遍历函数接收的是 DirEntry 而非 FileInfo。
以下是使用现有的 filepath.Walk 函数实现的 Go 版 list_non_dot:
func ListNonDot(path string) ([]string, error) {
var paths []string
err := filepath.Walk(path, func(p string, info os.FileInfo,
err error) error {
if strings.HasPrefix(info.Name(), ".") {
if info.IsDir() {
return filepath.SkipDir
}
return err
}
if !info.IsDir() {
paths = append(paths, p)
}
return err
})
return paths, err
}当然,这段代码在 Go 1.16 中仍能继续工作,但如果你想获得性能收益,就需要做一些非常小的改动——在这个例子中,只需将 Walk 改为 WalkDir,并将 os.FileInfo 改为 os.DirEntry:
err := filepath.WalkDir(path, func(p string, info os.DirEntry,顺带一提,在 Linux 上对我的家目录运行第一个函数,在缓存就绪的情况下大约需要 580 毫秒。而使用 Go 1.16 的新版本大约需要 370 毫秒——快了约 1.5 倍。差别不算巨大,但也值得——而在网络文件系统和 Windows 上,你会获得大得多的加速。
总结
新的 ReadDir API 易于使用,并且通过 fs.ReadDir 与新的文件系统接口很好地集成在一起。而要加速现有的 Walk 调用,切换到 WalkDir 所需的改动也微不足道。
API 设计很难。与跨平台、操作系统相关的 API 设计就更难了。在设计下一门编程语言的标准库时,一定要把这点做好!:-)
无论如何,我很高兴 Go 对目录读取的支持将不再落后——或者说不再“步履蹒跚”——于 Python。
随机一篇博客
评论
登录后参与讨论