跳转到内容

02 · 操作系统:IO 多路复用与零拷贝

定位:OS 题里“游戏服务器味”最重的一章——select/poll/epoll 是腾讯/网易游戏服岗的必考题,客户端岗也常被问(网络层、资源加载就是 IO 问题)。学完这章,“一个服务器怎么同时服务几万个连接”你就能完整讲下来了。 学完标准:能画出 select/poll/epoll 三者的数据结构与复杂度对比;能讲 ET/LT 的区别和 epoll 为什么快;能把 read+write 的四次拷贝数出来并给出零拷贝方案。


以“等快递”类比:数据从网卡到你的 buf,分**等数据到(等待)和搬数据(拷贝)**两段。五种模型的差别全在“怎么等”:

IO 模型 怎么等 怎么搬
阻塞 IO 卡在 recv 上干等 内核拷到用户 buf 期间也卡着
非阻塞 IO 轮询(recv 立刻返回 EWOULDBLOCK,忙等烧 CPU) 同上
IO 多路复用 一个线程用 select/epoll 同时等 N 个 fd,谁就绪通知谁 就绪后 recv(拷贝仍阻塞但几乎立刻完成)
信号驱动 IO 内核就绪时发 SIGIO 信号 自己收到信号后拷
异步 IO(AIO/io_uring) 发起读请求就返回 内核拷完再通知你(等待和拷贝都不占你)

同步/异步的分界:拷贝阶段要不要你自己动手。前四种都算“同步 IO”(就绪后还得自己调 recv 搬);只有第五种是真异步。面试金句:“多路复用解决的是’等’的问题,没解决’搬’的问题——搬还是你自己搬,所以它是同步 IO。”

2. select / poll / epoll(本章主菜)

Section titled “2. select / poll / epoll(本章主菜)”

它们解决什么:一个服务器 1 万个连接,总不能开 1 万个线程各卡在 recv 上(内存和切换都爆)。多路复用 = 一个线程监听 N 个 fd,谁有数据唤醒谁。

select poll epoll
数据结构 fd_set 位图(1024 上限) pollfd 数组(无上限) 内核红黑树 + 就绪链表
每次调用 把整个集合拷进内核,内核线性扫全部 fd 同 select(拷数组线性扫) 注册一次(epoll_ctl 建树);epoll_wait 只返回就绪的
复杂度 O(n) 每次 O(n) 每次 O(1) 取就绪(就绪时回调挂链表)
fd 上限 1024 无硬限 无硬限(数十万轻松)
跨平台 ✅ Windows 也有 类 Unix Linux 专属(BSD/Mac 是 kqueue,Windows 是 IOCP)

epoll 为什么快(追问率 100%):

  1. select/poll 每次 syscall 都要把 fd 集合从用户态拷进内核再全量扫描——连接越多越亏;epoll 的 fd 注册一次就常驻内核(红黑树管理);
  2. 数据到达时,内核通过回调把该 fd 挂进就绪链表,epoll_wait 只是取走链表——返回的全是有事的,不用扫全部;
  3. 所以活跃连接占比越低(长连接、消息稀疏——正是游戏/IM 的形态),epoll 相对优势越大。

LT(水平触发)vs ET(边缘触发):

LT 水平触发(默认) ET 边缘触发
通知时机 只要缓冲区还有数据就每次都通知 只在新数据到达那一刻通知一次
编程难度 简单(这次没读完下次还通知) 高(必须一次读到 EWOULDBLOCK,否则剩余数据永远没人理)
配套 recv 正常读 必须配非阻塞 fd + 循环读

面试金句:“LT 是’还有货就吆喝’,ET 是’到新货才吆喝一声’——ET 少打扰但你要一次搬完,所以必须非阻塞 + 循环读到空。”

一个 echo 服务器的骨架(能白板默写加分):

