IO模型

内核(kernel)和用户(user)是操作系统中常用的两个术语。它们的定义很明确:内核空间是操作系统的一部分,以较高的权限级别运行;而用户空间通常指的是权限受限运行的应用程序。

操作系统内核提供了一组 API 供应用程序调用,通常它们称为“系统调用”。这些 API 与普通的库 API 有所不同,它们标志着执行模式从用户态切换到内核态的界限。为了确保应用程序的兼容性,系统调用的变动非常少,Linux 特别严格地执行这一原则。核心内核可以细分为多个逻辑子系统,如文件访问、网络和进程管理等。

系统资源,在用户进程中是无法被直接访问的,只能通过操作系统来访问,所以也把操作系统提供的这些功能成为:系统调用

上图是一个默认的系统调用模型,用户进程先要通过系统调用read(),进入内核态,然后把数据读取到内核态的Buffer Cache中,最终把数据 copy 到用户态 BufferCache。
从上图可以发现,用户进程受限权限没法直接磁盘和网络资源,所以需要来回的在内核态切换。这样一次IO过程就产生了4次上下文切换:

  • read 系统调用读磁盘上的文件时:用户态切换到内核态;
  • read 系统调用完毕:内核态切换回用户态;
  • write 系统调用写到socket时:用户态切换到内核态;
  • write 系统调用完毕:内核态切换回用户态。

上Linux 内核以 buffer cache 为介质,会通过预读和回写的机制,提高了文件 I/O 速度,和磁盘访问效率。

  • 数据预读(read_ahead): 数据预读指的是,当程序发起 read() 系统调用时,内核会比请求更多地读取磁盘上的数据,保存在缓冲区,以备程序后续使用。这种数据的预取基于一种预设:程序会重复地访问最近访问过的数据,且这种访问往往是顺序访问。当用户向内核请求读取数据时,内核会先从自己的 buffer cache 去寻找,如果命中数据,则不需要进行真正的磁盘 I/O,直接从内存中返回数据;如果缓存未命中,则内核会从磁盘中读取请求的 page,并同时读取紧随其后的几个 page(比如三个),如果文件是顺序访问的,那么下一个读取请求就会命中之前预读的缓存。预读提供了以下好处:
    • 减少了 I/O 时间对进程的影响。 因为进程的读取操作和真正的 I/O 可能发生在不同的时空,数据是预取的,当进程需要它的时候早已经在内存中准备好了,对于这个进程来说,I/O 时间是不存在的,但是对于整个系统来说,I/O 时间是一个必要成本,因为总要从磁盘读数据,只是发生的时间早晚罢了;
    • 提供了缓存。 当进程对文件重复访问时,buffer cache 提供了缓存,把本来应该发生的 I/O 省掉了,这个和第一点不同,是结结实实得省掉了一次 I/O 时间;
    • 减少了磁盘处理器的命令数,因为每个命令多读了几个相邻扇区,或者说,把小块的 I/O 变成了大块的 I/O,提升了磁盘性能;
  • 回写:指的是,当程序发起 write() 系统调用时,内核并不会直接把数据写入到磁盘文件中,而仅仅是写入到缓冲区中,几秒后才会真正将数据刷新到磁盘中。对于系统调用来说,数据写入缓冲区后,就返回了,因此一个 read() / write() 并非真正执行 I/O 操作,它只代表数据在用户空间 / 内核空间传递的完成。延迟往磁盘写入数据的一个最大的好处就是,可以合并更多的数据一次性写入磁盘。也就是上面说的,把小块的 I/O 变成大块 I/O,减少磁盘处理命令次数,提高提盘性能。
    另一个好处是,当其它进程紧接着访问该文件时,内核可以从直接从缓冲区中提供更新的文件数据。

上面linux系统的默认模型的IO调用成本太高,因此操作系统实现了多种不的系统IO的调用方式:

在Linux系统中,处理文件I/O操作有多种方式,包括Buffered I/O、mmap和Direct I/O

  • Buffered I/O: Buffered I/O是操作系统默认提供的I/O方式。在这种模式下,数据首先被读入到操作系统内核空间的缓冲区(也称为页面缓存),然后从这里复制到用户空间的缓冲区。写操作也是类似的,数据首先从用户空间缓冲区复制到内核空间缓冲区,之后操作系统决定何时将数据实际写入磁盘。
  • mmap: mmap是一种内存映射文件的方法,它允许将一个文件或者其它对象映射到进程的地址空间。通过这种方式,可以直接像访问内存一样访问文件内容,无需调用read或write等函数。提供了一种高效的方式来访问文件内容,减少了数据复制的次数。
  • Direct I/O: Direct I/O是指绕过操作系统内核缓冲区直接进行I/O操作的方式。在这种模式下,数据直接在用户空间和存储设备之间传输,没有经过操作系统内核的页面缓存。这种IO方式可以减少内存拷贝次数,提高读写效率,由于缺少缓存机制,可能导致更多的磁盘I/O操作,降低性能,同时需要应用程序自己负责缓存管理和同步问题。

当一个应用程序发起I/O请求时,这个请求会经过上述多个层次的处理,从用户空间进入内核空间,然后根据请求类型可能涉及页面缓存、文件系统、块I/O层以及最终到达设备驱动,由后者完成与物理硬件的实际交互。这一过程体现了Linux系统高度模块化的设计理念,同时也展示了其在保证灵活性的同时如何高效地管理资源。

IO模型

阻塞&非阻塞调用: 阻塞与非阻塞的概念是针对调用方

  • 阻塞调用:图1步骤1、2执行期间,没有数据到达内核缓冲区,这个时候web服务器进程发起的获取数据的请求会被直接阻塞,当前相关线程会被挂起,直到步骤1、2完成,有数据写入内核缓冲区,这个时候才会唤醒线程执行步骤3和4.
  • 非阻塞调用: 与阻塞调用相反,当没有数据到达内核缓冲区时,web服务发起的获取数据的请求不会发生阻塞,相关线程可以选择做其他事情,然后轮询着查询请求结果即可,当某次轮询出结果,则进行步骤3和4的操作。

同步&异步处理: 同步与异步的概念是针对被调用方

  • 同步处理:被调用方得到最终处理结果才返回给调用方。
  • 异步处理:被调用方不用得到结果,只需返回一个状态给调用方,然后开始IO处理,处理完了就主动返回通知调用方。

以一个网络请求为例,当应用收到一个请求,底层会有一个recvfrom 函数(经 Socket 接收数据)视为系统调用 。在阻塞式 I/O 模型中的 recvfrom 是一个用于接收数据报的系统调用或函数。它通常用于网络编程中,特别是在UDP协议中。这个函数会阻塞应用程序的进程,直到有数据报准备好可以被接收。

具体来说, recvfrom 通常用于接收来自网络的数据报,例如从套接字(socket)中接收数据。当应用程序调用 recvfrom 时,如果没有数据报可用,它会等待直到有数据报到达,然后将数据报的内容复制到应用程序指定的缓冲区中,并返回成功。

在阻塞式 I/O 模型中,这个调用会导致应用程序阻塞,即应用程序的执行被暂停,直到数据可用为止。这通常意味着应用程序无法执行其他操作,直到 recvfrom 返回并提供接收的数据。这种模型在某些情况下非常简单,但也可能导致应用程序出现延迟,因为它必须等待数据的到达。

阻塞式I/O模型


阻塞IO模型是指从应用程序发起从 socket 获取数据( recvfrom )那一刻起,如果内核里没有准备好的数据报,则直接阻塞应用程序,导致应用程序无法去做别的任何事情,直到数据报准备好,被阻塞的程序才会被唤醒,继续处理下面拿到的数据报。

  • 优点:程序简单,在阻塞等待数据期间进程/线程挂起,基本不会占用 CPU 资源。
  • 缺点:每个连接需要独立的进程/线程单独处理,当并发请求量大时为了维护程序,内存、线程切换开销较大,这种模型在实际生产中很少使用。

非阻塞IO模型


