S27

I/O 模型

点击正文段落选定收藏或划线位置;未选定时使用屏幕内第一段。

课内目录

1 I/O 模型要回答的问题

在 shell 中执行 cat /var/log/app.log,进程要从文件取数据;执行 curl http://127.0.0.1:8080/,进程要等待网络响应;把命令接成 producer | consumer,下游要等待管道中的字节。这些操作都属于 I/O:进程通过操作系统与文件、套接字、管道等对象交换数据。CPU 可以很快处理已经在内存中的字节,但数据何时到达、缓冲区何时腾出空间,并不由当前代码独自决定。

I/O 模型首先回答的是等待怎样安排。调用没有立即完成时,当前线程停在哪里?它能否先处理其他连接?谁通知它可以继续?操作系统通知的是“现在可能读到数据”,还是“你提交的读取已经完成”?这些区别直接影响程序的控制流程,也会影响线程数、内存占用、故障处理和延迟分布。背诵接口名称之前,先把这些问题说清楚。

以 TCP 连接上的 recv() 为例,应用请求读取最多 4096 字节。对端可能尚未发送,数据也可能只到达 80 字节,或者已经有序关闭连接。代码必须区分暂时没有数据、读到部分数据、读到流结束和调用失败。I/O 模型不会替应用定义消息边界。即使发送端调用了 2 次 send(),接收端也不能假定恰好调用 2 次 recv() 就得到对应的 2 条消息。

讨论时还要明确“谁被阻塞”。某个线程等待网络数据,不等于整台机器停止工作;同进程中的其他线程可能继续运行,其他进程也可能获得 CPU。如果服务只用 1 个线程负责所有连接,那么这个线程停下,才会让这组连接都失去处理机会。若服务给每条连接分配线程,等待位置发生变化,代价则转移到线程管理、栈内存和共享数据同步上。

文件描述符是进程持有的整数句柄,套接字、管道、普通文件都可以通过它操作,但“接口长得像”不表示等待行为相同。网络连接通常等待对端和网络;普通文件读取还涉及页缓存和存储设备。Linux 上普通文件通常不能像套接字那样加入 epoll 监听,设置非阻塞标志也不能保证磁盘读取立即返回。不能把网络事件循环的经验直接套到全部文件操作上。

本课采用 Linux 与 Python 标准库给出可迁移的直觉:先区分线程等待、就绪通知和操作完成,再用本机 TCP 实验观察慢连接如何影响其他连接。读图时沿着“进程提出需求—内核管理对象—外部条件变化—进程继续处理”的路径走。内核协议栈和驱动的内部实现留到后续学习,这里只追踪应用能够观察和控制的边界。

进程、内核与外部对象之间的 I/O 边界
进程、内核与外部对象之间的 I/O 边界

2 阻塞、非阻塞与多路复用

阻塞 I/O 的直觉最直接:在线程调用阻塞式 recv() 后,如果当前没有数据,线程等待,直到出现数据、连接结束、错误或适用的超时条件。顺序代码容易阅读,调用返回后继续处理即可。对连接数量有限、业务流程简单的程序,这可能就是合适选择。问题出现在负责多个对象的线程等待了某个对象,导致其他已经能够工作的对象也得不到处理。

非阻塞 I/O 改变调用的返回约定。套接字通过 setblocking(False) 或相应系统接口进入非阻塞模式后,读取暂时无法推进时,调用立即报告 EAGAIN 或 EWOULDBLOCK;Python 通常表现为 BlockingIOError。这不是连接坏了,而是“这次先别读”。调用者必须保存当前处理进度,并决定以后何时再尝试。写入同样可能暂时无法推进,成功写入也可能只接受部分数据。

如果程序把非阻塞读取放在无限循环中,遇到 EAGAIN 就立刻重试,它只是把睡眠等待换成了忙等。CPU 很忙,实际收到的字节却可能没有增加。简单加入固定 sleep() 能减少空转,但又引入新的取舍:间隔短则检查频繁,间隔长则数据到达后还要额外等候。应用真正需要的是让操作系统在有工作时唤醒自己。

