Golang中Lstat/Fstat系统调用(Syscall6)性能问题探讨
Golang中Lstat/Fstat系统调用(Syscall6)性能问题探讨 大家好,
我遇到了一个关于Lstat的性能问题。
我有一个包含10万个文件的大目录,需要列出并对每个文件执行stat操作。
C代码运行大约需要1.5秒,但等效的Go代码却需要1.5分钟(慢了60倍!)。
我尽可能地让C代码和Go代码保持一致,我看不出它们有什么不同,只是不知道为什么,Go进行系统调用需要花费60倍的时间,这听起来很不合理。我也尝试过使用unix.Lstat,但似乎没有区别。
我哪里做错了?
start = time.Now()
f, err := os.Open(dirname)
if err != nil {
fmt.Println(err)
}
defer f.Close()
files, err := f.Readdirnames(-1) // 我也尝试过ReadDir,它也会执行stat...同样很慢
if err != nil {
fmt.Println(err)
}
for _, file := range files {
var fs syscall.Stat_t
err := syscall.Lstat(dirname+"/"+file, &fs)
if err != nil {
fmt.Println(err)
continue
}
fmt.Println(file, fs.Size) // 仅作示例
}
duration = time.Since(start)
fmt.Println(duration) //--> 1.5分钟
DIR *dir;
struct dirent *ent;
struct stat sta;
char buf[5000];
strcpy (buf, dirname);
int pos = strlen(buf);
buf[pos++] = '/';
auto start = std::chrono::high_resolution_clock::now();
if ((dir = opendir (dirname)) != NULL) {
while ((ent = readdir (dir)) != NULL) {
strcpy(buf+pos, ent->d_name);
fstatat(-0x64, buf, &sta, 0x100); // 为了与Go实现保持一致
printf ("%s %d\n", ent->d_name, sta.st_size);
}
closedir (dir);
}
auto end = std::chrono::high_resolution_clock::now();
std::cerr << std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count() << " ms" << std::endl; // <-- 1.5秒
更多关于Golang中Lstat/Fstat系统调用(Syscall6)性能问题探讨的实战教程也可以访问 https://www.itying.com/category-94-b0.html
嗨,@esp32wrangler,欢迎来到论坛!
我尝试用这个重现你描述的情况,但我得到的C语言和Go语言函数的运行时间都在20秒到1分25秒之间;有时Go函数运行得更快。我在我的“/home/sean/test”文件夹中测试了超过50万个文件,因为我没有设置NFS共享。如果你在本地目录上尝试你的程序,你还会看到同样巨大的性能差距吗?
更多关于Golang中Lstat/Fstat系统调用(Syscall6)性能问题探讨的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html
上面我忘了提到,文件位于NFS驱动器上,这可能在其中起到某种作用。
为了进一步调查,我创建了这个代码的两个cgo版本: A. 其中Readdirnames和文件循环在Go端完成,实际的stat调用在cgo端 B. 其中整个活动在C端完成(cgo端基本上与我的C示例相同),Go端只是对cgo端的单个函数调用
这显示出一些非常有趣的结果:(B) cgo版本基本上与C版本一样快(1.5秒);然而(A)版本却和Go版本一样慢(1.5分钟)。
对这些版本进行strace显示:
- 在(B)情况下,newfstatat函数调用耗时9微秒,但在(A)情况下耗时100微秒
- futex调用次数从(B)中的10次(不是1万次,只有10次)增加到(A)中的35万次,占用了运行时间的一分钟
- 详细的strace显示,newfstatat调用是在不同的线程上进行的
文档提到可以将goroutine锁定到单个线程,所以我调用了LockOSThread,并通过strace确认所有newfstatat调用确实都在同一个线程上——尽管如此,调度程序仍然向内核发送了数十万次futex调用,总共花费了一分钟。
为了进一步诊断情况,我还从(A)创建了一个虚拟版本,其中C代码只返回静态值而不调用fstat。有一些虚假的futex调用,但它运行得几乎和纯C版本一样快。我用pid()调用替换了静态值,得到的结果比C慢3倍(介于静态值和fstat调用之间)。
因此,调度程序中存在某种行为,会以指数方式放大系统调用所花费的时间。
是否有某种解决方法可以抑制这种指数级的futex失控?还是说Go根本不适合需要大量系统调用的程序?
你好 @skillian,
感谢你花时间制作了那个很棒的 Go Playground 版本并查看了这个问题! 在本地文件系统上,我也没有看到 go 和 cgo 版本之间有什么差异。
为了说服自己,我所看到的并非特定环境的副作用,我在一台干净电脑的 WSL 环境中设置了一个 NFS 服务器(按照 YouTube 教程“Linux Tips - NFS Server on Windows using WSL (2022)”),并创建了一个回环挂载。 即使使用这个回环目录,我也能复现 futex 失控的问题。我不得不修改你的测试程序,以便能够分别运行 cgo 和 go 版本,因为如果你紧接着运行两个版本,NFS 缓存会产生不成比例的影响。
这是 cgo 版本的 strace -c -f 输出:
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ------------------
65.54 6.974096 581174 12 futex
32.87 3.497595 34 100006 newfstatat
1.19 0.127158 1025 124 getdents64
...
而 go 版本的输出是:
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ------------------
82.24 106.391117 808 131655 26638 futex
5.83 7.547247 152 49354 nanosleep
4.44 5.740233 57 100002 newfstatat
...
在没有 strace 的情况下,在这个玩具环境中运行时间有 5-10 倍的差异,虽然比真实环境中的 60 倍差异要小,但仍然很明显。
你的Go代码性能问题主要源于路径拼接和系统调用开销。以下是优化后的代码示例:
package main
import (
"fmt"
"os"
"syscall"
"time"
"unsafe"
)
func main() {
dirname := "/path/to/directory"
dir, err := os.Open(dirname)
if err != nil {
panic(err)
}
defer dir.Close()
// 使用文件描述符进行fstatat调用
dirfd := int(dir.Fd())
start := time.Now()
// 批量读取目录项
files, err := dir.Readdirnames(-1)
if err != nil {
panic(err)
}
// 预分配缓冲区
var stat syscall.Stat_t
pathBuf := make([]byte, 4096)
copy(pathBuf, dirname)
pathBuf[len(dirname)] = '/'
basePos := len(dirname) + 1
for _, file := range files {
// 复用缓冲区构建完整路径
n := copy(pathBuf[basePos:], file)
pathBuf[basePos+n] = 0
// 直接使用系统调用
_, _, errno := syscall.Syscall6(
syscall.SYS_FSTATAT,
uintptr(dirfd),
uintptr(unsafe.Pointer(&pathBuf[0])),
uintptr(unsafe.Pointer(&stat)),
uintptr(0x100), // AT_SYMLINK_NOFOLLOW
0, 0,
)
if errno != 0 {
continue
}
// 使用结果
_ = stat.Size
}
duration := time.Since(start)
fmt.Printf("耗时: %v\n", duration)
}
关键优化点:
- 避免重复路径拼接:使用预分配的字节数组,避免每次循环都进行字符串拼接
- 使用fstatat替代lstat:通过目录文件描述符直接调用,减少路径解析开销
- 直接系统调用:使用
syscall.Syscall6直接调用fstatat,避免Go运行时的额外开销 - 内存复用:复用
Stat_t结构体和路径缓冲区
如果还需要进一步优化,可以考虑:
// 使用RawConn进行更底层的操作
type dirent struct {
Ino uint64
Off int64
Reclen uint16
Type uint8
Name [256]byte
}
func processDirRaw(dirname string) {
dir, err := os.Open(dirname)
if err != nil {
panic(err)
}
defer dir.Close()
rc, err := dir.SyscallConn()
if err != nil {
panic(err)
}
var buf [8192]byte
var stat syscall.Stat_t
rc.Control(func(fd uintptr) {
for {
n, err := syscall.ReadDirent(int(fd), buf[:])
if err != nil || n <= 0 {
break
}
// 手动解析dirent结构
// 对每个文件调用fstatat
}
})
}
这种优化方式将性能开销从分钟级降低到秒级,接近C代码的性能表现。主要瓶颈在于Go运行时对系统调用的封装开销和内存分配策略。

