Linux 基础体系 · 第 28/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux 系统调用与 strace:调用边界、阻塞、错误和性能诊断

1. 为什么需要理解“系统调用边界”

用户态程序不能直接执行内核中的文件系统、网络协议栈或进程调度代码。它必须通过**系统调用(system call)**请求内核代为完成特权操作。

系统调用边界不是一条抽象的函数调用边界,而是用户态与内核态之间的受控入口:

用户态程序
   │
   │ C 函数、C++ 封装、运行库
   ▼
libc 系统调用封装
   │
   │ syscall 指令、陷阱或架构相关入口
   ▼
内核系统调用入口
   │
   │ 参数检查、权限检查、对象查找、实际操作
   ▼
内核返回值
   │
   ▼
libc 转换错误并返回用户态

例如,应用调用:

ssize_t n = read(fd, buf, sizeof(buf));

通常会经过以下层次:

  1. 程序调用 libc 提供的 read() 包装函数。
  2. libc 按当前架构的系统调用 ABI 准备系统调用号和参数。
  3. CPU 执行 syscall 等架构指令,进入内核。
  4. 内核根据系统调用号分派到对应实现。
  5. 内核访问文件对象、页缓存、设备驱动或网络协议栈。
  6. 内核返回成功结果或负的错误码。
  7. libc 将错误结果转换为 -1,并设置线程局部变量 errno

read() 这个 C 函数和名为 read 的系统调用通常相关,但二者不是同一个概念。libc 函数可能增加参数校验、线程取消处理、兼容性逻辑,也可能根本不触发系统调用。例如:

  • strlen() 通常完全在用户态执行;
  • gettimeofday() 在很多架构上可能通过 vDSO 在用户态读取内核维护的数据;
  • printf() 可能先写入用户态缓冲区,只有缓冲区刷新时才调用 write()
  • fopen() 通常会进一步调用 openat(),但它还负责创建 FILE 对象和 stdio 缓冲。

因此,strace 看到的是系统调用边界附近的行为,不是所有 C 函数调用。


2. 一次系统调用如何跨越用户态和内核态

2.1 系统调用 ABI

系统调用 ABI(Application Binary Interface)规定了至少以下内容:

  • 系统调用号放在哪个寄存器;
  • 参数放在哪些寄存器;
  • 返回值放在哪个寄存器;
  • 如何保存和恢复用户态寄存器;
  • 用户地址如何传递给内核;
  • 错误如何编码。

以 x86-64 Linux 为例,常见规则是:

内容 典型位置
系统调用号 rax
第 1 个参数 rdi
第 2 个参数 rsi
第 3 个参数 rdx
第 4 个参数 r10
第 5 个参数 r8
第 6 个参数 r9
返回值 rax

用户程序不应把这些规则当作跨架构接口。ARM64、RISC-V、x86-32 的寄存器和入口指令不同。直接使用 syscall() 或内联汇编也会绕过一部分 libc 逻辑,通常只适用于实验、底层运行库或需要特定系统调用的场景。

系统调用号同样是架构相关的。直接写死系统调用号会降低可移植性;如果系统调用已经有 libc 包装,优先使用标准接口通常更安全。

2.2 内核入口并不等于“立刻完成”

执行系统调用入口指令后,内核还需要完成一系列工作:

  1. 保存用户态寄存器和返回地址。
  2. 切换到内核栈。
  3. 根据系统调用号查找处理函数。
  4. 验证用户传入的地址、长度、标志和对象句柄。
  5. 检查权限、命名空间、资源限制和安全策略。
  6. 执行实际操作。
  7. 必要时睡眠等待其他事件。
  8. 返回结果并恢复用户态执行。

例如:

write(fd, buf, 4096);

并不意味着内核可以直接相信 buf 是有效地址。内核必须验证该地址属于调用进程可访问的用户空间,并在复制数据时处理缺页、并发解除映射或非法地址等情况。用户缓冲区通常通过类似 copy_from_user() 的机制访问,而不是直接当作内核指针使用。

这也是为什么把任意整数强制转换为指针并传给系统调用,可能得到 EFAULT,而不是让内核安全地“猜测”调用者的意图。

2.3 系统调用返回值与 errno

内核实现通常直接返回一个非负结果或负的错误码。例如,抽象地表示为:

内核返回:
    n >= 0        成功,n 可能是实际字节数或对象编号
    -EACCES       权限错误
    -ENOENT       对象不存在
    -EINTR        被信号中断
    -EAGAIN       当前不能立即完成

libc 包装函数通常把它转换为应用更熟悉的形式:

内核返回 -EACCES
        │
        ▼
