这一章在做什么
从离工作最近的地方查起:应用自己。先把线程的时间分成两半,在 CPU 上的用剖析和火焰图落到函数,不在 CPU 上的(等锁、等 IO、睡眠)用 off-CPU 分析、系统调用分析和锁分析去找它在等什么。顺带过一遍应用常用的性能技术:IO 大小、缓冲、并发和锁、非阻塞 IO。最后是剖析时最常碰到的两个坑:函数名没了、栈断了。对 C/C++ 程序员,最该带走的是编译时保留帧指针,和「CPU 不忙却慢」时去问线程在等什么。
什么时候翻这一章
- 服务慢,CPU 却闲着一大半线程不在 CPU 上:在等锁、等下游、等磁盘。先分清线程时间落在哪种状态,再对「不在 CPU 上」的那部分做 off-CPU 分析。§5.4.5§5.4.2场景手册 →
- 线程加了一倍,吞吐不涨甚至往下掉多半是一把锁让大家排了队。看锁的竞争和持有时间;锁哈希表、按桶分锁是常见的改法。§5.4.6§5.2.5
- %sys 很高,不知道内核在替你干什么做系统调用分析:哪个调用最多、每次多大、从哪段代码发出。注意 strace 本身的开销。§5.4.3§5.5.4
- 火焰图里一片 [unknown],或者栈只有一两层缺符号和缺栈,原因和补法都在这一章最后。§5.6.1§5.6.2
- CPU 很忙,top 里却找不到是谁可能是一个脚本在循环起短命进程,用 execsnoop 抓新进程。§5.5.5场景手册 →
- 换了版本、换了编译选项后变慢静态调优的那串问题:版本、调试构建、编译器和优化级别、内存分配器、限额。§5.4.7§5.3.1场景手册 →
这一章是第 6 章到第 10 章的入口:应用这一侧查出线程在等 CPU、内存、磁盘或网络,再翻到对应资源的那一章。CPU 剖析和火焰图的细节在第 6 章(§6.5.4、§6.7.3),perf 和 BPF 工具的用法在第 13、15 章。
先用中文过一遍
线程状态:先问时间花在哪一种状态上
作者说,每遇到一个性能问题,他用的第一个方法就是线程状态分析(§5.4.5):把一个线程的时间分进几种有意义的状态,看哪一种占得最多。最粗的分法是两种:在 CPU 上(on-CPU)和不在 CPU 上(off-CPU)。前者再分用户态、内核态;后者原因就多了:排队等 CPU、等换页、等磁盘、等网络、主动睡、等锁,还有闲着等活儿。书里把它细分成九种,每种指向不同的下一步。
Linux 内核的线程状态里,和这件事相关的主要是三种(书里也只用这三种):R(在跑或可以跑)、S(可中断睡眠)、D(不可中断睡眠,多半在等磁盘),离九种差得远。所以要拼:/proc/PID/stat 有用户态和内核态时间,/proc/PID/schedstat 的第二个数是排队等 CPU 的累计纳秒,/proc/PID/status 里有两个切换计数。下面这张图说明这两个计数各在什么时候加一:线程自己拿不到锁、等 IO 而睡下去,是主动切换;时间片到了被别人抢下,是被动切换。主动切换涨得飞快、被动切换几乎不动、排队时间也不涨,线程就是在等 CPU 以外的东西。
pidstat -w 把这两个计数换算成每秒的 cswch/s 和 nvcswch/s,-t 按线程列出。还要提防一个误读:不在 CPU 上的时间里,常常有一大块是「闲着等活儿」。工作线程在条件变量上等任务、在 socket 上等下一个请求,看起来像等锁、等网络,其实是空闲。书里说,优化的是除空闲以外的每一种状态。
off-CPU 分析:在线程睡下去的那一刻记账
CPU 剖析是定时打断正在跑的线程、记下栈,所以它天生看不见睡着的线程。off-CPU 分析补的就是这一半(§5.4.2)。现在常用的做法是跟踪调度器:线程被切下 CPU 的那一刻记下它的栈和时间戳,切回来时算出睡了多久,按栈累加。睡着的线程不会改自己的栈,所以每次阻塞只要读一次栈。累加出来可以画成 off-CPU 火焰图,宽度是睡着的时间,读法和 CPU 火焰图一样:先看最宽的塔。
代价是开销。CPU 剖析的成本跟 CPU 数和采样频率走;off-CPU 跟的是线程数和调度事件数,书里举的例子是一台机器每秒十万次以上的调度事件。作者的 offcputime 用 BPF 在内核里汇总,只交出不同的栈和总时长,还默认丢掉极短的阻塞,即便如此,他也建议先在测试环境量一量开销再上生产。另一个难处是解读:系统范围的 off-CPU 火焰图往往被闲着的线程占满,要放大到处理请求的那个函数,或者用 -M 丢掉长睡(书里建议试 -M 900000,即丢掉超过 0.9 秒的阻塞,§5.5.3)。
锁:等的人是在转圈,还是在睡
多线程程序的锁,一旦成了瓶颈,拿锁的线程就串成一队一个一个过。要分析锁,书里只问两件事:现在有没有竞争,持有时间是不是太长(今天不是问题,负载上去就是)。对剖析来说,关键是等锁的人在干什么。自旋锁(spin lock)的等待者在 CPU 上转圈,等待时间显示成 CPU 时间,CPU 剖析一眼就能看到;互斥锁(mutex)的等待者睡下去,不占 CPU,剖析里几乎没有它。Linux 内核的 mutex 是两者的混合,分三条路走,见下图(§5.2.5)。
pthread_mutex_lock 占比小,不能说明锁没问题;书里的建议是先看 CPU 剖析,要知道等了多久,再上 off-CPU 或专门的锁跟踪。改锁的思路也在 §5.2.5:一把全局锁最简单,但所有线程排一队;每份数据一把锁,竞争最少,却要为每把锁付内存和创建销毁的钱;中间是锁哈希表(hash table of locks),固定若干把锁,按数据地址的哈希挑一把,桶数最好不少于 CPU 数。还有一个 C/C++ 程序员会撞上的坑:一排锁挨着放在内存里,两把锁落进同一条缓存行,两个 CPU 改不同的锁也会互相作废对方的缓存,这叫伪共享(false sharing),用填充把每把锁隔到自己的缓存行里。
栈为什么会断,函数名为什么没了
剖析器拿到的是一串指令地址,要变成函数名得查符号表(§5.6.1)。发行版的二进制大多 strip 过,符号表被剥掉,你看到的就是十六进制地址或 [unknown];补法是构建时别剥,或者另装调试信息(Debian 上是 -dbg、-dbgsym 包)。
栈断了是另一回事(§5.6.2)。perf 和 BPF 工具默认靠帧指针回溯:x86-64 上 RBP 寄存器指向当前栈帧,帧里存着上一帧的 RBP 和返回地址,一路读下去就是调用链。可是编译器为了多一个通用寄存器,常常把帧指针省掉(本站查过 gcc 14:从 -O1 起默认开着 -fomit-frame-pointer),RBP 里就成了随便什么数。运气好,回溯工具查不到它,打印 [unknown];运气差,它碰巧落在某个函数的地址范围里,你看到一个根本没被调用过的函数名。libc 通常是不带帧指针编译的,所以经过 libc 的栈特别容易断。书里给的补法:自己的代码加 -fno-omit-frame-pointer 重编,开销常常量出来不到 1%;或者换不靠帧指针的回溯方式,比如 perf 的 DWARF 回溯。
pthread_mutex_lock 这种导出函数才有名字。| 英文 | 中文 | 一句话 |
|---|---|---|
| on-CPU / off-CPU | 在 CPU 上 / 不在 CPU 上 | 线程时间的两大块;CPU 剖析只看得见前一块 §5.4.2 术语页 |
| thread state analysis | 线程状态分析 | 把线程时间分进几种状态,看哪种最多;作者每次排查的第一步 §5.4.5 术语页 |
| off-CPU analysis | off-CPU 分析 | 记下线程阻塞时的栈和时长,按栈累加,可画成 off-CPU 火焰图 §5.4.2 术语页 |
| wait time (idle) | 等活儿的时间 | 线程闲着等任务;在 off-CPU 剖析里常占大头,要先滤掉 §5.4.2 |
| voluntary / involuntary context switch | 主动 / 被动上下文切换 | 自己阻塞让出 CPU,还是被抢下;前者多说明在等东西 §5.4.5 |
| mutex / adaptive mutex | 互斥锁 / 自适应互斥锁 | 拿不到就睡;自适应的先自旋一会儿,持有者不在跑或转够了再睡 §5.2.5 术语页 |
| spin lock | 自旋锁 | 拿不到就在 CPU 上转圈;等待显示成 CPU 时间 §5.2.5 |
| futex | futex | 用户态锁要阻塞时进内核用的系统调用;火焰图里搜 utex 能找到锁的痕迹 §5.4.1 术语页 |
| hash table of locks | 锁哈希表 | 固定几把锁按哈希分给大量数据,介于一把大锁和每项一把锁之间 §5.2.5 |
| false sharing | 伪共享 | 不相干的两个变量在同一缓存行,两个 CPU 互相作废缓存;靠填充隔开 §5.2.5 术语页 |
| non-blocking I/O | 非阻塞 IO | 发起 IO 后线程不睡、接着干别的;书里列了 O_ASYNC、Linux AIO、sendfile、io_uring 几种 §5.2.6 |
| frame pointer | 帧指针 | x86-64 上的 RBP,把栈帧串起来;被优化掉后基于它的回溯就断 §5.6.2 术语页 |
| symbol table / debuginfo | 符号表 / 调试信息 | 把地址翻成函数名要用;strip 过的只剩十六进制 §5.6.1 术语页 |
| mixed-mode profile | 混合模式剖析 | 一条栈里同时有内核帧和用户帧,perf 和 profile 默认如此(要 root) §5.4.1 |
方法:按什么顺序查
书里列了八种方法,建议按表里的顺序试:CPU 剖析、off-CPU 分析、系统调用分析、USE、线程状态分析、锁分析、静态调优、分布式跟踪(§5.4)。可作者又说,线程状态分析是他每次的第一步(§5.4.5)。两句话不矛盾:先用几个便宜的数分清时间落在 CPU 上还是 CPU 外,再决定走剖析还是走 off-CPU。本站按这个思路排成下面几步。
- 线程的时间在 CPU 上,还是不在?
pidstat -u -w -t -p PID 1看每个线程的%usr+%system和cswch/s;perf stat -e task-clock -p PID -- sleep 10看「CPUs utilized」。干活的线程有 8 个、却只用了一两个 CPU,切换又以主动的为主,就是在等;%wait高才是等 CPU,去第 6 章。§5.4.5 - 在 CPU 上:哪段代码?
perf record -F 99 -g,画火焰图,先看最宽的塔和平台。顺便找「脚印」:火焰图里搜utex、tcp、blk、ext4,能看出锁、网络、块设备、文件系统有没有参与,但看不出等了多久。§5.4.1 - 不在 CPU 上:睡在哪?
有 root 就用
offcputime-bpfcc -p PID 10,画 off-CPU 火焰图;先放大到处理请求的函数,或者用-M丢掉闲着等活儿的长睡。没有 root 时,cat /proc/PID/task/*/wchan能看到每个线程睡在哪个内核函数上,futex 开头的就是锁或条件变量。§5.4.2§5.5.3 - %sys 高或者 IO 很碎:发了哪些系统调用?
先汇总,再逐条:
strace -f -c(加-w算墙钟时间),看次数和每次的大小。strace 每个系统调用都要停两次,只在测试环境或很短的时间里用;生产上用syscount-bpfcc、perf trace -s。一次几十字节、成千上万次的读写,就去改缓冲和 IO 大小。§5.4.3§5.2.1 - 在等锁:哪把锁,谁拿着不放?
自旋在 CPU 剖析里看得见;睡眠要用 off-CPU 或锁跟踪(
pmlock.bt看拿锁等了多久,pmheld.bt看持有了多久,都要 root)。找到锁以后问:能不能缩短临界区、能不能分成几把、锁之间有没有伪共享。§5.4.6§5.5.7 - 把应用内部的池子也当资源查
线程池:忙着的线程占几成(利用率)、请求队列多长(饱和度)、拒了多少(错误);文件描述符:用了上限的几成、有没有
EMFILE。这些数应用多半自己有,没有就加上。§5.4.4 - 别漏掉配置
是不是调试构建、用的什么编译器和优化选项、哪个内存分配器、线程池和缓存多大、有没有 CPU 或内存限额、版本有没有已知的性能 bug。这些剖析里不显眼,却常常是答案。§5.4.7
工具速查
| 想知道 | 敲什么 | 看哪里 | 节 |
|---|---|---|---|
| 每个线程在 CPU 上的比例 | pidstat -u -w -t -p PID 1 | 各线程 %usr + %system 远低于 100、cswch/s 几千、nvcswch/s 接近 0:主动阻塞;%wait 高才是排队等 CPU | §5.4.5 |
| 一个进程实际用了几个 CPU | perf stat -e task-clock -p PID -- sleep 10 | CPUs utilized 远小于干活的线程数,说明线程大多不在 CPU 上 | §5.4.5 |
| 不装工具:线程在睡什么 | cat /proc/PID/task/*/wchangrep ctxt /proc/PID/task/*/status | wchan 是 futex 开头的名字:睡在锁或条件变量上;voluntary 涨得快、nonvoluntary 不动:在等 CPU 以外的东西 | §5.4.5 |
| 哪段代码在烧 CPU | perf record -F 99 -g -p PID -- sleep 30perf report --no-children | 最宽的函数;搜 utex、tcp、blk 找 off-CPU 的脚印 | §5.4.1§5.5.1 |
| 栈断了,换个回溯方式 | perf record --call-graph dwarf -p PID -- sleep 10 | 能补回没有帧指针的那几层;要调试信息,perf.data 大几十倍 | §5.6.2 |
| 低开销的 CPU 剖析 | profile-bpfcc -F 49 -p PID 10 | 栈在内核里汇总,只交出计数;-f 的输出能直接画火焰图 | §5.5.2 |
| 线程睡在哪、睡了多久 | offcputime-bpfcc -p PID 10offcputime-bpfcc -f -M 900000 -p PID 10 | 按栈累加的阻塞时间(微秒);-M 丢掉长睡,滤掉闲着的线程 | §5.5.3 |
| 系统调用的次数和耗时 | strace -f -c ./progstrace -f -w -c ./prog | calls 列看次数;默认统计系统 CPU 时间,-w 统计墙钟时间(等待算进去) | §5.5.4 |
| 只跟几个系统调用,少点开销 | strace --seccomp-bpf -f -e trace=openat ./prog | 只在选中的调用上停;不加这个选项,-e 照样每个调用都停 | §5.5.4 |
| 全系统谁的系统调用最多 | syscount-bpfcc -Pperf trace -s -p PID | 次数最多的进程和调用;perf trace 用每 CPU 缓冲,比 strace 安全得多。两个都要 root(本站没有 root 时 perf trace 读不了 tracefs) | §5.5.6§5.5.1 |
| 谁在不停地起短命进程 | execsnoop-bpfcc -t | 同一个命令每秒冒出几十次,就是它在吃 CPU | §5.5.5 |
| pthread 锁等了多久、谁拿着 | pmlock.bt PIDpmheld.bt PID | 按锁地址和栈的延迟直方图;uprobes 有开销,锁事件又多,短时间用 | §5.5.7 |
| 这个二进制有没有符号 | file ./progreadelf -S ./prog | grep debug | stripped 还是 not stripped、有没有 with debug_info;有没有 .debug_* 段 | §5.6.1 |
pidstat 在 sysstat 包里;perf 在 Debian 上是 linux-perf 包;strace 是同名的包;带 -bpfcc 后缀的是 bpfcc-tools 包里的 BCC 工具,要 root;pmlock.bt、pmheld.bt 是作者 bpf-perf-tools-book 仓库里的 bpftrace 脚本,要先装 bpftrace,也要 root。不要 root 的:perf 剖析自己启动的程序、pidstat、/proc 下自己进程的文件、strace 跟踪自己启动的程序(挂到已经在跑的进程上要 ptrace 权限)。常用的几个:pidstat、perf、strace、offcputime、profile、syscount、execsnoop、bpftrace。
动手:在自己的机器上试
8 个线程抢一把锁:不用 root,看出它在等
下面的程序起 8 个线程更新一张共享的表,每次更新都要先拿锁,锁里做几十微秒的活。参数是锁的个数:1 是一把全局锁,8 是按桶分的 8 把锁(就是书里的锁哈希表)。先猜:16 核的机器上,一把锁的版本会用掉几个 CPU?
// lock.c:8 个线程更新一张共享的计数表,每次更新都要先拿锁
// 用法:./lock 锁的个数(1 = 一把全局锁;8 = 按桶分的 8 把锁)
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#define NOINLINE __attribute__((noinline))
#define NTHREADS 8
#define NBUCKETS 64
#define ROUNDS 10000
// 每把锁填充到 64 字节,各占一条缓存行,免得伪共享
static struct {
pthread_mutex_t m;
char pad[64 - sizeof(pthread_mutex_t) % 64];
} locks[NBUCKETS];
static unsigned long table[NBUCKETS][64];
static int nlocks = 1;
NOINLINE static void update(unsigned long *row, unsigned key) {
for (int k = 0; k < 1024; k++) // 在锁里做一段活,几十微秒
for (int i = 0; i < 64; i++)
row[i] = row[i] * 31 + key + k;
}
NOINLINE static void *worker(void *arg) {
unsigned key = (unsigned)(long)arg;
for (int r = 0; r < ROUNDS; r++) {
key = key * 1103515245u + 12345u; // 换一个桶
unsigned b = key % NBUCKETS;
pthread_mutex_t *m = &locks[b % nlocks].m;
pthread_mutex_lock(m);
update(table[b], key);
pthread_mutex_unlock(m);
}
return NULL;
}
int main(int argc, char **argv) {
if (argc > 1) nlocks = atoi(argv[1]);
if (nlocks < 1 || nlocks > NBUCKETS) nlocks = 1;
for (int i = 0; i < NBUCKETS; i++) pthread_mutex_init(&locks[i].m, NULL);
pthread_t t[NTHREADS];
for (long i = 0; i < NTHREADS; i++) pthread_create(&t[i], NULL, worker, (void *)i);
for (int i = 0; i < NTHREADS; i++) pthread_join(t[i], NULL);
printf("locks=%d done, table[0][0]=%lu\n", nlocks, table[0][0]);
return 0;
}
# 第一步:它用了几个 CPU?
$ gcc -O2 -g -fno-omit-frame-pointer -pthread lock.c -o lock
$ perf stat -e task-clock ./lock 1
$ perf stat -e task-clock ./lock 8
# 第二步:程序跑着的时候,看每个线程的状态、睡在哪、切换了多少次
$ ./lock 1 & sleep 1
$ P=$(pgrep -n -x lock); for t in /proc/$P/task/*; do echo ${t##*/} $(cut -d' ' -f3 $t/stat) $(cat $t/wchan) $(awk '/ctxt/{print $2}' $t/status); done
$ ./lock 1 & sleep 0.5; pidstat -u -w -t -p $(pgrep -n -x lock) 1 1
# 第三步:它们在发什么系统调用(-w:按墙钟时间统计)
$ strace -f -w -c ./lock 1
# 第四步:CPU 剖析能看到什么
$ perf record -F 99 -g ./lock 1
$ perf report --stdio --no-children
$ perf record -F 99 --call-graph dwarf ./lock 1
$ perf report --stdio --no-children
本站在 16 核的 WSL2 上看到的,各截了几行:
perf stat, 1 把锁: 4,497.27 msec task-clock # 1.434 CPUs utilized 3.137 seconds time elapsed perf stat, 8 把锁: 3,783.51 msec task-clock # 5.561 CPUs utilized 0.680 seconds time elapsed /proc(TID、状态、wchan、主动切换、被动切换): 3720900 S futex_wait_queue 3074 0 3720901 S futex_wait_queue 2993 0 3720902 R 0 3269 0 pidstat: TID %usr %system %wait %CPU 3701529 9.90 4.95 0.00 14.85 TID cswch/s nvcswch/s 3701529 2957.43 0.00 strace: % time seconds usecs/call calls errors syscall 99.94 17.454654 161 107829 46913 futex perf report(帧指针): 94.94% lock lock [.] update ---update 0x70e9de596b7b perf report(dwarf): 97.36% lock lock [.] update ---update worker
- 8 个线程只用了 1.4 个 CPU,换成 8 把锁就是 5.6 个,时间从 3.1 秒降到 0.7 秒,执行的指令数两边一样。活没变,变的是排队。这一个数就说明线程大部分时间不在 CPU 上。
- 每个线程的 CPU 只有一成多,
%wait是 0(不是在等 CPU),每秒却主动切换近三千次、被动切换几乎为零;大多数线程处在 S 状态,睡在futex_wait_queue上。这就是线程状态分析的「线索法」,几条命令就把时间落到了「等锁」这一格。wchan 里的函数名随内核版本会变,认 futex 这个前缀就行。 - strace 里 futex 占了九成九,十万多次,四万多次返回错误:那是 futex 等待时发现锁的值已经变了,立即返回(EAGAIN),在激烈竞争时很常见。
-w统计的是墙钟时间,17 秒是八个线程睡在 futex 里的时间加起来;不加-w时这一列是系统 CPU 时间,意思不同。 - CPU 剖析的结论是「update 占了九成五」。这句话没错,可它答的是「CPU 花在哪」,不是「为什么慢」:等锁的七个线程在睡,采不到。
pthread_mutex_lock那条路径加起来不到 2%。 - 帧指针那一版里,
update下面直接跳到一个 libc 地址,worker不见了。本站反汇编看到,gcc 14 没给update这个叶子函数建栈帧,回溯从 RBP 读起,就跳过了它的调用者。换成--call-graph dwarf,worker回来了,pthread_mutex_lock那条路径也补全了,代价是 perf.data 从 83 KB 涨到 7.3 MB。libc 内部的等锁函数仍然只有地址:本机的 libc 被 strip 过(file显示stripped)。 - 另一个坑:没有 root 时,
perf stat -e context-switches会报 0。perf 自动加了:u,只数用户态的事件,而切换发生在内核里。上下文切换要看/proc或 pidstat。
- WSL2
- 本站实测过上面全部命令(Debian 13,内核 6.6,perf 6.12,sysstat 的 pidstat;strace 用的是 Debian strace 6.13 包解出来的二进制),都不需要 root。
strace -p挂到已经在跑、又不是自己子进程的程序上,在ptrace_scope=1时不行;/proc/PID/task/TID/stack(内核栈)读不了,要 root。要看睡着时的完整栈,用offcputime-bpfcc -p PID 10,要 root,本站没有在 WSL2 上实测。 - 服务器
- 有 root 时,对正在跑的服务用
perf record -F 99 -g -p PID -- sleep 30能同时看到内核栈(futex 那一段);offcputime-bpfcc -f -p PID 10的输出交给 FlameGraph 的flamegraph.pl就是 off-CPU 火焰图,书里用蓝色背景和 CPU 火焰图区分。生产上的 off-CPU 跟踪先在测试机量开销。 - ARM 板
/proc/PID/task/*/status的两个切换计数一般都在,busybox 的ps也能看到 S、R 状态;wchan 显示函数名要内核带符号表(kallsyms),裁掉了可能只给 0。perf 要交叉编译;ARM64 上帧指针是 x29,gcc 的开关一样是-fno-omit-frame-pointer,BSP 里的 libc 有没有帧指针要自己查。
一次写一行:系统调用分析,和 strace 自己的代价
日志、协议编码、串口转发,C 程序里最常见的低效写法之一是每凑出一小段数据就 write 一次。下面的程序写 20 万条短记录,一种是每条一次 write,一种是先攒进 64 KB 的缓冲区。
// smallio.c:写 20 万条日志记录
// 用法:./smallio raw 文件名 每条记录一次 write(2)
// ./smallio buf 文件名 先攒进 64 KB 的缓冲区,满了再写
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#define NREC 200000
#define RECSZ 32
int main(int argc, char **argv) {
if (argc < 3) return 1;
int raw = strcmp(argv[1], "raw") == 0;
int fd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) return 1;
static char buf[64 * 1024];
size_t used = 0;
char rec[RECSZ];
for (int i = 0; i < NREC; i++) {
int n = snprintf(rec, sizeof rec, "%09d level=info msg=ok\n", i);
if (raw) { write(fd, rec, n); continue; }
if (used + n > sizeof buf) { write(fd, buf, used); used = 0; }
memcpy(buf + used, rec, n); used += n;
}
if (used) write(fd, buf, used);
close(fd);
return 0;
}
$ gcc -O2 -g smallio.c -o smallio $ time ./smallio raw out.log $ time ./smallio buf out.log $ strace -c ./smallio raw out.log $ strace -c ./smallio buf out.log # strace 自己的代价:同一个程序,挂上 strace 再计时 $ time strace -o /dev/null ./smallio raw out.log $ time strace -e trace=openat -o /dev/null ./smallio raw out.log $ time strace --seccomp-bpf -f -e trace=openat -o /dev/null ./smallio raw out.log $ rm out.log
本站在 WSL2 上看到的:
raw: real 0m0.225s user 0m0.016s sys 0m0.209s buf: real 0m0.019s strace -c, raw: calls 200000 write strace -c, buf: calls 86 write 不挂 strace: real 0.22s strace(全跟): real 24.8s strace -e trace=openat: real 21.3s strace --seccomp-bpf -e openat: real 0.24s
- 同样 5.6 MB 的数据,一次一行要 20 万次系统调用,时间几乎全花在内核态;攒成 64 KB 一块,86 次就写完,快十倍多。这就是 §5.2.1 说的:每次 IO 都要交一笔固定的「入场费」,块越大,摊得越薄。
strace -c的calls列一眼就看出来。 - 反过来,攒批会让第一条记录等后面的记录(§5.2.3)。日志可以攒,交互协议的应答未必能攒,按延迟要求定缓冲多大、多久刷一次。
- 挂上 strace,这个程序慢了一百倍。只跟
openat也一样慢:strace 靠 ptrace,默认每个系统调用进出都停,-e只是少打印。加上--seccomp-bpf,内核只在选中的调用上把程序停下来,开销就几乎没了;这个选项 strace 5.3(2019 年)就有,书里没有提到。注意它要和-f一起用:本站的 strace 6.13 不加-f时会提示--seccomp-bpf cannot be used without -f,然后照旧每个调用都停。要全量统计,还是 BPF 工具或perf trace更稳。
- WSL2
- 本站实测过(strace 6.13)。WSL2 的盘是虚拟的,写文件的绝对时间不代表真机,但系统调用的次数和比例一样;ptrace 在 WSL2 上的开销未必和真机一样,但书里用 dd 做的对比也慢了七十多倍,是同一个量级的问题。
- 服务器
- 对已经在跑的服务,
strace -c -p PID要 root 或 ptrace 权限,挂几秒就撤;更稳的是syscount-bpfcc -p PID或perf trace -s -p PID(要 root,本站没有实测)。看每次写多大,用 bpftrace 跟sys_enter_write的count参数画直方图。 - ARM 板
- 板子上往往没有 perf 和 BPF,strace 可以交叉编译后拷上去,只依赖 ptrace。写 eMMC、SD 卡的程序更值得查一次
write的次数和大小,以及有没有每条都fsync。
成书之后
发行版开始默认保留帧指针
- 书里
- 栈断了,多半是因为二进制(尤其是 libc)编译时省掉了帧指针;作者在脚注里说,他给软件包维护者发过构建说明,请他们打包一个带帧指针的 libc(§5.6.2)。
- 今天
- Fedora 38(2023 年)起把
-fno-omit-frame-pointer加进了打包的默认编译选项,Python 解释器除外,i686 和 s390x 两个架构也没做;Ubuntu 24.04 LTS 起在 64 位平台上默认用帧指针构建软件包,同样有少数例外,而且 Phoronix 后来报道,有些包的构建脚本写死了编译选项,并没有真的带上帧指针。在这些系统上剖析,经过 libc 的栈断得少多了。Debian 的情况本站没有查到权威说法,只能说本站这台 Debian 13 上,经过 libc 的栈仍然会断(见上面的实验)。自己的程序不管在哪个发行版上,都照样加上这个开关。
libpthread 并进了 libc
- 书里
- 跟踪 pthread 锁,是用 uprobes 挂到 libpthread 库里的
pthread_mutex_lock()等函数上,作者的 pmlock.bt、pmheld.bt 就是这么做的(§5.5.7)。 - 今天
- glibc 2.34(2021 年 8 月)把 libpthread、libdl 等并进了 libc 本体,新程序不再需要
-lpthread,老的libpthread.so.0只是个空壳。本站的 perf 输出里,pthread_mutex_lock就落在libc.so.6里。用 bpftrace 或 BCC 跟踪 pthread 函数时,探针要挂在 libc 上,照搬旧脚本里的 libpthread 路径,可能挂不上。
分节地图
这一章先讲应用的背景和常用的性能技术(§5.1–5.3),再讲方法(§5.4)和工具(§5.5),最后是缺符号、缺栈(§5.6)。上手只需要方法里的五节、编译型语言一节和最后两节的坑;技术部分 C/C++ 程序员大多熟悉,用到时再查,各个 BPF 工具也是。
- Performance is best tuned closest…约 200 词开篇为什么从应用查起,以及这一章的学习目标。
- Application Basics约 600 词应用基础接手一个陌生应用时先问的一串问题:它干什么、怎么配置、有哪些指标和日志。
- Objectives约 400 词目标延迟、吞吐、资源利用率、价格四类目标,怎样写成能量化的数;Apdex。
- Optimize the Common Case约 200 词优化常见路径先找生产负载下最常走的代码路径。
- Observability约 100 词可观测性为什么看得见内部的应用,长期来看很可能比跑分快一成的应用更好。
- Big O Notation约 200 词大 O 记法算法复杂度的复习,C/C++ 程序员都学过。
- Application Performance Techniques约 100 词应用的性能技术七种技术的引子。
- Selecting an I/O Size约 200 词选 IO 大小大块省固定开销,太大又浪费和拖延迟。
- Caching约 100 词缓存部署时查清有哪些缓存、各多大。
- Buffering约 100 词缓冲攒批写提高效率,代价是第一笔要等;环形缓冲区。
- Polling约 200 词轮询轮询的两种代价;poll 为什么在连接多时变慢,epoll 怎么避开。
- Concurrency and Parallelism约 1500 词并发与并行协程和事件驱动、三种线程池模型、同步原语、锁哈希表和伪共享。改锁之前读。
- Non-Blocking I/O约 200 词非阻塞 IO一个连接一个线程的两个问题;几种异步接口,含 io_uring。
- Processor Binding约 200 词绑定处理器绑核换局部性;容器里多个租户绑到同一批核的坑。
- Performance Mantras约 100 词性能箴言指回第 2 章那串从「别做」开始的调优顺序。
- Programming Languages约 200 词编程语言所谓语言的性能优化,多半是运行时的。
- Compiled Languages约 500 词编译型语言符号表、优化级别、为什么要保留帧指针和 -g;临时降优化级别为什么靠不住。
- Interpreted Languages约 200 词解释型语言剖析脚本时只看见解释器自己的函数。
- Virtual Machines约 200 词语言虚拟机JVM 一类,C/C++ 读者用不到;要剖析 Java 时再来。
- Garbage Collection约 300 词垃圾回收C/C++ 没有 GC;用 Java、Go 时再看。
- Methodology约 100 词方法八种方法的总表和建议的顺序。
- CPU Profiling约 800 词CPU 剖析为什么先用内核里的剖析器;读不懂函数名怎么办;从火焰图里找 off-CPU 的脚印。
- Off-CPU Analysis约 900 词off-CPU 分析三种做法和各自的开销;off-CPU 火焰图;怎样滤掉闲着等活儿的时间。
- Syscall Analysis约 200 词系统调用分析跟新进程、剖析 IO、解释内核态时间三个用处。
- USE Method约 300 词USE 方法把线程池、文件描述符这类软件资源也当资源查。
- Thread State Analysis约 1400 词线程状态分析九种状态各指向哪一步;在 Linux 上用线索、off-CPU、直接测量三种办法拼出来。
- Lock Analysis约 200 词锁分析竞争和持有时间;自旋锁和自适应锁在 CPU 剖析里的样子。
- Static Performance Tuning约 300 词静态性能调优一串检查配置的问题:版本、调试构建、编译选项、分配器、大页、限额。
- Distributed Tracing约 300 词分布式跟踪跨服务的请求怎么拆开看;头部采样和尾部采样。
- Observability Tools约 200 词观测工具工具总表;栈里出现 [unknown] 时先翻 §5.6。
- perf约 600 词perfCPU 剖析、火焰图、perf trace 跟系统调用和它比 strace 好在哪。
- profile约 100 词profileBCC 的定时采样剖析器,细节在第 6 章。
- offcputime约 300 词offcputime按栈汇总阻塞时间;-m、-M 两个阈值;画 off-CPU 火焰图。
- strace约 500 词strace常用选项、-c 的各列,以及它的开销为什么大。
- execsnoop约 200 词execsnoop跟踪新进程,抓循环启动的短命进程。
- syscount约 100 词syscount全系统按调用或按进程数系统调用。
- bpftrace约 900 词bpftrace跟信号、IO 大小和延迟直方图、pthread 锁的等待和持有、USDT 和 uprobes。
- Gotchas约 100 词常见的坑缺符号和缺栈的引子。
- Missing Symbols约 400 词缺符号ELF 二进制被 strip;JIT 运行时的符号表和 perf map 文件。
- Missing Stacks约 400 词缺栈栈为什么只剩一两层,帧指针和其他回溯方式。C/C++ 读者必读。
- Exercises约 400 词习题习题;动手部分本页已经给了替代。
词与短语
只收先读和用到再查两档的节。斜体那一行是原句里连续的几个词,在电子书里搜它就能找到那一句。
§5.1.1 Objectives
§5.1.3 Observability
§5.2.1 Selecting an I/O Size
§5.2.5 Concurrency and Parallelism
§5.2.7 Processor Binding
§5.3 Programming Languages
§5.3.1 Compiled Languages
§5.3.2 Interpreted Languages
§5.4.1 CPU Profiling
§5.4.2 Off-CPU Analysis
§5.4.5 Thread State Analysis
§5.4.6 Lock Analysis
§5.5.1 perf
§5.5.4 strace
§5.5.5 execsnoop
§5.6.1 Missing Symbols
§5.6.2 Missing Stacks
诊断题:换成你来查
场景和输出都是本站编的。先自己判断,再点选项看解释。
场景:16 核服务器上,你的 C++ 行情网关有 32 个工作线程,高峰期延迟翻倍,可 top 里整机 CPU 还空着七成。你截了一秒 pidstat:
TID %usr %system %wait cswch/s nvcswch/s 21877 4.0 3.0 0.0 2810.0 0.0 21878 5.0 2.0 0.0 2944.0 1.0 21879 4.0 3.0 0.0 2776.0 0.0 (其余线程相近)
每个线程只用了 7% 左右的 CPU,%wait 是 0、被动切换几乎没有,说明不是在排队等 CPU;每秒近三千次主动切换,说明它们在频繁阻塞。下一步 cat /proc/PID/task/*/wchan 看睡在哪,有 root 就 offcputime-bpfcc -p PID 10 看栈。如果是一把锁,再加线程只会让队排得更长。
场景:一个 8 线程的视频转码服务扩到 16 线程后吞吐没涨。同事剖析了一下,说:「锁只占 1.5%,不是锁的问题,该优化 encode_block。」
perf stat: 1.31 CPUs utilized
perf report: 91.2% encode_block
1.5% pthread_mutex_lockCPU 剖析只回答 CPU 花在哪。mutex 的等待者大部分时间睡着,几乎不留样本(§5.4.6 说 CPU 剖析只给出故事的一部分)。1.3 个 CPU 对 16 个线程,这才是关键的数。下一步看 off-CPU 栈或者 pmlock.bt,找到锁之后缩短临界区或者分锁。--children 只改变占比的算法,采不到的样本照样采不到。
场景:你给自己的 C 网络服务画了 CPU 火焰图,底部一片只有一两层的细栈,perf report 里大量这样的:
12.4% [.] __send
|
---__send
[unknown]只剩一层、下面是 [unknown],正是书里说的「草」:回溯走到没有帧指针的函数就断了,libc 尤其常见。自己的代码加 -fno-omit-frame-pointer;经过 libc 的那几层,换 DWARF 回溯(要调试信息,数据大得多)或者用带帧指针构建的发行版。采样频率只影响样本多少,和栈断不断无关。
场景:ARM 板上的日志采集程序吃掉了一个核的三成,大半是 %sys。你拷了个 strace 上去,跑 10 秒 strace -c -p PID,又用 -e trace=write 看了几行,每次写四十几个字节:
% time calls syscall 97.30 48210 write 1.10 120 read
每秒近五千次、每次几十字节的 write,时间花在系统调用的固定开销上,不在存储上(页缓存把写先接住了)。攒批能把次数降两三个数量级,§5.2.1 和 §5.2.3 讲的就是这个;代价是记录落盘晚一点,按需要定时刷。O_DIRECT 要求缓冲区、长度和偏移按块对齐,四十几字节的写多半直接报 EINVAL,就算对齐了也是每次小写都打到设备上,更糟;多线程只是把同样多的调用分到几个核上。另外,strace 挂着的这 10 秒里程序会慢很多,看完尽快撤掉。
场景:服务的 p99 偶尔很高,你用 offcputime-bpfcc -p PID 30 跟了 30 秒。32 个工作线程,结果里最大的一块是:
pthread_cond_wait
pool_wait_task
worker_main
start_thread
- worker (21901)
870412311 (us, 32 个线程合计)32 个线程跟 30 秒,总共 960 秒的线程时间,大部分睡在等任务的条件变量上,这是空闲,书里专门提醒过 off-CPU 剖析常被这种等待占满。要找让请求变慢的阻塞,就放大到处理请求的函数,或者用 -M 900000 丢掉超过 0.9 秒的长睡。时间是按线程累加的,超过墙钟时间很正常。
值得停下来的句子
This is the first methodology I use for every performance issue, but it is also an advanced activity on Linux.— §5.4.5。本站译:每个性能问题,我用的第一个方法都是它;可在 Linux 上,这也是一件高级的活儿。
「第一个」和「高级」放在一句里,说的是 Linux 的现状:内核给出的主要是 R、S、D 这几种状态,离九种差得远,要靠几样工具拼。可拼起来并不难:本页的实验里,perf stat 的一个数、/proc 里的两个切换计数和 wchan,几分钟就把时间落到了「等锁」这一格。先问状态,就不会对着一张 CPU 火焰图去优化一个根本不是瓶颈的函数。
the benefits of being able to use stack traces to find performance wins usually far outweigh this cost.— §5.6.2。本站译:能用调用栈找到性能收益,这个好处通常远远超过(保留帧指针的)代价。
省掉帧指针换来的那一点性能,是现在就能拿到的;栈断了丢掉的收益,是以后才发现拿不到的。对 C/C++ 程序员,这句话直接落到构建脚本上:发布版也加 -fno-omit-frame-pointer,加 -g 再把调试信息单独存一份(§5.3.1 的脚注建议做成 debuginfo 包)。等到线上出问题再重编,往往已经复现不了。
带走的几条
一句话:先问线程在不在 CPU 上:在,就剖析;不在,就去找它在等什么。
perf stat 的 CPUs utilized 和干活的线程数比一比;pidstat -w 看主动切换。用得少、切得多,就是在等。
§5.4.5等锁、等 IO 的线程在睡,火焰图里没有它。锁在剖析里占比小,不能说明锁没问题。
§5.4.6最宽的栈常是 pthread_cond_wait 里等活儿的线程。只看处理请求的函数下面,或者用 -M 丢掉长睡。
§5.4.2-fno-omit-frame-pointer -g,开销常在 1% 以下。栈断了也可以试 perf record --call-graph dwarf。
§5.6.2默认每个系统调用都停,-e 也一样,本站实测慢了一百倍。要过滤就加 --seccomp-bpf(要和 -f 一起用),要全量统计就用 BPF 工具或 perf trace。
§5.5.4一行一次 write,开销全在系统调用上。strace -c 的 calls 列一眼就看出来。
§5.2.1