Linux-09 用户态、内核态与系统调用
前置阅读:Linux-07 进程调度与上下文切换
「系统调用」这个词在前面几篇里出现了几十次,但一直没有正面讲它。这一篇补上——系统调用是用户程序与内核之间唯一的合法通道,理解它的成本和机制,是理解一切 IO 性能优化的前提。
本篇会解释:为什么要有用户态和内核态之分、一次 read() 到底经过了什么、为什么 time.Now() 比 os.Getpid() 快十倍、以及怎么用 strace 找出「进程到底卡在哪」。
1. 为什么要分用户态和内核态
一句话:因为不能让应用程序直接碰硬件。
如果任何程序都能直接读写磁盘、访问任意内存、操作网卡,那么一个有 bug 的程序就能把整台机器搞崩,一个恶意程序能读取所有其他进程的数据。所以 CPU 硬件层面提供了特权级机制。
1.1 CPU 的特权环
x86 架构提供四个特权级(Ring 0 ~ Ring 3),但 Linux 只用了两个:

Ring 0 内核空间 最高权限,可以执行任何指令、访问任何内存和硬件
Ring 1 (未使用)
Ring 2 (未使用)
Ring 3 用户空间 受限权限,不能执行特权指令,不能直接访问硬件
Ring 3 被禁止的事情:
- 执行特权指令(
hlt停机、lgdt加载描述符表、cli关中断) - 直接访问硬件端口(
in/out指令) - 访问被标记为内核所有的内存页
- 修改页表、切换地址空间
一旦 Ring 3 的代码尝试这些操作,CPU 立刻触发保护异常(General Protection Fault),内核接管并通常给进程发 SIGSEGV。
「段错误」的本质就是这个——你的代码试图访问不属于它的内存,CPU 硬件层面拦住了。
1.2 同一个进程的两种状态
这里有个常见的误解:用户态和内核态不是两类进程,而是同一个进程的两种运行状态。
一个进程的生命周期里,不断在两种状态之间来回:
用户态执行业务代码 --系统调用--> 内核态执行内核代码 --返回--> 用户态
(Ring 3) (Ring 0) (Ring 3)
同一个进程,同一个 task_struct,只是特权级变了
top 里的 us 和 sy 就是这两种状态的时间统计:
top
# %Cpu(s): 23.1 us, 8.4 sy, 0.0 ni, 68.5 id
# ^^^^^^^ 进程在用户态跑业务逻辑的时间
# ^^^^^^ 进程陷入内核执行系统调用的时间
sy 高说明程序在频繁进出内核——大量系统调用、频繁的上下文切换、或者中断处理繁重。这是判断问题方向的重要依据(第 7 篇讲过)。
每个进程都有两个栈:
# 用户栈:在进程的虚拟地址空间里,业务代码用
cat /proc/self/maps | grep stack
# 7ffd8c3e1000-7ffd8c402000 rw-p ... [stack]
# 内核栈:进入内核时使用,很小(通常 8KB 或 16KB)
# 每个线程一个,在内核内存里,用户态完全无法访问
内核栈只有 8~16KB,这是内核代码里禁止大数组和深递归的原因——栈溢出会直接踩坏相邻的内核数据结构。
2. 一次系统调用的完整路径
以 read(fd, buf, 1024) 为例,走完全程:
① 用户代码调用 glibc 的 read()
v
② glibc 把系统调用号放进 rax 寄存器(read = 0),
参数依次放进 rdi, rsi, rdx, r10, r8, r9
v
③ 执行 syscall 指令 <- 这一条指令触发 Ring 3 -> Ring 0 的切换
v
【CPU 硬件动作】
- 保存用户态的 rip(返回地址)到 rcx
- 保存 rflags 到 r11
- 从 MSR_LSTAR 寄存器加载内核入口地址
- 切换到 Ring 0,切换到内核栈
v
④ 内核入口 entry_SYSCALL_64
- 保存全部用户态寄存器到内核栈
- 用 rax 的值查系统调用表 sys_call_table[]
v
⑤ 执行 sys_read()
- 检查 fd 合法性、检查 buf 是否在合法的用户地址空间
- 走 VFS -> 具体文件系统 -> 块设备层
- 如果数据在 page cache 里 -> 直接 copy_to_user()
- 如果不在 -> 发起磁盘 IO,进程进入 D 状态睡眠,等待唤醒
v
⑥ sysret 指令返回
- 恢复寄存器,切回 Ring 3
- 返回值放在 rax(读到的字节数,或负的错误码)
v
⑦ glibc 检查返回值,若为负则设置 errno 并返回 -1
注意第 ⑤ 步的分支——这是理解 IO 性能的关键:
数据在 page cache 里 -> 纯内存拷贝,约 1~2 μs (快)
数据不在 -> 磁盘 IO,进程睡眠 50μs ~ 10ms (慢,且进入 D 状态)
**同一个 read() 调用,耗时可以差 3 个数量级。**这就是「page cache 命中率」如此重要的原因,也是第 11 篇要展开的内容。
2.1 系统调用号与调用表
系统调用靠编号而不是名字来分发:
# 查看系统调用号(x86_64)
grep -E "^(0|1|2|3|60|257)\s" /usr/include/asm/unistd_64.h 2>/dev/null || \
ausyscall --dump 2>/dev/null | head -8
# 0 read
# 1 write
# 2 open
# 3 close
# 60 exit
# 257 openat
// 内核里的分发表(简化)
const sys_call_ptr_t sys_call_table[] = {
[0] = sys_read,
[1] = sys_write,
[2] = sys_open,
[3] = sys_close,
/* ... 约 350 个 */
};
// entry_SYSCALL_64 里的核心分发逻辑
if (nr < NR_syscalls)
regs->ax = sys_call_table[nr](regs);
「系统调用号永不改变」是 Linux ABI 稳定性的基石。read 在 x86_64 上永远是 0——新增功能只能追加新号,绝不能改动已有的。这就是第 1 篇讲的「we do not break userspace」在实现层面的体现,也是为什么十年前编译的静态二进制今天还能运行。
注意:系统调用号是架构相关的。
read在 x86_64 上是 0,在 ARM64 上是 63。所以直接写死系统调用号的代码无法跨架构移植,必须用 glibc 的封装或SYS_read宏。
2.2 glibc wrapper 不只是转发
read() 这个 C 函数并不是系统调用本身,而是 glibc 提供的封装。它做了几件用户看不见的事:
// glibc 的 read 大致等价于
ssize_t read(int fd, void *buf, size_t count) {
long ret = syscall_asm(SYS_read, fd, buf, count); // 真正的 syscall 指令
if (ret < 0) {
errno = -ret; // 内核返回负错误码,glibc 转成 errno
return -1;
}
return ret;
}
内核的错误约定与用户态不同:内核返回 -EAGAIN(负数),glibc 把它转成「返回 -1 + 设置 errno = EAGAIN」。这是 C 语言里必须检查 errno 而不是返回值本身的原因。
有些 libc 函数根本不是系统调用:
| 函数 | 是系统调用吗 | 说明 |
|---|---|---|
read / write / open |
是 | 一对一封装 |
malloc / free |
不是 | 用户态内存池,只在需要时调 brk/mmap |
printf |
不是 | 格式化 + 缓冲,攒够了才调 write |
fopen / fread |
不是 | 带缓冲的封装,底层是 open/read |
pthread_create |
是(clone) |
|
pthread_mutex_lock |
看情况 | 无竞争时纯用户态 CAS,竞争时才调 futex |
malloc 不是系统调用这一点很重要。它维护一个用户态内存池,大部分 malloc 只是在池子里划一块,不进内核。只有池子不够了才调用 brk(扩展堆)或 mmap(映射新区域)。所以 malloc 的平均开销是几十纳秒,而不是系统调用的上百纳秒。
pthread_mutex_lock 的两阶段设计同理:无竞争时只是一次原子 CAS(约 20ns),有竞争才调 futex 陷入内核睡眠(微秒级)。这解释了第 7 篇实战案例里「futex 调用暴增 = 锁竞争激烈」的判断依据。
3. vDSO:不进内核的「系统调用」
有些系统调用被调用得极其频繁但又非常简单——最典型的是获取当前时间。每条日志、每个 metric、每次超时判断都要取一次时间,如果每次都陷入内核,开销相当可观。
vDSO(virtual Dynamic Shared Object)的解法:内核把一小段代码和数据映射到每个进程的地址空间里,让进程在用户态直接读。
# 每个进程的地址空间里都有 vdso 和 vvar
cat /proc/self/maps | grep -E "vdso|vvar"
# 7ffd8c5f5000-7ffd8c5f9000 r-xp 00000000 00:00 0 [vdso]
# ^^^ 可读可执行的代码
# 7ffd8c5f1000-7ffd8c5f5000 r--p 00000000 00:00 0 [vvar]
# ^^^ 只读数据:内核持续更新的时间戳就放这里
工作原理:
内核每次时钟中断都把当前时间写入 vvar 页
v
进程调用 gettimeofday()
v
glibc 发现有 vDSO -> 跳转到 vdso 里的实现
v
直接从 vvar 页【读取】时间值,做点换算,返回
v
全程在 Ring 3,【没有 syscall 指令】,没有特权级切换
通过 vDSO 实现的调用(x86_64):
# 只有这几个
# __vdso_clock_gettime
# __vdso_gettimeofday
# __vdso_time
# __vdso_getcpu
性能差距实测:
// bench.c
#include <time.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <stdio.h>
int main() {
struct timespec ts;
// ① 走 vDSO
for (int i = 0; i < 10000000; i++) clock_gettime(CLOCK_MONOTONIC, &ts);
// ② 强制走真正的系统调用
for (int i = 0; i < 10000000; i++) syscall(SYS_clock_gettime, CLOCK_MONOTONIC, &ts);
return 0;
}
① clock_gettime(vDSO) 约 20 ~ 25 ns/次
② syscall(SYS_clock_gettime) 约 150 ~ 250 ns/次
差距约 8 ~ 10 倍
规律:time.Now() 之所以可以随便调,是因为它走 vDSO;而 os.Getpid() 这类没有 vDSO 实现的调用要真正陷入内核。
坑:
clocksource配置错误会让 vDSO 失效。如果虚拟机的时钟源是xen或acpi_pm而不是tsc,内核无法保证用户态读取的一致性,vDSO 会退化为真正的系统调用,性能骤降。这在云主机上是个真实的性能陷阱:
cat /sys/devices/system/clocksource/clocksource0/current_clocksource
# tsc <- ✅ 最快,vDSO 生效
# kvm-clock <- ✅ 虚拟机上正常
# xen / acpi_pm / hpet <- ❌ vDSO 失效,每次取时间都进内核
高并发服务在这种机器上,仅「取时间」一项就可能吃掉 10% 以上的 CPU。
4. strace:看进程在做什么系统调用
strace 是排查「进程卡住了」「进程为什么这么慢」的第一工具。它基于 ptrace 系统调用,拦截目标进程的每次系统调用。
4.1 基本用法
# 跟踪一个新启动的命令
strace ls /tmp
# 附加到已运行的进程(最常用)
strace -p 1234
# 跟踪所有线程和子进程 <- 多线程服务必须加 -f
strace -f -p 1234
4.2 统计模式最有用
排查性能问题时,先用 -c 拿到全局分布,而不是看逐条输出:
strace -c -f -p 1234
# 按 Ctrl+C 停止后输出统计
# % time seconds usecs/call calls errors syscall
# ------ ----------- ----------- --------- --------- ----------------
# 68.21 12.340219 138 89234 futex
# 15.10 2.734012 11 234012 epoll_wait
# 8.32 1.505234 6 234011 read
# 4.11 0.743012 3 234010 write
# 2.01 0.363901 3639 100 12 connect
# ------ ----------- ----------- --------- --------- ----------------
# 100.00 18.096378 791367 12 total
从这张表能直接读出结论:
futex占 68% → 锁竞争严重(第 7 篇的案例就是这么定位的)connect单次 3.6ms 且有 12 个错误 → 连接下游有问题read/write调用次数相同且都是 23 万 → 一读一写配对,说明是请求-响应模式
4.3 常用选项
-c 只输出统计,不输出每条调用 <- 性能排查首选
-f 跟踪子进程和线程 <- 几乎总要加
-e trace=... 只跟踪指定的调用
-T 显示每个调用的耗时 <- 找慢调用
-tt 显示微秒级时间戳
-s 1024 字符串参数显示长度(默认只有 32,太短)
-o file 输出到文件
-y 把 fd 显示成实际的文件名/socket <- 很实用
-p PID 附加到进程
按类别过滤:
strace -e trace=file -p 1234 # 只看文件操作(open/stat/unlink...)
strace -e trace=network -p 1234 # 只看网络(socket/connect/send...)
strace -e trace=process -p 1234 # 只看进程操作(fork/exec/wait...)
strace -e trace=openat,read,write -p 1234 # 指定几个
4.4 三个实战场景
场景一:找出程序在读哪个配置文件
strace -f -e trace=openat -y ./myapp 2>&1 | grep -v ENOENT
# openat(AT_FDCWD, "/etc/myapp/config.yaml", O_RDONLY) = 3</etc/myapp/config.yaml>
# ^^^^^^^^^^^^^^^^^^^^^^^ 找到了,原来读的是这个路径
「程序说找不到配置文件」但你确定文件存在时,这一招能立刻看出它在找哪个路径。过滤掉 ENOENT 是因为动态库查找会产生大量正常的失败尝试。
场景二:进程卡住了,在等什么
strace -p 1234
# futex(0x7f8c4c0e9128, FUTEX_WAIT_PRIVATE, 2, NULL
# ^ 挂在这里不动了 -> 在等一把锁,死锁或长时间持锁
# 另一种
# read(8, ^ 卡住
# 用 -y 看 fd 8 是什么
strace -y -p 1234
# read(8</dev/pts/2>, ... <- 在等终端输入
# read(8<socket:[12345]>, ... <- 在等网络对端
配合 /proc 交叉验证:
ls -l /proc/1234/fd/8
# lrwx------ 1 app app 64 Aug 6 22:00 8 -> socket:[89234]
ss -tanp | grep 89234
# ESTAB 0 0 10.0.0.5:45678 10.0.3.9:3306 users:(("myapp",pid=1234,fd=8))
# ^^^^ 在等 MySQL 响应 -> 查慢 SQL
这个链路(strace 找到卡住的 fd → /proc 查 fd 是什么 → ss 查对端是谁)是排查「服务无响应」的标准套路。
场景三:找出慢的系统调用
strace -T -f -p 1234 2>&1 | awk -F'<' '$2+0 > 0.1 {print}'
# read(8, "...", 4096) = 234 <2.845123>
# ^^^^^^^^ 这次 read 花了 2.8 秒
4.5 strace 的代价
strace 会让目标进程慢 10~100 倍,因为每次系统调用都要陷入 ptrace 让 tracer 处理,等于给每个系统调用增加了两次上下文切换。
正常: syscall -> 内核处理 -> 返回
被 strace: syscall -> 内核停下进程 -> 唤醒 strace -> strace 处理 -> 恢复进程 -> ...
生产环境使用的纪律:
# ✅ 短时间采样,用 timeout 强制结束
timeout 10 strace -c -f -p 1234
# ✅ 只跟踪关心的调用,减少拦截次数
strace -c -e trace=read,write -p 1234
# ❌ 不要长时间挂着 strace 跑一个高 QPS 的服务
# ❌ 不要对 QPS 上万的进程无过滤地 strace
坑:
strace -p需要ptrace权限。容器里或开了kernel.yama.ptrace_scope=1的系统上会报Operation not permitted。容器里需要--cap-add=SYS_PTRACE,K8s 里需要securityContext.capabilities.add: ["SYS_PTRACE"]。
替代方案:现代内核上 perf trace 和 bpftrace 基于 eBPF,开销小得多(通常 < 5%),适合生产环境常态观测。第 18 篇会讲。
# perf trace:开销远小于 strace
perf trace -p 1234
# bpftrace:统计系统调用分布,几乎无侵入
bpftrace -e 'tracepoint:raw_syscalls:sys_enter /pid == 1234/ { @[probe] = count(); }'
5. 减少系统调用:性能优化的重要方向
既然一次系统调用要 100~200ns(而普通函数调用只要 1~2ns),减少次数就是实打实的优化。
5.1 批量化
// ❌ 每写一行调用一次 write —— 1 万次系统调用
for (int i = 0; i < 10000; i++) {
write(fd, line[i], len[i]);
}
// ✅ 用 writev 一次提交多个缓冲区 —— 1 次系统调用
struct iovec iov[10000];
/* 填充 iov */
writev(fd, iov, 10000);
// ✅ 或者用户态缓冲,攒够再写 —— 这就是 stdio 和 bufio 的原理
这正是 printf 比 write 快的原因——它在用户态攒缓冲,攒到 4KB 或遇到换行才真正调一次 write。
// Go 里的对应实践
// ❌ 每次 Write 都是一次系统调用
for _, line := range lines {
file.Write(line)
}
// ✅ 用 bufio 包一层,默认 4KB 缓冲
w := bufio.NewWriterSize(file, 64*1024)
for _, line := range lines {
w.Write(line)
}
w.Flush() // 别忘了
5.2 零拷贝
「把文件内容发到网络」的传统方式要 4 次拷贝、2 次系统调用:
传统 read + write:
磁盘 -> 内核 page cache --copy--> 用户态 buffer --copy--> 内核 socket buffer -> 网卡
(系统调用①) (系统调用②)
4 次上下文切换,2 次多余的内存拷贝
sendfile:
磁盘 -> 内核 page cache ------直接----> 内核 socket buffer -> 网卡
(1 次系统调用,数据不进用户态)
2 次上下文切换,0 次多余拷贝
// 一个系统调用完成「读文件 + 发网络」
sendfile(out_socket_fd, in_file_fd, &offset, count);
nginx 的 sendfile on;、Kafka 的高吞吐、Go 的 io.Copy(对 *os.File → *net.TCPConn 会自动用 sendfile)都是这个机制。
# 验证 Go 的 io.Copy 用了 sendfile
strace -f -e trace=sendfile,read,write ./fileserver
# sendfile(6, 5, NULL, 1048576) = 1048576 <- 走了零拷贝
其他零拷贝手段:
| 机制 | 适用场景 |
|---|---|
sendfile |
文件 → socket(最常用) |
splice |
两个 fd 之间,至少一端是管道 |
mmap + write |
需要在用户态处理内容时 |
MSG_ZEROCOPY |
大块网络发送(4.14+) |
io_uring |
通用异步 IO,批量提交(5.1+) |
5.3 io_uring:批量提交的终极形态
io_uring(5.1+,5.10 后成熟)用共享内存环形队列替代了「每个操作一次系统调用」的模型:
传统: 每个 IO 一次系统调用
read() read() read() read() -> 4 次陷入内核
io_uring: 批量提交 + 批量收割
把 4 个请求写进 SQ(提交队列,共享内存)
v
io_uring_enter() 一次系统调用提交全部
v
从 CQ(完成队列)读结果,甚至可以完全不用系统调用(轮询模式)
在高 IOPS 场景下,io_uring 相比 epoll + read/write 能有 2 倍以上的提升。但它要求内核 5.10+,且 API 复杂,目前主要在数据库、存储系统里使用。
6. 实战:一个「莫名很慢」的服务
现象:一个 Go 写的日志聚合服务,QPS 只有 3000,但 CPU 已经占满 4 核。用 pprof 看不出明显热点,最大的函数只占 8%。
① 先看 CPU 时间的用户态/内核态分布
pidstat -u 1 3 -p 1234
# 时间 UID PID %usr %system %CPU CPU Command
# 22:15:01 0 1234 92.00 308.00 400.00 3 logagg
# ^^^^^ ^^^^^^ 内核态占了 308%,用户态只有 92%
**关键发现:77% 的 CPU 时间在内核态。**pprof 主要采样用户态代码,所以看不出问题——这就是 pprof 找不到热点的原因。
② 用 strace 统计看是什么系统调用
timeout 10 strace -c -f -p 1234
# % time seconds usecs/call calls errors syscall
# ------ ----------- ----------- --------- --------- ----------------
# 71.23 8.234012 2 4012340 write
# 12.01 1.388234 5 277612 epoll_pwait
# 8.44 0.975623 3 325204 read
# ^ 每次只 2μs,但调了 400 万次!
400 万次 write,QPS 只有 3000 —— 每个请求平均产生 1300 次 write。
③ 确认写的是什么
strace -f -y -e trace=write -p 1234 2>&1 | head -5
# write(2</var/log/logagg.log>, "level=debug msg=\"parsing field\"...", 48) = 48
# write(2</var/log/logagg.log>, "level=debug msg=\"field parsed\"...", 45) = 45
# write(2</var/log/logagg.log>, "level=debug msg=\"validating\"...", 42) = 42
# ^^^^^^^^^^^^^^^^^^^^^^ debug 日志,而且【每条都单独 write】
原因:两个问题叠加。
- 生产环境误开了 debug 日志级别,每处理一个字段都打一条
- 日志库配置成了无缓冲直写(
os.Stderr没有包bufio),每条日志一次write系统调用
每次 write 只 2μs 看起来很快,但 400 万次 × 2μs = 8 秒的纯内核时间。
修复:
// ❌ 原来的配置:直接写 os.Stderr,无缓冲
logger := zerolog.New(os.Stderr)
// ✅ 加缓冲 + 调日志级别
zerolog.SetGlobalLevel(zerolog.InfoLevel) // 关掉 debug
bw := bufio.NewWriterSize(os.Stderr, 256*1024) // 256KB 缓冲
logger := zerolog.New(bw)
// 定时 flush,保证低流量时日志也能及时落盘
go func() {
t := time.NewTicker(time.Second)
for range t.C { bw.Flush() }
}()
// 别忘了在优雅关闭时 bw.Flush()(第 8 篇)
验证:
timeout 10 strace -c -f -p 1234
# % time seconds usecs/call calls errors syscall
# 42.11 0.234012 8 29234 epoll_pwait
# 21.03 0.116823 4 29230 read
# 18.22 0.101234 32 3140 write
# ^^^^ 从 400 万降到 3140 次
结果:%system 从 308% 降到 34%,QPS 从 3000 升到 41000,CPU 使用率反而从 400% 降到 120%。
规律:%system 远高于 %usr 时,pprof 帮不上忙——要用 strace -c 看系统调用分布。日志和 IO 的缓冲配置是最常见的元凶。
7. 一些量级感
| 操作 | 开销 |
|---|---|
| 普通函数调用 | 1 ~ 2 ns |
| 原子操作 / 无竞争的 mutex | 10 ~ 25 ns |
clock_gettime(走 vDSO) |
20 ~ 25 ns |
malloc(池内命中) |
20 ~ 50 ns |
一次系统调用(如 getpid) |
100 ~ 200 ns |
clock_gettime(vDSO 失效时) |
150 ~ 250 ns |
| 有竞争的 mutex(走 futex) | 1 ~ 5 μs |
| 线程上下文切换 | 1 ~ 2 μs |
read 命中 page cache(4KB) |
1 ~ 3 μs |
read 未命中,SSD |
50 ~ 150 μs |
read 未命中,机械盘 |
5 ~ 10 ms |
用法举例:一个接口如果调用了 500 次系统调用,光这部分就是 500 × 150ns = 75μs。对于目标 P99 = 10ms 的接口无所谓,但如果目标是 P99 = 200μs,这就占了 37%——高频交易、缓存代理这类场景必须逐个抠系统调用次数。
8. 面试题
Q:为什么要区分用户态和内核态?
为了保护。如果应用程序能直接执行特权指令、访问任意内存和硬件,一个有 bug 的程序就能搞崩整台机器,恶意程序能读取所有进程的数据。所以 CPU 硬件提供特权级机制,x86 有 Ring 0~3,Linux 只用 Ring 0(内核)和 Ring 3(用户)。Ring 3 不能执行特权指令(hlt、cli、in/out)、不能访问内核内存页、不能修改页表,一旦尝试就触发保护异常——「段错误」就是这个机制在起作用。注意:用户态和内核态不是两类进程,而是同一个进程的两种运行状态,top 里的 us 和 sy 就是这两种状态的时间统计。
Q:一次系统调用的开销大概是多少?为什么比函数调用贵这么多?
约 100~200ns,而普通函数调用只有 1~2ns,差两个数量级。开销来自:执行 syscall 指令触发特权级切换、CPU 保存用户态的 rip/rflags、切换到内核栈、保存全部寄存器、查系统调用表分发、返回时恢复现场。此外还有隐性开销——进出内核会污染 CPU cache 和分支预测器。开了 Spectre/Meltdown 缓解(KPTI)的机器上开销会翻倍,因为进出内核要切换页表。这就是「减少系统调用次数」成为性能优化重要方向的原因:批量写(writev/bufio)、零拷贝(sendfile)、批量提交(io_uring)都在做这件事。
Q:malloc 是系统调用吗?
不是。malloc 是 glibc 在用户态维护的内存池,大部分调用只是在已有的池子里划一块内存返回,全程不进内核,开销只有几十纳秒。只有当池子空间不足时,才会调用真正的系统调用 brk(扩展堆顶)或 mmap(映射新的内存区域)向内核申请。同理 printf 也不是系统调用——它在用户态格式化并缓冲,攒够 4KB 或遇到换行才调一次 write。pthread_mutex_lock 是两阶段的:无竞争时只做一次原子 CAS(约 20ns),有竞争才调 futex 陷入内核睡眠(微秒级)——所以 strace 里 futex 调用暴增就是锁竞争激烈的信号。
Q:vDSO 是什么?为什么 gettimeofday 不需要陷入内核?
vDSO(virtual Dynamic Shared Object)是内核映射到每个进程地址空间里的一小段代码和数据(/proc/self/maps 里的 [vdso] 和 [vvar])。内核在每次时钟中断时把当前时间写入 vvar 页,进程调用 clock_gettime 时,glibc 跳转到 vdso 里的实现,直接从用户态读取那个内存页,全程没有 syscall 指令、没有特权级切换。性能差距约 8~10 倍(20ns vs 200ns)。这类调用只有 clock_gettime、gettimeofday、time、getcpu 几个——它们的共同特征是「调用极频繁 + 逻辑极简单 + 只读内核数据」。坑:如果虚拟机的 clocksource 不是 tsc/kvm-clock(比如是 xen 或 acpi_pm),内核无法保证用户态读取的一致性,vDSO 会退化成真正的系统调用,高并发服务仅取时间就可能吃掉 10% CPU。
Q:strace 的原理是什么?为什么会让程序变慢?生产环境能用吗?
基于 ptrace 系统调用。内核在目标进程每次进入和退出系统调用时停下它,唤醒 tracer(strace)去读取寄存器和参数,处理完再恢复目标进程。所以每次系统调用额外增加了两次上下文切换和两次进程停止/恢复,导致目标进程慢 10~100 倍。生产环境要用的话必须有纪律:用 timeout 10 限时、用 -c 只取统计而不逐条输出、用 -e trace= 限定范围,绝不长时间挂在高 QPS 服务上。更好的选择是基于 eBPF 的 perf trace 或 bpftrace,开销通常小于 5%,适合常态观测。注意:容器里用 strace 需要 SYS_PTRACE capability。
Q:怎么用 strace 排查一个「无响应」的服务?
三步链路。① strace -p PID 看它卡在哪个系统调用——卡在 futex 是等锁(死锁或长持锁),卡在 read/epoll_wait 是等外部数据。② 如果卡在 read,加 -y 让 strace 显示 fd 对应的实体,或者查 ls -l /proc/PID/fd/N。③ 如果是 socket,用 ss -tanp | grep <inode> 找出对端是谁——通常会发现在等数据库或下游服务响应,问题就转移到那边去了。多线程服务记得加 -f,否则只跟踪主线程会看不到真正卡住的那个。
Q:sendfile 为什么叫零拷贝?省掉了什么?
传统的「读文件发网络」要 read + write 两次系统调用、四次数据拷贝:磁盘 → 内核 page cache → 用户态 buffer → 内核 socket buffer → 网卡。中间那两次「内核↔用户态」的拷贝是纯粹的浪费,因为应用根本不需要看这些数据。sendfile 让数据直接在内核内部从 page cache 送到 socket buffer,一次系统调用完成,省掉了两次内存拷贝和两次上下文切换。nginx 的 sendfile on、Kafka 的高吞吐都依赖它,Go 的 io.Copy 在 *os.File → *net.TCPConn 时会自动使用。代价是应用无法在传输过程中修改数据——需要处理内容(如加密、压缩)时就用不了,得退回 mmap + write。
Q:一个服务 CPU 占满但 pprof 看不出热点,怎么查?
先用 pidstat -u -p PID 看 %usr 和 %system 的比例。如果 %system 远高于 %usr,pprof 就帮不上忙——它主要采样用户态代码的调用栈,内核态的时间它看不见。这时候用 strace -c -f -p PID 看系统调用分布,通常能立刻发现问题:某个系统调用被调用了几百万次。最常见的元凶是日志无缓冲(每条日志一次 write)、误开 debug 级别、小块 IO 没有批量化。修复方式是加 bufio 缓冲、用 writev 批量提交。这类问题的特征很鲜明:单次系统调用很快(几微秒),但次数是业务 QPS 的成百上千倍。
上一篇:Linux-08 信号与进程间通信 | 下一篇:Linux-10 内存管理
xingliuhua