libc 返回 -1,并设置 errno = EACCES

因此,应用应检查函数规定的返回值,再读取 errno

int fd = open("/root/secret", O_RDONLY);
if (fd == -1) {
    perror("open");
}

不能无条件读取 errno。如果函数成功,errno 的旧值可能仍然存在;errno 不是“最近一次操作是否失败”的全局状态,而是线程局部的错误码槽位。

还要区分“错误”和“正常的特殊结果”:

  • read() 返回 0 表示到达文件末尾,或管道、套接字已经观察到 EOF,不是错误;
  • write() 返回小于请求长度的正数,表示部分写入,不是自动失败;
  • 非阻塞文件描述符返回 -1/EAGAIN-1/EWOULDBLOCK,表示现在不能完成,不一定代表系统异常;
  • waitpid() 返回 0 在使用 WNOHANG 时表示当前没有可回收的子进程。

3. 用一个可运行程序观察边界、阻塞和错误

下面的程序同时包含:

  • 一个失败的 open(),用于观察错误;
  • 一个管道 read(),用于观察阻塞;
  • 子进程稍后写入数据,触发阻塞中的父进程继续运行;
  • 最后的 write(),用于观察用户态输出如何到达内核。
// syscall_demo.c
#define _GNU_SOURCE

#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    int fd = open("/definitely/not/exist", O_RDONLY);
    if (fd == -1) {
        fprintf(stderr, "open failed: %s\n", strerror(errno));
    } else {
        close(fd);
    }

    int pipefd[2];
    if (pipe(pipefd) == -1) {
        perror("pipe");
        return 1;
    }

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {
        const char message[] = "message from child\n";

        close(pipefd[0]);
        sleep(1);

        ssize_t n = write(pipefd[1], message, sizeof(message) - 1);
        if (n == -1) {
            perror("child write");
            _exit(1);
        }

        close(pipefd[1]);
        _exit(0);
    }

    close(pipefd[1]);

    char buffer[128];
    ssize_t n = read(pipefd[0], buffer, sizeof(buffer));
    if (n == -1) {
        perror("parent read");
        return 1;
    }

    if (write(STDOUT_FILENO, buffer, (size_t)n) == -1) {
        perror("stdout write");
        return 1;
    }

    close(pipefd[0]);

    if (waitpid(pid, NULL, 0) == -1) {
        perror("waitpid");
        return 1;
    }

    return 0;
}

编译和运行:

cc -O0 -g -Wall -Wextra -o syscall_demo syscall_demo.c
./syscall_demo

典型输出为:

open failed: No such file or directory
message from child

其中:

  • open() 失败是因为路径不存在,通常对应 ENOENT
  • 父进程关闭了管道写端后调用 read()
  • 此时管道为空,但子进程仍然持有写端,所以父进程不能把空管道当作 EOF;
  • 父进程进入睡眠;
  • 子进程等待约一秒后写入数据;
  • 内核唤醒等待中的父进程,父进程的 read() 返回;
  • 父进程再调用 write(1, ...) 将数据写到标准输出。

使用 strace 跟踪主进程及其子进程:

strace -f -tt -T -yy ./syscall_demo

参数含义:

  • -f:跟踪 fork()clone()vfork() 创建的子进程或线程;
  • -tt:在每行前显示更精细的时间戳;
  • -T:在系统调用行末显示该调用耗费的墙上时间;
  • -yy:尽可能把文件描述符解析为更具体的对象,例如管道两端或文件路径。

输出格式会因发行版、glibc、内核和 strace 版本不同而变化,但关键片段大致类似:

openat(AT_FDCWD, "/definitely/not/exist", O_RDONLY) = -1 ENOENT (No such file or directory)
pipe2([3, 4], 0)                    = 0
clone(...)                          = 12345
[pid 12344] read(3<pipe:[...], ..., 128) = 18 <1.000...>
[pid 12345] write(4<pipe:[...], "message from child\n", 18) = 18 <0.000...>
[pid 12344] write(1<pipe:[...], "message from child\n", 18) = 18 <0.000...>
[pid 12344] wait4(12345, ..., 0, NULL) = 12345

这里的行号、进程号、系统调用名称和参数表示方式可能不同。例如,libc 的 pipe() 在较新的 Linux 环境中可能最终显示为 pipe2(..., 0)。这不是程序语义改变,而是 libc 与内核接口选择不同。

read() 行末接近一秒,说明该调用从进入到返回之间包含了等待时间。这个时间不是“CPU 执行时间”,而是该系统调用在用户可见意义上的墙上耗时,可能包含调度、等待 I/O、等待锁或等待其他进程。


4. 阻塞系统调用:从“不能完成”到睡眠与唤醒

4.1 阻塞的严格含义

对一个可能阻塞的操作,可以把“立即完成的条件”记作 CC

例如,对管道读操作:

C = 管道中存在数据
    或所有写端都已关闭
    或发生了信号/错误

如果文件描述符是阻塞模式:

C 为真  →  系统调用完成并返回
C 为假  →  当前任务睡眠,等待 C 可能变为真

如果文件描述符设置了 O_NONBLOCK

C 为真  →  系统调用完成并返回
C 为假  →  立即返回 -1,并设置 EAGAIN/EWOULDBLOCK

这里的“阻塞”不是指 CPU 忙等。典型路径是:

  1. 进程进入系统调用;
  2. 内核检查当前对象暂时不能满足请求;
  3. 内核把进程加入等待队列;
  4. 进程状态变为可中断睡眠或不可中断睡眠等内核状态;
  5. 调度器运行其他可运行任务;
  6. 数据到达、设备完成操作或相关状态变化;
  7. 内核唤醒等待队列中的进程;
  8. 进程重新检查条件;
  9. 条件满足则返回,条件仍不满足则可能再次睡眠。

“唤醒”不等于“系统调用一定成功”。唤醒后可能发生竞争:另一个线程先取走了数据,或者设备状态又发生变化。因此内核通常需要重新检查条件,而不是假设一次唤醒就足够。

4.2 管道的完整状态例子

假设管道容量为 KK,当前数据量为 BB

对于阻塞读:

B > 0                  → 读取最多 requested 字节
B = 0 且写端仍存在     → 睡眠等待
B = 0 且所有写端关闭   → 返回 0,表示 EOF

对于阻塞写,若一次写入请求为 NN,剩余空间为 KBK-B,则是否阻塞还取决于管道写入的原子性规则和请求大小。不能简单概括为“有一个字节空间就一定写入全部数据”。大写入可能出现部分写入、等待或由内核按具体规则处理。

这个例子也说明了为什么父进程必须关闭自己的管道写端。若父进程保留一个写端,即使子进程退出,内核仍认为至少有一个写端存在,读端可能持续等待而不是返回 EOF。

4.3 文件、终端和套接字的差异

“文件描述符”只是进程中的整数句柄,不代表所有对象具有相同的阻塞行为。

  • 普通磁盘文件的 read() 往往不会因为“没有数据”而长期等待,因为文件长度是已知的;但读取可能等待页缓存填充、块设备完成 I/O 或文件系统锁。
  • 管道和 Unix socket 的读操作常因没有数据而阻塞。
  • TCP socket 的 read() 可能等待网络数据、对端关闭或错误。
  • 终端设备可能按行规程等待输入。
  • 某些设备文件的阻塞语义由驱动实现,不能只依据文件名判断。

因此,strace 中一个耗时很长的 read() 不一定意味着磁盘慢。它可能是在等待网络、管道生产者、终端输入、设备中断完成,或者内核内部资源。


5. 信号、EINTR 与自动重启

系统调用睡眠期间,如果进程收到信号,系统调用可能被中断并返回 -1/EINTR。但这不是所有情况下都必然发生。

是否返回 EINTR,通常受以下因素影响:

  • 信号是否实际递送;
  • 信号处理器是否安装;
  • 信号处理器的 sigaction() 标志是否包含 SA_RESTART
  • 具体系统调用是否支持自动重启;
  • 系统调用是否已经产生部分结果;
  • libc 是否对接口做了额外处理。

因此,下面这种写法对可能被信号中断的调用更稳妥:

ssize_t n;

do {
    n = read(fd, buffer, sizeof(buffer));
} while (n == -1 && errno == EINTR);

if (n == -1) {
    perror("read");
}

但不能机械地对所有系统调用无限重试。对于 read()write(),重试前必须考虑是否已经返回了部分数据;对于连接、发送、接收等接口,还要考虑操作是否已经造成了外部可见副作用。

strace 经常显示类似:

read(3, ..., 4096) = ? ERESTARTSYS (To be restarted if SA_RESTART is set)

ERESTARTSYS 等值主要是内核内部用于重启系统调用的状态,通常不会直接作为用户程序看到的 errno。如果信号处理返回后内核自动重启,最终的 strace 输出可能显示为:

read(3, ..., 4096) = 12

如果不能重启,则可能显示:

read(3, ..., 4096) = -1 EINTR (Interrupted system call)

这也是观察系统调用时必须区分“内核内部返回路径”和“最终用户态可见结果”的原因。


6. 非阻塞 I/O 与就绪通知