int ep = epoll_create1(0);
epoll_event lev{};
lev.events = EPOLLIN;
lev.data.fd = listen_fd;
epoll_ctl(ep, EPOLL_CTL_ADD, listen_fd, &lev);
while (true) {
epoll_event evs[64];
int n = epoll_wait(ep, evs, 64, -1); // 只返回就绪的
for (int i = 0; i < n; ++i) {
int fd = evs[i].data.fd;
if (fd == listen_fd) {
int c = accept(listen_fd, nullptr, nullptr);
set_nonblocking(c);
epoll_event cev{};
cev.events = EPOLLIN | EPOLLET; // 边缘触发
cev.data.fd = c;
epoll_ctl(ep, EPOLL_CTL_ADD, c, &cev);
} else {
while (true) { // ET:读到空为止
int r = recv(fd, buf, sizeof buf, 0);
if (r > 0) handle(buf, r);
else if (r < 0 && errno == EWOULDBLOCK) break;
else { close(fd); break; } // 对端关闭或真出错
}
}
}
}

游戏里的位置:客户端的网络线程(收发服务器消息)、资源加载线程(异步 IO 完成通知)都靠这套;服务器侧(帧同步/状态同步网关)epoll+ET 是标配。

Reactor 和 Proactor(追问“你这个循环叫什么”):

上面的 while + epoll_wait 就是 Reactor:内核通知你某个 fd 就绪了,你自己再 recv / send 把数据搬完。变体是单个 reactor 把事件丢给 worker,或者 one loop per thread(每个线程自己持有一个 epoll)。

Proactor 通知的是”已经做完“:你提交”把这块缓冲读满”,内核搬完再回调。Windows 的 IOCP、Linux 的 io_uring 走这条路。一句话:Reactor 通知就绪,Proactor 通知完成。前者你还要自己拷,后者连拷贝都交给内核,系统调用更少,编程模型是完成队列,不是“可读了再读”。

epoll 一定比 select 快吗?fd 很少、而且几乎全部活跃时,select 的常数更小,差距可以忽略。优势出现在连接多、同时活跃的少——游戏长连接正是这种形态。

3. 零拷贝:read+write 为什么慢,怎么省

Section titled “3. 零拷贝:read+write 为什么慢,怎么省”

经典问题:“服务器把一个文件发出去,数据拷贝了几次?”——朴素写法:

read(file_fd, buf, 4096); // 磁盘→内核页缓存→用户 buf (2 次拷贝,2 次切换)
write(sock_fd, buf, 4096); // 用户 buf→socket 内核缓冲→网卡(2 次拷贝,2 次切换)

4 次拷贝 + 4 次用户/内核态切换,而数据只是“路过”用户态一次,纯属白搬。优化阶梯:

方案 做法 省掉什么
mmap + write 把文件映射进用户空间,直接 write 映射区 省“内核→用户”那次读拷贝(4→3)
sendfile sendfile(sock, file, ...) 一次系统调用,内核内部完成文件→socket 用户态完全不过手(4→3,2 次切换→2 次仍在但无用户拷贝)
sendfile + SG-DMA 网卡支持分散聚集 DMA,内核只传文件描述符和偏移 真正零次 CPU 拷贝(数据全程 DMA 搬)
splice 管道中转,两个 fd 间内核内搬运 类似,适合管道/socket 组合

面试金句:“零拷贝不是不拷,是 CPU 不拷——该搬的活全交给 DMA,用户态连摸都不摸。” 游戏关联:资源服务器分发补丁(nginx sendfile)、引擎资源异步加载(io_uring 正是零拷贝+真异步 IO 的现代统一答案,可作加分谈资)。

  • 硬中断:硬件(网卡/磁盘/定时器)通知 CPU 的电信号——“键盘中断你正在跑的程序”。处理要快,所以上半部只做最小工作。
  • 软中断 / tasklet /下半部:把耗时的收尾(如协议栈解析)推迟处理——网卡硬中断只把数据挂队列,协议解析在软中断里做。
  • 信号:内核发给进程的“软件版中断”(kill、SIGSEGV、Ctrl+C)。默认行为可被自定义处理器接管,但处理器里只能用异步信号安全函数(printf/malloc 都不是——它们可能持着锁时被你打断,死锁)。崩溃收集器(minidump)就是这个原理,但写它要极其小心。

