系统调用与中断 入门

一个跑着你代码的核,碰不到磁盘、碰不到套接字、也碰不到别的进程的内存。它对外部世界做的每一件事,都是因为控制权跨进了内核又跨了回来。 3 个你能亲手拖出来的论断:门是由引发它的那一方挑的;价钱横跨 4 个数量级,从 2 ns 的一次函数调用,到 26 µs 的冷缓存尾巴;贵的从来不是那条指令 —— 而是被刷掉的 TLB、冷掉的缓存,和被唤醒的线程。

01

跨过这条线的三扇门

控制权离开用户态只有 3 条路。真正把它们分开的不是内核接下来做什么,而是谁挑的这个时刻。

你的代码跑在 ring 3 里,特权指令在这里会出错,内核自己的页也没有映射进来。进 ring 0 不是跳到某个地址:硬件只给 3 扇门,没有第 4 扇。

syscall 指令是你自己的。缺页被钉死在引发它的那条 load 上。中断则落在设备恰好逮住流水线的地方 —— 挑一扇门,再把那根紫色的标记沿着指令流拖一拖,看 3 扇门里哪一扇会跟着你的手走:

落在第 8 条和第 9 条之间。左右拖动;方向键一次移动一格,Home 回到初始位置
系统调用 —— 落在 syscall 指令上 —— 第 7 条

注意只有中断会动。选着系统调用去拖,紫色虚线跑遍整条流,而那次跨越一直钉在第 7 条上;换成异常,它就同样倔强地待在第 4 条。同步和异步的全部区别就在这里:系统调用和异常是指令流的后果,中断只是跟指令流撞上了而已。

它们差别更大的地方在回来的路上。点播放,看标记离开指令条、穿过内核、再落回来 —— 然后换一扇门,看落点那一格怎么挪:

系统调用 —— 运行中 · ring 3

系统调用落在第 8 条,也就是它的下一条。中断落在第 9 条,正是它离开的地方。异常落在第 4 条 —— 是同一条 load 重跑一遍,因为处理程序的全部工作就是让它这次能成功。

进来的路上,3 扇门付的是同一张硬件账单,而 2018 年以后这张账单上多了几项附加费。一项一项加上去,看一次空的 getpid() 怎么往上爬:

开了 0 项 —— 每次空系统调用 50 ns

注意每一档都从上一档结束的地方开始,所以写在条子末端的数是累计值,两条之间的落差就是那项缓解的价钱:;在有 PCID 的硬件上 ;。值得盯住的是中间那一档:同样是两次 CR3 写,硬件能给它打标签时是 36 ns,不能打标签时要再多 77 ns —— 因为不带标签的那次写会把这个线程所有的地址翻译全扔掉。

纳秒只有乘起来才有意思。第一根滑块设调用速率,第二根设开几项缓解,读出一个核里有多大一份纯粹花在进出内核上:

100 k/s —— 一个核的 0.86%

看着这根条子随速率一格格填满。 —— 一台普通 web 服务器 —— 是 0.86%,没人会去 profile 它。是 8.6%。,是 97%:一个核完全被跨线占满,你的指令一条都没执行。这就是 io_uring 和 epoll 存在的原因,也是为什么有意思的优化从来不是把系统调用做快。

02

进来的路上,硬件做了什么

第一条内核指令跑起来之前,有 4 件事已经发生了,而每一件都能藏下一笔开销或者一个 bug。

光把 ring 抬上去没有用,还得有个地址可抬。对中断和异常来说,这个地址来自一张表 —— 内核在启动时填好,CPU 自己用硬件去索引,路上没有任何软件。

256 个向量每一个都装着一个 16 字节的门 —— 处理程序地址、目标 ring、栈切换提示。走一遍向量号,看内核往里放了什么:

向量 14 —— CPU 异常

这张表是 4096 字节:256 个门乘 16,正好一个页,这并不是巧合。头 32 项由体系结构钉死 —— 不管操作系统怎么想,第 14 号就是缺页 —— 32 以上则由内核在探测设备时自己分配。

