Linux 基础体系 · 第 28/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。
Linux 系统调用与 strace:调用边界、阻塞、错误和性能诊断
1. 为什么需要理解“系统调用边界”
用户态程序不能直接执行内核中的文件系统、网络协议栈或进程调度代码。它必须通过**系统调用(system call)**请求内核代为完成特权操作。
系统调用边界不是一条抽象的函数调用边界,而是用户态与内核态之间的受控入口:
用户态程序
│
│ C 函数、C++ 封装、运行库
▼
libc 系统调用封装
│
│ syscall 指令、陷阱或架构相关入口
▼
内核系统调用入口
│
│ 参数检查、权限检查、对象查找、实际操作
▼
内核返回值
│
▼
libc 转换错误并返回用户态
例如,应用调用:
ssize_t n = read(fd, buf, sizeof(buf));
通常会经过以下层次:
- 程序调用 libc 提供的
read()包装函数。 - libc 按当前架构的系统调用 ABI 准备系统调用号和参数。
- CPU 执行
syscall等架构指令,进入内核。 - 内核根据系统调用号分派到对应实现。
- 内核访问文件对象、页缓存、设备驱动或网络协议栈。
- 内核返回成功结果或负的错误码。
- 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 内核入口并不等于“立刻完成”
执行系统调用入口指令后,内核还需要完成一系列工作:
- 保存用户态寄存器和返回地址。
- 切换到内核栈。
- 根据系统调用号查找处理函数。
- 验证用户传入的地址、长度、标志和对象句柄。
- 检查权限、命名空间、资源限制和安全策略。
- 执行实际操作。
- 必要时睡眠等待其他事件。
- 返回结果并恢复用户态执行。
例如:
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 阻塞的严格含义
对一个可能阻塞的操作,可以把“立即完成的条件”记作 。
例如,对管道读操作:
C = 管道中存在数据
或所有写端都已关闭
或发生了信号/错误
如果文件描述符是阻塞模式:
C 为真 → 系统调用完成并返回
C 为假 → 当前任务睡眠,等待 C 可能变为真
如果文件描述符设置了 O_NONBLOCK:
C 为真 → 系统调用完成并返回
C 为假 → 立即返回 -1,并设置 EAGAIN/EWOULDBLOCK
这里的“阻塞”不是指 CPU 忙等。典型路径是:
- 进程进入系统调用;
- 内核检查当前对象暂时不能满足请求;
- 内核把进程加入等待队列;
- 进程状态变为可中断睡眠或不可中断睡眠等内核状态;
- 调度器运行其他可运行任务;
- 数据到达、设备完成操作或相关状态变化;
- 内核唤醒等待队列中的进程;
- 进程重新检查条件;
- 条件满足则返回,条件仍不满足则可能再次睡眠。
“唤醒”不等于“系统调用一定成功”。唤醒后可能发生竞争:另一个线程先取走了数据,或者设备状态又发生变化。因此内核通常需要重新检查条件,而不是假设一次唤醒就足够。
4.2 管道的完整状态例子
假设管道容量为 ,当前数据量为 。
对于阻塞读:
B > 0 → 读取最多 requested 字节
B = 0 且写端仍存在 → 睡眠等待
B = 0 且所有写端关闭 → 返回 0,表示 EOF
对于阻塞写,若一次写入请求为 ,剩余空间为 ,则是否阻塞还取决于管道写入的原子性规则和请求大小。不能简单概括为“有一个字节空间就一定写入全部数据”。大写入可能出现部分写入、等待或由内核按具体规则处理。
这个例子也说明了为什么父进程必须关闭自己的管道写端。若父进程保留一个写端,即使子进程退出,内核仍认为至少有一个写端存在,读端可能持续等待而不是返回 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 和部分读写
这里存在一个关键边界:就绪不等于操作必然成功,也不等于一次操作能够处理完全部数据。
例如:
epoll_wait()报告 socket 可读;- 线程 A 和线程 B 同时处理同一个 socket;
- 线程 A 先读完数据;
- 线程 B 随后调用
read(); - 线程 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 长时间等待
但统计结果不能直接作为“程序总耗时分解”。原因包括:
- 用户态计算时间不在系统调用累计时间中;
-T和统计模式测量的是系统调用边界附近的墙上时间;- 多线程并发时,各线程耗时相加可能超过真实墙上时间;
- strace 自身会引入停止、寄存器读取和日志开销;
- 某些调用的时间包含睡眠,不等于 CPU 消耗;
- 系统调用内部的 CPU、I/O 等待和锁等待没有被进一步拆分。
例如,一个 read() 平均耗时 5 毫秒,可能是磁盘等待,也可能是网络延迟或管道生产者每 5 毫秒发送一次数据。要区分 CPU 与等待,应结合 perf、eBPF 工具、块层指标、网络指标和应用日志,而不能只看 strace -c。
13. 性能诊断:先确定问题属于哪一类
系统调用性能问题通常至少有三种不同形态。
13.1 系统调用次数过多
假设每次系统调用边界和固定准备工作成本近似为 ,调用次数为 ,则边界相关时间可以粗略表示为:
其中:
- 是系统调用次数;
- 包括用户态到内核态切换、参数处理、返回和跟踪等固定成本;
- 公式只用于解释趋势,不是硬件无关的精确性能模型。
例如,程序逐字节写出 1 MiB 数据,可能产生约一百万次 write();如果改为 64 KiB 一次写出,调用次数约为:
这通常能减少边界成本和锁竞争。但大批量并不总是更好:过大的单次 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 的测量结果直接声称“线上原始性能就是这个数”。更合理的用途是:
- 在复现环境确认调用路径;
- 用过滤器减少无关调用;
- 使用
-o避免污染标准输出; - 只跟踪短时间或特定请求;
- 修改后重新比较相同场景;
- 生产环境优先考虑低侵入性的采样或内核观测方案。
对于只想看系统调用数量和错误的场景,可以减少字符串解码:
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;ENOENT和EACCES的路径差异。
第四步:对长调用关联对象
对长时间未返回的 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 完整学习路线:从内核与文件系统到网络、性能和生产运维
- 上一篇:Linux 生产运行手册:容量、变更、监控、应急和复盘
- 下一篇:Linux 调度器深入:CFS、实时策略、优先级、Affinity 和 NUMA
- 延伸:Linux 内核与用户态:系统调用、异常、中断和硬件抽象
- 延伸:Linux procfs 与进程观测:状态、文件描述符、内存映射和线程
官方资料
本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论
0 条讨论