把文件描述符设为非阻塞模式,只改变“不能立即完成时的行为”,并不会让操作本身变快:

int flags = fcntl(fd, F_GETFL);
if (flags == -1) {
    perror("F_GETFL");
    return 1;
}

if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) {
    perror("F_SETFL");
    return 1;
}

设为非阻塞后,读取空管道的结果通常是:

read(...) = -1 EAGAIN (Resource temporarily unavailable)

应用一般配合 poll()select()epoll_wait() 等接口:

循环:
    等待多个 fd 就绪
    对报告就绪的 fd 执行非阻塞 read/write
    处理 EAGAIN、EOF 和部分读写

这里存在一个关键边界:就绪不等于操作必然成功,也不等于一次操作能够处理完全部数据

例如:

  1. epoll_wait() 报告 socket 可读;
  2. 线程 A 和线程 B 同时处理同一个 socket;
  3. 线程 A 先读完数据;
  4. 线程 B 随后调用 read()
  5. 线程 B 仍可能得到 EAGAIN

所以事件循环必须允许“收到就绪通知但实际读到 EAGAIN”这一正常路径。边沿触发(edge-triggered)模式尤其要求应用持续读取直到 EAGAIN,否则可能错过后续通知。


7. 错误诊断不能只看 errno

7.1 参数、权限、资源和时序错误

同一个系统调用失败,可能来自完全不同的层次:

参数错误       → EINVAL、EFAULT、EBADF
对象不存在     → ENOENT、ESRCH
权限或策略     → EACCES、EPERM、EKEYREJECTED 等
资源耗尽       → ENOMEM、EMFILE、ENFILE、ENOSPC
并发/时序      → EAGAIN、EBUSY、EINPROGRESS
信号干扰       → EINTR
协议或设备     → ECONNRESET、ETIMEDOUT、EIO

例如:

  • EACCES 常表示权限检查失败;
  • EPERM 可能表示需要的特权缺失,也可能是安全策略拒绝;
  • EMFILE 通常表示当前进程的文件描述符数量达到限制;
  • ENFILE 表示系统范围文件表资源紧张;
  • ENOSPC 不一定是磁盘字节耗尽,也可能是 inode 或特定文件系统资源耗尽;
  • ETIMEDOUT 说明协议或设备等待超过超时条件,但不直接证明远端机器宕机。

strace 可以显示系统调用和错误码,但不能仅凭一行错误码推断完整根因。需要结合:

cat /proc/$$/limits
cat /proc/sys/fs/file-nr
dmesg --level=err,warn
journalctl -k

这些命令的权限、日志配置和容器隔离会影响结果。生产环境中读取内核日志可能受到 dmesg_restrict 或 journald 权限控制。

7.2 部分结果必须由应用处理

以下代码存在逻辑错误:

write(fd, data, length);

它没有检查返回值。正确的基本模式至少要处理:

size_t written = 0;

while (written < length) {
    ssize_t n = write(fd, data + written, length - written);

    if (n > 0) {
        written += (size_t)n;
        continue;
    }

    if (n == -1 && errno == EINTR) {
        continue;
    }

    if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
        /* 对非阻塞 fd:等待就绪后继续 */
        break;
    }

    perror("write");
    break;
}

对于普通文件,短写可能少见但接口仍允许;对于 socket、管道和非阻塞 fd,部分写入是常见行为。对 write() 无条件重试还可能造成重复业务数据,尤其是在应用没有正确维护偏移量时。


8. strace 到底观察了什么

8.1 基本工作方式

常规 strace 主要通过 ptrace 机制跟踪进程。在系统调用进入和退出等事件处,跟踪器被内核通知,读取被跟踪任务的寄存器和部分内存,从而解码:

  • 系统调用名称;
  • 参数;
  • 返回值;
  • errno
  • 时间戳;
  • 进程或线程 ID;
  • 某些文件描述符指向的对象。

它观察的是系统调用边界,不是内核函数内部的每一步。比如它能看到:

read(5, ..., 4096) = 4096

但不能仅凭这一行知道:

  • 数据来自页缓存还是磁盘;
  • 是否经过了某个具体文件系统锁;
  • 哪个内核函数占用了 CPU;
  • 设备驱动内部经历了哪些状态;
  • 其他线程是否同时争用同一把锁。

因此,strace 适合回答“进程对内核请求了什么、何时请求、如何失败、等待了多久”,不适合单独回答所有内核内部性能问题。

8.2 跟踪一个命令

strace ./syscall_demo

默认输出通常写到标准错误。保存到文件:

strace -o trace.log ./syscall_demo

只观察部分系统调用:

strace -e trace=file ./program
strace -e trace=network ./program
strace -e trace=process ./program
strace -e trace=read,write,openat,close ./program

常用分类包括:

  • file:文件和目录相关调用;
  • network:套接字相关调用;
  • process:进程创建、等待和执行;
  • signal:信号相关调用;
  • memory:内存映射和保护相关调用。

分类名和可用过滤项可能随 strace 版本变化,应以目标机器上的:

strace -h
man strace

为准。

8.3 跟踪线程和子进程

现代程序通常包含线程、工作进程或运行库创建的辅助进程。不使用 -f 时,可能只看到主线程或被附加的那个任务:

strace -f -o trace.log ./server

如果希望每个任务单独保存输出:

strace -ff -o trace ./server

这通常会生成类似:

trace.1000
trace.1001
trace.1002

文件名中的数字是进程或线程 ID。分析并发行为时,必须结合时间戳和任务 ID;单个文件中的行序不能代替所有线程之间的全局时序。

8.4 附加到正在运行的进程

strace -p "$PID"

指定多个线程或进程时,需要注意附加目标和权限。常见限制包括:

  • ptrace_scope 等安全策略;
  • 被跟踪进程与当前用户不同;
  • 目标进程是 setuid/setgid 程序;
  • 容器的 PID 命名空间或 capability 限制;
  • 目标程序主动禁止某些调试操作。

附加操作本身也会改变目标进程的时序。正在阻塞的系统调用可能被停止、重启或受到信号相关影响。对低延迟服务进行附加前,应先确认生产风险和恢复方式。


9. 如何读懂一行 strace 输出

典型格式如下:

12:34:56.123456 openat(AT_FDCWD, "/etc/hosts", O_RDONLY|O_CLOEXEC) = 3 <0.000031>

可以分成几个部分:

时间戳
  │
系统调用和参数
  │
返回值
  │
耗时

具体解释:

  • AT_FDCWD 表示以当前工作目录作为相对路径解析基准;
  • O_CLOEXEC 表示在执行 execve() 时自动关闭该文件描述符;
  • = 3 表示成功返回文件描述符 3;
  • <0.000031> 表示从系统调用进入到返回约 31 微秒。

失败调用通常形如:

openat(AT_FDCWD, "/missing", O_RDONLY) = -1 ENOENT (No such file or directory)

这表示系统调用确实进入了内核,但对象查找失败。它不同于:

openat(...) = 3
read(3, ...) = -1 EACCES

后者表示打开动作成功,但后续读操作失败;故障位置已经不同。

读写参数可能被截断:

write(1, "very long ...", 4096) = 4096

为了控制输出量,可以使用:

strace -s 256 ./program

这里的 -s 控制字符串显示长度,不改变被跟踪程序实际传给内核的长度。

-v 可请求更详细的数据结构显示,-x 可显示十六进制内容,-yy 可增加文件描述符对象解析。这些选项会增加日志量,不能把“显示得更多”误认为“观察得更深入”。


10. 用 strace 定位阻塞点

当程序看起来“卡住”时,最先要回答的是:它是没有运行,还是正在等待某个内核条件。

可以先附加:

strace -tt -T -f -p "$PID"

如果看到:

read(7,  <unfinished ...>

或一个长期没有返回的:

read(7, ..., 4096

说明线程进入了 read(),当前尚未返回。进一步需要确定 fd 7 指向什么:

readlink /proc/"$PID"/fd/7
cat /proc/"$PID"/fdinfo/7

可能结果包括:

pipe:[123456]
socket:[654321]
/var/log/app.log
/dev/tty

/proc/PID/fd 显示进程文件描述符到内核对象的引用关系;fdinfo 还可能显示文件位置、打开标志、事件通知信息等。读取其他进程的 procfs 内容同样受权限限制。

从对象类型继续推导:

  • pipe:[...]:寻找生产者是否写入,或是否仍有写端未关闭;
  • socket:[...]:检查对端、连接状态、事件循环和超时设置;
  • 普通文件:考虑页缓存、存储设备、文件系统和锁;
  • /dev/*:查阅对应设备驱动的阻塞语义;
  • /dev/tty:确认是否等待终端输入。

也可以查看调度状态:

ps -o pid,tid,stat,wchan:32,comm -p "$PID"
cat /proc/"$PID"/wchan

wchan 反映任务当前可能等待的内核等待点,但符号名称受内核配置、权限和版本影响,不能当作稳定 API。它适合提供线索,最终仍应结合系统调用、内核事件和应用状态判断。

一个常见误判是:看到线程处于睡眠状态,就认为它“死锁”。等待管道数据、网络数据、定时器或设备完成都可能是正常睡眠;死锁需要进一步证明存在循环等待或永远不会满足的条件。


11. 用 strace 定位错误路径

对启动失败的程序,可以先缩小系统调用范围:

strace -f -e trace=file -o trace.file.log ./program

如果程序报告“找不到配置文件”,不要只搜索字符串,而要按路径和返回值检查:

grep -E 'openat|statx|access|readlink' trace.file.log
grep -E '= -1 (ENOENT|EACCES|ENOTDIR|ELOOP)' trace.file.log

例如:

openat(AT_FDCWD, "/etc/app/config.yaml", O_RDONLY|O_CLOEXEC) = -1 ENOENT
openat(AT_FDCWD, "./config.yaml", O_RDONLY|O_CLOEXEC) = 3

这说明程序先查找系统路径失败,再从当前目录成功打开。此时真正的根因可能是:

  • 工作目录和预期不同;
  • 服务管理器没有设置正确的 WorkingDirectory
  • 容器内没有挂载配置文件;
  • 访问的是相对路径;
  • 程序的搜索顺序与部署假设不同。

如果日志只显示 EACCES,应继续检查:

namei -l /path/to/file
ls -ld /path /path/to
getfacl /path/to/file

还要考虑 SELinux、AppArmor、mount namespace 和 seccomp 等安全机制。普通 Unix 权限正确,并不意味着安全模块一定允许操作。内核拒绝信息有时只会出现在审计日志或内核日志中。


12. 用统计模式寻找系统调用放大

单次跟踪适合看时序,统计模式适合看数量和累计时间:

strace -f -c ./program

典型输出包含:

% time     seconds  usecs/call     calls    errors syscall
------  ----------- ----------- --------- --------- --------
  ...

这些字段通常表示:

  • calls:调用次数;
  • errors:返回错误的次数;
  • usecs/call:平均每次耗时;
  • seconds:累计耗时;
  • % time:在统计的系统调用时间中的比例。

它可以快速暴露以下问题:

read/write 调用次数极高
stat/openat 在循环中反复出现
futex 调用很多且单次等待时间长
poll/epoll_wait 长时间等待

但统计结果不能直接作为“程序总耗时分解”。原因包括:

  1. 用户态计算时间不在系统调用累计时间中;
  2. -T 和统计模式测量的是系统调用边界附近的墙上时间;
  3. 多线程并发时,各线程耗时相加可能超过真实墙上时间;
  4. strace 自身会引入停止、寄存器读取和日志开销;
  5. 某些调用的时间包含睡眠,不等于 CPU 消耗;
  6. 系统调用内部的 CPU、I/O 等待和锁等待没有被进一步拆分。

例如,一个 read() 平均耗时 5 毫秒,可能是磁盘等待,也可能是网络延迟或管道生产者每 5 毫秒发送一次数据。要区分 CPU 与等待,应结合 perf、eBPF 工具、块层指标、网络指标和应用日志,而不能只看 strace -c


13. 性能诊断:先确定问题属于哪一类

系统调用性能问题通常至少有三种不同形态。

13.1 系统调用次数过多

假设每次系统调用边界和固定准备工作成本近似为 cc,调用次数为 NN,则边界相关时间可以粗略表示为:

TboundaryN×cT_{\text{boundary}} \approx N \times c

其中:

  • NN 是系统调用次数;
  • cc 包括用户态到内核态切换、参数处理、返回和跟踪等固定成本;
  • 公式只用于解释趋势,不是硬件无关的精确性能模型。

例如,程序逐字节写出 1 MiB 数据,可能产生约一百万次 write();如果改为 64 KiB 一次写出,调用次数约为:

1 MiB64 KiB=16\frac{1\text{ MiB}}{64\text{ KiB}} = 16

这通常能减少边界成本和锁竞争。但大批量并不总是更好:过大的单次 I/O 可能增加延迟、内存占用或阻塞时间。

strace -c 适合验证“调用次数是否异常”,但不能单独证明合并批次后整体性能一定提升。修改后应重新测量吞吐、尾延迟和错误行为。

13.2 单次系统调用等待时间过长

如果调用次数不多,但某些调用的 -T 时间很长,重点应转向等待条件:

epoll_wait(...) = 1 <2.001234>
read(5, ..., 4096) = 4096 <0.500812>
futex(... FUTEX_WAIT_PRIVATE ...) = 0 <0.100421>

这些结果分别可能表示:

  • 事件循环没有事件;
  • 网络或设备数据迟到;
  • 线程等待锁或条件变量;
  • 进程间协作的生产者没有及时运行。

不能把“系统调用耗时”简单归因于系统调用本身慢。系统调用是等待发生的边界,真正的原因可能在另一条线程、另一个进程、远端服务或硬件设备。

13.3 系统调用不多,但用户态很慢

如果 strace -c 显示系统调用时间很少,而程序总耗时很长,问题很可能在:

  • 用户态算法;
  • CPU 计算;
  • 垃圾回收;
  • 锁竞争但没有体现为明显系统调用;
  • 忙等;
  • 页错误、缓存未命中或编译器生成代码。

此时应使用采样分析工具,例如:

perf stat ./program
perf record -g ./program
perf report

perf、eBPF 和调度跟踪工具同样具有权限、内核配置和生产风险;它们与 strace 的观察层次不同,不能互相替代。


14. strace 本身会改变被观察程序

传统 strace 通过在系统调用进入和退出附近暂停被跟踪任务来读取状态。结果是:

未跟踪:
    应用 → 系统调用 → 返回

跟踪时:
    应用 → 系统调用入口 → 停止/通知 strace
         → strace 读取参数和寄存器
         → 继续执行
         → 系统调用返回 → 再次停止/通知 strace
         → strace 输出 → 应用继续

这会带来几个后果:

  • 系统调用次数很多时,开销可能显著;
  • 线程调度顺序可能改变;
  • 竞态条件可能消失或更容易出现;
  • 超时、重试和心跳行为可能改变;
  • 高吞吐网络程序可能因输出日志而严重变慢;
  • 跟踪敏感进程可能触发安全策略或改变其行为。

因此,不能使用 strace 的测量结果直接声称“线上原始性能就是这个数”。更合理的用途是:

  1. 在复现环境确认调用路径;
  2. 用过滤器减少无关调用;
  3. 使用 -o 避免污染标准输出;
  4. 只跟踪短时间或特定请求;
  5. 修改后重新比较相同场景;
  6. 生产环境优先考虑低侵入性的采样或内核观测方案。

对于只想看系统调用数量和错误的场景,可以减少字符串解码:

strace -f -qq -e trace=network,read,write -c ./program

但选项的具体语义和组合能力依赖 strace 版本,使用前应检查本机帮助信息。


15. 常见误解与反例

15.1 “所有库函数都对应一个系统调用”

反例:

size_t n = strlen("abc");

这个操作通常完全在用户态执行,strace 看不到对应的 strlen 行。

另一个反例是 stdio:

printf("hello\n");

如果 stdout 被终端连接,可能立即触发写;如果 stdout 被重定向到普通文件或管道,输出可能先停留在用户态缓冲区,直到缓冲区满、调用 fflush() 或程序正常退出时才出现 write()

15.2 “看到 read() 就说明程序在读磁盘”

反例:

read(3<socket:[...]>, ..., 4096)
read(4<pipe:[...]>, ..., 4096)

第一个可能在等网络,第二个可能在等另一个线程或进程。必须结合 /proc/PID/fd、系统调用参数和进程间关系判断对象类型。

15.3 “系统调用返回成功就表示完成了全部业务”

反例:

write(fd, buffer, 100000) = 32768

系统调用成功,但只写入了 32768 字节。应用仍需更新偏移量并继续处理剩余数据。

类似地:

read(fd, buffer, 4096) = 0

不是“读了零字节所以失败”,而是 EOF。网络协议则还要结合协议层判断消息是否完整。

15.4 “EAGAIN 是系统故障”

对非阻塞 fd,EAGAIN 的含义是:

现在不能完成,但以后可能可以

它是事件循环的正常控制流。只有在应用没有等待就绪、持续高速重试并造成 CPU 忙等时,才构成实现问题。

15.5 “strace 没看到某个系统调用,所以程序没有执行该操作”

可能原因包括:

  • 操作通过 vDSO 在用户态完成;
  • 调用发生在未跟踪的线程或子进程;
  • 系统调用被过滤器排除;
  • 运行库使用了不同但等价的系统调用;
  • 程序在执行前已经失败;
  • 操作由另一个进程完成;
  • 输出被截断或跟踪权限不足。

诊断时应先确认 -f、过滤器、进程生命周期和输出文件,而不是从缺失一行直接推导不存在该行为。


16. 系统调用与进程状态的关联

系统调用边界还能与 procfs 中的进程观测信息对应起来。

当线程正在阻塞时,可以同时查看:

ps -eLo pid,tid,stat,wchan:32,comm

其中:

  • STAT 反映进程或线程的调度状态;
  • WCHAN 给出可能的等待点;
  • TID 允许区分多线程程序中的具体线程。

查看某个进程打开的文件描述符:

ls -l /proc/"$PID"/fd
cat /proc/"$PID"/fdinfo/"$FD"

查看内存映射:

cat /proc/"$PID"/maps

这些信息可以解释 strace 中的参数:

  • strace 显示 read(7, ...)
  • /proc/PID/fd/7 说明 7 是一个 socket;
  • /proc/PID/status 或线程状态显示该线程正在睡眠;
  • strace 的 -T 显示 read() 持续很久;
  • 由此可以建立“线程在等待某个 socket 数据”的证据链。

但 procfs 文件也是观测接口,不是对所有内核内部状态的完整快照。多个文件的读取之间可能发生状态变化,不能假定它们组成同一时刻的原子视图。


17. 一套可复用的诊断流程

第一步:确认进程和线程范围

ps -T -p "$PID"

确认程序是否有多个线程、是否创建子进程。需要时使用:

strace -f -tt -T -o trace.log -p "$PID"

第二步:先观察系统调用类别

启动失败优先看文件和进程:

strace -f -e trace=file,process ./program

网络问题优先看:

strace -f -e trace=network ./program

锁等待和事件循环问题则可关注:

strace -f -e trace=futex,poll,select,epoll_wait,epoll_pwait ./program

过滤的目的不是让输出“更简洁”本身,而是减少无关事件,使因果关系更容易重建。

第三步:记录错误和返回值

关注:

= -1 E...

同时检查是否出现:

  • 短读;
  • 短写;
  • EINTR
  • EAGAIN
  • ETIMEDOUT
  • ECONNRESET
  • ENOENTEACCES 的路径差异。

第四步:对长调用关联对象

对长时间未返回的 fd 执行:

readlink /proc/"$PID"/fd/"$FD"
cat /proc/"$PID"/fdinfo/"$FD"

再结合:

ss -tanp
ip route
df -h
mount

这些命令分别能补充 socket、路由、磁盘空间和挂载视图,但它们读取的状态可能已经晚于 strace 中发生的事件,因此只能作为相关证据。

第五步:统计调用数量

strace -f -c ./program

用它回答:

  • 哪些系统调用调用次数异常;
  • 哪些调用错误率高;
  • 时间主要集中在哪些调用;
  • 是否存在明显的逐字节、逐文件或逐请求调用模式。

第六步:必要时更换工具

当问题变成“内核内部哪个函数慢”“CPU 时间花在哪里”“I/O 等待发生在哪个设备”时,应转向:

perf stat
perf record
iostat
pidstat
bpftrace

具体工具和探针取决于内核版本、权限、编译选项及生产环境安全要求。strace 的价值在于建立用户态请求与内核返回之间的证据链,而不是覆盖所有性能分析层次。


18. 权限、版本和生产风险

现代 Linux 发行版通常都提供 strace,但默认构建选项和版本不同,可能影响:

  • 支持的过滤器名称;
  • 是否能显示栈回溯;
  • 对新系统调用的解码能力;
  • 文件描述符和网络对象的详细解析;
  • ptrace 附加权限;
  • 容器内是否能观察宿主或其他命名空间中的任务。

使用具体选项前应查看:

strace --version
strace -h
man strace

内核系统调用的基本 ABI 通常具有较强兼容性,但系统调用参数结构、标志位、扩展命令和新接口可能随内核版本变化。不能因为某个内核支持某个系统调用,就假设所有发行版的 libc 都已经提供同名包装。

生产跟踪还需注意:

  • strace 可能记录密码、令牌、请求正文和文件内容;
  • -s-xx 等选项可能扩大敏感数据暴露;
  • 跟踪会放大延迟和 CPU 消耗;
  • 附加到关键服务可能触发超时;
  • 容器中的 PID、挂载点和网络命名空间可能与宿主不同;
  • 权限提升或绕过安全策略的做法不应作为常规诊断手段。

低侵入性要求较高时,可以优先采用短时、过滤后的跟踪,或者使用内核侧统计与采样工具;但更换工具并不会消除权限和数据脱敏要求。


系统调用是用户态请求内核服务的正式边界。理解这条边界后,strace 中的每一行都可以按同一条因果链解释:

谁发起了请求
→ 请求了哪个内核对象
→ 当时满足了什么条件
→ 是否进入睡眠
→ 是否被信号或并发打断
→ 内核返回了什么结果
→ libc 如何把结果交给应用

阻塞说明完成条件尚未满足,错误码说明内核选择了哪条失败路径,部分返回值说明操作可能只完成了一部分,而性能统计则说明调用数量、等待时间和用户态计算之间的比例。只有把这些层次分开,才能避免把“系统调用慢”“磁盘慢”“网络慢”“程序没有处理短写”等不同问题混为一谈。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。