在非阻塞式 I/O 模型中,应用程序把一个套接口设置为非阻塞,就是告诉内核,当所请求的 I/O 操作无法完成时,不要将进程睡眠。而是返回一个错误,应用程序基于 I/O 操作函数将不断的轮询数据是否已经准备好,如果没有准备好,继续轮询,直到数据准备好为止。

  • 优点:不会阻塞在内核的等待数据过程,每次发起的 I/O 请求可以立即返回,不用阻塞等待,实时性较好。
  • 缺点:轮询将会不断地询问内核,这将占用大量的 CPU 时间,系统资源利用率较低,所以一般 Web 服务器不使用这种 I/O 模型。

上面轮询阶段返回的是EWOULDBLOCK错误码,通常在网络编程和非阻塞 I/O 中使用。它表示某个操作(通常是非阻塞的)因为当前状态而无法立即执行,但并不算是一种错误。在不同的操作系统和编程语言中,它有时也被称为 EAGAIN ,表示 “操作再次尝试”。当你在非阻塞模式下进行 I/O 操作(如读取或写入数据),有时可能会遇到 EWOULDBLOCK 错误。

I/O 复用模型

在 I/O 复用模型中,会用到 Select 或 Poll 函数或 Epoll 函数(Linux 2.6 以后的内核开始支持),这两个函数也会使进程阻塞,但是和阻塞 I/O 有所不同。这两个函数可以同时阻塞多个 I/O 操作,而且可以同时对多个读操作,多个写操作的 I/O 函数进行检测,直到有数据可读或可写时,才真正调用 I/O 操作函数。

  • 优点:可以基于一个阻塞对象,同时在多个描述符上等待就绪,而不是使用多个线程(每个文件描述符一个线程),这样可以大大节省系统资源。
  • 缺点:当连接数较少时效率相比多线程+阻塞 I/O 模型效率较低,可能延迟更大,因为单个连接处理需要 2 次系统调用,占用时间会有增加。

信号驱动模型


在信号驱动式 I/O 模型中,应用程序使用套接口进行信号驱动 I/O,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用 I/O 操作函数处理数据。

  • 优点:线程并没有在等待数据时被阻塞,可以提高资源的利用率。
  • 缺点:信号 I/O 在大量 IO 操作时可能会因为信号队列溢出导致没法通知。

信号驱动 I/O 尽管对于处理 UDP 套接字来说有用,即这种信号通知意味着到达一个数据报,或者返回一个异步错误。但是,对于 TCP 而言,信号驱动的 I/O 方式近乎无用,因为导致这种通知的条件为数众多,每一个来进行判别会消耗很大资源,与前几种方式相比优势尽失

异步 I/O 模型

由 POSIX 规范定义,应用程序告知内核启动某个操作,并让内核在整个操作(包括将数据从内核拷贝到应用程序的缓冲区)完成后通知应用程序。这种模型与信号驱动模型的主要区别在于:信号驱动 I/O 是由内核通知应用程序何时启动一个 I/O 操作,而异步 I/O 模型是由内核通知应用程序 I/O 操作何时完成。

  • 优点:异步 I/O 能够充分利用 DMA 特性,让 I/O 操作与计算重叠。
  • 缺点:要实现真正的异步 I/O,操作系统需要做大量的工作。目前 Windows 下通过 IOCP 实现了真正的异步 I/O
    而在 Linux 系统下,Linux 2.6才引入,目前 AIO 并不完善,因此在 Linux 下实现高并发网络编程时都是以 IO 复用模型模式为主。

Linux异步IO缺陷

  • 有限的文件系统支持:Linux AIO 主要对直接 I/O(O_DIRECT)有效,这意味着数据不会经过操作系统缓存。因此,它对于大多数标准文件系统操作的支持是有限的,特别是当不使用 O_DIRECT 标志打开文件时,可能会遇到意外的行为。
  • 不完全异步:虽然 Linux AIO 设计为异步操作,但在实践中,并非所有情况下都能保证完全异步。例如,某些类型的 I/O 操作可能仍会导致进程被阻塞,尤其是在处理磁盘 I/O 时,如果请求无法立即排队,则调用可能会阻塞直到请求可以被处理。
  • 复杂性增加:与传统的同步 I/O 相比,正确地使用 AIO 需要更复杂的编程模型。这包括管理回调函数、处理错误情况以及确保资源的正确释放等,增加了开发和维护的难度。
  • 调试困难:由于其异步特性,跟踪和调试基于 AIO 的应用程序可能会更加困难。比如,确定某个特定的 I/O 操作何时完成及其结果状态可能不如同步 I/O 那样直观。
  • 性能问题:在某些情况下,Linux AIO 可能不会带来预期的性能提升,甚至可能导致性能下降。这是因为底层实现细节、硬件特性和工作负载都会影响 AIO 的实际表现。
  • 库支持不足:相比于其他平台上的 AIO 实现,Linux AIO 在高级语言中的库支持相对较少,这限制了它在跨平台应用中的使用。
  • 内核版本兼容性:不同版本的 Linux 内核对 AIO 的支持程度可能有所不同,这要求开发者注意目标环境的具体版本,以避免兼容性问题。