I/O 多路复用把等待集中到 select()、poll()、epoll_wait() 等接口:线程登记关心的多个描述符,然后等待其中一些变为就绪。返回后,应用针对这些描述符执行实际读写。一次等待可以覆盖许多连接,因此不必为每条空闲连接保留正在等待的专属线程。这里复用的是等待和处理机会,描述符对应的连接仍然各自存在,也仍然需要独立的状态。

“可读”是就绪提示,不承诺完整业务消息已经到齐。它也可能意味着接下来读取将发现连接结束。提示到应用执行读取之间,对象状态还可能变化,例如其他线程先取走了数据。因此,事件循环中的套接字通常仍设置为非阻塞,让“醒来后已经没有数据”的情况成为可处理分支,而不是再次把事件循环挂住。可写也只表示当前通常有一些发送空间,不保证任意大小的数据都能立即写完。

可以把 3 种安排放到相同的场景比较:有 12 条连接,其中 1 条暂时不发数据。串行阻塞读取若先选中它,后面的连接就被连带推迟;逐条非阻塞扫描能够绕过去,却可能不断重复无效检查;多路复用让线程等待就绪集合,再处理有进展的对象。连接数并不是唯一差别,关键是等待期间线程还能为谁服务,以及为了发现新工作付出了多少代价。

阻塞读取、非阻塞重试与多路复用的控制流程
阻塞读取、非阻塞重试与多路复用的控制流程

3 epoll 与事件驱动直觉

epoll 是 Linux 提供的 I/O 多路复用机制。可以先把它理解成内核维护的关注列表:应用用 epoll_ctl() 增加、修改或删除关注的描述符,再用 epoll_wait() 等待就绪事件。Python 的 selectors.DefaultSelector 提供较统一的上层接口,在 Linux 的常见环境中使用 epoll。学习时不必先记住全部标志,先能解释“登记关注—等待—处理—更新关注”这条循环。

事件驱动代码需要显式保存连接状态。假设采用换行结尾的命令协议,某次读取只得到 sta,下一次得到 t\n,程序要把字节存入该连接的接收缓冲区,直到凑出完整命令再处理。响应也可能没有全部发出,需要保存尚未发送的部分。阻塞代码中藏在调用顺序和局部变量里的进度,在事件循环里常常变成连接对象上的字段。

先采用水平触发的直觉:只要对象仍满足关注的就绪条件,就可能继续报告它。读取后若还有数据,下次等待仍可能得到可读事件。这便于入门,但不能因此忽视处理公平性。某条连接持续提供大量数据,如果回调一直处理它,其他连接就只能排队。实际循环通常需要限制每次处理的字节量、消息量或执行时间,并让尚未完成的工作以后继续。

边沿触发只在这里认识名字:它对状态变化的通知方式不同,通常需要更严格地读写到暂时无法推进,并谨慎安排再次处理。不要在还没有写对部分读取、连接结束和输出缓冲时,先把它当成加速开关。错误的读取循环可能让程序漏掉后续处理机会,看起来像连接无故停住。触发方式是协议与状态管理已经可靠之后才应考虑的细化选择。

可写事件尤其容易被误用。大多数空闲 TCP 连接在发送缓冲区有空间时都可写,如果始终订阅可写事件,等待可能不断返回,让 CPU 长期忙碌。通常只在存在待发送数据、且本轮没有发完时关注可写;输出队列清空后撤销这项关注。如果对端读取很慢,还应给输出队列设上限,否则事件循环能够继续运行,内存却会持续增长。

事件循环的能力边界也很明确:它让线程有效安排等待,不会自动缩短 CPU 计算、同步磁盘访问或外部依赖响应时间。若在回调中计算大文件摘要,或者调用会阻塞的 DNS 查询与文件读取,同循环上的其他连接仍会延迟。理解这点后,epoll 就不再是“高并发按钮”,而是组织就绪通知的工具;真正的服务能力还取决于每次被唤醒后做了什么。

