目录

Linux-09 用户态、内核态与系统调用

前置阅读:Linux-07 进程调度与上下文切换

「系统调用」这个词在前面几篇里出现了几十次,但一直没有正面讲它。这一篇补上——系统调用是用户程序与内核之间唯一的合法通道,理解它的成本和机制,是理解一切 IO 性能优化的前提。

本篇会解释:为什么要有用户态和内核态之分、一次 read() 到底经过了什么、为什么 time.Now()os.Getpid() 快十倍、以及怎么用 strace 找出「进程到底卡在哪」。

1. 为什么要分用户态和内核态

一句话:因为不能让应用程序直接碰硬件。

如果任何程序都能直接读写磁盘、访问任意内存、操作网卡,那么一个有 bug 的程序就能把整台机器搞崩,一个恶意程序能读取所有其他进程的数据。所以 CPU 硬件层面提供了特权级机制。

1.1 CPU 的特权环

x86 架构提供四个特权级(Ring 0 ~ Ring 3),但 Linux 只用了两个:

./特权环.png

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 里的 ussy 就是这两种状态的时间统计:

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 失效。如果虚拟机的时钟源是 xenacpi_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 tracebpftrace 基于 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 的原理

这正是 printfwrite 快的原因——它在用户态攒缓冲,攒到 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】

原因:两个问题叠加。

  1. 生产环境误开了 debug 日志级别,每处理一个字段都打一条
  2. 日志库配置成了无缓冲直写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 不能执行特权指令(hltcliin/out)、不能访问内核内存页、不能修改页表,一旦尝试就触发保护异常——「段错误」就是这个机制在起作用。注意:用户态和内核态不是两类进程,而是同一个进程的两种运行状态top 里的 ussy 就是这两种状态的时间统计。

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 或遇到换行才调一次 writepthread_mutex_lock 是两阶段的:无竞争时只做一次原子 CAS(约 20ns),有竞争才调 futex 陷入内核睡眠(微秒级)——所以 stracefutex 调用暴增就是锁竞争激烈的信号。

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_gettimegettimeofdaytimegetcpu 几个——它们的共同特征是「调用极频繁 + 逻辑极简单 + 只读内核数据」。:如果虚拟机的 clocksource 不是 tsc/kvm-clock(比如是 xenacpi_pm),内核无法保证用户态读取的一致性,vDSO 会退化成真正的系统调用,高并发服务仅取时间就可能吃掉 10% CPU。

Q:strace 的原理是什么?为什么会让程序变慢?生产环境能用吗?

基于 ptrace 系统调用。内核在目标进程每次进入和退出系统调用时停下它,唤醒 tracer(strace)去读取寄存器和参数,处理完再恢复目标进程。所以每次系统调用额外增加了两次上下文切换和两次进程停止/恢复,导致目标进程慢 10~100 倍。生产环境要用的话必须有纪律:用 timeout 10 限时、用 -c 只取统计而不逐条输出、用 -e trace= 限定范围,绝不长时间挂在高 QPS 服务上。更好的选择是基于 eBPF 的 perf tracebpftrace,开销通常小于 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 内存管理