众所周知,出于对 OS 安全性的考虑,用户进程是不能直接操作 I/O 设备的。必须通过系统调用请求操作系统内核来协助完成 I/O 动作。
下图展示了 Linux I/O 的过程。
操作系统内核收到用户进程发起的请求后,从 I/O 设备读取数据到 kernel buffer 中,再将 buffer 中的数据拷贝到用户进程的地址空间,用户进程获取到数据后返回给客户端。
在 I/O 过程中,对于输入操作通常有两个不同的阶段:
- 等待数据准备好
- 将数据从内核缓冲区拷贝到用户进程
根据这两个阶段等待方式的不同,可以将 Linux I/O 分为 5 种模式:
- blocking I/O,阻塞式 I/O
- nonblocking I/O,非阻塞式 I/O
- I/O multiplexing(select and poll),I/O 多路复用
- signal driven I/O(SIGIO),信号驱动 I/O
- asynchronous I/O(the POSIX aio_functions),异步 I/O
对于 Socket 上的输入操作:
- 第 1 步通常是等待网络上的数据到达。当数据包到达时,它被复制到内核的缓冲区中。
- 第 2 步是从内核缓冲区复制数据到应用程序缓冲区。
下面详细介绍 Linux 中的 5 种 I/O 模式。
1. Blocking I/O
默认情况下,所有的 Socket 都是阻塞式的。下图展示了一个基于 UDP 的网络数据获取流程。
用户进程调用了 recvfrom 系统调用,此后一直处于等待状态,直到数据包到达并被拷贝到应用程序缓冲区,或者发生 error 才返回。整个过程从开始 recvfrom 调用到它返回一直处于阻塞状态。当 recvfrom 调用返回后,应用进程才能处理数据。
BIO的方式,每次客户端来一个连接,因为BIO是阻塞,所以服务端每次要创建一个线程去处理,主要是防止新的客户端连接被阻塞。
流程:
- 用户线程调用
read,内核发现还没收到网络数据 →线程阻塞,挂起(阶段①阻塞) - 数据到达内核缓冲区后,内核开始把数据拷贝到用户内存,线程继续阻塞(阶段②阻塞)
- 拷贝完成,read 返回,线程继续执行。
特点:
- 两个阶段全部阻塞;
- 简单粗暴,一个连接占用一个线程;
- Java BIO 底层就是这个模型。
2. Nonblocking I/O
可以设置 Socket 为非阻塞模式。这种设置相当于告诉内核“当 I/O 操作时,如果请求是不可能完成的,不要把进程进入睡眠状态,返回一个错误即可“。下图展示了整个流程:在前三次调用 recvfrom 系统调用时,没有就绪的数据返回,所以内核立即返回 EWOULDBLOCK 错误。第四次调用 recvfrom 时,数据报已经准备好,它被复制到应用程序缓冲区中,然后 recvfrom 成功返回。最后应用进程对数据进行处理。当应用程序在一个非阻塞描述符上循环调用 recvfrom 系统调用时,这种方式也被称为轮询。应用程序不断轮询内核,以查看是否有某些操作准备好了。很明显,这通常会浪费 CPU 时间,但这种模式偶尔也会被使用。通常在专门用于一个功能的系统上使用。
由于BIO的阻塞,当连接数很多时,会创建很多线程,浪费资源,所以进行改进,将处理连接的方式改为非阻塞,出现了NIO,一个线程轮训多个连接,客户端轮询进行系统调用,向内核询问,是否准备好数据。数据未准备好返回一个BWOULDBLOCK标识,不会阻塞。解决了创建多个线程问题,但是出现了CPU空转,以及轮训每次都要系统调用,出现了海量系统调用,所以需要再包一层,就是IO多路复用。
流程:
- 设置 socket 为非阻塞,调用
read;内核没数据 → read 立刻返回错误,不阻塞(阶段①非阻塞) - 用户线程不断循环调用 read 轮询,直到内核有数据
- 数据就绪后,再次调用 read,此时拷贝阶段阻塞(阶段②阻塞),拷贝完成返回。
特点:
- 阶段 1 轮询不阻塞;阶段 2 拷贝依旧阻塞;属于同步 IO
- 缺点:CPU 空转,不停循环 read,生产几乎不会单独使用。
⚠️注意:这个≠Java NIO!名字容易混淆。
3. I/O Multiplexing
I/O 多路复用通常使用 select、poll、epoll 系统调用。这种方式下的阻塞只是被 select、poll、epoll 系统调用阻塞,而不会阻塞实际的 I/O 系统调用(即数据输入、输出不会被阻塞)。下图展示了整个过程。当调用 select 时,应用进程被阻塞。同时,系统内核会“监视”所有 select 负责的 Socket。只要其中有 1 个 Socket 的数据准备好了,select 调用就返回。然后调用 recvfrom 将数据报复制到应用程序缓冲区,最后返回给用户进程。
乍一看,这种方式和 blocking I/O 相比似乎更差,因为整个过程产生了 2 次系统调用,select 和 recvfrom。但是使用 select 的好处是可以一个线程同时等待多个描述符准备好。换句话说可以同时“聆听”多个 Socket 通道,同时处理多个连接。select 的优势不是对于单个连接处理得更快,而是能同时处理更多的连接。这和多线程阻塞式 I/O 有点类似。【只不过后者是使用多个线程(每个文件描述符对应一个线程)来处理 I/O,每个线程都可以自由地调用阻塞式系统调用,比如 recvfrom。】我们知道线程多了会带来上下文切换的开销,因此未必优于 select 方式。
Linux 内核将所有外部设备都当成一个个文件来操作。我们对文件的读写都通过调用内核提供的系统调用;内核给我们返回一个文件描述符(file descriptor)。而对一个 Socket 的读写也会有相应的描述符,称为 socketfd。应用进程对文件的读写通过对 fd 的读写完成。
由于NIO在内核准备数据阶段,客户端会轮询很多次无用的系统调用去询问内核是否准备好数据,并且每个客户端都会询问内核,都是单个的,为了改进去除不必要的操作,将NIO的主动询问和单个操作改为批量的,等待通知的方式,就是IO多路复用select、poll。客户端的多个连接会被注册到select、poll中进行监听,当监听到read或write事件,则将内核注册的连接集合全部复制到用户空间,由用户空间遍历判断哪些连接需要处理。
每次select和poll都需要将注册管理的多个client连接从用户空间拷贝到内核,在管理百万连接时,拷贝操作会带来较大资源开销,影响性能,所以出现了epoll。epoll改进就是注册时将连接复制一次到内核,在系统调用时,不拷贝连接,在返回数据时,不拷贝全部连接,而是将就绪链表中的连接准确返回给用户进程。
流程:
- 将多个 socket fd 注册到 epoll,调用
epoll_wait阻塞等待事件(阶段①阻塞在 epoll_wait) - 当某个 fd 数据就绪,epoll_wait 返回就绪 fd 集合
- 用户线程拿到就绪 fd,手动调用
read,拷贝阶段阻塞(阶段②阻塞)
特点:
- 同样是同步 IO;
- 单个线程可以监听大量连接;
- Java NIO(Selector)底层就是这个模型:非阻塞 socket + IO 多路复用。
阻塞点不在单个 socket read,而是阻塞在 epoll_wait。
4. Signal Driven I/O
信号驱动方式就是等数据准备好后,由内核发出 SIGIO 信号通知应用进程。示意图如下:
应用进程通过 sigaction 系统调用建立起 SIGIO 信号处理通道,然后此系统调用就返回,不阻塞。当数据准备好后,内核会产生一个 SIGIO 信号通知到应用进程。此时既可以使用 SIGIO 信号处理器通过 recvfrom 系统调用读取数据,然后通知应用进程数据准备好了,可以处理了;也可以直接通知应用进程读取数据。不管使用何种方式,好处都是应用进程不会阻塞,可以继续执行,只要等待信号通知数据准备好被处理了、数据准备好被读取了。
IO多路复用在等待数据准备时,客户端还是阻塞状态的,而信号驱动,则发起一次系统调用之后就返回,不会阻塞。
流程:
- 给 socket 注册信号回调函数,然后用户线程继续做别的事,不阻塞
- 内核收到数据就绪后,发送 SIGIO 信号通知进程
- 进程收到信号,在回调里面调用 read,拷贝阶段阻塞(阶段②阻塞)
特点:
- 阶段 1 不阻塞;阶段 2 拷贝阻塞;同步 IO
- 实际项目极少使用,信号处理有坑,面试了解即可。
5. asynchronous I/O
异步 I/O 是由 POSIX 规范定义的。和信号驱动 I/O 模型的区别是前者内核告诉我们何时可以开始一个 I/O 操作,而后者内核会告诉我们一个 I/O 操作何时完成。示意图如下:
当用户进程发起系统调用后会立刻返回,并把所有的任务都交给内核去完成,不会被阻塞等待 I/O 完成。内核完成之后,只需返回一个信号告诉用户进程已经完成就可以了。
流程:
- 用户发起异步 read 调用,立刻返回,线程继续干活(阶段①不阻塞)
- 内核自己等待数据到达,并且内核自动完成内核缓冲区→用户内存的拷贝
- 全部完成之后,内核发送通知回调用户程序。
特点:
- 阶段①、阶段②都不阻塞用户线程;唯一的异步 IO 模型。
- Java AIO(AsynchronousSocketChannel)对应这个模型;Linux 原生 AIO 场景有限。
五种 I/O 模式可以从同步、异步,阻塞、非阻塞两个维度来划分:
- 阻塞/非阻塞 → 关注:等待结果时的状态(能不能干别的事,阻塞挂起/继续执行)
- 同步/异步 → 关注:获取结果的方式(主动获取结果/被动获取结果)
select、poll、epoll的区别:
select
从流程上来看,使用select函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。但是,使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket,然后不断地调用select读取就绪的socket,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。
select的缺点
- 每次调用select,都需要把fd_set集合从用户态拷贝到内核态,如果fd_set集合很大时,那这个开销也很大
- 同时每次调用select都需要在内核遍历传递进来的所有fd_set,如果fd_set集合很大时,那这个开销也很大
- 为了减少数据拷贝带来的性能损坏,内核对被监控的fd_set集合大小做了限制,并且这个是通过宏控制的,大小不可改变(限制为1024)
poll
poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。也就是说,poll只解决了上面的问题3,并没有解决问题1,2的性能开销问题。
epoll
epoll在Linux2.6内核正式提出,是基于事件驱动的I/O方式,相对于select来说,epoll没有描述符个数限制,使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。
| 维度 | select | poll | epoll (LT/ET) |
|---|---|---|---|
| 最大 fd 数 | 1024(硬编码) | 无上限 | 无上限 |
| 数据结构 | 位图fd_set | 数组struct pollfd[] | 内核红黑树 + 链表 |
| 扫描方式 | O(n) 轮询全部 fd | O(n) 轮询全部 fd | O(1) 只轮询就绪链表 |
| 内核→用户拷贝 | 每次重新拷贝整个位图 | 每次重新拷贝整个数组 | mmap 共享内存,零拷贝 |
| 支持边缘触发 | ❌ | ❌ | ✅(EPOLLET) |
| 结论 | C10K 天花板 | 大 fd 数可行,仍 O(n) | C100K 标配 |
epoll的边缘触发ET和水平触发LT模式的区别:
| 特性 | 水平触发 (Level Triggered) | 边缘触发 (Edge Triggered) |
|---|---|---|
| 触发时机 | 只要文件描述符处于可读/可写状态,就会持续触发 | 只有状态发生变化时(无数据→有数据)触发一次 |
| 默认模式 | ✅ 默认模式 | 需要显式设置EPOLLET标志 |
| 编程难度 | 简单 | 复杂,需要非阻塞IO配合 |
| 效率 | 较低,可能重复触发 | 较高,减少epoll_wait调用次数 |
| 数据完整性 | 不易丢数据 | 必须一次性读完所有数据,否则丢失事件 |
水平触发 (LT - Level Triggered):
特点:
- 只要 socket 缓冲区中还有数据未读取,每次 epoll_wait 都会返回该 fd
- 可以只读部分数据,剩下的下次继续读
- 编程简单,不容易丢数据
工作流程:
数据到达 → epoll_wait 返回 → 读取部分数据 → epoll_wait 再次返回(还有数据)→ 继续读…
边缘触发 (ET - Edge Triggered):
特点:
- 只有数据新到达时触发一次,之后即使数据没读完也不会再触发
- 必须使用非阻塞IO,循环读取直到 EAGAIN
- 效率高,减少系统调用次数
工作流程:
数据到达 → epoll_wait 返回 → 必须一次性读完所有数据(直到EAGAIN)→ 下次新数据到达才触发
引用:https://zhuanlan.zhihu.com/p/543661648