io_uring

io_uring 来自资深内核开发者 Jens Axboe 的想法,从最早的 patch aio: support for IO polling 可以看出,这项工作始于一个很简单的观察:随着设备越来越快,中断驱动(interrupt-driven)模式效率已经低于轮询模式(polling for completions)。
通常IO只负责对发生在fd描述符上的事件进行通知,事件的获取和通知部分是非阻塞的,但收到通知之后的操作,却是阻塞的,即使使用多线程去处理这些事件,它依然是阻塞的。如果把这些系统调用都放在操作系统里完成,那么就可以节省下这些系统调用的时间。io_uring 的核心原理是基于用户空间与内核空间共享的环形缓冲区,通过减少系统调用和上下文切换来大幅提升I/O性能。它采用生产者-消费者模型,用户程序向提交队列(Submission Queue)提交I/O请求,内核处理后将结果放入完成队列(Completion Queue),用户程序再从完成队列中获取结果。这里io_uring 实例包含两个环形队列(ring),在内核和应用程序之间共享:

  • 提交队列:submission queue (SQ)
  • 完成队列:completion queue (CQ)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-----------------------------------------------------------------
|user space |
| |
| |
| application produces application consumes |
| | ^ |
| v | |
| ------------------ ------------------ |
|------| submission queue |-----------| completion queue |------|
| ------------------ ------------------ |
| | ^ |
| v | |
| kenel consumes -> exec syscalls -> kernel produces |
| |
|kernel |
-----------------------------------------------------------------

实现机制

  • 提交队列(Submission Queue, SQ):用户进程作为生产者,将I/O请求(称作SQE,Submission Queue Entry)放入SQ。之后,用户程序只需更新SQ的尾部指针,通知内核有新的请求。
  • 完成队列(Completion Queue, CQ):内核作为生产者,在完成I/O请求后,将结果(称作CQE,Completion Queue Entry)放入CQ,并更新CQ的尾部指针。用户程序通过读取CQ来获取完成的I/O结果。
  • 共享内存:通过 io_uring_setup() 和 mmap() 系统调用,用户空间和内核空间映射同一块内存区域,用于SQ和CQ的读写。这种共享内存的设计消除了用户态和内核态之间的数据拷贝开销,是io_uring高性能的关键。

在实现上,SQ和CQ都是无锁的环形缓冲区,通过原子操作和内存屏障来协调用户态和内核态对环形缓冲区的访问,这种访问方式避免了锁带来的性能开销。同时,用户程序写入SQ尾部,内核读取SQ头部,内核写入CQ尾部,用户程序读取CQ头部,形成高效的单生产者单消费者模型。

io_uring支持三种主要的工作模式,以平衡性能和CPU开销:

  • 提交请求时唤醒(默认):用户程序填充SQE并更新SQ尾部指针后,通过 io_uring_enter() 系统调用通知内核。内核被唤醒后处理SQ中的请求,并将结果放入CQ。如果CQ为空,用户程序可以进入睡眠等待完成事件,或继续执行其他任务。这种模式在有大量I/O请求时会产生上下文切换,但在空闲时能有效节约CPU资源

  • 提交队列轮询(SQ Poll):启动io_uring时,通过 IORING_SETUP_SQPOLL 标志来开启。这种模式下,io_uring会创建一个内核线程,专门负责轮询SQ,主动检查是否有新的I/O请求。用户程序提交请求后,甚至可以完全不调用系统调用,由内核线程自动处理。只有在内核线程长时间空闲进入睡眠时,用户程序才需要 io_uring_enter() 唤醒它。这种模式减少了每次提交I/O时的系统调用和上下文切换,但会消耗更多的CPU资源用于轮询

  • 完全轮询(IO Poll)通过 IORING_SETUP_IOPOLL 标志,结合SQ Poll模式使用。在这种模式下,内核线程不仅轮询SQ,还会轮询底层的设备驱动队列。这实现了真正的无系统调用I/O,用户程序和内核线程完全通过轮询环形队列进行通信和处理I/O,进一步降低延迟,但CPU开销最大。