4 动手:对比两种 I/O 模型的延迟

这个实验用本机真实 TCP 套接字比较串行阻塞读取与基于就绪通知的读取。共建立 12 条连接,每条发送 1 字节并等待 1 字节响应;编号 0 的连接在发送前等待约 600 毫秒,其余连接立即发送。服务端按接收顺序保存连接,因此串行模式会先等待编号 0。实验特意固定这种顺序,用来暴露连接之间的等待影响,不能据此推算生产吞吐量。

把下面内容保存为 io_compare.py,分别执行 python3 io_compare.py blocking 与 python3 io_compare.py selector。脚本只监听 127.0.0.1 的临时端口,适合普通用户运行,不需要开放外部端口。这里把客户端与服务端放在同进程的不同线程中,方便复现;线程调度与本机负载仍会影响数值,所以重点比较等待关系。代码假设连接正常,生产程序还需要完整的错误、取消和清理路径。

import selectors
import socket
import sys
import threading
import time

mode = sys.argv[1]
if mode not in {"blocking", "selector"}:
    raise SystemExit("mode: blocking | selector")
n = 12
listener = socket.socket()
listener.bind(("127.0.0.1", 0))
listener.listen(32)
listener.settimeout(5)
clients = []
for _ in range(n):
    c = socket.create_connection(listener.getsockname(), timeout=5)
    clients.append(c)

def serve():
    peers = [listener.accept()[0] for _ in range(n)]
    listener.close()
    if mode == "blocking":
        for peer in peers:
            with peer:
                peer.settimeout(5)
                if peer.recv(1):
                    peer.sendall(b"y")
        return
    with selectors.DefaultSelector() as sel:
        for peer in peers:
            peer.setblocking(False)
            sel.register(peer, selectors.EVENT_READ)
        deadline = time.monotonic() + 5
        while sel.get_map():
            if time.monotonic() >= deadline:
                for key in list(sel.get_map().values()):
                    sel.unregister(key.fileobj)
                    key.fileobj.close()
                raise TimeoutError("server deadline exceeded")
            for key, events in sel.select(timeout=0.1):
                peer = key.fileobj
                try:
                    if events & selectors.EVENT_READ:
                        data = peer.recv(1)
                        if data:
                            sel.modify(peer, selectors.EVENT_WRITE)
                            continue
                    else:
                        if peer.send(b"y") == 0:
                            continue
                except BlockingIOError:
                    continue
                sel.unregister(peer)
                peer.close()

start = threading.Event()
results = [None] * n

def request(i, client):
    with client:
        start.wait()
        begin = time.monotonic()
        if i == 0:
            time.sleep(0.6)
        client.sendall(b"x")
        if client.recv(1) != b"y":
            raise RuntimeError("unexpected response")
        results[i] = (time.monotonic() - begin) * 1000

server = threading.Thread(target=serve)
server.start()
workers = [threading.Thread(target=request, args=(i, c))
           for i, c in enumerate(clients)]
for worker in workers:
    worker.start()
start.set()
for worker in workers:
    worker.join()
server.join()
if any(value is None for value in results):
    raise SystemExit("incomplete run; inspect thread errors")
for i, value in enumerate(results):
    print(f"{mode},client={i},latency_ms={value:.2f}")

串行模式中,编号 0 的延迟包含主动等待,其他连接的延迟也通常会被拖到相近量级。就绪模式中,编号 0 仍然要等自己的发送时间,其余连接通常可以较早完成。注意测量起点在主动等待之前,所以这里测的是从客户端准备执行到收到响应的耗时,包含实验人为安排的等待;它不是单次系统调用耗时,也不是纯粹的网络往返时间。

先各运行 5 次,记录模式、连接编号、耗时以及是否成功,失败的运行保留错误信息。不要只比较全部连接的平均值,应把慢连接与其他连接分别看,再观察各次运行的中位数和最大值。只有 12 个样本时,不宜把尾部分位数说得很精确。若机器同时运行编译任务,调度延迟也会进入结果;若出现超时,应先检查线程错误与资源状态,再决定这次运行是否能参与延迟比较。

