在网上搜资料准备面试的时候,总会看到“socket 多进程并发”这几个字被摆在 C++ 后端八股文的高频名单里。我做了几年网络服务端的开发,也参加过不少次技术面试,这个主题几乎每次都会被拿出来聊,而且聊法差异极大。有人只会描述“accept 到连接就 fork 一个子进程去处理”,有人能顺着文件描述符、信号、进程回收一路讲下去,高下立判。这篇文章想做的事情很简单:把“socket 多进程并发”这条八股线完整摊开,讲清楚它到底解决什么问题、代码应该怎么写、背后有哪些非背不可的原理,以及面试官递过来的追问到底在考什么。
如果你是正在准备 C++ 后端岗位面试的候选人,或者已经写了几版 socket demo、但没仔细想过“为什么父子进程要各自 close 一个 fd”的开发者,这篇文章值得看完。多进程并发并不只是 fork + accept 两行代码,它背后是操作系统对文件对象、信号、进程生命周期的一整套资源管理逻辑。把这些理清,再碰到类似“多进程和多线程怎么选”“惊群怎么解决”的问题,你会有东西可讲,而不是背完模板就慌。
1. 起点复盘:单进程 socket 服务端到底卡在哪里
1.1 最朴素的迭代模型与它的死穴
先把最基本的 TCP 服务端代码写出来。这个代码没有做任何并发,但它是一切问题的起点。
#include <sys/socket.h> #include <netinet/in.h> #include <unistd.h> #include <cstring> #include <cstdio> int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(8080); bind(listen_fd, (sockaddr*)&addr, sizeof(addr)); listen(listen_fd, 128); char buffer[1024]; while (true) { sockaddr_in client{}; socklen_t len = sizeof(client); int conn_fd = accept(listen_fd, (sockaddr*)&client, &len); printf("client connected: %s:%d\n", inet_ntoa(client.sin_addr), ntohs(client.sin_port)); std::string hello = "hello\n"; send(conn_fd, hello.c_str(), hello.size(), 0); ssize_t n = recv(conn_fd, buffer, sizeof(buffer) - 1, 0); if (n > 0) { buffer[n] = 0; printf("recv: %s\n", buffer); } close(conn_fd); } }这段代码能跑,但它有一个结构性的毛病:一次只能处理一个连接。accept 返回之后,程序就停在 recv 上等客户端数据;此时第二个客户端哪怕已经完成 TCP 握手,也只能在内核的完成队列里排队,应用层根本没机会再调 accept。假如业务逻辑里再带一些耗时操作,比如查一次数据库、读一个文件,整个服务就被单条连接“焊死”了。
这个问题的根源在于阻塞。accept 阻塞、recv 阻塞,两个阻塞串在一起,应用层的执行流就被锁死。你可能觉得这是很基础的东西,但面试里很多答不好“多进程并发”的人,恰恰是因为没有真正理解这个起点场景。他们说出来的是“为了并发所以 fork”,却讲不出“不并发,服务端就只能串行处理请求”。
用生活化的类比来看,这个模型就像只有一个窗口的银行柜台,第一个客户不办完业务,后面的人只能在大厅干等。TCP 本身是支持大量并发连接的,但应用层一次只服务一个人,协议栈的能力就被白白浪费了。
1.2 三条路摆在一起,为什么先讲多进程
要解决单进程迭代模型的问题,常见方案有三类:多进程、多线程、IO 多路复用(select/poll/epoll)。从操作系统直觉出发,最直接的就是多进程——既然一个进程里的执行流被阻塞卡住了,那就 fork 出更多执行流,让它们各自去处理不同连接。
多进程在教科书里排在很前面的位置,是有道理的。进程是操作系统进行资源分配的基本单位,它天然具备地址空间隔离;一个子进程崩了,父进程和兄弟进程照常运行,不会像多线程那样一个线程越界写坏内存,整个进程都可能陪葬。对于真实服务端来说,这种稳定性往往比极限吞吐更重要,因为线上问题的粒度最好是“挂掉一条连接”,而不是“整台机器上的服务全没”。
从内核视角来理解更清晰。TCP 连接本质上是内核里的一个 socket 结构。accept 返回的 conn_fd 指向这个已连接的 socket。所谓并发处理,并不需要拆开协议栈,而是让不同执行流各自持有一个已连接的 fd,各自阻塞在自己的 recv / send 上。多进程模型正好利用了这个特性:父进程持有监听用的 listen_fd,子进程持有已连接用的 conn_fd,分工明确。
所以面试时讲到多进程并发,我不建议直接背“fork 之后就并发”这种话。更完整的回答是:先说明单进程迭代模型的瓶颈,再说明多进程方案如何通过复制执行流来打破瓶颈,最后指出这种方案的适用场景和代价。这样一个三段式回答,比任何死记硬背的模板都更有说服力。
2. 多进程并发骨架:fork、accept 与文件描述符的全套逻辑
2.1 fork 到底复制了什么,文件描述符为什么是重点
关于 fork,很多基础书都给了一句话结论:“调用一次,返回两次。”这个结论没错,但面试追问起来就会不够用。你需要把“复制了什么”拆开看:调用进程的地址空间、寄存器上下文、打开的文件描述符表都会被复制。注意,文件描述符表是复制,但复制出来的 fd 指向的内核对象是同一个。
这里有个非常关键的点:fork 之后,父子进程里相同编号的 fd 指向同一份内核 file 对象。拿 socket 来说,listen_fd 和 conn_fd 对应的都是同一个 socket 结构体,内核里对它的引用计数会增加。这意味着什么?意味着 TCP 连接的真正关闭,要等所有指向它的 fd 都 close 之后才会发生。只要还有一份 fd 副本没被关闭,内核就不会真正释放这个连接资源。
我在面试里经常碰到一种回答:子进程处理完连接后调了 close(conn_fd),然后问“连接关了吗”。答案可能是“没关”。因为父进程手里还握着同一份 conn_fd,如果父进程不 close,连接就一直被内核视为存活。反过来也成立:子进程如果继承了 listen_fd 却不关闭,父进程想停止监听也无济于事,因为 listen socket 的引用计数里还有子进程那份。
这个知识点是理解多进程 accept 模型的钥匙。只要把它想清楚,下面给出的代码模板里每一步操作就都顺理成章了。
2.2 一连接一进程的经典写法与关键步骤
直接给一份可以照着写的经典模板。它对应的模型是:父进程负责 accept,每来一个连接就 fork 一个子进程,子进程专门处理这条连接。
#include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <unistd.h> #include <sys/wait.h> #include <cstring> #include <cstdio> #include <cstdlib> #include <csignal> void handle_client(int conn_fd) { char buffer[1024]; while (true) { ssize_t n = recv(conn_fd, buffer, sizeof(buffer) - 1, 0); if (n <= 0) break; buffer[n] = '\0'; printf("child %ld get: %s\n", (long)getpid(), buffer); std::string resp = "echo: "; resp.append(buffer); send(conn_fd, resp.c_str(), resp.size(), 0); } } void wait_child(int sig) { while (waitpid(-1, nullptr, WNOHANG) > 0) {} } int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(8080); bind(listen_fd, (sockaddr*)&addr, sizeof(addr)); listen(listen_fd, 128); signal(SIGCHLD, wait_child); while (true) { sockaddr_in client{}; socklen_t len = sizeof(client); int conn_fd = accept(listen_fd, (sockaddr*)&client, &len); if (conn_fd < 0) continue; pid_t pid = fork(); if (pid < 0) { close(conn_fd); continue; } if (pid == 0) { // 子进程:只处理已连接 socket close(listen_fd); handle_client(conn_fd); close(conn_fd); exit(0); } // 父进程:保留 listen_fd,继续 accept close(conn_fd); } }这份代码里有几个细节必须能吃透。
第一,fork 之后,父子进程从同一行代码继续向下执行。子进程如果不 exit,就会和父进程一起回到 while 循环里抢 accept,整个模型立刻乱套。所以子进程处理完业务必须 exit,这是流程边界,不是可有可无的收尾。
第二,父进程 close(conn_fd) 和子进程 close(listen_fd) 这两行,缺一不可。子进程不需要 listen_fd,它的职责只有处理当前这一条连接;父进程不需要 conn_fd,因为连接已经交给子进程了。这两次 close 正好把父子进程的文件描述符职责切开,也保证了 socket 引用计数在合适的时候归零。
第三,SIGCHLD 处理函数绝不是装饰品。子进程 exit 后,如果父进程不调用 waitpid,子进程就会变成僵尸进程。僵尸进程不消耗 CPU,也不占用多少内存,但它会占着进程表项。高并发场景下,子进程成千上万地退出而无人回收,系统的进程表会被慢慢挤满,新进程 fork 不出来。处理函数里用 while + WNOHANG,是为了一次性尽可能多地回收,避免信号合并在父进程里漏掉子进程。
代码里还有一个隐含的问题值得单独说:accept 是阻塞调用,如果进程收到 SIGCHLD 信号,它可能被信号打断,返回 EINTR。这个 demo 里我用 continue 直接略过,但真实项目里这种处理过于粗暴。更稳妥的做法是把 accept 放在一个专门处理 EINTR 的循环里,或者使用带信号屏蔽的 pselect。我自己就踩过类似的坑:SIGCHLD 处理逻辑写得稍重,高连接数下 accept 频繁被打断,服务端表现出间歇性“停住”,排查了很久才定位到 EINTR 一直在骚扰阻塞调用。
2.3 文件描述符继承带来的两条隐蔽坑
继续沿着 fd 继承这条线深挖,有两个坑是面试加分项,也是工程里的真问题。
第一个坑:fork 之前创建的 epoll fd、eventfd、定时器 fd,在子进程里全都存在。如果父进程用 epoll 监听 listen_fd,然后 fork 一堆子进程,麻烦就来了:每个子进程的地址空间里都有一份 epoll fd,也有一份 listen_fd,还各自注册过监听。子进程如果不显式清理,这些父进程创建的上下文会被继承下来,造成 fd 泄漏,甚至可能在未来误操作同一个事件源。我在看多进程网络服务的源码时,特别注意过这类问题。正确做法就是在 fork 之后、子进程正式开始业务之前,把它不需要的 fd 全部 close 掉;需要独立事件循环的子进程,应该自己重新创建 epoll 实例。
第二个坑:fd 数值会复用。内核对 fd 分配的策略是“当前最小可用编号”。父进程如果忘记 close conn_fd,那么 5 号 fd 会一直被占用,后续 accept 返回的编号会跳过它。一旦某个 fd 被误关,后面新连接可能复用同一个编号,而业务代码如果缓存了 fd 数值、没有意识到它已经被替换,数据就可能被写到另一个连接上去。这个问题在任何基于 fd 的服务里都要小心,但在八股文面试里,能主动把 fd 引用计数、fd 复用、fd 泄漏三个概念串起来讲的人非常少。讲出来,面试官对你的评价就不只是“背过题”,而是“理解到位”。
3. 从动态 fork 到 pre-fork:工程化的多进程并发形态
3.1 连接到达再 fork 的代价
一连接一进程的模型,代码简洁,理解起来也直观,但它最大的毛病是“fork 发生得太晚”。每次有连接到来才创建进程,fork 的代价就落在请求的关键路径上。fork 不是一次廉价操作,它要复制页表、处理写时复制的元数据,还要复制内核里的若干数据结构。如果子进程起来后还需要重新初始化运行时、加载配置、打开日志,那开销更大。
另一个问题是 fork 时机不可控。连接已经 accept 成功,客户端已经完成 TCP 握手,这时候如果 fork 失败——进程数达到上限、内存不足——服务端就只能把这条连接 close 掉。客户端刚连上就被踢,这种体验显然不好。所以生产环境通常不会在高负载期临时 fork,而是把 fork 成本拆散到服务启动阶段。这就是 pre-fork 的核心思路:提前创建一批子进程,让它们一起等待任务分配。
面试时如果能把“动态 fork 的代价在关键路径上”这个点说出来,已经比只说代码模板的人高一个层次。很多八股答案只讲模型形态,不太讲“为什么生产环境不用动态 fork”,这部分恰恰是我作为面试官最想听到的工程思考。
3.2 pre-fork 的形态与惊群问题的本质
pre-fork 的实现并不复杂。父进程创建好 listen_fd,再预先 fork 出 N 个子进程。每个子进程都继承 listen_fd,然后在自己的循环里调用 accept。谁 accept 到连接,谁就去处理。
这里就出现了一个在面试里几乎必被追问的话题:惊群。
惊群,简单说就是多个进程同时阻塞在同一个 listen_fd 的 accept 上,当新连接到来时,内核会把多个等待者都唤醒,但最终只有一个进程能 accept 成功,其他被唤醒的进程发现自己没抢到,只能再次回去睡。在连接密集的服务上,这种无意义的唤醒会白白啃掉不少 CPU。虽然较新的内核在 accept 的惊群处理上做了优化,减少了一部分无效唤醒,但从模型层面看,多个进程争抢同一个 accept 入口本身就是低效的。
解决惊群的经典方向有两个。一个是给 accept 加一把进程间互斥锁,只有拿到锁的进程才能去 accept,拿到锁就说明“现在轮到我来接客”。要注意,这里不能用普通的线程互斥量,因为多个子进程是独立的进程,必须使用能跨进程工作的锁机制,比如把一个互斥量放到共享内存里,或者对一个文件加 flock。另一个方向是父进程统一 accept,然后把已连接的 fd 通过 IPC 传递给子进程,比如用 Unix domain socket 或者管道来传 fd。后者已经偏离了“多个子进程各自 accept”的模型,但面试中能提出来,说明你对方案演进有感觉。
这里我想强调一个很多候选人容易翻车的地方:不要只背“惊群是内核问题,新版内核已经解决了”这种半吊子答案。新版内核优化的是“唤醒范围”,不是“竞争模型不存在”。你在做题时应先把惊群的定义讲清楚,再讲你的工程解法,最后才提一句内核层面的优化程度有限。这样整个回答的逻辑才完整。
3.3 子进程回收、补位与优雅退出
pre-fork 模型运行起来之后,父进程没那么轻松,它要持续维护子进程池的规模。子进程可能因为业务异常崩溃,也可能因为内部逻辑错误退出。父进程收到 SIGCHLD 后,除了 waitpid 收回退出状态,还需要补充新 fork 一个子进程,把池子补齐。这其实是一个很朴素的“进程编排”逻辑:父进程在维护“预期进程数”和“实际进程数”之间的差值。
优雅退出是另一个容易被忽略的工程细节。假设服务需要重启,如果直接向父进程发送 SIGKILL,已 fork 出来的子进程会被“一刀切”,所有活跃连接在同一瞬间断裂,线上用户体会到的就是集体掉线。成熟做法一般分几步:先让父进程停止 accept,同时把 listen_fd 关闭,让新连接直接失败;然后向所有子进程发送退出信号,子进程把当前连接处理完再自行退出;最后父进程等待全部子进程退出,自己再退出。整个过程的核心原则是“先断新,再等旧”,面试里能把这一步说清楚,说明你不只是会写 demo,而是考虑过线上运维的真实节奏。
4. 多进程、多线程、事件驱动:面试对比的三板斧
4.1 一张对照表讲清三者的适用边界
面试官很爱问:“多进程好还是多线程好?为什么还有一个 epoll?”这个问题没有标准答案,但有标准答法。我会先把三个方案放在一张对照表里,再结合场景讲取舍。
| 维度 | 多进程 | 多线程 | 事件驱动(epoll) |
|---|---|---|---|
| 内存开销 | 每个进程独立地址空间,较大 | 线程共享地址空间,较小 | 单线程内处理,最小 |
| 隔离性 | 进程间隔离好,崩溃不互相影响 | 同进程内互相影响,一个越界可能全挂 | 单进程内处理,依赖代码健壮性 |
| 数据共享 | 需要 IPC,成本高 | 共享内存/全局变量,方便但需同步 | 单线程内共享,天然避免竞争 |
| 调度模型 | 内核按进程调度 | 内核按线程调度 | 应用层事件循环自己调度 |
| 并行瓶颈 | 进程数多,上下文切换成本高 | 锁竞争、线程安全 | 单线程 CPU 上限,需多进程配合 |
| 典型场景 | 稳定优先、弱耦合业务 | 高内聚业务、共享状态多 | 高并发、短请求、IO 密集 |
这张表不需要背,重要的是理解每一行背后的逻辑。比如“多进程隔离性好”的背后,是地址空间隔离带来的稳定性;但代价是“数据共享需要 IPC”,而 IPC 的复制和同步成本在多线程模型里基本不存在,因为线程天然共享进程地址空间。
面试里回答这个问题的正确姿势,是先反问一句“什么场景”。如果业务模块之间需要强隔离,一个子模块的崩溃不能拖垮其他模块,多进程更合适;如果业务对共享状态的需求很强,比如大量缓存、玩家数据、全局配置,多线程更顺手;如果目标是撑住海量长连接,并且请求本身很轻很短,epoll 这类事件驱动才是主力。但 epoll 也有软肋:单线程事件循环只能跑在一个 CPU 核上,想用满多核,最终还是得把多进程和事件驱动结合起来,也就是常说的多 Reactor 模型。
4.2 多进程模型独有的开销与实现细节
既然主题是多进程,这部分的几个独有开销得讲清楚。
首先是进程上下文切换成本。进程切换除了要切换寄存器、栈等用户态上下文,还可能要切换地址空间,导致 TLB 失效。线程切换在同一地址空间内进行,开销相对要小。所以进程数一旦上千,调度器就会明显忙碌,这也是多进程并发不能无限扩进程数的原因。一台机器上跑几十上百个进程是常见操作,再往上走就要仔细评估了。
其次是 IPC 成本。多进程之间不能像多线程那样随手访问同一个全局变量,只能走管道、消息队列、共享内存、socketpair 这类机制。共享内存效率最高,但要处理同步;管道使用方便,但每次数据都要从用户态复制到内核态再复制出来,开销不小。如果多个子进程之间需要高频交换状态,这笔成本必须计入架构设计。我经常和朋友聊一个现象:很多网关服务坚持多进程,因为要极强的隔离和稳定性;而很多游戏服务器走向单进程多线程,因为玩家状态高度共享,用 IPC 交换状态会把性能拖垮。两者没有绝对优劣,只有场景适配。
还有一点是调试体验的差异。多线程出 bug,常见于时序问题,gdb 里看到的往往是一堆线程卡在锁上;多进程出 bug,则可能是“A 进程改了,B 进程没看到”“fd 被谁悄悄关掉了”。信号、僵尸进程、继承的 fd,这些概念在多线程模型里根本不存在。所以多进程八股虽然看起来基础,它考的是你对操作系统资源管理的综合理解。
4.3 accept 层面的竞争:加锁还是派发
回到 accept 竞争这个话题,再深入一点。前面提到用锁解决惊群,这里要补一个关键技术点:锁的语义必须是全局唯一的“谁能去 accept”,不能只是各进程在脑子里想一想自己要不要去。用线程互斥锁是不行的,因为它只对线程有效;多个子进程之间必须使用进程间互斥机制。常见的做法是把一个互斥量放进共享内存,或者干脆用文件锁 flock 包住 accept 调用。把这句话说明白,面试官立刻知道你区分过线程级互斥和进程级互斥,而不是只会说“加锁”两个字。
另一种优化是把 accept 设置成非阻塞,Listen socket 上设置 O_NONBLOCK,然后循环调用 accept,直到返回 EAGAIN。这样任何一个子进程都不会因为一次 accept 阻塞而卡死,配合锁或派发机制,可以构建更稳定的并发服务。这个细节很多人不熟,但我认为它在工程里的价值很大。面试时提到它,会显得你不是纸上谈兵。
5. 高频追问与实战避坑清单
5.1 十个高频追问的速答要点
这个主题在面试中衍生出的追问非常多,我挑十个最典型的,按“问题 + 核心回答思路”的形式整理。
- fork 之后父子进程各自持有 socket 的什么?各自有一份 fd 引用,指向内核同一个 file 对象;socket 引用计数加一。
- 什么时候才算是真正关闭连接?所有 fd 都 close 之后内核才释放 socket。所以父子进程各 close 一份,缺一不可。
- 多进程 accept 为什么会有惊群?多个进程同时阻塞在同一 listen socket 上,新连接唤醒多个等待者,但只有一单能成功。
- 惊群怎么解决?加进程间锁保证单点 accept,或由父进程 accept 后通过 IPC 派发 fd。
- 为什么需要处理 SIGCHLD?子进程退出后父进程不 waitpid,它会变成僵尸进程,占用进程表项。
- 为什么 accept 会返回 EINTR?阻塞期间收到信号,系统调用被打断。正确处理是循环重试,或用 pselect。
- 多进程模型里还能用 epoll 吗?可以。每个子进程维护自己的 epoll 实例,监听自己的已连接 fd。
- 为什么不建议一个连接创建一个进程?进程创建和切换开销大,业务连接多时资源消耗太高。
- 子进程崩溃会影响其他进程吗?多进程隔离性正是优势所在,一个子进程崩溃不会拖垮父进程和其他兄弟进程。
- 单机多进程并发能撑多少连接?没有孤立数字,受限于进程数、fd 限额、内存、CPU 调度,要结合业务耗时评估。
这十条不是用来背的。如果你看到第二条,只想到“父子各自 close”,却讲不出引用计数的含义,说明你对 socket 生命周期的理解还停在 API 层面。建议再把内核里 socket 的引用计数和 TCP 四次挥手的关系梳理一遍。
5.2 从真实排查现场总结的三条经验
多进程服务跑起来容易,跑稳难。我把印象最深的三个问题列出来,希望读者能少走弯路。
第一个问题:父子进程都调用了 close,但连接仍然显示 ESTABLISHED。最后发现是一个第三方模块在 fork 之后自己又复制了一次 fd,那份副本没关闭。这个案例最能说明引用计数模型——任何一份你没想到的副本,都会让连接像幽灵一样赖着不走。排查方法很直接:到 /proc/{pid}/fd 目录下看某个进程到底持有哪些 fd,谁能快速定位谁还握着连接。
第二个问题:用共享内存做计数器,多个子进程各自往上加,跑了一段时间发现数值明显偏小。原因是没做原子操作,多个进程同时读改写产生丢失更新。这类问题不只在多线程里有,多进程的共享内存一样存在。现在写类似代码,我会习惯性地用原子操作来保障共享内存里的计数变量,并在初始化时明确内存序。
第三个问题:子进程正常 exit 了,但父进程的 SIGCHLD 处理函数里 waitpid 一直拿不到子进程 ID。后来定位到是一个多线程环境下的信号竞争问题,SIGCHLD 的投递语义变得含糊。这提醒我:多进程服务里信号处理的边界必须想清楚,尤其要注意信号处理函数不能调用非异步安全函数,否则会出现极难复现的偶发故障。
5.3 一套能直接抄进笔记的答题脉络
最后给一份可以直接抄进笔记的打法。面试时被问到 socket 多进程并发,可以按下面这条线组织回答。
- 先讲业务模型:当前服务是大量短连接还是少量长连接,这决定该用多进程还是多路复用。
- 再讲进程模型:一连接一进程 vs pre-fork vs 进程池,分别适用什么场景。
- 接下来说文件描述符管理:父进程负责 accept,子进程负责收发,fork 之后各自的 close 怎么做,引用计数如何理解。
- 然后讲异常处理:SIGCHLD、EINTR、僵尸进程、fd 泄漏,这些是工程代码里才能体会到的坑。
- 最后落到性能取舍:多进程隔离性换来了稳定性,但付出了内存和调度开销;要极致并发,再叠加 epoll 和进程池。
把这条线讲完,面试官通常会产生一种“这个人真写过服务端”的感觉。我自己在做技术评估时,最怕遇到那种所有 API 都能背、但讲不清“某个 fd 为什么关不掉”的候选人。而能把 fd、信号、进程生命周期串成一条线的人,写出的代码才更有可能在线上扛住压力。还有个实操小技巧:写多进程代码时,每 fork 一次都先停下,把“子进程从这里开始只做自己的事”这个边界标出来,再补齐父子进程各自的 fd 分配与释放。我 review 时见过太多逻辑没问题、但毁在“忘了 close”“忘了 waitpid”“忘了 EINTR”上的例子。先把边界想清楚再动手,比事后调 bug 高效得多。