行情线程调一次 recv,在代码里是一行函数调用,在 CPU 上却要离开程序自己的代码,跳进内核,做完再跳回来。这一来一回不出事时一两百 ns,出事时(没数据先睡下、回来前被别的线程抢了核)是几 µs 到几 ms。所以低延迟程序总说“热路径不进内核”。要知道哪些写法会进内核,得先知道 CPU 怎么区分“程序”和“内核”、什么事会让它从一边走到另一边、做完又怎么回去。地址空间里内核那一段在哪,可以对照进程地图看。
1. 特权级(Ring):用户态不能做的事
- 两个级别
- Ring 3(用户态):普通程序跑在这里,只能用普通指令、访问自己地址空间里的用户那一部分。
- Ring 0(内核态):内核跑在这里,什么指令都能执行,什么地址都能访问。
- 当前在哪一级:x86 记在 CS 寄存器的低 2 位(CPL,current privilege level)。Ring 1、Ring 2 Linux 不用。
- ARM:叫 EL0 和 EL1,道理一样。
- 用户态被拦住的两类事
- 特权指令:改页表根 CR3、关中断
cli、读写 MSR、停机hlt、直接读写设备端口。在 Ring 3 执行会触发 #GP 异常,内核给进程发 SIGSEGV。 - 内核的地址:每个进程的页表里都映射着内核空间(进程地图最上面那一段),这些页表项上有一位 U/S 标着“只给内核”。用户态一读写就缺页,内核同样发 SIGSEGV。
- 所以:要碰硬件、别的进程、内核里的数据结构,只能请内核代办。 请的办法就是第 2 节的系统调用。
- 特权指令:改页表根 CR3、关中断
- 进内核不是换线程
- 还是同一个线程:进内核以后跑的是内核的代码,但还是这个线程在跑,用的是这个线程自己的内核栈(每个线程一份,见进程和线程)。
- 页表也不换:内核空间本来就映射在每个进程里,进内核不用换地址空间。KPTI 是例外(3.3)。
- 线程切换是另一回事:它只发生在内核里,所以进了内核才可能被换走(4.1)。
- 区别:进内核是“同一个线程换个特权级”,线程切换是“换一个线程来跑”。 前者每次都有,后者不一定。
2. 三种入口:系统调用、异常、中断
能让 CPU 从 Ring 3 进到 Ring 0 的只有三种事。区别在于是谁引起的,以及和当前这条指令有没有关系。这决定了能不能靠改代码避开它。
- 系统调用(system call):自己要的
- 怎么发起:把调用号放进 RAX,参数依次放进 RDI、RSI、RDX、R10、R8、R9,执行
syscall指令。read、send、epoll_wait、futex、mmap、clone这些 glibc 函数,里面就是这几步。 - 同步:发生在代码的固定位置,每次跑到这里都会进。
- 看着像、其实不进的:
clock_gettime、gettimeofday、getcpu走 vDSO。内核把一小段代码和时间数据映射进每个进程(进程地图里的[vdso]、[vvar]),在用户态读就行。 - 不一定进的:
malloc大多在 glibc 自己的空闲链表里就找到了(new 一次走到哪);printf先写进 stdio 的用户态缓冲区(文件 IO)。只有不够用、要刷出时,才调brk、mmap、write。
- 怎么发起:把调用号放进 RAX,参数依次放进 RDI、RSI、RDX、R10、R8、R9,执行
- 异常(exception):当前这条指令出了事
- 缺页(#PF):访问的页还没有物理页(第一次写
new来的内存),或者权限不对(fork之后写一个写时复制的页)。CPU 把出错的地址放进 CR2,进内核。 - 其他:除以 0(#DE)、非法指令(#UD)、在用户态执行特权指令(#GP)、调试断点
int3(#BP)。 - 同步:由当前指令引起,同一条指令、同样的数据,每次都会触发。
- 结局两种:内核修得好(补上物理页、复制一页),程序就接着跑,察觉不到,只是这条指令慢了;修不好就给进程发信号(SIGSEGV、SIGFPE、SIGILL、SIGTRAP),默认处理是杀掉进程。
- 缺页(#PF):访问的页还没有物理页(第一次写
- 中断(interrupt):外面来的,和当前指令无关
- 设备中断:网卡收到包、磁盘读完。数据是设备用 DMA 写进内存的,不靠中断处理函数搬;网卡的处理函数基本只是登记一下,把收包交给软中断(NAPI)去做。
- 时钟中断:每个核的本地定时器周期性地打断,调度器靠它记时间片、决定要不要换线程。
- 核间中断(IPI):别的核发来的,例如让这个核作废 TLB 里的旧项(TLB shootdown),或者叫它重新调度。
- 异步:随时可能来,代码里没有对应的位置。CPU 在两条指令之间检查,开着中断(RFLAGS 的 IF 位)才响应。在内核里跑的时候也会被打断。
| 系统调用 | 异常 | 中断 | |
|---|---|---|---|
| 谁引起 | 程序执行 syscall | 当前指令出错 | 设备、定时器、别的核 |
| 同步还是异步 | 同步 | 同步 | 异步 |
| 例子 | read、futex、mmap | 缺页、除以 0、int3 | 网卡收包、时钟、TLB shootdown |
| 改代码能不能避开 | 能:不调,或换成不进内核的做法 | 能:提前把页写好 | 不能:要靠配置把它挪走 |
前两种由自己的代码引起,改代码就能避开;中断不是,要靠配置把它从热核上挪走(第 6 节)。
3. 进去的那一刻:硬件只做一小步
三种入口在硬件上走两条路:syscall 一条,中断和异常一条。CPU 只做最少的事:换特权级、跳到内核入口,中断和异常再换一次栈、压五个值。通用寄存器从来不是硬件存的,是内核入口代码自己存的。
3.1 syscall 指令:存两个寄存器,跳到入口
- CPU 做的
- 记下回来的地址:下一条指令的地址存进 RCX,RFLAGS 存进 R11。
- 跳过去:RIP 换成 MSR
IA32_LSTAR里记的内核入口(Linux 是entry_SYSCALL_64),CS、SS 换成内核的段。特权级在跳转的同时变成 Ring 0,不是单独一步。 - 屏蔽标志:按
IA32_FMASK清掉 RFLAGS 里的几位。Linux 用它关掉中断,入口代码换好栈之前不会被打断。 - 不碰栈:RSP 还是用户栈,CPU 不往任何栈上压东西。
- 内核入口代码做的
swapgs:把 GS 换成指向本核数据区的值,后面才找得到本核的变量。- 换栈:把用户的 RSP 存到本核的临时位置,RSP 改成当前线程内核栈的栈顶。
- 切页表:只在开了 KPTI 时才做(3.3)。
- 保存现场:把用户态的通用寄存器全部压到内核栈上。这一块叫
pt_regs,回去时从这里恢复。 - 分派:打开中断,按 RAX 里的调用号,调到
sys_getppid这类函数。 - 结论:
syscall不是中断,也不换栈:硬件只做了跳转,现场是内核自己存的。 32 位时代的int 0x80才是借软中断的路进内核,比syscall慢。
- 为什么
syscall可以只存两个寄存器- 调用的一方知道:
syscall在代码里的位置是固定的,调用约定事先说好了 RCX 和 R11 会被覆盖,编译器不会在这两个寄存器里留有用的值。 - 所以:CPU 可以直接拿它们记回来的地址,不用压栈。
- 调用的一方知道:
3.2 中断和异常:查表,CPU 自己换栈、压五个值
- CPU 做的
- 查 IDT:每种中断、异常有一个号(缺页是 14,设备中断的号由内核分配),CPU 用这个号在中断描述符表(IDT)里找到处理函数的地址。
- 换栈:从用户态进来时,CPU 从 TSS(task state segment)里取出一个内核栈的地址,装进 RSP。Linux 在这里放的是本核的一小块入口栈,入口代码随后再换到当前线程自己的内核栈。
- 压五个值:把用户的 SS、RSP、RFLAGS、CS、RIP 压到内核栈上。缺页这类异常再压一个错误码,说明这次是读还是写、页在不在。
- 跳过去:跳到处理函数,特权级同时变成 Ring 0。中断门还会清掉 IF,处理函数开头不会被别的中断打断。
- 内核做的
- 保存现场:和 3.1 一样,处理函数开头把通用寄存器存成
pt_regs。 - 分派:再调到具体的处理代码,缺页是
handle_mm_fault。
- 保存现场:和 3.1 一样,处理函数开头把通用寄存器存成
- 为什么这次要硬件压栈
- 随时会来:中断和异常可能发生在任何一条指令处,那一刻每个寄存器里都可能是程序要用的值。
- 不能借寄存器:CPU 不能像
syscall那样拿 RCX、R11 记东西,只能先换到一个可靠的栈,把回来要用的五个值压上去。 - 结论:
syscall能少做,是因为它发生在程序事先知道的位置;中断和异常不知道在哪发生,硬件就得多做一步。
3.3 KPTI:进出时还要切页表
- 为什么要切:Meltdown 漏洞让用户态能通过推测执行,读到内核映射里的数据。修法是给每个进程两份页表:用户态用的那份几乎不映射内核,进内核时把 CR3 切到完整的那份,回去时再切回来。
- 代价:每次进出多写两次 CR3。CPU 没有 PCID 时,TLB 还会跟着清空。这是
mitigations=off能让系统调用变快的主要原因之一(#19 内核调优)。 - 哪些机器要切:只有受 Meltdown 影响的老 Intel 才要。看
/sys/devices/system/cpu/vulnerabilities/meltdown,写着Not affected的不切。
4. 回去:先看要不要换线程,再回到某一条
内核把事做完,不是马上回到用户态:它先查这个线程还有没有别的事要办,办完才执行返回指令。返回指令把特权级降回 Ring 3,跳回去的那一条,按入口不同而不同。
4.1 回去之前:内核先看要不要换线程
- 每次回用户态前都要查
- 要重新调度(need_resched):时间片用完了,或者有优先级更高的线程被唤醒,就先调
schedule(),把 CPU 交给别的线程。这个线程要等下次轮到它,才回用户态。 - 有信号要处理:先去跑信号处理函数(4.3),再回原来的位置。
- 都没有:直接返回,几十 ns 就回去了。
- 要重新调度(need_resched):时间片用完了,或者有优先级更高的线程被唤醒,就先调
- 会睡的系统调用
- 哪些会睡:
recv没有数据、futex抢不到锁、epoll_wait没有事件。线程直接在内核里睡下(状态 S),事件来了被唤醒,再排队等 CPU。 - 这段时间都算在“这次进内核”里:从唤醒到真正跑起来,约 2–10 µs(#16 自旋锁)。
- 结论:进出一次的直接开销是固定的几十到几百 ns;拖成长尾的,是在内核里睡下,或者回来前被调度走。
- 哪些会睡:
4.2 返回指令:降权限是它自己做的
- 系统调用走
sysret:内核先从pt_regs恢复通用寄存器,切回用户页表(KPTI 时)、swapgs、换回用户栈,再执行sysret。这条指令从 RCX 取回 RIP、从 R11 取回 RFLAGS,特权级同时降回 Ring 3。 - 中断和异常走
iret:从内核栈上弹出那五个值,RIP、RSP、RFLAGS、CS、SS 一起恢复,特权级同时降回 Ring 3。 - 顺序:降权限和跳回去是同一条指令做的,不是先降权限再返回。 权限一降,就再也执行不了内核的代码了。
4.3 回到哪一条:缺页重新执行出错的那条
- 系统调用
- 回到哪:
syscall的下一条,也就是 RCX 里记的那条。 - 返回值:放在 RAX 里。出错时是负的错误码,glibc 的包装函数把它存进
errno,再返回 -1。
- 回到哪:
- 中断
- 回到哪:被打断时还没执行的那条。
- 为什么:CPU 只在两条指令之间响应中断,所以那条还没开始执行。
- 异常分两种
- 故障(fault):回到出错的那条,重新执行。缺页就是这种:页补上之后,同一条指令再执行一遍就成功了。内核修不好的故障不会回来,而是发信号。
- 陷阱(trap):回到下一条。
int3属于这种,报上来的 RIP 已经指向它的下一条。
- 有信号时
- 先绕到处理函数:内核在用户栈上搭一个帧,把 RIP 指向信号处理函数,回用户态先跑它。
- 再回原处:处理函数返回时调
rt_sigreturn,再进一次内核,从那个帧里恢复寄存器,这才回到原来那一条。
5. 代价:进一次内核贵在哪
| 事情 | 大概多久 | 时间花在哪 |
|---|---|---|
clock_gettime(vDSO) | 约 20–30 ns | 不进内核,在用户态读 TSC 和换算参数 |
| 空的系统调用,不切页表 | 约 50–150 ns | 进出各一次、存取 pt_regs、返回前的检查 |
| 空的系统调用,切页表(KPTI) | 约 0.2–0.5 µs | 再写两次 CR3,没有 PCID 时还清 TLB |
| 缺页,补一页 | 约 0.5–2 µs | 分配物理页、清零 4 KiB、改页表 |
| 一次中断 | 约 1–5 µs | 处理函数加软中断,被打断的代码一直等着 |
| 在内核里睡下再被唤醒 | 约 2–10 µs 起 | 两次线程切换,再加排队等调度 |
- 直接开销
- 进出本身:见上表。
- 做事的系统调用:
send、read这类还要加上它自己的工作,比如过一遍协议栈、拷一次数据。
- 间接开销:回来以后还要慢一阵
- 缓存和 TLB 被挤:内核的代码和数据会挤掉 L1、L2 里热路径的缓存行和 TLB 项。回到用户态后,前几次访问要从更远的地方重新取。
- 分支预测:内核里的分支也用同一套预测器,回来后用户代码的预测可能变差。开了漏洞缓解时,内核还可能在进出时主动清掉一部分预测状态。
- 所以:只量
syscall那一下会低估,回来以后变慢的那一段也是代价。
- 放进延迟预算里
- 预算 2 µs 时:一次 0.1–0.5 µs 的系统调用就占 5%–25%,一次缺页或中断可能直接超出预算(#21 交易系统性能衡量)。
- 结论:热路径上要避开的不是“慢操作”,而是“可能进内核的操作”:直接开销已经不小,最坏情况更是没有上限。
6. 低延迟程序怎么少进内核
| 热路径上的来源 | 入口 | 怎么避开 | 详见 |
|---|---|---|---|
收发网络包:recv、send | 系统调用,没数据时还会睡 | 内核旁路:网卡把包直接 DMA 到用户态的内存,线程忙轮询(DPDK、ef_vi) | — |
等别的线程:futex、epoll_wait、条件变量 | 系统调用,会睡 | 忙轮询,循环里加 pause | #16 自旋锁 |
取时间:clock_gettime | 不进(vDSO) | 已经不进;要更快就直接读 rdtsc | #20 用 TSC 测延迟 |
分配内存:malloc、new | 偶尔系统调用,第一次写时缺页 | 启动时预分配、写一遍,mlockall 锁住,热路径用内存池 | new 一次走到哪、#3 内存池 |
写日志:write | 系统调用 | 把数据放进队列,另一个线程去写 | #4 无锁队列 |
| 设备中断、时钟中断 | 中断 | IRQ 亲和性把设备中断挪到别的核;isolcpus、nohz_full 清出热核 | #1 CPU 隔离、#19 内核调优 |
| TLB shootdown | 中断(IPI) | 运行期间不 munmap、不 mprotect,关掉透明大页的后台合并 | 上下文切换、页表与 TLB |
- 系统调用和异常:都在自己的代码里,热路径上一个一个找出来,挪到启动阶段,或者换成用户态的做法。
- 中断:只能靠配置把它挪走。
nohz_full也不是零,核上只有一个线程时,时钟中断还剩每秒约 1 次。 - 结论:热路径线程跑起来以后,应当一直留在 Ring 3:不调会进内核的函数,不碰没写过的页,核上不来中断。 做到了没有,要用第 7 节的办法数出来,不靠猜。
7. 在自己机器上看
一次系统调用和一次 vDSO 调用差多少
// 一次系统调用 vs 一次 vDSO 调用:各跑 100 万次,算平均每次多少 ns // g++ -O2 syscall_cost.cpp -o syscall_cost && ./syscall_cost #include <chrono> #include <cstdio> #include <ctime> #include <sys/syscall.h> #include <unistd.h> template <class F> double ns_per_call(F f, int n) { auto t0 = std::chrono::steady_clock::now(); for (int i = 0; i < n; ++i) f(); auto t1 = std::chrono::steady_clock::now(); return std::chrono::duration<double, std::nano>(t1 - t0).count() / n; } int main() { const int n = 1'000'000; volatile long sink = 0; // 真进内核:syscall() 直接发 syscall 指令,glibc 不缓存结果 double sys = ns_per_call([&] { sink = syscall(SYS_getppid); }, n); // 不进内核:clock_gettime 走 vDSO,在用户态读内核映射出来的时间数据 timespec ts; double vdso = ns_per_call([&] { clock_gettime(CLOCK_MONOTONIC, &ts); sink = ts.tv_nsec; }, n); std::printf("getppid(系统调用) %.0f ns/次\n", sys); std::printf("clock_gettime(vDSO) %.0f ns/次\n", vdso); }getppid:内核里几乎什么都不做,量出来的差不多就是进出一次的开销。用syscall()直接发指令,是为了不被 glibc 的包装挡住。clock_gettime:走 vDSO,全程在用户态。volatile的sink:让编译器不能把循环删掉。- 实测:在一台云虚拟机上(不受 Meltdown 影响,不切页表),
getppid约 100–130 ns 一次,clock_gettime约 20–26 ns 一次。
数一个程序调了哪些系统调用
strace -c -f ./syscall_cost # 结束时按系统调用汇总次数和时间 strace -f -tt -p <PID> # 跟着一个正在跑的进程,逐条打印- 汇总里有什么:上面的程序跑出
getppid1000000 次,没有clock_gettime,这正说明 vDSO 不进内核。 - 别拿 strace 测时间:strace 让每次系统调用都停下来,切到 strace 进程那边去记录。同一个程序在 strace 下,
getppid从约 100 ns 变成约 80 µs 一次。strace 只用来看调了什么、调了几次。
- 汇总里有什么:上面的程序跑出
看用户态和内核态各花了多久
time ./syscall_cost # user 是用户态的 CPU 时间,sys 是内核态的 top # 第三行的 us、sy 也是这两样- 怎么读:上面的程序
user0.072 s、sys0.075 s,一半时间花在内核里,就是那 100 万次getppid。 - 热路径线程:
sys应当接近 0。
- 怎么读:上面的程序
看线程睡了几次、核上来了多少中断
grep ctxt_switches /proc/<PID>/status # voluntary:自己睡下;nonvoluntary:被换下 cat /proc/interrupts # 每个核一列,各种中断来了多少次voluntary_ctxt_switches在涨:热路径上有会睡的调用。- 隔离核那一列的数在涨:中断没挪干净。
LOC是本地时钟,TLB是 TLB shootdown。