5. CPU 调度与“帧率的敌人”(游戏视角)

Section titled “5. CPU 调度与“帧率的敌人”(游戏视角)”
  • 抢占式时间片调度(CFS:按虚拟运行时间排队,公平优先);实时线程(SCHED_FIFO/RR)可抢占普通线程——音频线程在游戏里常要求提升优先级,否则爆音。
  • 优先级反转:低优先级线程拿着锁,高优先级等锁,中优先级又抢走 CPU——经典事故(Mars Pathfinder);解法:优先级继承(持锁者临时继承等锁者的优先级)。mutex 设 PTHREAD_PRIO_INHERIT 可开。
  • 对帧率的启示:把“必须按时完成”的(音频回调、渲染提交)和“随时可断”的(资源加载)分线程+分优先级;后台线程记得降低自己的优先级,别和主循环抢核。

6. 高频面试题 Q&A(合上书能讲)

Section titled “6. 高频面试题 Q&A(合上书能讲)”

Q1:select、poll、epoll 的区别? select:位图 fd 集合,上限 1024,每次调用全量拷进内核线性扫描 O(n);poll:数组替代位图,无硬上限,机制同 select;epoll:fd 用 epoll_ctl 注册一次进内核红黑树,数据到达回调挂就绪链表,epoll_wait 只取就绪的 O(1)——连接多且活跃率低时优势最大。epoll 是 Linux 专属,对应 Mac 的 kqueue、Windows 的 IOCP。

Q2:epoll 为什么快? 三点:注册一次常驻内核(不用每次拷集合);内核回调挂就绪链表(不用全量扫描);wait 只返回有事件的 fd(用户态不用遍历判断)。本质是把“每次全量扫描”变成“事件驱动增量通知”。

Q3:LT 和 ET 的区别? LT 水平触发:缓冲区还有数据就一直通知,编程简单;ET 边缘触发:只在状态变化(新数据到达)时通知一次,必须一次读到 EWOULDBLOCK,且必须配非阻塞 fd,否则读一半卡死整个线程。ET 通知少、效率更高但易写错。

Q4:什么是零拷贝? 让文件→网卡的过程不经过用户态、不消耗 CPU 拷贝:mmap+write 省一次,sendfile 让内核内部转发(用户态不过手),配 SG-DMA 网卡后数据全程 DMA 搬运、CPU 零拷贝。适用:静态文件下发(补丁服务器)、日志投递。

Q5:一台服务器怎么同时服务上万连接?(C10K) 每连接一线程不可行(内存+切换爆炸)。方案:单/少量 reactor 线程 epoll 监听所有连接(ET+非阻塞),就绪后处理或丢给 worker 线程池(one loop per thread / 主从 reactor);配连接池、无锁队列。这就是 nginx/redis/游戏网关的形态。

Q6:阻塞 IO 和非阻塞 IO 的区别?同步和异步呢? 阻塞:没数据时 recv 卡住线程;非阻塞:立即返回 EWOULDBLOCK,自己决定何时再试(常配多路复用)。同步/异步看拷贝阶段:前四种模型就绪后都要自己调 recv 搬数据=同步;异步 IO(io_uring/AIO)是内核搬完再通知你。

Q7:硬中断和软中断的区别? 硬中断是硬件发的电信号,打断 CPU,必须极快返回,所以只做“把数据挂队列”这类最小工作;耗时的协议解析等放软中断/下半部处理。信号则是内核发给进程的“软件中断”,处理函数里只能用异步信号安全函数。

Q8:什么是优先级反转?怎么解决? 低优先级持锁、高优先级等锁、中优先级抢占低优先级——结果高优先级被不相关的中优先级间接卡住。解法:优先级继承/优先级天花板(持锁者临时提权)。历史上 Mars Pathfinder 就栽在这。游戏里音频线程的锁要小心这个。