有 strace 时,可用 strace -f -tt -T -e trace=network,epoll_wait,poll,select python3 io_compare.py selector 观察调用顺序。跟踪会扰动时间,因此用于解释等待位置,不作为正式计时结果。观察服务进程时,还可使用 ps -L -p PID 看线程、top -H -p PID 看线程 CPU、ss -tin 看 TCP 连接与队列。命令是否可用取决于系统安装和权限,不能把工具缺失当成 I/O 故障。

从客户端计时、进程状态和系统调用建立证据对应关系
从客户端计时、进程状态和系统调用建立证据对应关系

5 异步 I/O 与线程池取舍

多路复用通知的是就绪:现在可以尝试读写,应用仍要执行读取或发送。异步 I/O 则可以采用提交操作后稍后接收完成结果的方式,例如提交读取请求,再通过完成通知得知实际读取的字节数或错误。区分两者的关键不是函数名字里有没有 async,而是应用收到通知时,原先请求的操作处于什么阶段。库也可能把就绪事件包装成异步语法,让上层代码顺序书写。

因此,看到 await 不能直接推断底层使用了某种内核异步接口。运行库可能使用事件循环管理网络连接,也可能把阻塞文件操作放入线程池。对调用者来说都是“等待期间让出执行机会”,对资源预算来说却不同:线程池会消耗工作线程与排队空间;完成式接口需要管理未完成请求、缓冲区与完成结果。入门阶段先学会询问底层路径,不必展开所有接口细节。

线程池的价值是把数量受控的工作线程用于阻塞操作,使负责网络事件的线程及时返回。例如读取本地配置片段、调用只能同步使用的库,都可能适合交给池内线程执行。但提交动作完成不代表实际工作已经开始:池满时,请求可能在队列里等候。若监控只计量工作线程真正运行的时间,用户已经承担的排队延迟就被遗漏了。

线程池必须同时限制线程数量和排队数量。假设只有 8 个线程,每个操作平均占用线程 50 毫秒,在工作条件稳定且没有其他限制的理想估算下,服务能力约为每秒 160 个操作。持续到达速率超过它,增加队列容量只能把失败推迟,不能让处理能力凭空变大。真实耗时存在波动,还应留出余量;若线程共享同一慢设备,增加线程甚至可能让设备排队更严重。

超时与取消也要按阶段理解。调用者等待超时,不代表正在执行的系统调用已经停止;取消某个等待对象,也不一定能中断池内线程。若资源直到后台任务实际完成才释放,监控和容量计算必须覆盖这段时间。缓冲区、文件描述符和连接的拥有者应明确,避免请求结束后后台工作仍访问已经复用的数据。具体取消能力需要查所用接口的约定。

网络等待多、每次处理较短且能维护连接状态时,事件循环通常很合适;连接数有限、同步库多、团队需要简单调用链时,受控线程池也很有价值;确实需要提交大量文件操作时,再评估平台支持的完成式接口。选择时把编程复杂度也计入成本。某种模型节省了等待线程,却可能增加状态管理、调试和错误清理的工作量,不能只用接口先进程度决定架构。

网络事件循环与受限工作线程池的协作边界
网络事件循环与受限工作线程池的协作边界

6 常见坑:把高并发当唯一目标

并发连接数、每秒完成量和响应时间是不同指标。1 万条大部分空闲的连接,与 100 条持续发送数据的连接,可能产生完全不同的 CPU、带宽和缓冲压力。仅报告“支持多少连接”,无法判断服务能处理多少有效工作。说明负载时,至少要包括活跃比例、消息大小、请求到达速率、慢客户端行为,以及成功与超时的定义。

延迟应从明确的起点测到明确的终点。只测事件回调执行时间,会漏掉数据到达后在队列中等待处理的时间;只统计成功请求,会隐藏大量超时造成的体验退化。需要同时观察延迟分布、完成速率、错误率和排队长度。低流量下的平均值很漂亮,也不能证明接近容量边界时仍然稳定,因为短暂的处理停顿就可能积累成明显队列。

