IO的基本概念
什么是IO?
I/O(input/output)也就是输入和输出,在著名的冯诺依曼体系结构当中,将数据从输入设备拷贝到内存就叫做输入,将数据从内存拷贝到输出设备就叫做输出。
对文件进行的读写操作本质就是一种IO,文件IO对应的外设就是磁盘。
对网络进行的读写操作本质也是一种IO,网络IO对应的外设就是网卡。
输入的本质与中断机制:操作系统如何得知外设有数据?
输入的本质:从外设拷贝到内存
输入(Input)在操作系统层面,本质上就是将数据从外设拷贝到内存的过程。但这里有一个关键问题:操作系统必须能够得知特定外设上是否有数据就绪。
并不是操作系统想读数据时,外设就一定有数据。例如:
-
用户访问某台服务器,发出请求报文后,需要等待从网卡读取服务器发回的响应数据。
-
但此时,对方服务器可能还没收到请求,或者正在处理,又或者响应数据还在网络中传输。
如果操作系统采用主动轮询的方式,不断去检测外设的状态,那么绝大多数检测都是徒劳的------因为大部分时间外设中并没有数据。这种做法会严重降低操作系统的效率。
中断机制:让外设主动通知 CPU
实际的操作系统采用中断(Interrupt) 方式来感知外设数据就绪。当某个外设上有数据就绪时,该外设会向 CPU 中的中断控制器发送中断信号。中断控制器根据中断的优先级,按顺序将信号传递给 CPU。
中断处理流程
-
中断信号产生:外设数据就绪 → 发送中断信号给中断控制器。
-
中断向量表:系统维护一张 中断向量表,存储中断信号与对应中断处理程序的映射关系。
-
CPU 响应:CPU 收到中断信号后,暂停当前正在运行的程序。
-
执行处理程序:根据中断向量表,跳转到对应的中断处理程序执行。
-
恢复现场:处理完毕后,返回原被暂停的程序继续运行。
注意
CPU 不直接与外设打交道,指的是数据层面(数据传输通过内存或 DMA)。但外设可以直接将某些控制信号发送给 CPU 中的控制器(如中断请求线),这正是中断机制的硬件基础。
OS如何处理从网卡中读取到的数据包?
操作系统任何时刻都可能会收到大量的数据包,因此操作系统必须将这些数据包管理起来。所谓的管理就是"先描述,再组织",在内核当中有一个结构叫做sk_buff,该结构就是用来管理和控制接收或发送数据包的信息的。
简化版的sk_buff结构:

当操作系统从网卡当中读取到一个数据包后,会将该数据依次交给链路层、网络层、传输层、应用层进行解包和分用,最终将数据包中的数据交给了上层用户,那对应到这个sk_buff结构来说具体是如何进行数据包的解包和分用的呢?
核心设计:双链表 + 指针偏移
-
sk_buff 中的 data 指针指向原始数据包。
-
多个 sk_buff 以双链表形式组织,便于内核进行增删查改。
-
通过不同指针(mac_header、network_header、transport_header)标记协议头的位置,实现零拷贝解包------无需移动数据,只需移动指针。
链路层处理
数据包首先交给链路层。让 sk_buff->mac_header 指针指向数据包的起始位置,然后向后读取链路层报头(如以太网头)。报头之后的部分,就是需要交给网络层的有效载荷。链路层的解包至此完成。
网络层处理
"向上交付"并非将数据拷贝到网络层的新缓冲区,而是让 sk_buff->network_header 指针指向链路层报头之后的位置。然后从这个位置向后读取网络层报头(如 IP 头),剩下的就是传输层的有效载荷。
传输层处理
同理,让 sk_buff->transport_header 指针指向网络层报头之后的位置,读取传输层报头(如 TCP 或 UDP 头)。解包完成后,根据传输层协议类型,将剩余的数据(即应用层有效载荷)拷贝到对应协议的接收缓冲区(如 TCP 的接收队列或 UDP 的接收缓冲区),等待用户进程通过 socket 读取。
整个过程中,数据本身在内存中只存在一份拷贝。每向上一层,只需移动指针并减少长度字段,从而极大提高了网络协议栈的处理效率。这是 Linux 内核高性能网络的关键设计之一。
什么是高效的IO?
IO主要分为两步:
- 第一步是等,即等待IO条件就绪。
- 第二步是拷贝,也就是当IO条件就绪后将数据拷贝到内存或外设。
任何IO的过程,都包含"等"和"拷贝"这两个步骤,但在实际的应用场景中"等"消耗的时间往往比"拷贝"消耗的时间多,因此要让IO变得高效,最核心的办法就是尽量减少"等"的时间。
五种IO模型
IO 的过程其实和钓鱼非常相似。
钓鱼可以分成两个步骤:"等" 和 "拷贝"。
-
"等":鱼上钩之前,我们只能耐心等待。
-
"拷贝":鱼上钩之后,迅速把它从河里"拷贝"到鱼桶里。
IO 也是一样:先等待数据就绪(比如从网卡或磁盘),再把数据从内核拷贝到用户空间。
有趣的是,无论是在 IO 还是钓鱼中,"等"消耗的时间往往比"拷贝"要多得多。钓鱼时,大部分时间都在等鱼上钩,真正提竿、取鱼的瞬间很短。IO 也是如此:数据就绪前的等待常常是性能瓶颈,而一旦数据来了,拷贝的过程相对很快。
下面给出五个人的钓鱼方式:
-
张三:拿 1 个鱼竿,鱼钩入水后就死死盯着浮漂,什么也不做。有鱼上钩,立刻提竿。
→ 这是阻塞 I/O。
-
李四:拿 1 个鱼竿,鱼钩入水后去做别的事,定期回来看一眼浮漂。有鱼上钩就提竿,否则继续干别的。
→ 这是非阻塞 I/O(轮询)。
-
王五:拿 1 个鱼竿,鱼钩入水后在竿头绑个铃铛,然后放心去做别的事。铃铛一响,回来提竿。
→ 这是信号驱动 I/O。
-
赵六:拿 100 个鱼竿,全部抛入水中,然后定期轮流观察每个鱼竿的浮漂。哪个有鱼,就提哪个。
→ 这是I/O 多路复用(如 select/poll/epoll)。
-
田七:有钱的老板。他给司机一个桶、一个电话、一个鱼竿,让司机去钓鱼。等鱼桶装满了,司机再打电话告诉田七。田七自己则开车去做别的事。
→ 这是异步 I/O(真正的非阻塞,完成后通知)。
张三、李四、王五的钓鱼效率是否一样?为什么?
-
钓鱼步骤一致
三人的钓鱼过程都是:先等鱼上钩,再将鱼钓上来。没有谁用了更高效的"捕鱼技巧"。
-
鱼竿数量相同
每人只有一根鱼竿。在河里鱼咬钩的概率相同的情况下,每根鱼竿单位时间内钓上鱼的数量期望是一样的。
-
等待方式不影响效率
张三:死盯着浮漂(阻塞等待)
李四:定期回来检查(非阻塞轮询)
王五:绑铃铛等通知(信号驱动)
他们只是"等鱼上钩"的方式不同,但鱼咬钩的时机和概率完全相同。因此,最终钓上来的鱼的数量没有本质区别。
这里问的是钓鱼效率(即单位时间内钓上来的鱼的数量),而不是整体做的杂事多少。如果比较的是谁做的事情多:
-
王五做了最多事(绑铃铛、做其他活、回来提竿)
-
李四次之(定期回来检查)
-
张三最少(只是干等)
但效率只看结果,不看过程中是否"顺手干了别的"。
张三、李四、王五它们三个人分别和赵六比较,谁的钓鱼效率更高?
赵六拿了 97 根鱼竿,加上张三、李四、王五各 1 根,一共 100 根。
假设河里每条鱼咬钩时,咬到任意一根鱼竿的概率相等(1%)。那么:
-
张三、李四、王五的鱼竿被咬的概率各为 1%
-
赵六的 97 根鱼竿中,至少有一根被咬的概率接近 97%
-
单位时间内,赵六的鱼竿上有鱼的概率是其他人的 97 倍。
高效的 I/O 本质就是减少"等"的时间,增加"拷贝"的时间。赵六之所以效率高,是因为他一次等待多个鱼竿(监控多个文件描述符),将"等"的时间重叠起来------同一段时间里,他在为 97 个鱼竿同时等待,任何一个有鱼都能立刻处理。
对应到 I/O 模型:I/O 多路复用(select/poll/epoll)正是通过批量监控多个描述符,大幅提升单位时间内的 I/O 就绪概率,从而提升整体效率。
田七的这种钓鱼方式
田七的"钓鱼效率"取决于司机的速度和鱼的数量。但田七本人的时间利用率是最高的------他的时间完全没有花在钓鱼上,全部用在了自己的业务上。这也是异步 I/O 在高并发、高吞吐场景下极具价值的原因。
阻塞IO
阻塞IO就是在内核将数据准备好之前,系统调用会一直等待。

典型场景:recvfrom 调用
以 recvfrom 函数从套接字读取数据为例:
-
进程调用 recvfrom,进入内核。
-
如果底层数据还没有准备好(比如网卡还没收到数据包),进程就需要等待数据就绪。
-
数据就绪后,内核将数据从内核空间拷贝到用户空间。
-
拷贝完成,recvfrom 函数返回,进程继续执行。
在 recvfrom 等待数据就绪的这段时间里,从用户视角看,进程或线程"卡住了"------这就是阻塞。
其背后的内核行为是:
-
操作系统将该进程/线程的状态设置为非 R 状态(如 TASK_INTERRUPTIBLE),意味着它暂时不被调度。
-
内核将其放入等待队列中,与所等待的事件(如套接字接收缓冲区有数据)关联。
-
当数据就绪(例如网卡收到数据包并触发中断,内核将其放入套接字接收缓冲区),内核从等待队列中唤醒该进程/线程。
-
被唤醒的进程/线程将数据从内核拷贝到用户空间,然后返回。
非阻塞IO
非阻塞IO就是,如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错误码。

