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

4 回复

嗨,@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显示:

  1. 在(B)情况下,newfstatat函数调用耗时9微秒,但在(A)情况下耗时100微秒
  2. futex调用次数从(B)中的10次(不是1万次,只有10次)增加到(A)中的35万次,占用了运行时间的一分钟
  3. 详细的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)
}

关键优化点:

  1. 避免重复路径拼接:使用预分配的字节数组,避免每次循环都进行字符串拼接
  2. 使用fstatat替代lstat:通过目录文件描述符直接调用,减少路径解析开销
  3. 直接系统调用:使用syscall.Syscall6直接调用fstatat,避免Go运行时的额外开销
  4. 内存复用:复用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运行时对系统调用的封装开销和内存分配策略。

回到顶部