门里还写着要切到哪个栈,因为用户栈信不过。把陷阱帧一个槽一个槽地压到内核自己的栈上:

压了 0 个槽 —— 0 字节

注意头 5 个先下去:那是 CPU 自己用微码压的,在任何一条内核指令执行之前。剩下的是入口桩干的活。21 个槽、168 字节,压在一个 16 KiB 的栈上 —— 只占 1%,所以内核栈溢出来自很深的调用链,而不是来自这个帧。

系统调用根本不用那张表。SYSCALL 直接跳到 LSTAR MSR 里的地址 —— 而这条捷径的代价是一个寄存器:

C 调用 —— 第 4 个参数走 rcx

C 约定把第 4 个参数放在 rcx 里。内核从 r10 里取,因为在你的代码还没停下来的时候,SYSCALL 就已经把返回地址写进了 rcx、把 RFLAGS 写进了 r11。只有一个格子不一样,而每个手写系统调用桩的人都会在这个格子上错一次。

最后一块是 KPTI,它给内核自己一套页表。于是每次跨越要写两次 CR3,疼不疼取决于一个 bit。设一个工作集,再把标签关掉:

什么都没刷掉

因为带标签的写不动那些条目,64 条全都活了下来,没有页表要走。时,同样这次写会把它们刷掉:64 个页在返回之后要花 533 ns 走页表,而要付 12.8 µs —— 摊在接下来几千条指令上,任何套在系统调用外面的计时器都看不见。

03

为什么上半部必须很小

硬件处理程序跑的时候,自己那条中断线是被屏蔽的。就这一条事实,塑造了 Linux 所有的设备驱动。

屏蔽不是出于礼貌 —— 它是防止一个处理程序被它正在处理的那个中断重入。但它同时也意味着,在处理程序返回之前,设备说不了别的话。

于是处理程序的长度就成了整条线的上限。拖它的右边缘,看有多少次到达掉进这个聋掉的窗口里:

处理程序跑 1.5 µs. 左右拖动;方向键一次移动一格,Home 回到初始位置
屏蔽 1.5 µs —— 上限 667 k/s

注意这门槛有多低。1.5 µs 的处理程序把线封在每秒 66.7 万,已经低于 10 GbE 满帧时每秒 81.3 万个包。 10 微秒 —— 一段完全合理的代码 —— 把它封在每秒 10 万,比线速差 8 倍。

Linux 的答案是把活切成两半。把它挪出被屏蔽的窗口,放进一个开着中断跑的 softirq 里:

推迟 0.00% —— 线上限 100 k/s

看什么变了、什么没变。线的上限从每秒 10 万涨到每秒 250 万,因为聋掉的窗口从 10 µs 缩到了 0.4。而核的上限自始至终都是每秒 10 万,因为推迟的那部分活仍然要花掉某个人的 10 µs。这个切分买来的是可用性,不是吞吐 —— 把这两件事搞混,就是驱动「修好了」而机器照样丢包的原因。

softirq 也不能一直跑下去。它排干能排的,剩下的交给 ksoftirqd —— 一个跟你的线程抢 CPU 的普通被调度线程。把环填满,去找那堵墙:

排着 150 个 —— 0 个给 ksoftirqd

墙在 200 个包那里,而不是在人人都去调的 netdev_budget 的 300 上。net_rx_action 会在 300 个包或者 netdev_budget_usecs 处停下,而 2000 µs 的墙上时间在 200 个 10 µs 的包之后就用完了。在这台机器上把包预算调大,什么都不会改变。

剩下的就是中断本身了,仍然是一个包一次。NAPI 把它也干掉了:收下第一次,然后改成轮询。把一次轮询排干的批量调大,看每个包的开销怎么塌下去:

排着 1 个包。左右拖动;方向键一次移动一格,Home 回到初始位置
1 个包 —— 每个 2.0 µs