非阻塞IO往往需要程序员以循环的方式反复尝试读写文件描述符,这个过程称为轮询,这对CPU来说是较大的浪费,一般只有特定场景下才使用。
-
如果底层数据还没有准备好,recvfrom 立即返回错误(通常是 EWOULDBLOCK 或 EAGAIN),不会让进程或线程阻塞等待。
-
因为没有读到数据,后续进程需要继续主动调用 recvfrom,反复检测数据是否就绪。
-
每次检测,如果数据仍未就绪,recvfrom 仍然立即错误返回。
-
直到某一次检测发现数据就绪,recvfrom 将数据从内核拷贝到用户空间,然后成功返回。
非阻塞 I/O 本身并不会提高 I/O 效率,反而可能因为频繁的系统调用增加 CPU 开销。
信号驱动IO
信号驱动IO就是当内核将数据准备好的时候,使用SIGIO信号通知应用程序进行IO操作。

信号驱动 I/O(Signal-Driven I/O)的工作方式如下:
-
通过 signal 或 sigaction 函数,将 SIGIO 信号的处理程序自定义为所需的 I/O 操作(例如调用 recvfrom 读取数据)。
-
当底层数据就绪时,操作系统向当前进程/线程递交 SIGIO 信号。
-
进程收到信号后,自动执行信号处理程序,在程序中调用 recvfrom 将数据从内核拷贝到用户空间。
-
拷贝完成后,进程继续原来的工作。
同步与异步的判断标准
判断一个 I/O 过程是同步还是异步,关键在于:当前进程/线程是否需要亲自参与 I/O 操作(即数据拷贝)。
-
如果要参与 → 同步 I/O
-
如果完全不参与(由内核代劳,完成后通知)→ 异步 I/O
阻塞 I/O、非阻塞 I/O、I/O 多路复用、信号驱动 I/O 都属于 同步 I/O。
只有真正意义上的 异步 I/O(如 Windows IOCP、Linux AIO)才是异步 I/O。
IO多路转接
IO多路转接也叫做IO多路复用,能够同时等待多个文件描述符的就绪状态。

I/O 多路转接的核心思想
I/O 过程分为 "等" 和 "拷贝" 两个步骤。我们常用的 recvfrom 等接口,底层实际上做了两件事:
-
如果数据不就绪,就等。
-
数据就绪后,进行拷贝。
问题在于,recvfrom 这类接口一次只能等一个文件描述符上的数据或空间就绪,效率太低。
多路转接接口:专门负责"等"
系统提供了三组专门用于"等"的接口:select、poll、epoll。它们可以一次等待多个文件描述符,将"等"的时间重叠起来。
-
我们把所有"等"的工作交给这些多路转接接口。
-
当某个文件描述符上的数据就绪后,我们再调用 recvfrom 等函数进行拷贝。
-
此时,这些拷贝函数不需要再等待,直接拷贝并返回。
多路转接的核心价值:用一个系统调用(select/poll/epoll)等待多个 fd,减少"等"的总时间,提高单位时间内的 I/O 就绪概率。
异步IO
异步IO就是由内核在数据拷贝完成时,通知应用程序。

- 进行异步IO需要调用一些异步IO的接口,异步IO接口调用后会立马返回,因为异步IO不需要你进行"等"和"拷贝"的操作,这两个动作都由操作系统来完成,你要做的只是发起IO。
- 当IO完成后操作系统会通知应用程序,因此进行异步IO的进程或线程并不参与IO的所有细节。
高级IO重要概念
同步通信 VS 异步通信
同步和异步关注的是消息通信机制。
- 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回,但是一旦调用返回,就得到返回值了;换句话说,就是由调用者主动等待这个调用的结果。
- 异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果;换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果;而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。
为什么非阻塞IO在没有得到结果之前就返回了?
- IO是分为"等"和"拷贝"两步的,当调用recvfrom进行非阻塞IO时,如果数据没有就绪,那么调用会直接返回,此时这个调用返回时并没有完成一个完整的IO过程,即便调用返回了那也是属于错误的返回。
- 因此该进程或线程后续还需要继续调用recvfrom,轮询检测数据是否就绪,当数据就绪后最后再把数据从内核拷贝到用户空间,这才是一次完整的IO过程。
因此,在进行非阻塞IO时,在没有得到结果之前,虽然这个调用会返回,但后续还需要继续进行轮询检测,因此可以理解成调用还没有返回,而只有当某次轮询检测到数据就绪,并且完成数据拷贝后才认为该调用返回了。
同步通信 VS 同步与互斥
在多进程和多线程当中有同步与互斥的概念,但是这里的同步通信和进程或线程之间的同步是完全不相干的概念。
- 进程/线程同步指的是,在保证数据安全的前提下,让进程/线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,谈论的是进程/线程间的一种工作关系。
- 而同步IO指的是进程/线程与操作系统之间的关系,谈论的是进程/线程是否需要主动参与IO过程。
因此当看到"同步"这个词的时候,一定要先明确这个同步是同步通信的同步,还是同步与互斥的同步。