计算机网络 29:一个进程怎样服务很多连接,阻塞、非阻塞、就绪通知与超时
第 02 篇用长度前缀恢复消息边界,第 16 篇区分连接与半关闭,第 18 篇说明接收方变慢会形成背压。这些机制在单个连接上成立,但服务器还要回答另一个问题:一个连接停在 recv(),其他连接是否仍有机会被处理?
阻塞、非阻塞和就绪通知不是三种协议。它们是应用安排等待的方式。阻塞线程可以把一个连接的顺序逻辑写得很直接;事件循环把多个连接的等待集中起来,只在内核报告“当前可能推进”时执行少量工作。两者都必须正确处理短读写、EOF、背压和超时,也都不能仅凭连接数推导吞吐。
本篇用同一个四字节长度前缀回显协议比较两种本机实现:阻塞模型为每条连接分配一个工作线程;事件循环使用 Python selectors.DefaultSelector 和非阻塞 socket。实验只验证有限的并发正确性,不做容量或性能结论。
阻塞描述等待发生在哪里
阻塞 socket 默认允许 accept()、recv() 或 send() 暂停当前线程,直到操作能够推进、发生错误或命中超时。暂停的是调用线程,不是“整个进程必然停止”。如果每条连接有独立工作线程,一个慢客户端阻塞其中一个线程时,其他线程仍能处理各自连接。
这种结构的优点是控制流接近协议顺序:读长度、读消息体、处理、写响应。代价是每个并发连接通常需要一个执行单元以及相关栈和调度状态。线程池可以限制线程总量,但当所有工作线程都被慢连接占住时,新连接即使已经可读,也可能只能排队。
settimeout(t) 给一次阻塞操作增加时间上限。它不是请求总截止时间,也不自动覆盖排队、重试和业务处理的全部时间。若每次成功读到少量数据后都重新开始同样的超时,攻击者仍可能用缓慢但持续的字节流长期占用连接。端到端截止时间应以单调时钟计算剩余预算。
EOF 也必须进入状态机。Python 文档规定,recv() 返回空字节表示对端断开。如果此时只收到长度头的一部分,或声明的消息体还没收完,这不是一条合法的空消息,而是“帧中途结束”。
非阻塞描述一次操作能否立刻完成
setblocking(False) 使 socket 操作在不能立即完成时返回平台对应的 would-block 错误。它没有让读写自动发生,只是把“等待”交还给应用。应用必须保存每条连接的解析进度和待发送缓冲,等下一次就绪通知后继续。
非阻塞读的状态至少包括:尚未收齐四字节长度头、已知长度但正文不足、已得到一个或多个完整消息、对端 EOF。非阻塞写也要保存偏移,因为 send() 返回的是本次实际接受的字节数,不保证把整个响应写完。把一次 send() 等同于整条响应成功,会在发送缓冲不足时截断消息。
事件循环的每次回调应当有界。某个连接持续可读时,如果一个回调无限处理它,其他就绪连接仍可能饥饿。边沿触发接口还要求持续读写到 EAGAIN,否则已经留在缓冲区的数据未必再次产生边沿通知。本篇使用 DefaultSelector 的默认语义,不把它泛化为所有平台、所有触发模式。
就绪不是完成
selector 维护已注册对象及其关注事件,再返回当前就绪集合。EVENT_READ 只表示一次读操作现在可能取得数据、EOF 或错误;它不保证得到完整应用消息。EVENT_WRITE 只表示当前可能接受一些发送数据;它不保证待发缓冲能够一次清空,更不证明对端应用已经读取。
Linux 上 DefaultSelector 在本次环境解析为 EpollSelector。epoll 的 interest list 是关注集合,ready list 是当前可能执行 I/O 的对象集合。这个内核集合减少了应用逐个探测描述符的需要,但应用自己的连接状态仍不能省略:内核不知道四字节长度头、消息上限或业务截止时间。
| 层次 | 保存的状态 | 一次就绪能证明什么 |
|---|---|---|
| 内核 socket | 接收/发送缓冲、连接错误 | 当前一次 I/O 可能推进 |
| selector | 注册对象与关注掩码 | 哪些对象出现所关注事件 |
| 协议解析器 | 长度头、剩余正文、待发偏移 | 是否得到完整应用消息 |
| 请求生命周期 | 开始时间、截止时间、取消原因 | 是否还能在预算内继续 |
因此,事件循环不是“一个线程一次处理完很多连接”。它是在一个循环里轮流推进许多连接,每次只消费已就绪且有界的工作。
慢客户端同时考验读路径和写路径
慢客户端可能只发送长度头,然后迟迟不发消息体。阻塞工作线程会停在该连接的 recv();事件循环会保留解析状态并转去处理其他就绪连接。只要阻塞实现为其他连接保留了工作线程,两种模型都能让快速客户端继续。这一事实不能推出哪种模型吞吐更高。
写路径同样会慢。接收端不读取时,发送缓冲最终可能填满。阻塞 sendall() 会停住工作线程;非阻塞实现应把未发送部分保存在 per-connection buffer,只在 EVENT_WRITE 就绪时继续,并在缓冲清空后取消写关注。始终注册写事件通常会导致空转,因为 socket 在大量时间里都可写。
连接级超时还要区分闲置和总截止时间。闲置超时关注最近一次有效进展;总截止时间限制请求从开始到结束的最长时间。事件循环通常按最近截止时间计算 select(timeout),超时返回后再扫描过期连接。阻塞模型则可以使用 socket timeout、定时器或上层取消,但无论采用哪种方式,都要定义超时后是否还能重试以及响应是否可能已经部分发送。
本机对照实验
同名素材目录中的 lab.py 启动两个仅绑定 127.0.0.1 的临时服务器。两个服务器复用同一个 FrameState 解析器和同一组客户端:
- 慢客户端先发完整长度头,等待测试驱动放行;
- 快客户端在慢请求未完成时发送
fast并收到FAST; - 分片客户端把线上的一条消息拆成多次
sendall(); - 异常客户端声明 5 字节正文,只发 2 字节后关闭。
运行命令:
1 | |
两种实现都返回 FAST、FRAGMENTED、SLOW,也都记录不完整 EOF。阻塞实现为四次连接创建四个工作线程;selector 实现使用一个事件循环线程,运行时选择器为 EpollSelector。这些数字只描述脚本结构,不是连接容量、CPU 成本或吞吐测量。
条件、限制和反例
“非阻塞一定更快”不成立。低并发、每个请求包含较多 CPU 工作、现有库以阻塞 API 为主时,线程模型可能更直接。事件循环若在回调里执行阻塞 DNS、磁盘 I/O 或长时间计算,同样会拖住所有连接。
“就绪等于消息到齐”也不成立。TCP 提供字节流,一次可读可能只有长度头的一部分,也可能包含多条消息。解析状态必须跨事件保留。
“线程模型天然处理不了慢客户端”过于绝对。独立线程确实隔离了单个阻塞调用,但线程数量、队列长度、超时和发送缓冲仍需有界。没有这些边界,慢连接只是以另一种方式消耗资源。
“一个事件循环线程等于只能使用一个 CPU”混淆了网络等待与计算扩展。多个进程、多个事件循环、工作线程池都能承接 CPU 或阻塞任务;怎样分工属于服务架构,不由 epoll 单独决定。
练习
练习一:某非阻塞连接先收到长度头 00 00 00 05,随后收到 ab,接着出现 EOF。写出解析器在三步后的 expected、缓冲字节数和最终错误。说明为什么不能把 ab 当作一条完整消息。
练习二:事件循环中连接 A 始终可读,连接 B 刚完成握手,连接 C 的待发缓冲还有 8 KiB。设计每轮处理预算和关注掩码更新规则,使 A 不会饿死 B,并避免 C 在缓冲清空后持续触发写就绪。
模式速查表
| 现象 | 优先检查 | 常见错误 |
|---|---|---|
| 一个慢连接影响其他请求 | 执行单元是否被占满 | 把“阻塞”误写成“进程停止” |
| 可读但解析不出消息 | 接收缓冲和帧状态 | 把 recv() 次数当消息边界 |
| 可写却响应不完整 | 待发缓冲与发送偏移 | 假设一次 send() 写完 |
| 事件循环 CPU 空转 | 是否长期关注写事件 | 忽略可写通常持续成立 |
| 连接一直不结束 | 闲置超时、总截止时间 | 每次进展后无限重置总预算 |
官方参考资料
- Python 3.12
socket文档,Python Software Foundation,访问于 2026-09-24。 - Python 3.12
selectors文档,Python Software Foundation,访问于 2026-09-24。 - Linux
epoll(7),Linux man-pages project,访问于 2026-09-24。 - RFC 9293:Transmission Control Protocol,IETF,2022-08;半关闭与 user timeout 的传输边界。
验证边界
已验证:Python 3.12.3 在 Linux 5.10 的 loopback 上分别运行线程式阻塞服务器和 EpollSelector 事件循环;固定客户端检查短读解析、慢请求期间的另一连接、帧中途 EOF 和完整回显。构建与页面检查只证明文章及素材能够生成。
未验证:没有运行压力测试,没有改变进程文件描述符或线程限制,没有测量吞吐、延迟、CPU、内存、调度开销,也没有访问公网或生产服务。active_connection_units 不是性能指标。本机结果不能外推为某种模型在其他负载下更快。