io_uring 通过用户/内核共享的环形缓冲区、生产者-消费者模型以及批量提交/完成等机制,极大地优化了I/O操作的性能。它通过减少系统调用和上下文切换,并提供灵活的轮询模式,解决了传统Linux异步I/O(AIO)的诸多限制,成为了现代高性能I/O应用的首选框架。

零拷贝技术操作系统层面支持

零拷贝技术目的是为了减少上下文切换与数据复制,在系统层面有两种实现方式:

  • mmap() + write()
  • sendfile()

mmap + write

1
2
buf = mmap(file, len);
write(sockfd, buf, len);

read()系统调用把内核缓冲区的数据拷贝到用户的缓冲区里,用 mmap() 替换 read(), mmap() 直接把内核缓冲区里的数据映射到用户空间,减少这一次拷贝。

具体调用过程如下:

  • 应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。因为建立了这个内存的mapping,所以用户态的数据可以直接访问了;
  • 应用进程再调用 write(),CPU将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态
  • DMA把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里

mmap + write 并没有实现零拷贝,mmap()相对于使用read()减少了一次拷贝。

sendfile

sendfile()系统调用允许直接在两个文件描述符之间传输数据,而无需将数据复制到用户空间。它通常用于高效地将数据从一个文件(通常是磁盘上的文件)传输到另一个文件(如网络套接字)。这个过程完全由操作系统内核管理,极大程度上减少了CPU的参与。

1
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
  • 真正实现了零拷贝,即数据从磁盘直接传输到网络接口,完全绕过了用户空间,减少了CPU的使用和数据复制次数。
  • 简化了编程模型,因为不需要显式地管理内存映射或数据传输逻辑。

使用sendfile()可以替代前面的 read() 和 write() 这两个系统调用,减少一次系统调用和 2 次上下文切换。在linux内核2.1(上图虚线部分)实现上,sendfile可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,优化后只有 2 次上下文切换,和 3 次数据拷贝。

从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 可以从Buffer Cache 复制数据到网卡。因为没有内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。

MappedByteBuffer(java mmap实现)

Java 中的通过MappedByteBuffer可以将文件或部分文件映射到内存中,适用于大文件的读写操作,能够显著提高性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MMapFileSender {
public static void sendFile(String filePath, SocketChannel socketChannel) throws IOException {
try (FileChannel fileChannel = new FileInputStream(filePath).getChannel()) {
long fileSize = fileChannel.size();
MappedByteBuffer buffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileSize);

// 使用 socketChannel 直接发送内存映射缓冲区
while (buffer.hasRemaining()) {
socketChannel.write(buffer);
}
}
}
}

FileChannel.transferTo()与transferFrom()-(java sendfile实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.io.*;
import java.nio.channels.*;

public class SendFileExample {
public static void sendFile(SocketChannel socketChannel, String filePath) throws IOException {
try (FileChannel fileChannel = new FileInputStream(filePath).getChannel()) {
long fileSize = fileChannel.size();
long offset = 0;

while (offset < fileSize) {
// transferTo 返回实际传输的字节数
long transferred = fileChannel.transferTo(offset, fileSize - offset, socketChannel);
if (transferred == 0) {
System.out.println("No data transferred. Wait or retry.");
Thread.sleep(100); // 可选等待
} else {
offset += transferred;
}
}
}
}
}

IO模型
http://example.com/2025/06/14/network-IO模型/
作者
ares
发布于
2025年6月14日
许可协议