Q9:recv 返回 0 和返回 -1 各是什么意思? 返回 0 = 对端正常关闭连接(读到 EOF);-1 看 errno:EWOULDBLOCK/EAGAIN=非阻塞下暂时没数据(正常),EINTR=被信号打断可重试,其余是真错误。

Q10:write 大量数据后对端不读,会发生什么? 发送缓冲区塞满,write 阻塞(或非阻塞返回 EWOULDBLOCK);TCP 流量控制会让对端窗口缩到 0,本端不再发包——所以要有发送队列+背压处理,游戏服务器踢掉不消费的客户端。

Q11:Reactor 和 Proactor 的区别? Reactor(epoll / kqueue)是内核告诉你 fd 就绪,你自己 recv,把数据从内核缓冲搬到用户缓冲。Proactor(IOCP、io_uring)是你提交操作和缓冲,内核搬完再通知完成。游戏网关主流仍是 reactor 加非阻塞。io_uring 把真异步和批量提交放进用户态与内核共享的环形队列,进一步省掉系统调用本身。

7. 本章自测(10 题,限时 20 分钟,先自己答再看答案)

Section titled “7. 本章自测(10 题,限时 20 分钟,先自己答再看答案)”

1. 五种 IO 模型分别怎么"等数据"?哪个是真异步?

2. select 的两个硬伤?

3. epoll_wait 为什么不用遍历所有 fd?

4. ET 模式下读事件处理的固定套路?

5. read+write 发文件共几次拷贝?

我的原答:sendfile 后呢?

6. "零拷贝"里 CPU 到底干了什么?

7. 什么是 reactor 模式?

8. 信号处理函数里为什么不能调 printf?

9. 非阻塞 accept 可能发生什么(惊群/EMFILE)?

10. 音频线程为什么要提优先级?

我的原答:这引出什么经典问题?

📖 答案(先自己答完再展开)
  1. 阻塞卡等 / 非阻塞轮询 / 多路复用一个等 N 个 / 信号驱动 / 异步 IO 内核搬完才通知——最后一个是真异步。
  2. fd_set 位图 1024 上限;每次全量拷进内核线性扫 O(n)。
  3. 数据到达时内核回调把 fd 挂就绪链表,wait 只是取链表——事件驱动增量。
  4. 非阻塞 fd + while 循环 recv 直到返回 <0 且 errno==EWOULDBLOCK。
  5. 4 次(磁盘→页缓存→用户→socket 缓冲→网卡,DMA 部分);sendfile 后 3 次或配 SG-DMA 后 CPU 0 次。
  6. CPU 只填描述符/偏移,数据全程 DMA 搬——“零”指 CPU 拷贝次数为零。
  7. 事件循环(epoll)+ 分发就绪事件给处理逻辑;变体:单 reactor+worker 池、one loop per thread(多 reactor)。
  8. 信号随时打断代码,printf 内部可能正持锁,在处理器里再调它=重入死锁;只能用 write 等异步信号安全函数。
  9. 惊群:多线程同时 accept 被无谓唤醒(EPOLLEXCLUSIVE/SO_REUSEPORT 解);连接数打满 fd 时 accept 成功立刻又被 EMFILE 噎住——要先留 reserve fd。
  10. 音频回调必须按时完成否则爆音;但它可能等一个被低优先级线程持有的锁→优先级反转,用优先级继承解决。

8. 进阶追问(答不上来就回来复习)

Section titled “8. 进阶追问(答不上来就回来复习)”
  • io_uring 和 epoll 的区别?见本章 §2(完成通知 + 共享环形队列,省掉 syscall 本身;epoll 仍是就绪通知)
  • reactor 和 proactor 的区别?见本章 §2(就绪通知 vs 完成通知,IOCP 是 proactor)
  • epoll 一定比 select 快吗?见本章 §2(fd 极少且几乎全活跃时,select 的常数可以更小)
  • 与 09 章贯通:网络线程收到包后怎么交给逻辑线程?(无锁队列/消息队列,逻辑帧统一消费——正是游戏主循环的网络接入点)