非阻塞并不等于无阻塞。事件回调里调用同步日志写入、读取大文件、获取竞争严重的锁,都会让当前线程停顿。把全部任务改成协程也不会改变这个事实;同事件循环上的协程需要执行到能够让出控制的位置,其他协程才有机会运行。若 CPU 计算占满线程,需要单独考虑计算资源和任务拆分,不能指望新增 I/O 监听来解决。

还有资源上限会早于 CPU 到来。运行 ulimit -n 能查看当前 shell 的文件描述符限制,但它不一定等于服务管理器启动进程时采用的限制。出现 EMFILE 时,应检查目标进程的限制和描述符增长,不能只修改当前终端的值。每条连接还会占用接收缓冲、发送缓冲和应用状态;把连接上限调高之前,要知道连接关闭后资源是否及时回收。

背压是另一条基本规则:下游处理或发送速度跟不上时,上游必须减速、排队到有界容量,或明确拒绝。无限接收并保存数据,只是把慢客户端问题转成内存问题。可以设每连接输出上限、全局待处理字节上限和空闲超时,但阈值需要与正常消息大小相容。超限时要有可观察的关闭或拒绝原因,避免用户只看到随机断线。

设计压测时还要防止客户端掩盖过载。若客户端总在收到响应后才发下个请求,服务变慢时到达速率也会下降,可能低估外部持续请求下的排队。测试报告应写明产生负载的方式,并检查压测端是否先达到 CPU、端口或带宽上限。先验证正确处理部分读写、断连和超时,再扩展规模,得到的并发数字才有可解释的边界。

7 案例:惊群与错误唤醒放大 CPU

某个多工作者网络服务在加入更多工作者后,CPU 使用率显著升高,每秒完成的连接数却几乎没有增加。延迟尾部反而变长。最初的解释是“连接太多”,但连接数量与消息大小没有明显变化。这时应把问题改写为可检验的假设:工作者是不是被唤醒了很多次,却没有取得对应的工作?先建立时间窗口,再对照唤醒、成功操作和完成量。

惊群描述的是多个等待者因某个事件同时获得运行机会,最终只有少数真正取得工作,其余等待者再次等待的现象。共享监听套接字的接收连接路径可以帮助理解:有新连接到来,多个工作者被唤醒,但实际可接收的连接数量有限。具体是否发生、唤醒多少个,取决于等待方式、内核行为和服务结构,不能断言所有多进程服务都会如此。

假设某次唤醒后,工作者 A 成功 accept(),工作者 B、C 随后得到 EAGAIN。非阻塞模式下,这是必须正确处理的正常竞争结果。危险在于 B、C 把它当成错误立即重试,或者马上重新进入永不阻塞的循环。每次循环都消耗调度和系统调用成本,却没有新增连接。若还同步打印错误日志,磁盘和日志队列也会被无效工作占据。

另一个相似现象来自错误订阅:程序给所有连接长期注册可写事件,即使没有响应需要发送。大量事件不断返回,工作者不停检查空输出队列,再回到等待。这里可能只有 1 个工作者,也没有共享连接竞争,因此不宜把它也统称为惊群。它与惊群的共同诊断线索是“唤醒很多、有效进展很少”,但修复位置不同。

排查先观察服务 CPU、完成量、错误率和事件循环空转计数,再短时间采样系统调用。pidstat -w -p PID 1 可以辅助观察上下文切换,strace -f -c -p PID 可辅助查看系统调用分布;附加进程可能受权限限制,也会干扰运行,应控制采样窗口。如果 epoll_wait() 高频返回,继续看返回事件及对应操作;如果大量线程等待锁,证据则指向共享状态竞争,而非直接归因于 epoll。

