操作系统 IO 相关知识
IO 就是计算机内部与外部进行数据传输的过程,常见的 IO 包含网络 IO 与磁盘 IO。所有 IO 都需要系统调用,由操作系统代理执行,并经历从 IO 设备拷贝到内核空间拷到用户空间的环节。在内核收到调用请求之后,会有数据准备、数据就绪、数据拷贝的阶段。
每一个程序员面试的时候或多或少都会被问到相关知识,了解一下相关概念是非常重要的。
# 阻塞与非阻塞
阻塞与非阻塞指线程在等待调用结果(数据,消息,返回值)时的状态。
阻塞是指应用线程等待结果时会被挂起,等待内核(或者 IO)完成操作,实际上,内核所做的事情是将 CPU 时间切换给其他有需要的线程,网络应用程序在这种情况下是得不到 CPU 时间做该做的事情的,它会在那里睡觉。因此这里的阻塞应当理解成线程被其他线程阻塞,或者等待 IO 资源结果的时候阻塞
非阻塞是指该线程等待结果(数据准备)时可以执行其他操作,只有在拷贝数据的时候才会阻塞。此时该线程可以做一些其他的 CPU 计算,但是一般会伴随不停的访问请求结果这一操作
注意,阻塞与非阻塞的概念只有同步中才有,没有异步阻塞与异步非阻塞的说法,接下来说一下为什么
# 同步与异步
同步和异步关注的是消息通信机制
同步指发出调用请求后,请求跟随调用结果一起返回
异步指发出请求后,请求或者调用立即返回,在 IO 设备或者其他的机器操作完毕后,通过回调函数或者其他方式返回状态或者数据信息
比如我们使用消息队列来处理某个功能,一般来说发生消息后就不用管之后的操作了,该过程就是异步的。主线程不关心另一个线程怎么样,所以谈不上阻塞非阻塞
# IO 和系统调用
所有的 IO 操作都会有系统调用的过程,同时所有的系统 IO 都分为两个阶段:等待就绪和操作。举例来说,读函数,分为等待系统可读和真正的读;同理,写函数分为等待网卡可以写和真正的写
read(file, tmp_buf, len);
write(socket, tmp_buf, len);
2
为什么会这样分呢,因为当一个应用程序(位于用户空间)需要执行 I/O 操作,比如读写文件、网络通信等,它不能直接访问硬件设备,因为这样做可能会破坏系统的稳定性和安全性。因此,应用程序会通过系统调用来请求操作系统内核执行 I/O 操作。操作系统会将文件从 IO 设备(磁盘、网络)先复制到内存缓冲区,然后再将数据复制到用户空间缓冲区
需要说明的是等待就绪的阻塞是不使用 CPU 的,是在空等,这期间数据通过网线以电流的方式输入我们的电脑,并且被保存在缓冲区中;而真正的读写操作的阻塞是使用 CPU 的,即 CPU 将数据从缓冲区拷贝到目标位置的过程,这个时候 CPU 真正在干活。而且这个速度是非常快的,数据从内核缓冲区复制到用户空间缓冲区的时间通常是在纳秒(ns)到微秒(μs)级别。具体时间取决于复制的数据量和 CPU 的速度,复制几千字节的数据可能只需要几十微秒,而复制几兆字节的数据可能需要几百微秒到几毫秒(ms)。这点时间对于磁盘或者网络 IO 来说几乎可以忽略不计
# 传统的 IO
这里使用普通的 read 和 write 命令来展示一下传统 IO 的弊端
- 用户进程通过 read 方法向操作系统发起调用,此时上下文从用户态转向内核态
- DMA 控制器把数据从硬盘(或者套接字的缓冲区)中拷贝到内核读缓冲区
- CPU 把内核读缓冲区数据拷贝到应用缓冲区,然后上下文从内核态转为用户态,read 和数据一起返回
其过程如下图所示:
write 指令:
- 用户进程通过 write 方法发起调用,上下文从用户态转为内核态
- CPU 将应用缓冲区中数据拷贝到 socket 缓冲区
- DMA 控制器把数据从 socket 缓冲区拷贝到网卡,上下文从内核态切换回用户态,write 返回
read 和 write 分别进行了两次内核态用户态切换和两次 CPU 复制操作,在操作系统中这些是可以被优化的
但是优化之前先处理一个问题,我们可以看到数据会先复制到中间的 socket buffer 中,我们很可能有一个问题就是为什么数据不能从磁盘直接复制到用户缓冲区?原因主要在下面两点:
1,权限隔离:操作系统的内核空间和用户空间是严格分离的,以防止恶意程序直接访问系统资源,造成系统不稳定或安全漏洞。内核负责管理硬件资源,包括磁盘读写,而用户空间的应用程序则运行在更高的抽象层次上 2,地址空间不同:内核空间和用户空间拥有各自的虚拟地址空间。用户缓冲区位于用户空间的虚拟地址范围内,而内核在执行I/O操作时使用的是内核缓冲区,这些缓冲区位于内核空间的地址范围内
# DMA
DMA 是 Direct Memory Access,直接内存存取,算零拷贝技术之一
DMA 是直接内存访问技术,他是一块主板上独立的芯片,是一种计算机硬件技术,允许外部设备(如网卡、硬盘、显卡)直接与系统内存进行数据传输,无需 CPU 全程参与。这种机制显著提高了数据传输效率,减少了 CPU 的负载,尤其适用于大量数据的快速传输场景
早期计算机中,用户进程需要读取磁盘数据,需要 CPU 中断和 CPU 参与(就是整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,数据会先从磁盘拷贝到 CPU 寄存器,然后再写入对应的缓冲区),因此效率比较低,发起 IO 请求,每次的 IO 中断,都带来 CPU 的上下文切换。 DMA 控制器接管了数据读写请求。并且加快了 IO 拷贝速度,从而减少 CPU 的处理时间
举个例子说明:
// DMA 控制器负责数据搬运
1. 磁盘 → 内核缓冲区 (DMA直接搬运)
2. 内核缓冲区 → 应用缓冲区 (CPU拷贝)
3. 应用缓冲区 → 内核Socket缓冲区 (CPU拷贝)
4. 内核Socket缓冲区 → 网卡 (DMA直接搬运)
2
3
4
5
# buffer 和 cache
我们在 IO 的时候经常会遇到不同的组件速度不一样的问题,比如 CPU 运行是很快的,但是磁盘的写入或者读取速度是很慢的,因此我们需要引入中间层来兼容速度不一致的问题。一般引入 buffer 和 cache 来处理问题,但是两者的侧重点不一样
buffer:缓冲区,解决速度不匹配问题。例如,应用程序快速生成数据,而磁盘写入速度慢,缓冲区暂存数据后批量写入,避免阻塞。因此 buffer 重视处理多个组件之间读写速度不一致的问题
cache:缓存,通过存储热点数据减少对慢速设备的访问。例如,CPU 缓存避免频繁访问内存,文件缓存避免重复读磁盘,将数据从慢速的地方读出来,在快速的地方做缓存
# 零拷贝技术
# 传统拷贝
先看看普通文件传输的过程,以发送文件给网络客户端为例:
1,磁盘→内核缓冲区:从硬盘读取文件数据到内核空间。为什么要读到缓冲区中呢,我们可以想象一个现实场景:消防车(CPU)通过水管(总线)从水库(磁盘)抽水(数据)灭火(计算)
问题是消防车抽水速度是10吨/分钟,但是水库供水速度是1吨/分钟,直接连接会导致消防车90%时间在等待,所以消防车把这事情交给 DMA,让它从水库抽水,存到一个地方(缓冲区),然后消防车消费缓冲区里的数据
2,内核缓冲区→用户缓冲区:数据拷贝到应用程序内存,这一步是为了必须满足操作系统和用户空间的隔离性
3,用户缓冲区→Socket 缓冲区:应用程序再把数据拷贝到内核网络缓冲区,这一步也是为了处理网络传输和 CPU 处理速度不一致的问题
4,Socket 缓冲区→网卡:最后发送到网络。Socket 缓冲区在操作系统内核内存中,目的是临时存储待发送和接收的网络数据,而网卡是将数字信号转换为电信号或者光信号,它是物理硬件设备
# mmap 内存映射
接下来我们看看上面的过程中有哪些可以优化的点,零拷贝技术是优化拷贝次数的技术,以下的技术包括 mmap 都是零拷贝技术的一种
mmap 将用户空间的一段内存区域映射到内核空间,映射成功后,用户对这段内存区域的修改可以直接反映到内核空间,同样,内核空间对这段区域的修改也直接反映用户空间。即将用户空间和内核空间关联起来,例如堆外内存就是 mmap 零拷贝技术
内核缓冲区和应用缓冲区共享,从而减少了从内核缓冲区到用户缓冲区的一次 CPU 拷贝
# sendfile
sendfile 方法 IO 数据对用户空间完全不可见,所以只能适用于完全不需要用户空间处理的情况,比如将磁盘中的数据复制到网卡
整个过程发生了两次用户态和内核态的上下文切换和三次拷贝,数据不经过用户区,只在内核态进行复制。简单介绍一下流程,就是数据存在于内核中时,并未被真正复制到 socket 关联的缓冲区内。取而代之的是,只有记录数据位置和长度的描述符被加入到 socket 缓冲区中。DMA 模块将数据直接从内核缓冲区传递给协议引擎,从而消除了遗留的最后一次复制
Linux2.4内核版本之后对 sendfile 做了进一步优化,通过引入新的硬件支持,这个方式叫做 DMA Scatter/Gather 分散/收集功能,将整个过程优化到两次用户态和内核态的上下文切换和两次拷贝
# splice
Linux 从2.6.17支持 splice 数据从磁盘读取到 OS 内核缓冲区后,在内核缓冲区直接可将其转成内核空间其他数据 buffer,而不需要拷贝到用户空间,这是不是和 sendfile 有点像?
注意 splice 和 sendfile 的不同,sendfile 是 DMA 硬件设备不支持的情况下将磁盘数据加载到 kernel buffer(内核空间维护的缓存空间)后,需要一次 CPU copy,拷贝到 socket buffer
而 splice 是直接将两个内核空间的 buffer 进行 pipe 管道传输,不需要用户进程介入
#include <fcntl.h>
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);
2
# 常用的 IO 模型
每个 IO 模型的区别


