读一个网络连接,代码就一行:
n=read(sockfd,buf,len);看着和读文件没区别,可这一行挂在那儿的时间可能有好几秒——因为数据还在网络上,一点一点往这边爬。这期间线程会不会被卡住、还能不能去干别的,"IO 模型"回答的就是这个问题。
阻塞、非阻塞、多路复用、信号驱动、异步这五种模型经常被摆在一起,它们其实只是在同一件事上做了不同的选择。要看清这些选择,得先知道一次 IO 到底经历了什么。
一次 IO 的两个阶段
以读网络为例,read一个 socket 背后是两件分开的事:
- 等数据就绪。数据在网络上,先要到网卡、再进内核里这个 socket 的接收缓冲区。数据没到齐之前,缓冲区是空的。
- 把数据拷到用户空间。数据到了内核还不算完,
read得把它从内核缓冲区复制到你传进去的buf里,复制完read才返回。
发起 read │ ▼ ┌──────────────┐ 阶段① 等数据就绪 │ 内核等待数据 │ ←── 数据从网络到达网卡、进内核 socket 缓冲区 └──────────────┘ │ ▼ ┌──────────────┐ 阶段② 拷贝 │ 内核拷贝数据 │ ←── 从内核缓冲区复制到用户 buf └──────────────┘ │ ▼ read 返回 n这两个阶段合起来才是一次完整的 IO。五种模型的全部区别,就落在这两个阶段上:每一步进程要不要被阻塞,是谁在等,谁在拷。记住这张两段式的图,后面每一节都是往它上面填一种做法。
CS162 Note5 里一次 IO 请求的生命周期讲的是请求从系统调用到设备驱动的路径,可以对照着看,那篇看的是"请求往下走了哪几层",这里看的是"进程在每一层上等不等"。
阻塞 IO
最直白的做法。进程调read,数据没到就把进程挂起(进入睡眠状态),让出 CPU,一直等到两个阶段都做完才醒过来。
进程 read ────────── 阻塞(睡眠) ────────── read 返回 内核 等数据就绪 ———→ 数据到了 ———→ 拷贝到用户阶段①阻塞,阶段②也阻塞。一个线程盯一个连接,连接一多就得开一堆线程,而每个线程大部分时间都在睡觉。这就是最原始的服务器模型。
非阻塞 IO
进程不想睡,就把 fd 设成非阻塞(O_NONBLOCK)。再调read,数据没到不会挂起,而是立刻返回一个错误EWOULDBLOCK,意思是"还没好,等会儿再来"。于是进程只能不停地问:
进程 read→EWOULDBLOCK read→EWOULDBLOCK read→EWOULDBLOCK read→就绪┐ 内核 没数据 没数据 没数据 有数据 → 拷贝阶段①变成了"轮询",进程不睡了,但要反复空转着问内核,白白烧 CPU。阶段②(拷贝)该阻塞还是阻塞。应用得自己写循环去问,问的间隔还得拿捏——问太勤空耗,问太疏延迟高。
IO 多路复用
非阻塞的问题在于"一个线程只盯一个 fd,还得自己轮询"。多路复用把这活儿交给内核:一次系统调用同时盯一大堆 fd,谁就绪了内核告诉你。
select(maxfd+1,&readfds,NULL,NULL,NULL);// 或 poll / epoll_wait进程平时阻塞在select上,只要盯的 fd 里任意一个有数据了,select就返回,进程再对那个就绪的 fd 调一次read(这一下拷贝还是阻塞的)。
进程 select ─── 阻塞等任意 fd 就绪 ─── 返回「fd=7 就绪」── read(fd7) ── 拷贝 ── 返回 内核 盯着 fd 3,5,7 fd7 有数据 拷贝 fd7关键在阶段①的"等"被合并了:一个线程就能等成千上万个连接,谁有事才处理谁。这是现在所有高并发网络服务的地基。
三种实现里select、poll每轮都要把全部 fd 重新交给内核检查一遍,fd 一多就是线性扫描;select的 fd 数还被FD_SETSIZE卡在 1024。epoll把 fd 一次性注册进内核(存在红黑树里),就绪的 fd 由内核主动塞进一个就绪链表,epoll_wait直接取这个链表,不用每轮重扫,也没有数量上限。CS162 Note5 的 Socket 一节有 echo server 的例子,配合着看更直观。
注意
select/epoll这些经常被叫成"异步",其实它们是同步的——select返回之后,真正把数据搬进用户空间的那次read,还是会让进程阻塞。它只是让"等"变高效了,没让"拷"跑到后台去。
信号驱动 IO
换一种不轮询的等法:进程先用fcntl给 socket 设上O_ASYNC、指定接收信号的进程,然后去干别的。等数据真到了,内核给进程发一个SIGIO信号,进程在信号处理函数里再调read把数据取走。
进程 设 SIGIO ── 干别的 ───────────── 收到 SIGIO ── read ── 拷贝 ── 返回 内核 等数据就绪 ———→ 就绪 → 发信号 拷贝阶段①不阻塞了(由信号异步通知),但阶段②的拷贝还是在read里同步做。这种做法用得很少,一是信号编程本身就麻烦(能在信号处理函数里安全调用的函数很受限),二是它只能应对"数据到了"这一种事件,连接一多还不如直接上epoll。
异步 IO
前面四种不管怎么折腾阶段①,阶段②的拷贝都得进程自己动手,一动手就阻塞。异步 IO 把这最后一块也交出去:进程调aio_read后立刻返回,连拷贝都不用管,内核把"等数据"和"拷数据"全干完,完事再通知进程。
structaiocbcb={.aio_fildes=fd,.aio_buf=buf,.aio_nbytes=len,...};aio_read(&cb);// 立刻返回,进程可以接着干别的// ...之后通过 aio_error / 信号 / 回调得知完成进程 aio_read ── 立刻返回,干别的 ────────────── 收到完成通知 ── 数据已在 buf 里 内核 等数据就绪 ———→ 拷贝到用户 buf ———→ 发通知两个阶段都不阻塞进程,这才是"异步 IO"这个词真正的意思。
Linux 上这块历史绕:POSIX 的aio_*在 glibc 里是用用户态线程池模拟出来的;内核原生的 AIO(io_submit)又只对O_DIRECT生效,对普通文件和 socket 都不好使。直到 2019 年的io_uring,Linux 才算把"真正的异步 IO"做成了通用能力。
五种模型对比
| 模型 | 阶段① 等数据 | 阶段② 拷贝 | 同步 / 异步 |
|---|---|---|---|
| 阻塞 IO | 阻塞 | 阻塞 | 同步 |
| 非阻塞 IO | 轮询,不阻塞但空转 | 阻塞 | 同步 |
| IO 多路复用 | 阻塞在select/epoll上 | 阻塞 | 同步 |
| 信号驱动 IO | 不阻塞,信号通知 | 阻塞 | 同步 |
| 异步 IO | 不阻塞 | 不阻塞 | 异步 |
判断一个模型是不是异步,只看阶段②:数据从内核缓冲区拷到用户缓冲区这一步,进程要不要亲自参与。
阻塞、非阻塞、多路复用、信号驱动这四种,最后都得进程自己调read去把数据搬回来,搬的时候会阻塞,所以它们全都是同步 IO——包括那个最常被误叫成异步的epoll。只有异步 IO 连拷贝都扔给了内核,进程从头到尾没被卡住,这才是真正的异步。