修复要针对已证实的原因。无数据待发时撤销可写关注;遇到 EAGAIN 回到等待路径;为每轮处理设置合理预算;检查监听连接由哪些工作者负责。特定平台提供减少重复唤醒的机制,但要先确认共享方式和适用条件,不宜盲目开启标志。验证时使用相同负载比较成功量、CPU、无效唤醒比例和尾部延迟,避免只看到 CPU 下降,却忽略实际接收能力同时下降。

CPU 升高但完成量不变时的分支排查
CPU 升高但完成量不变时的分支排查

8 把 I/O 模型选型写进架构说明

架构说明应从工作负载与目标开始。例如:“服务接收 shell 采集器上传的短文本,每条连接按换行切分;多数连接空闲,少量连接持续传输;文件写入允许短时间排队。”这样的描述才能支持后续选择。接着写清延迟从哪里计时、何时算成功、何时算超时,以及哪些故障必须能够隔离。只有“采用事件驱动提高并发”还不足以指导实现。

把资源拥有关系画出来:监听套接字由谁接收,连接交给哪个事件循环,文件操作是否进入线程池,关闭动作最终由谁执行。若多个线程都能操作同个连接,要说明如何避免读取竞争与重复关闭。若采用每循环独立管理连接的方式,也要交代跨线程任务怎样返回结果。读者不必知道内部所有类名,但应能追踪任意请求从进入到释放资源的路径。

容量部分需要列出明确预算:连接上限、每连接输入与输出上限、工作线程数、排队任务上限和超时。预算不是随手填的最大值,而应来自消息大小、可接受排队时间和资源测量。排队长度乘以典型服务耗时只能给出粗略直觉;存在并行处理、长尾和共享设备时,还要通过负载实验确认。所有估算都写清前提,避免把实验环境的上限当成部署承诺。

错误策略要落到可观察行为。连接读取返回 0 时,如何处理尚未完成的协议消息;输出长时间无法发送时,是继续等待还是关闭;线程池满时,是暂停接收、拒绝任务还是给出忙碌响应。超时后后台任务可能继续执行的情况也要注明。对每条策略配上计数与日志字段,使现场能够区分客户端关闭、服务端拒绝、协议不完整和内部处理失败。

选型证据至少包含正常负载、慢发送端、慢接收端和短时突发。慢发送端验证等待隔离,慢接收端验证输出缓冲与背压,突发验证队列上限。保持消息协议、响应内容和资源约束尽量可比,记录运行版本与机器条件。若改变线程数的同时也改变连接上限,就不能把结果全部归功于模型变化;应写明变量变化,并解释比较范围。

最后写出复查触发条件。例如同步文件操作频繁阻塞事件循环、线程池排队持续增长、无效事件比例上升,都会提示当前安排需要调整。复查可以先改队列边界、回调工作量或任务归属,不必马上更换全部 I/O 接口。好的架构说明让维护者知道当初为什么这样选,也知道出现什么证据时应该重新评估,而不是只留下一串性能术语。

从负载事实到模型选择、预算和复查条件
从负载事实到模型选择、预算和复查条件

9 从 I/O 模型走向文件系统深入(S28)

网络实验中的就绪,描述的是套接字此刻是否适合尝试读写。文件系统还会引入另个层次的问题:路径如何找到文件,文件数据是否已经在内存中,应用写入返回后数据处于哪里。不能因为对网络等待有了事件驱动方案,就认为读取普通文件也拥有相同的就绪通知能力。S28 将接着追踪文件操作,这里先建立测量边界。

执行 cat sample.txt > /dev/null 时,第 2 次可能明显快于第 1 次,其中可能包含缓存影响,但不能仅凭这个现象断言全部数据都来自内存。文件大小、系统负载和先前访问都会改变结果。/usr/bin/time -p cat sample.txt > /dev/null 可以观察整个命令耗时,仍需区分进程启动、读取、复制与输出。不要为了制造“冷读”而随意清空共享机器缓存。

写入也需要分层理解。write() 成功通常表示数据被系统接受,并不直接保证掉电后仍然存在;用户态缓冲刷新与持久化要求也不是相同动作。当需求说“日志不能丢”,必须进一步明确允许丢失多长时间范围、何时向调用者确认、失败时怎样报告。具体持久化接口与文件系统语义留到 S28,本课只要求不要把快速返回等同于全部工作已经完成。

从服务排查角度,网络慢与文件慢可能相互传导。事件循环同步读取文件时停顿,客户端表现为网络响应延迟;文件写入线程池积压时,若上游没有背压,输入缓冲会增加。错误归因往往发生在只看最终症状时。应沿处理路径拆出接收等待、应用排队、文件操作和发送等待,先知道时间花在哪个边界,再选择深入哪类机制。

进入 S28 前,可以整理当前程序实际使用的文件接口:启动时读取配置、运行中读取内容、追加日志、更新状态文件,各自是否位于事件循环线程,以及何时确认成功。这个清单会把抽象的 I/O 模型连接到真实文件系统操作。它也提醒我们,相同进程可能同时采用不同安排:网络等待交给事件循环,部分同步库交给受限线程池,关键写入按明确持久化要求执行。

本课留下的核心能力是沿接口追问:线程在哪里等待,等待期间其他任务能否前进,通知代表就绪还是完成,未完成的数据由谁保存,资源在哪里释放。到了文件系统主题,这组问题仍然适用,只是需要继续追踪缓存、目录和存储的责任边界。先用可观察事实解释现象,再逐步进入内部机制,会比给所有延迟都贴上“I/O 慢”的标签更有帮助。

放进方案里

在方案中先写负载:本机采集服务通过 TCP 接收换行分隔的文本,预计同时维持 2000 条连接,活跃比例先按 10% 估算,单条消息最大 8 KiB。延迟从完整消息到达服务端开始,计到响应全部交给本机发送接口为止;客户端另行记录端到端延迟。这些数字是待验证的设计输入,不能直接当作服务能力结论。

网络部分采用非阻塞套接字与事件循环,每条连接保存接收进度和待发送内容,仅在输出未清空时关注可写事件。同步文件任务进入 8 个工作线程组成的池,排队上限先设为 128 个任务。每连接输出上限设为 64 KiB,同时设置全局缓冲预算;消息超限、连接超时或队列饱和时执行明确的拒绝与关闭策略,并记录对应原因。容量阈值根据真实消息与磁盘测试调整。

验收包含正常短消息、发送前停顿 600 毫秒、接收端暂停读取和突发提交文件任务。比较吞吐、成功率、延迟分布、事件循环停顿、线程池排队与内存峰值;超时样本单独统计。先证明慢连接不会阻塞其他连接,再证明过载时队列有界,拒绝能够被调用者识别。测试保留脚本、环境与负载参数,便于后续复现。

上线观测持续记录有效事件与无效重试、连接数、待发送字节和任务等待时间。若 CPU 升高而完成量不变,先检查可写订阅和重复唤醒;若文件任务等待持续增长,先检查设备耗时与到达速率。架构说明同时记录任务超时后的清理责任,以及回退到较低连接上限的操作条件,让模型选择与故障处置形成可执行的约定。

10 小结:下一步文件系统深入(S28)

阻塞 I/O 让调用线程等待;非阻塞 I/O 让暂时无法推进的调用立即返回;多路复用集中等待多个对象的就绪;完成式异步 I/O 则围绕已提交操作的结果组织处理。线程池可以承接同步操作,但仍需要线程预算、队列边界与取消约定。选择依据应同时覆盖正确性、延迟、资源与维护成本。

自测时尝试解释 3 个现象:收到可读事件后为什么仍可能遇到 EAGAIN;连接没有待发送数据时为什么不宜一直关注可写;把同步文件读取放进事件回调为什么会拖慢其他连接。能沿等待位置和状态保存方式回答,就可以进入 S28,继续分析文件路径、缓存与写入确认之间的关系。

同阶段相关课

AI 答疑

默认关闭。启用后,只发送本课标题、最近阅读的屏幕内正文摘录(最多 6000 字)与本次问题。

配置自己的服务