IO 模型

一次网络或磁盘 IO 通常包含两个阶段:等待数据就绪把数据从内核拷贝到用户空间。围绕这两个阶段,Unix 演化出阻塞、非阻塞、多路复用、信号驱动、异步五种 IO 模型;面试反复追问的是"阻塞 vs 非阻塞、同步 vs 异步、select/poll/epoll 的区别",这是服务端并发的根基。

先理解一次 read 的两个阶段

用户程序调用 read 时,数据要先到达内核缓冲区,再从内核缓冲区拷贝到用户缓冲区:

阶段1 等待数据:用户进程 → read() → 内核等待网卡数据到达
阶段2 拷贝数据:数据从内核缓冲区拷贝到用户缓冲区 → read 返回

区分维度由此而来:阻塞指两个阶段都等;非阻塞指阶段 1 不等待、立即返回;异步指连阶段 2 也由内核代办,完成后才通知用户程序。

阻塞 IO 与非阻塞 IO

import socket

s = socket.socket()
s.setblocking(True)          # 阻塞模式
try:
    data = s.recv(1024)      # 数据未到之前,进程一直挂起等待
except BlockingIOError:
    pass                     # 非阻塞模式:无数据时 recv 立即抛此异常

阻塞模式一个线程只能服务一个连接;非阻塞模式让程序自行轮询数据是否就绪,单线程可管理多个连接,但空转轮询浪费 CPU——于是需要内核帮忙"集中等待"。

IO 多路复用:select / poll / epoll

多路复用由一个线程同时监视大量 fd,内核替它等待"其中任意一个就绪":

select/poll:每次调用把全部 fd 从用户态拷入内核,就绪后线性扫描 —— O(n),select 上限 1024
epoll       :内核维护就绪链表,只返回真正就绪的 fd —— O(就绪数),无上限,Linux 首选
import selectors, socket

sel = selectors.DefaultSelector()          # Linux 下即 epoll
sock = socket.socket()
sock.bind(("0.0.0.0", 8080)); sock.listen(128); sock.setblocking(False)
sel.register(sock, selectors.EVENT_READ)   # 把监听 fd 交给内核等待

while True:
    for key, _ in sel.select(timeout=1):   # 阻塞等待"有事件"的 fd
        conn, _ = key.fileobj.accept()
        print("收到新连接")                 # 输出:收到新连接

这是 Redis、Nginx 高并发的底层模型:单线程 + epoll + 非阻塞 IO,用事件循环处理成千上万的连接。

信号驱动与异步 IO

信号驱动 IO:进程注册 SIGIO 信号,数据就绪时内核发信号通知,进程仍需自己调用 read(阶段 2 仍是同步拷贝)。异步 IO(Linux aio、Windows IOCP):进程发起请求后立刻返回,内核完成"等待 + 拷贝"两个阶段后再通知,是真正意义上的异步,也是高性能网络库追求的方向。

注意区分"异步 IO 模型"与"异步编程框架":Node.js、Go 的网络层多数靠 epoll + 线程池模拟异步效果,而非直接使用内核异步 IO。

同步 vs 异步速查

同步 IO 在做 IO 时自己等待结果——阻塞、非阻塞、多路复用、信号驱动都属于同步,区别只在"等待的方式";异步 IO 由内核代劳,完成后回调通知。另需注意:多路复用虽然在 select 上阻塞,但它等待的对象从"一个 fd"变成了"一大批 fd",所以仍能支撑高并发。

小结

IO 模型面试主线:先记两阶段 → 阻塞/非阻塞描述阶段 1 → 同步/异步描述阶段 2 → 多路复用解决"单线程管多连接"。能说清 epoll 的就绪链表与事件驱动,就抓住了当下服务端并发的标准答案。

笔记加载中…