# BIO:同步阻塞 IO
同步阻塞 IO 模型,该模型用于描述一方发起调用后,阻塞等待另外一方返回的场景,举几个例子:
- 应用程序发起 read 调用后,会一直阻塞,直到在内核把数据拷贝到用户空间
- 用户请求服务器后,一直等待接口返回
- HTTP 长轮询
当线程调用 recv() 或 read() 读取数据时,若客户端没有发送数据,线程会一直阻塞,直到以下情况发生:
- 客户端发送数据
- 客户端关闭连接(触发 recv() 返回 0)
- 出现网络错误(触发异常)
以下是代码示例,平时我们可能会使用多线程来接受数据
int client_fd = accept(listen_fd, ...); // 接受新连接
char buffer[1024];
ssize_t n = recv(client_fd, buffer, sizeof(buffer), 0); // 线程在此阻塞
2
3
# NIO:同步非阻塞 IO
同步非阻塞 IO 模型中,该模型用于描述一方发起调用后,虽然等待该请求的结果,但是我们依然执行自己的一些逻辑流程,然后通过定时轮训来获取最终结果,此种方式有较高的 CPU 占用率
在普通的 NIO 中应用程序会一直轮询发起 read 调用(发起调用会降低 CPU 性能),在数据准备的时间中线程不挂起,直到磁盘准备好数据
数据就绪后,实际的 IO 操作会等待数据复制到应用进程的缓冲区中以后才返回
fcntl(client_fd, F_SETFL, O_NONBLOCK); // 设置非阻塞
ssize_t n = recv(client_fd, buffer, sizeof(buffer), 0);
if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
// 无数据,可继续处理其他逻辑
}
2
3
4
5
# IO 多路复用
java 中的 NIO、Radis 中的单线程、nginx 中的主进程就是 IO 多路复用(使用选择器,缓冲区、通道来实现,通道将用户数据拷贝到缓冲区中,选择器让程序读取缓冲区中数据),IO 多路复用是 NIO 的一种,但是它的好处是可以在一个线程上处理多个不同端口的监听并且不用轮询套接字
epoll 用了事件通知的方式,而不是轮询。也就是说,当某个文件描述符就绪时,内核会通知应用程序,而不是应用程序主动去检查
epoll 有三个主要的系统调用:epoll_create、epoll_ctl 和 epoll_wait。epoll_create 用来创建一个 epoll 实例,返回一个文件描述符。epoll_ctl 用来向这个实例中添加、修改或删除要监控的文件描述符及其事件类型。epoll_wait 则是等待事件的发生,返回就绪的事件列表
epoll 内部是使用红黑树和就绪队列来管理的。红黑树用来存储所有被监控的文件描述符,这样在添加、删除和查找的时候效率比较高,时间复杂度是 O(log n)。而就绪队列则用来存放已经就绪的事件,当某个文件描述符的事件发生时,内核会将其放入就绪队列中,这样 epoll_wait 只需要检查这个队列,而不需要遍历整个集合
当某个文件描述符上有数据到达时,网卡会通过中断通知内核,内核处理中断后将数据复制到对应的 socket 缓冲区,然后将对应的 epoll_item 结构体放入就绪队列。当应用程序调用 epoll_wait时,内核将就绪队列中的事件返回给用户空间,同时清空队列,以便下次等待新的事件
同时这里 epoll 使用了 mmap 内存映射做了优化,用户进程无需将数据从内核复制到用户空间中
据此我们实现了 IO 多路复用:通过一个线程(或少量线程)高效管理多个 I/O 通道(如套接字),其核心是事件驱动和资源复用
# 信号驱动 IO
这是同步 IO 的一种。信号驱动 I/O 与异步 I/O 的区别是从缓冲区获取数据这个步骤的处理,信号驱动 IO 的关键点在于,当文件描述符上有事件发生时,系统会发送一个信号(比如 SIGIO)给进程,通知进程可以开始进行 I/O 操作,将数据从内核复制到用户空间的过程还是需要阻塞,因此信号驱动 IO 还是属于同步 IO

