第47期 | 慢系统调用,被信号拍了肩膀
一、慢系统调用,被信号拍了肩膀:内核不替你做的决定
系统调用有第三种结局:不是成功,不是失败,是话说到一半,被人拍了肩膀。
Stevens 给这种调用起了个名字:慢系统调用²——可能永远阻塞的那些:读终端、读一只空 pipe、读 socket、wait、pause。它们进内核不是办事,是等人。等的时候信号来了,内核被问住:这事还办不办?read 读到一半,是交半截结果,还是从头再来?内核不知道应用想要哪种,于是做了最诚实的选择:叫醒调用,塞回一个 -EINTR——你自己决定。
这纠缠从第一天就在。Ritchie 和 Thompson 1974 年的 UNIX 论文里,"陷阱与信号"是正经的一节¹。1991 年的 Linux 0.01 也延续了这份诚实:do_signal 挂在系统调用返回的必经之路上,被信号叫醒的慢调用,老老实实带着 EINTR 回用户态。
1983 年,4.2BSD 把这份诚实收了回去³:被打断的慢调用,内核悄悄重来,应用感觉不到发生过什么。可有人不想要这份体贴——拿 alarm 打断 read 做超时的程序,重来就全乱了。后来的 4.3BSD 补了个开关,允许按信号关掉自动重启。Stevens 在《UNIX环境高级编程》第 10 章把这段公案讲透了²:System V 从不自动重启,BSD 默认重启,POSIX 只说"可以"不说"必须"。
2003 年,Linux 2.6 把"悄悄重来"做到极致:restart_syscall⁴,一个只给内核自己用的系统调用。进程被 SIGSTOP 冻住、SIGCONT 化冻后接着睡,超时连"被冻住的那段时间"都扣掉——像时间倒流,对应用完全透明。
照见一层平时看不见的。硬约束:内核真的不知道应用想要什么,EINTR 不是缺陷,是诚实。谁受益谁买单:自动重启让千万程序员少写重试样板,买单的是内核复杂度和跨平台的可移植性噩梦。被大家默认的前提是错的:"调用要么成功要么失败"——不,还有第三种结局:被打断,不算成功,也不算失败。
Brooks 在 1987 年给这类麻烦分过类⁵:本质困难 vs 偶然困难。EINTR 的处理是典型的偶然困难,于是被一层层往下搬:内核的 restart_syscall,到语言运行时——Python 在 2015 年专门立了 PEP 475⁶,3.5 起标准库调用自动重试 EINTR。偶然困难少了一层,可"被打断了到底该怎么办"这个判断,永远还在应用手里,搬不走。
美学时刻:这像书法的"顿挫"。信号是外力顿的那一下,笔锋被迫顿住;顿住了接着写还是另起一行,本来是执笔的手说了算(EINTR),后来有人发明了"顿完自动接着写"(重启)。两种笔法,四十年没分出高下。(思想化用)
二、意图被打断,从哪儿接着想:如果 AI OS 先出现
如果 AI 的操作系统先出现,"系统调用被信号打断"这个概念可能根本不会出生——执行单位变了:不再是一次调用,而是一个意图。
意图可挂起、可恢复。被打断的不是调用,是意图执行流上的一个检查点:从哪儿停的,就从哪儿接着想,甚至换一台机器接着想。今天的对应物已经冒头:Erlang 的"let it crash"、协程的取消语义、AIOS 那篇论文里 agent 调度的一等公民地位⁷。
范式之眼在这里最锋利:信号打断是为顺序指令流设计的——"当前这一条流"被打断。可 GPU 上没有"这一条",只有"这一批":一次 kernel launch 撒出去几万个线程,谈不上打断某一条,只能整个批次取消。AI 时代的中断语义,要从"流"搬到"批",再搬到"意图"。
三、亲手制造一次 EINTR:看不见的三次打断
实验一,看见打断。这段 Python 关掉自动重启,让 handler 里抛异常——1 秒后闹钟响,阻塞的 read 没返回任何数据就被打断:
import os, signal
class Alarmed(Exception): pass
def handler(s, f):
print("(信号到了)")
raise Alarmed("闹钟响了")
signal.signal(signal.SIGALRM, handler)
signal.siginterrupt(signal.SIGALRM, True) # 关掉自动重启
signal.alarm(1)
r, w = os.pipe()
print("阻塞在 read 上,等 1 秒后的闹钟……")
try:
os.read(r, 1024)
except Alarmed as e:
print("read 没返回任何数据就被打断了:", e)
实验二,打断被藏起来。闹钟改成每 2 秒响一次,handler 只打印不抛异常,5 秒后有人往 pipe 写数据:
import os, signal, time, threading
def handler(s, f):
print("[handler] SIGALRM 到了")
signal.signal(signal.SIGALRM, handler)
signal.setitimer(signal.ITIMER_REAL, 1, 2)
r, w = os.pipe()
def writer():
signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGALRM})
time.sleep(5)
os.write(w, b"hello")
threading.Thread(target=writer, daemon=True).start()
print("read 阻塞中,闹钟每 2 秒响一次……")
print("读到:", os.read(r, 1024))
直接跑,你会看到"毫发无伤":闹钟响三次,read 照样读到 hello。再用 strace 看:
strace -e trace=read python3 eintr_demo2.py 2>&1 | grep -E 'ERESTARTSYS|"hello"'
三行 "= ? ERESTARTSYS":read 被打断了三次,每次都被 Python 默默重试。门被拍了三次肩膀,read 每次都假装没事,重新排队。(两段代码都已在本机实测跑通。)
落点:C 写的采集循环,被信号打断的 read 若不处理,会变成偶发的提前退出——把重试写进循环,或用 ppoll 把"等数据"和"等信号"一次等齐。Python 写的编排层,解释器替你重试,但重试是无声的:一次长连接 read 被打断三次,日志里什么都看不见,strace 才看得见。排障卡住时,记得还有这一层。
觉 然 小 注
被打断了就不住,接着来。重启即无住——化用《金刚经》"应无所住而生其心"的思想。(思想化用)
引 用 与 出 处
- [1] Ritchie, D. M.; Thompson, K. The UNIX Time-Sharing System. Communications of the ACM(期刊), 1974. https://doi.org/10.1145/361011.361061
- [2] Stevens, W. R.; Rago, S. A. Advanced Programming in the UNIX Environment, 3rd ed. Addison-Wesley(出版社), 2013. 中译本《UNIX环境高级编程(第3版)》,尤晋元、张亚英、戚正伟等译,人民邮电出版社,2014. https://m.douban.com/book/subject/11626789/
- [3] FreeBSD 项目. siginterrupt(3) 手册页. man.freebsd.org(存档站), FreeBSD 16.0-CURRENT. https://man.freebsd.org/cgi/man.cgi?query=siginterrupt&sektion=3&apropos=0&manpath=FreeBSD+16.0-CURRENT
- [4] Linux man-pages 项目. restart_syscall(2). manpages.ubuntu.com(存档站). https://manpages.ubuntu.com/manpages/trusty/man2/restart_syscall.2.html
- [5] Brooks, F. P. No Silver Bullet — Essence and Accidents of Software Engineering. IEEE Computer(期刊), 1987. DOI: 10.1109/MC.1987.1663532
- [6] PEP 475 -- Retry system calls failing with EINTR. Python 3.5, 2015. https://peps.python.org/pep-0475/
- [7] Mei et al. AIOS: LLM Agent Operating System. arXiv:2403.16971, 2024. https://arxiv.org/abs/2403.16971
· · ·