golang对遍历目录操作的优化

news/2024/11/15 16:54:49/文章来源:https://www.cnblogs.com/apocelipes/p/18334195

一转眼go1.23都快发布了,时间过得真快。

不过今天我们把时间倒流回三年半之前,来关注一个在go1.16引入的关于处理目录时的优化。

对于go1.16的新变化,大家印象最深的可能是io包的大规模重构,但这个重构实际上还引进了一个优化,这篇文章要说的就是这个优化。

本文默认Linux环境,不过这个优化在BSD系统上也是通用的。

遍历目录时的优化

go1.16对于遍历目录增加了几个新接口:os.ReadDir(*os.File).ReadDirfilepath.WalkDir

这几个接口最大的特征是对目录项使用fs.DirEntry表示而不是os.FileInfofs.DirEntry是一个接口,它提供了类似os.FileInfo的方法:

type DirEntry interface {Name() stringIsDir() boolType() FileModeInfo() (FileInfo, error)
}

它还提供了一个叫Info的方法以便获得os.FileInfo

这个接口有什么神奇的呢?我们看下性能测试:

func IterateDir(path string) int {// go1.16 的 os.ReadDir 就是这么实现的,为了测试我们把它展开成对(*os.File).ReadDir的调用f, err := os.Open(path)if err != nil {panic(err)}defer f.Close()files, err := f.ReadDir(-1)if err != nil {panic(err)}length := 0for _, finfo := range files {length = max(length, len(finfo.Name()))}return length
}func IterateDir2(path string) int {// 1.16之前遍历目录的常用方法之一f, err := os.Open(path)if err != nil {panic(err)}defer f.Close()files, err := f.Readdir(-1)if err != nil {panic(err)}length := 0for _, finfo := range files {length = max(length, len(finfo.Name()))}return length
}func BenchmarkIter1(b *testing.B) {for range b.N {IterateDir("../test")}
}func BenchmarkIter2(b *testing.B) {for range b.N {IterateDir2("../test")}
}

test目录是一个有5000个文件的位于Btrfs文件系统上的目录,我们的测试用例会遍历目录并找出名字最长的文件的文件名长度。

这是测试结果:

可以看到优化后的遍历比原先的快了480%。换了个函数为什么就会有这么大的提升?想知道答案的话就继续看吧。

优化的原理

继续深入前我们先看看老的接口是怎么获取到目录里的文件信息的。答案是遍历目录拿到路径,然后调用os.Lstat获取完整的文件信息:

func (f *File) Readdir(n int) ([]FileInfo, error) {if f == nil {return nil, ErrInvalid}_, _, infos, err := f.readdir(n, readdirFileInfo)if infos == nil {// Readdir has historically always returned a non-nil empty slice, never nil,// even on error (except misuse with nil receiver above).// Keep it that way to avoid breaking overly sensitive callers.infos = []FileInfo{}}return infos, err
}

这个f.readdir会根据第二个参数的值来改变自己的行为,根据值不同它会遵循1.16前老代码的行为或者采用新的优化方法。这个函数不同系统上的实现也不同,我们选则*nix系统上的实现看看:

func (f *File) readdir(n int, mode readdirMode) (names []string, dirents []DirEntry, infos []FileInfo, err error) {...for n != 0 {// 使用系统调用获得目录项的数据// 目录项的元信息一般是存储在目录本身的数据里的,所以读这些信息和读普通文件很类似if d.bufp >= d.nbuf {d.bufp = 0var errno errord.nbuf, errno = f.pfd.ReadDirent(*d.buf)runtime.KeepAlive(f)if errno != nil {return names, dirents, infos, &PathError{Op: "readdirent", Path: f.name, Err: errno}}if d.nbuf <= 0 {break // EOF}}buf := (*d.buf)[d.bufp:d.nbuf]reclen, ok := direntReclen(buf)if !ok || reclen > uint64(len(buf)) {break}// 注意这行rec := buf[:reclen]if mode == readdirName {names = append(names, string(name))} else if mode == readdirDirEntry {// 这里的代码后面再看} else {info, err := lstat(f.name + "/" + string(name))if IsNotExist(err) {// File disappeared between readdir + stat.// Treat as if it didn't exist.continue}if err != nil {return nil, nil, infos, err}infos = append(infos, info)}}if n > 0 && len(names)+len(dirents)+len(infos) == 0 {return nil, nil, nil, io.EOF}return names, dirents, infos, nil
}

ReadDirent对应的是Linux上的系统调用getdents,这个系统调用会把目录的目录项信息读取到一块内存里,之后程序可以解析这块内存里的数据来获得目录项的一些信息,这些信息一般包括了文件名,文件的类型,文件是否是目录等信息。

老代码在读取完这些信息后会利用文件名再次调用lstat,这个也是系统调用,可以获取更完整的文件信息,包括了文件的拥有者,文件的大小,文件的修改日期等。

老的代码有啥问题呢?大的问题不存在,接口也算易用,但有些小瑕疵:

  1. 大多数时间遍历目录主要是要获得目录中文件的名字或者类型等属性,显然os.FileInfo返回的信息过多了。这些用不着的信息会浪费不少内存,获取这些信息也需要额外花时间——lstat需要去进行磁盘io才能得到这些信息,而目录里的文件不像目录项信息那样紧密的存储在一起,它们是分散的,所以一一读取它们的元信息带来的负担会很大。
  2. 使用的系统调用太多了。由于我们测试目录的文件很多,但getdents可能要调用多次,这里假设为两次好了。对于每一个目录项,都需要用lstat去获取文件的详细信息,这样又有5000次系统调用,加起来是5002次。系统调用的开销是很大的,积累到5000多次则会带来肉眼可见的性能下降。实际上linux本身对lstat有优化,不会真的出现要反复进入系统调用5000次的情况,但几十到上百次还是需要的。

优化的代码其实只改了一行,是f.readdir(n, readdirDirEntry),第二个参数变了。新代码会走上面注释掉的那段逻辑:

// rec := buf[:reclen] 防止你忘了rec是哪来的
de, err := newUnixDirent(f.name, string(name), direntType(rec))
if IsNotExist(err) {// File disappeared between readdir and stat.// Treat as if it didn't exist.continue
}
if err != nil {return nil, dirents, nil, err
}
dirents = append(dirents, de)

取代lstat的是函数newUnixDirent,这个函数可以不依赖额外的系统调用获取文件的一部分元数据:

type unixDirent struct {parent stringname   stringtyp    FileModeinfo   FileInfo
}func newUnixDirent(parent, name string, typ FileMode) (DirEntry, error) {ude := &unixDirent{parent: parent,name:   name,typ:    typ,}// 检测文件类型信息是否有效if typ != ^FileMode(0) && !testingForceReadDirLstat {return ude, nil}info, err := lstat(parent + "/" + name)if err != nil {return nil, err}ude.typ = info.Mode().Type()ude.info = inforeturn ude, nil
}

文件名和类型都是在解析目录项时就得到的,因此直接设置就行。不过不是每个文件系统都支持在目录项数据里存储文件类型,所以代码里做了回退,一旦发现文件类型是无效数据就会使用lstat重新获取信息。

如果只使用文件名和文件的类型这两个信息,那么整个遍历的逻辑流程到这就结束了,文件系统提供支持的情况下不需要调用lstat。所以整个遍历只需要两次系统调用。这就是为什么优化方案会快接近五倍的原因。

对于要使用其他信息比如文件大小的用户,优化方案实际上也有好处,因为现在lstat是延迟且按需调用的:

func (d *unixDirent) Info() (FileInfo, error) {if d.info != nil {return d.info, nil}// 只会调用一次return lstat(d.parent + "/" + d.name)
}

这样也能尽量减少不必要的系统调用。

所以整体优化的原理是:尽量充分利用文件系统本身提供的信息+减少系统调用。要遍历的目录越大优化的效果也越明显。

优化的支持情况

上面也说了,能做到优化需要文件系统把文件类型信息存储在目录的目录项数据里。这个需要文件系统的支持。

如果文件系统不支持的话最后还是需要依赖lstat去读取具体文件的元数据。

不同文件系统的信息实在太分散,还有不少过时的,所以我花了几天看代码+查文档做了下整理:

  1. btrfs,ext2,ext4:这个几个文件系统支持优化,man pages加文件系统代码都能证实这一点
  2. OpenZFS:这个文件系统不在Linux内核里,所以man pages里没提到,但也支持优化
  3. xfs:支持优化,但得在创建文件系统时使用类似mkfs.xfs -f -n ftype=1的选项才行
  4. F2FS,EROFS:文档没提过,但看内核的代码里是支持的,代码的位置在xxx_readdir这个函数附近。
  5. fat32,exfat:文档没提过,但看内核代码发现是支持的,不过fat家族的文件类型没有那么多花样,只有目录和普通文件这两种,所以代码里很粗暴的判断目录项是否设置了dir标志,有就是目录没有统统算普通文件。这么做倒是正常的,因为fat本来就不支持别的文件类型,毕竟这个文件系统连软链接都不支持,更不用指望Unix Domain Socket和命名管道了。
  6. ntfs:支持,然而如注释所说,因为ntfs和其他文件系统处理type的方式不一样,导致虽然文件系统本身支持大部分文件类型,但type信息里只能获得文件是不是目录。所以它后面对于不是目录的文件会去磁盘上读取文件的inode然后再从inode里获取文件类型——实际上相当于执行了一次lstat,相比lstat减少了进入系统调用时的一次上下文切换,所以ntfs上优化效果会不如其他文件系统。

这么一看的话基本上主流的常见的文件系统都支持这种优化。

这也是为什么go1.16会引入这个优化,不仅支持广泛而且提升很大,免费的加速谁不爱呢。

别的语言里怎么利用这个优化

看到这里,你应该发现这个优化其实是系统层面的,golang只不过是适配了一下而已。

确实是这样的,所以这个优化不光golang能吃到,c/c++/python都行。

先说说c里怎么利用:直接用系统提供的readdir函数就行,这个函数会调用getdents,然后就能自然吃到优化了。注意事项和go的一样,需要检测文件系统是否支持设置d_type。

c++:和c一样,另外libstdc++的filesystem就是拿readdir实现的,所以用filesystem标准库也能获得优化:

// https://github.com/gcc-mirror/gcc/blob/master/libstdc++-v3/src/filesystem/dir-common.h#L270
inline file_type
get_file_type(const std::filesystem::__gnu_posix::dirent& d [[gnu::unused]])
{
#ifdef _GLIBCXX_HAVE_STRUCT_DIRENT_D_TYPEswitch (d.d_type){case DT_BLK:return file_type::block;case DT_CHR:return file_type::character;case DT_DIR:return file_type::directory;case DT_FIFO:return file_type::fifo;case DT_LNK:return file_type::symlink;case DT_REG:return file_type::regular;case DT_SOCK:return file_type::socket;case DT_UNKNOWN:return file_type::unknown;default:return file_type::none;}
#elsereturn file_type::none;
#endif
}// 如果操作系统以及文件系统不支持,则回退到lstat
// https://github.com/gcc-mirror/gcc/blob/master/libstdc++-v3/include/bits/fs_dir.h#L342
file_type
_M_file_type() const
{if (_M_type != file_type::none && _M_type != file_type::symlink)return _M_type;return status().type();
}

唯一的区别在于如果目标文件是软连接,也会调用stat。

python:使用os.scandir可以获得优化,底层和c一样使用readdir:https://github.com/python/cpython/blob/main/Modules/posixmodule.c#L16211,实现方法甚至类名都和golang很像,代码就不贴了。

总结

go虽然性能上一直被诟病,但在系统编程上倒是不含糊,基本常见的优化都有做,可以经常关注下新版本的release notes去看看go在这方面做的努力。

看着简单的优化,背后的可行性验证确实很复杂的,尤其是不同文件系统在怎么存储额外的元数据上很不相同,光是看代码就花了不少时间。

前面提到的ntfs在优化效果上会打点折扣,所以我特意拿Windows设备测试了下,测试条件不变:

可以看到几乎没什么区别。如果不是看了linux的ntfs驱动,我是不知道会产生这样的结果的。所以这个优化Windows上效果不理想,但在Linux和MacOS上是适用的。

大胆假设,小心求证,系统编程和性能优化的乐趣也正在于此。

参考

exfat的fuse驱动填充d_type的逻辑:https://github.com/relan/exfat/blob/master/libexfat/utils.c#L28

Linux的ntfs驱动需要获取文件的inode才能得到正确的file type:https://github.com/torvalds/linux/blob/master/fs/ntfs3/dir.c#L337

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/774491.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

老旧 Linux 系统搭建现代 C++ 开发环境 —— 基于 neovim

一台 CentOS7 老机器,能不能打造基于 vim 的现代化 IDE?本文以实操为主,带你一步步搭建自己的环境问题背景 公司配发的电脑是 macOS,日常开发需要访问 Linux 虚拟机,出于安全方面的考虑,只能通过跳板机登录。这阻止了大多数远程图形界面的使用,让写代码的工作变得复杂起…

UnitTest

UnitTest框架 是Python自带的单元测试框架,也可以用来做自动化测试(管理和执行用例) 核心要素(组成): 1、TestCase(测试用例) 2、TestSuite(测试套件):打包TestCase 3、TestRunner(测试执行):执行Testsuite 4、TestLoader(测…

帝国CMS网站什么是远程保存忽略地址?

什么是远程保存忽略地址?在此列表里的网站/地址将不会将文件远程保存本地扫码添加技术【解决问题】专注中小企业网站建设、网站安全12年。熟悉各种CMS,精通PHP+MYSQL、HTML5、CSS3、Javascript等。承接:企业仿站、网站修改、网站改版、BUG修复、问题处理、二次开发、PSD转HT…

You have an error in your SQL syntax. Check the manual that corresponds to your MySQL server version

错误记录: You have an error in your SQL syntax. Check the manual that corresponds to your MySQL server version for the right syntax to use near DEFAULT CHARSET=gbk at line 51 错误原因: 你新服务器上使用的是mysql4.0,而你原服务器使用的是4.1以上版本的原故。…

Got error 28 from table handler

错误记录: Got error 28 from table handler 错误原因: 数据库所在磁盘空间已满。 解决方案: 1.虚拟主机用户请联系空间商增加 MySQL 所在的磁盘空间或者清理一些无用文件; 2.独立主机用户请联系服务器管理员增加 MySQL 所在的磁盘空间或者清理一些无用文件。一般网站日志文…

帝国CMS网站采集入库错误 Fatal error: Call to undefined function: imagettfbbox() in ..../e/class/gd.php ..

采集入库错误 Fatal error: Call to undefined function: imagettfbbox() in ..../e/class/gd.php ..解答:你的空间的gd库不支持文字水印,推荐采用图片水印。扫码添加技术【解决问题】专注中小企业网站建设、网站安全12年。熟悉各种CMS,精通PHP+MYSQL、HTML5、CSS3、Javascr…

discuz数据库配置文件是哪个?怎样修改数据库配置信息?

网站系统需要修改的位置有两处 Discuz 和 UC-center  ①路径:/wwwroot/config/config_global.php 这个根据你网站安装的路径而定。  打开 config_global.php 文件修改:$_config[db][1][dbpw] = 原来密码;  原来密码 修改为新的数据库密码。  ②路径:/wwwroot/uc_serv…

matlab将.m文件导出dll

根据项目需要,需要这边引用matlab的函数进行数据处理,首先考虑是将matlab代码直接导出成c/c++代码,但是由于matlab代码中引用了其他的matlab库函数,无法直接导成c/c++代码,退而求其次,转而考虑导出为dll文件 1、需要安装matlab和vs,目前分别是2016版本和2022版本 导出步…

HR 专业人士: 持续绩效考核指南 CPM

这可能有悖于直觉,但解决每个人似乎都不喜欢绩效评估的问题,就是要有更多的绩效评估。如果你的组织在过去使用过绩效考核,而且是一种不怎么好的体验,那你就来对地方了。好消息是你并不孤单。害怕绩效考核的过程,以及对其效果不甚满意,是一个常见的问题。 不过,如果你让我…

计算机网络第四讲 网络层

计算机网络第四讲 网络层第一节:网络层概述 1. 网络层概述2. 网际协议IP3. 小结第二节:IP地址\(\bigstar \bigstar \bigstar \bigstar \bigstar\) 1. IP地址2. 基本分类IP地址特殊IP 全零:本,作为源地址 全1:全网广播,作为目的地址 网络号:127作为本地换回测试3. 划分子…

加密解密你了解多少?

这个题目一写出来,笔者自己也思考了下自己在以前职业生涯中涉及到的加密解密技术,也思考了自己熟知的公知度高的几种加密方式。 下面我来说说一些理解上的东西。 加密解密中间参与的是一个算法,有这个算法规律你才可以将简单的语言或者数据转换成只有了解此算法的人才能看懂…

【YashanDB知识库】IMP跨网络导入慢问题

问题现象 问题单:imp性能慢-通过异机导入性能下降太多-镜像环境可重现 现象: 同样一份数据290M, 在同一个机器本地导入,耗时2分钟多,本机用ip连接导入耗时4分钟多, 跨机器导入,耗时17分钟多,客户现场耗时30分钟多。 问题风险及影响 客户导入慢 问题影响的版本 客户测试…