# AIO:异步 IO 模型
异步 IO 是基于事件或者回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,过一段时间才会收到结果。举个例子:
用户下单时会立马得到下单结果,但是要等待一段时间后才可以收到下单成功通知
# IO 多路复用机制
select,poll,epoll 都是操作系统中 IO 多路复用的机制。IO 多路复用的本质是通过某种方法,让单个进程可以监视多个描述符,当发现某个描述符就绪之后,能够通知程序进行相应的读写操作
这三种方法都是操作系统提供的,为了处理 c10k 问题。所谓 c10k 问题,就是指每台机器的并发连接过多(concurrent 10,000 connections)后,机器该如何支持
我们来说说他们的原理
# select
select 使用固定长度的数组(fd_set)存储监控的文件描述符,数组大小由宏 FD_SETSIZE 决定(默认 1024),限制了可监控的 FD 数量
应用程序将需要监控的 FD(fild descriptor,是一个套接字描述器),加入读 / 写 / 异常事件集合。调用 select 时,内核遍历所有 FD,检查是否有就绪事件
返回就绪事件的总数,但不明确具体是哪些 FD 就绪,需应用程序遍历整个集合手动判断(轮询方式),虽然已经比 read 好很多了,但是这当然可以优化的,所以有了 poll
# poll
poll 使用链表存储监控的 FD,突破了 select 的 FD 数量限制,理论上大小仅受限于系统内存
每个 pollfd 结构体(列表里的节点)包含 FD、监控事件(读 / 写 / 异常)和事件状态。调用 poll 时,内核同样遍历所有 FD 检查就绪状态。返回就绪事件的总数,需应用程序遍历整个数组判断具体就绪 FD(仍为轮询方式)
仅 Linux 可用,FD 数量限制比 select 更灵活,但性能仍受制于轮询。学过数据结构的同学都清楚,优化这种需要知道知道套接字和事件对应关系的问题,使用 map 最合适了,所以有了最终解决方案 epoll
# epoll
epoll 使用红黑树管理监控的 FD,并用链表存储就绪事件(epoll_event)
通过 epoll_ctl 将 FD 添加到内核的红黑树中,注册监控事件(读 / 写等)。 内核通过事件驱动机制(而非主动轮询),当 FD 就绪时将其加入就绪链表,应用程序只需要遍历该链表即可获取 FD、事件、数据内容等完整信息
调用 epoll_wait 时,直接返回就绪链表中的事件,无需遍历所有 FD,应用程序可直接处理就绪事件
epoll 在 Linux 2.6 内核后引入,高性能,适合高并发场景