一次轮询一个包时,中断要吃掉这个包值 10 µs 里的 2.0 µs。到 NAPI_POLL_WEIGHT 的 64 时是 31 ns,到 300 时是 6.7 ns —— 不到百分之一。这条曲线的尾巴,就是为什么同一个驱动在空闲链路上延迟低、在繁忙链路上吞吐高,中间没有人拧过任何旋钮。

04

一次上下文切换真正的代价

人人都在引用的那个数字,是微基准测得见的那一半,而且是账单里小的那一半。

切换不是一扇门 —— 它发生在内核内部,在三扇门之一之后。但它正是一次会阻塞的系统调用真正买到的东西,所以它的价钱属于这一页。

先从好量的那部分开始。选一对任务,看直接开销里哪几行会被计费:

两个线程 —— 700 ns

注意这一对任务不用付的那几行只画了框,没有填色。同一个进程的两个线程共享地址空间,所以不用写 CR3:700 ns。两个进程要写一次:1.2 µs。开了 KPTI 之后,两边的进出各自还要再换一次表:1.9 µs。这个梯子上的每一项都是一次寄存器搬运或者一个表指针,而这些正是 lat_ctx 报出来的全部。

然后新线程开始跑,跑进一堆装满别人数据的缓存里。拖动它要拉回来的工作集,把尾巴跟切换本身比一比:

工作集 32 KiB. 左右拖动;方向键一次移动一格,Home 回到初始位置
尾巴 3.3 µs —— 切换的 2.7 倍

—— 一个 L1 —— 时,尾巴是 3.3 µs,已经是切换本身的 2.7 倍。到 是 26 µs,21.8 倍。这就是 Li、Ding 和 Shen 在 2007 年量到的那笔开销,也是为什么一个在两个什么都不干的线程之间来回切的基准,几乎没法告诉你任何关于你服务器的事。

这也正是把一次便宜的系统调用变贵的东西。页缓存能答上来的 read 从来不离开 CPU;会阻塞的那次要付两次切换外加一次唤醒。给设备一点延迟,比比看:

阻塞的 read 8.6 µs

1.2 µs 对 8.6 µs —— 7 倍,而且这还是设备秒答的情况。内核并没有把这段时间闲着:别人在这个空档里跑,这正是阻塞存在的全部理由。付账的是你的延迟,不是机器的吞吐。

调度器要决定多久付一次这笔钱,而它没法两头都赢。挪动时间片,看两条曲线怎么闯进对方的地盘:

开销 0.04% —— 最坏等 21 ms

看这两笔开销怎么换位置。在 CFS 自己那个 上,切换花掉 0.04%,而第 8 个可运行任务要等 21 ms。往下拖到 ,等待降到 350 µs,切换涨到 2.3%;到 是 11%,机器基本上在忙着改主意。两条曲线并不会真的相交 —— 它们的单位不一样 —— 但没有哪个设置能两边都好,所以延迟问题的答案通常是「可运行线程更少」,而不是「时间片更小」。

05

信号 —— 回去的那条路

内核没有自己进你进程的门。它只能等你的门开一次,而信号所有的怪脾气都是从这一点长出来的。

kill(pid, SIGTERM) 什么都没调用。它在目标的挂起掩码里置一个位,然后返回。这个位要等目标下一次从内核回到用户态时才被读到 —— 而且只在那里。

所以延迟不来自内核的调度,而是目标在里面待了多久。选一个睡眠状态,把它待在里面的时间拉长:

运行中 —— 立刻送到

运行中的目标和可中断睡眠都会立刻收到 —— 睡眠正是为此被打断的。不可中断睡眠,也就是 D 状态,根本不在会检查的那条路上:信号要等满 6 ms,SIGKILL 也跟着一起等。这就是卡在挂死 NFS 上的进程杀不掉的原因,也是 kill -9 并不是大家以为的那张王牌的原因。

这个位终于被读到时,内核并不直接调用你的处理函数。它会在你的栈上搭一个帧,然后返回到那里。挑一套寄存器组,再把你给它的栈缩小:

952 字节 —— 放得下

看这个帧怎么长过为它留的地方。SSE 下是 952 字节;带上 AVX-512 的 xsave 区是 3128 字节,比老的 2048 字节的 MINSIGSTKSZ 多出 1080。这个常量当了三十年的编译期数字,最后不得不变成运行期的 —— sysconf(_SC_MINSIGSTKSZ),glibc 2.34 —— 因为指令集把它撑破了。

而你的处理函数是在信号逮住你的地方跑起来的。走一遍正好落在 malloc 里的那次投递,再换换处理函数做什么:

处理函数调 malloc —— 在 malloc 里 · 锁空着

因为被打断的代码正拿着 arena 锁,一个去调 malloc 的处理函数会卡在自己这个线程已经拥有的锁上,而这把锁再也不会被放开。安全的处理函数只写一个 sig_atomic_t 然后返回。处理函数里合法调用的完整清单在 signal-safety(7) 里,printf 不在上面。

还有一个后果,而且是会波及普通代码的那个。要把信号送给一个正卡在慢系统调用里的线程,总得先把那次调用结束掉。挪一挪信号落在传输的哪个位置:

一个字节都还没到。左右拖动;方向键一次移动一格,Home 回到初始位置
read 带着 EINTR 返回 −1

第一个字节之前,read 返回 −1 加 EINTR —— 或者,开了 SA_RESTART 时,内核把 rip 倒回去,谁也没察觉。第一个字节之后,两种设置都返回一个短计数,外加根本没有错误,因为已经有结果要报,而报出去的收不回来。一个把短读当成流结束的循环,在信号到来之前一直是对的,之后就变成了安静的、复现不出来的错。

06

速查

3 个值得不查资料就能答的问题、一个永远正确的处理函数形状,还有 5 个红旗。

一次跨越到底有多贵?

取决于是哪一种跨越,差着 4 个数量级,所以唯一有用的答案是一次看完整个梯子。这一页量过的每一样,跟一次普通函数调用比:

函数调用 —— 2.0 ns

注意上面 4 档在轴上挤得多近。等于 43 次函数调用 —— 烦人,但不致命。 是 4300 次,是 13,107 次。值得为之做工程的横档全在轴的下半段,而它们里面没有一个是 syscall 指令。

一个请求的时间都去哪了?

去数跨越的次数,而不是去掐表。设一个请求跨几次线,读出它跟你自己那 50 µs 的活之间的分成:

59 µs —— 16% 在跨线

看中间那一段怎么随跨越次数长大。,是一个请求 59 µs,其中 16% 花在跨线上。是 88 µs 和 43%。解法是 readv 或 io_uring。

信号处理函数安全的形状是什么?

一次存储,加上一个预期自己会被打断的循环:

static volatile sig_atomic_t stop;
void on_term(int s) { stop = 1; }  /* all */

while (!stop) {
  n = read(fd, buf, len);
  if (n < 0 && errno == EINTR) continue;
  if (n <= 0) break;   /* real error, or EOF */
  consume(buf, n);     /* n may be short */
}

剩下的问题只有一个:按什么判据算安全?走一遍这条线两边的 8 个调用:

write —— 安全

安全那边全是裸系统调用,不安全那边都得先拿一把用户态的锁 —— 这就是 exit 不安全而 _exit 安全的原因。

评审时的 5 个红旗

  • 处理函数做的不止一次存储。那就是 §05 的死锁。
  • read 循环没有 EINTR 分支。只偶尔错。
  • 一个字段一次系统调用。每次 86 ns,用 readv。
  • 靠调小时间片修延迟。时间片减半,等待也减半,但切换开销翻倍:100 µs 时占一个核的 1.2%,50 µs 时 2.3%,10 µs 时 11%。
  • worker 的 CPU 上蹲着 ksoftirqd。用 /proc/irq/N/smp_affinity 挪走 IRQ。