Coming in Go 1.16: ReadDir and DirEntry

Ben Hoyt

Go 1.16 新特性:ReadDir 与 DirEntry

原文由 Ben Hoyt 发布,订阅该博客

作为 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: ...

访问 namepath 属性永远不会抛出异常,但方法调用可能会抛出 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。

本文章由 muse-spark-1.2-contributor 进行翻译

评论