在网络编程的世界里,我们经常会听到 BIO、NIO、epoll、AIO 这些名词。它们究竟是什么?为什么 Nginx 和 Redis 能支撑起海量的并发?这一切的答案,都藏在操作系统的网络 I/O 模型中。
今天,我们就来扒开表面看本质,深入聊聊 Linux 下的五种经典网络 I/O 模型。
核心前提:一次 I/O 到底经历了什么?
在讲解具体模型之前,我们必须先统一一个概念:在操作系统眼中,一次完整的网络读取操作,实际上分为两个截然不同的阶段。
阶段一:等待数据准备就绪 (Waiting for data)。 网卡接收到网络传来的数据,然后操作系统内核将这些数据读取到内核缓冲区 (Kernel Buffer) 中。
阶段二:将数据从内核空间拷贝到用户空间 (Copying data from kernel to user)。 操作系统将内核缓冲区中的数据,搬运到我们应用程序定义的用户缓冲区 (User Buffer) 中。
理解了这两个阶段,接下来的五种模型无非就是在这两个阶段上做了不同的取舍和优化。
1. 阻塞 I/O 模型 (Blocking I/O)
这是最经典、也是我们在 C 语言中调用 socket() 默认创建的模型。
当应用程序调用 recvfrom() 时,如果内核中没有数据,进程就会原地挂起(交出 CPU 执行权),死死等待。直到数据到达网卡并被装入内核缓冲区(阶段一完成),内核接着把数据拷贝到用户空间(阶段二完成),函数才返回成功。
用户进程 (User Space) 内核 (Kernel Space)
----------------- -------------------
| |
|------ 1. 调用 recvfrom() ------------>|
| |
阻 | | [阶段一:等待数据到达网卡]
塞 | | ... (数据准备就绪)
等 | |
待 | | [阶段二:内核拷贝数据到用户态]
| | ...
|<----- 2. 拷贝完成,返回成功 ----------|
| |
(处理数据)
AI写代码
痛点:整个过程应用进程全被阻塞。一个线程只能处理一个连接,面对高并发时,只能靠开启海量的线程来应对,上下文切换的开销会瞬间压垮服务器。
2. 非阻塞 I/O 模型 (Non-blocking I/O)
为了不让线程被死死卡住,我们可以将 Socket 设置为 O_NONBLOCK 标志。
在这种模型下,应用进程调用 recvfrom() 时,如果内核里没数据,内核会立刻返回一个 EWOULDBLOCK 错误。进程收到错误后,就知道数据没好,可以去干点别的,然后隔三差五地来轮询检查。但是,一旦数据准备好了,阶段二的数据拷贝依然是阻塞的。
用户进程 (User Space) 内核 (Kernel Space)
----------------- -------------------
| |
|------ 1. 调用 recvfrom() ------------>|
|<----- 2. 返回 EWOULDBLOCK (未就绪) ---|
| |
做 |------ 3. 再次调用 recvfrom() -------->|
点 ||<---- 4. 返回 EWOULDBLOCK (未就绪) ---|
别 | |
的 |------ 5. 再次调用 recvfrom() -------->|
| | ... (此时数据准备就绪!)
阻 | |
塞 | | [阶段二:内核拷贝数据到用户态]
|<----- 6. 拷贝完成,返回成功 ----------|
| |
AI写代码
痛点:虽然第一阶段不阻塞了,但应用进程需要不断地盲目轮询(Polling),这会导致 CPU 空转,极大地浪费系统资源。
3. I/O 多路复用模型 (I/O Multiplexing)
既然应用程序自己去轮询太浪费 CPU,那能不能让操作系统来帮我们盯着呢?这就诞生了 select、poll 以及大名鼎鼎的 epoll。
进程将多个需要监听的 Socket 注册到 epoll 上,然后调用 epoll_wait 阻塞等待。此时,只要这成千上万个连接中任意一个有数据到达了,内核就会唤醒进程,并准确告诉它是哪个 Socket 有动静。接着,进程再对这个就绪的 Socket 调用 recvfrom(),直接进行第二阶段的拷贝。
用户进程 (User Space) 内核 (Kernel Space)
----------------- -------------------
| |
|------ 1. 调用 select / epoll_wait --->|
阻 | | [阶段一:等待任意Socket数据到达]
塞 | | ... (某个Socket数据就绪)
|<----- 2. 返回可读的 Socket -----------|
| |
|------ 3. 对就绪Socket调用 recvfrom() >|
阻 | | [阶段二:内核拷贝数据到用户态]
塞 | | ...
|<----- 4. 拷贝完成,返回成功 ----------|
| |
AI写代码
优势:它是构建高并发服务器(如 Reactor 模式)的基石。单线程就能同时监控海量连接,系统开销极小。
4. 信号驱动 I/O 模型 (Signal-Driven I/O)
这种模型采用的是“事件通知”的思路。我们先注册一个信号处理函数,告诉内核:“数据好了就给我发个 SIGIO 信号”。然后进程继续全速运行,完全不阻塞。
当数据到达内核后,内核触发信号。进程在信号回调函数中调用 recvfrom(),将数据从内核拷贝到用户空间(此拷贝阶段依然阻塞)。
用户进程 (User Space) 内核 (Kernel Space)
----------------- -------------------
| |
|------ 1. 设置 SIGIO 信号处理函数 ---->|
|<----- 2. 立即返回,继续执行 ----------|
| |
正 | | [阶段一:等待数据到达网卡]
常 | | ...
执 |<----- 3. 数据就绪,内核递交 SIGIO 信号|
行 | |
|------ 4. 在信号回调中调用 recvfrom() >|
阻 | | [阶段二:内核拷贝数据到用户态]
塞 | | ...
|<----- 5. 拷贝完成,返回成功 ----------|
| |
AI写代码
局限:在 TCP 协议中,能触发 SIGIO 信号的情况太多了(如连接建立、断开、数据到达等),导致信号极其频繁且难以区分,因此在实际的底层 C 语言开发中较少用于 TCP,多见于 UDP。
5. 异步 I/O 模型 (Asynchronous I/O)
前四种模型,无论第一阶段怎么优化,在第二阶段(数据拷贝)时,应用程序都必须停下来,亲自等待数据搬运完成。所以它们本质上统称为同步 I/O。
而真正的异步 I/O(如 Linux 的 io_uring 或 glibc 的 AIO),则是把脏活累活全包了。
应用程序调用 aio_read,告诉内核要去哪个 Socket 读数据、读完放到用户空间的哪个地址。内核收到指令后立刻返回。接下来,内核不仅负责默默等待数据,还负责把数据拷贝到用户指定的内存中。 一切搞定后,内核发个信号或执行回调:“数据已经贴心放在你指定的内存里了,直接用吧!”
用户进程 (User Space) 内核 (Kernel Space)
----------------- -------------------
| |
|------ 1. 调用 aio_read() ------------>|
|<----- 2. 立即返回,不阻塞 ------------|
| |
正 | | [阶段一:等待数据到达网卡]
常 | | ...
执 | | [阶段二:内核主动拷贝数据到用户态]
行 | | ...
|<----- 3. 拷贝全部完成,发信号/调回调 -|
| |
(直接使用内存中已就绪的数据)
AI写代码
优势:真正的计算与 I/O 重叠,两个阶段全不阻塞,性能的绝对王者。
总结与进阶思考
回过头来看,无论是简单粗暴的阻塞 I/O,还是支撑千万并发的 epoll,只要是同步 I/O,都无法逃避一个宿命:数据必须经历一次从“内核缓冲区”到“用户缓冲区”的 CPU 内存拷贝。
在极端的网络吞吐量下(例如静态文件服务器、消息队列),这层拷贝带来的 CPU 时钟周期消耗和内存带宽占用,往往会成为系统的最终瓶颈。
那么,有没有一种魔法,能够直接绕过这层拷贝,让数据在内核中直接流转呢?这就涉及到了操作系统中更为高级的黑科技。在下一篇文章中,我们将顺着这个思路,深入探讨如何利用 mmap、sendfile 彻底打破 I/O 拷贝的枷锁,一探高性能服务器的终极武器。
————————————————
版权声明:本文为CSDN博主「2401_83883283」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/2401_83883283/article/details/158209894