一次 I/O 的读取请求分为两个阶段:
- 等待内核空间准备数据
- 数据从内核空间拷贝到用户空间
《Unix网络编程》根据这两个阶段的不同把 I/O 分成了以下五种 IO 模型:
网络I/O模型
阻塞I/O
用户线程发起读取请求、等待内核空间准备数据、内核空间将数据拷贝到用户空间并返回给用户线程,这个过程用户线程一直处于等待的过程,不能处理其他任务。等待数据准备的阶段和拷贝数据的阶段都是阻塞的。 如果有其他用户线程发起请求,那么这些线程会排队等待,等前面的线程处理完再处理后面的线程。
总结:适合用户线程数量不高的情况。如果用户线程数量过高, CPU 一次只处理一个线程,会影响 CPU 的性能。
优化:既然单线程效率低下,那么引入多线程进行优化怎么样?多线程可以解决 CPU 一次只能处理一个线程的问题,线程池也可以缓解一部分压力,但是用户线程的数量达到成千上万的时候,还是会有问题。因为线程池的维护也需要成本,比如线程的创建、销毁,上下文的切换,并不能从根本上解决问题。
非阻塞I/O
用户线程发起读取请求,然后不断询问内核空间数据是否准备就绪。如果没有,用户线程会一直询问,直到内核空间数据准备就绪,然后内核空间将数据拷贝到用户空间并返回给用户线程。拷贝数据的阶段是阻塞的。
总结:用户线程可以在等待数据的准备阶段处理其他的任务,但是需要不断询问内核空间数据是否已经准备就绪,从而导致 CPU 资源浪费,并且轮询之间的时间间隔会导致数据的延迟。
多路复用I/O
多路是指网络连接,复用指的是同一个线程
用户线程调用 select 函数,然后 select 会对用户线程进行监听,不断询问内核空间用户线程的数据是否准备就绪,如果用户线程的数据准备就绪,会通知对应的用户线程。然后用户线程发起读取数据的请求,内核空间将数据拷贝到用户空间并返回给用户线程。 用户线程调用 select 函数之后,用户线程不会阻塞,但是select有可能阻塞,拷贝数据的阶段是阻塞的。
总结:一个 select 函数可以同时监听多个用户线程,可以同时处理多个请求,从而提高 CPU 的效率。
文件描述符(File Description):简称FD,是一个从0开始递增的无符号整数,用来关联Linux系统中的一个文件。在Linux系统中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。
对 Linux 来说,一个用户线程相当于一个 FD 。
I/O 多路复用的实现有三种:
- select select 不知道哪个 FD (用户线程)的数据准备就绪,所以会轮询所有的 FD ,性能随着 FD 数量的增加而下降。底层采用了数组,支持的 FD 数量有限,最多1024个。每次 select 都要把监听的所有 FD 拷贝到内核空间,影响效率。 windows 系统只支持这种方法, linux 系统也支持。
- poll poll 和 select 实现类似,不过它底层采用了链表,支持的 FD 数量是没有限制。 linux 系统支持。
- epoll epoll 底层采用了红黑树,理论上 FD 的数量是没有限制的,而且 CURD 的效率都很高,性能不会随着 FD 数量的增加而下降。 epoll 会将就绪的 FD 放到一个链表里,不用轮询所有的 FD 。
信号驱动I/O
用户线程发起读取请求前先建立一个信号函数说明它需要读取哪些数据,然后内核空间收到信号后并返回。内核空间数据就绪后会返回信号,信号函数进行处理。然后用户线程再发起读取请求,内核空间将数据拷贝到用户空间并返回给用户线程。拷贝数据的阶段是阻塞的。
总结:用户线程过多的时候,产生的信号也很多, sigal 函数不能及时处理会导致信号队列溢出。其次用户空间和内核空间信号的频繁交互导致性能较低。在 Java 技术体系中使用场景很少。
异步I/O
用户线程发起读取请求,然后立即返回,用户线程可以处理其他的任务。内核空间接收到读取请求后,先准备数据,数据就绪后,内核空间将数据拷贝到用户空间并返回给用户线程。等待数据准备的阶段和拷贝数据的阶段都是不阻塞的。
总结:异步 I/O 模型是效率最高的,也是最理想的。不过它的实现比较复杂,高并发场景应用还不够广泛,不够成熟。 Netty 曾尝试使用异步 I/O 模型,但是性能没有太多的提升就放弃了。
Redis的I/O模型
Redis 采用的是多路复用模型,基于 Reactor 模式设计的事件驱动模型。 Redis 中的事件分为两种:
- 文件事件: Redis 服务器和客户端之间的网络 I/O ,比如连接、读取、写入等。
- 时间事件: Redis 服务器有一些定时任务,比如持久化。
文件事件
socket 指的是客户端的连接、读取、写入等请求,本质上是一个 FD; I/O 多路复用程序负责创建,监听 FD ,绑定事件; 事件分发处理器根据事件类型将事件转发给对应的处理器; 处理器负责执行具体事件对应的任务操作。
连接请求
有 client socket 连接 server sokcet 的时会产生 ae_readable 事件,然后多路复用程序会创建 ssFD 并绑定 ae_readable 事件,根据事件类型转发给连接应答处理器。连接应答处理器接收 client socket 并创建 client socket FD(FD),然后由多路复用程序对其进行监听。
命令请求
client socket 执行 set 命令 时会产生 ae_readable 事件,然后多路复用程序会创建 FD 并绑定 ae_readable 事件,根据事件类型转发给命令请求处理器。命令请求处理器读取 FD 的数据,解析命令,与FD ae_writeable 事件进行绑定,然后由多路复用程序对其进行监听。(黄色背景) 多路复用程序监听到 FD 的 ae_writeable 事件后,根据事件类型转发给命令回复处理器。命令回复处理器将数据写入并返回 client socket 。(绿色背景)
时间事件
Redis 将所有时间事件都放在一个无序链表中,每次 Redis 会遍历整个链表,查找所有已经到达的时间事件,并且调用相应的事件处理器。
参考资料:
传送门: