IO 模型的演进

IO JAVA

Posted by gomyck on December 19, 2023

阻塞&非阻塞 同步&异步

一个网络请求, 在被网卡接收之后, 大概经历了下述流程

1
2
3
4
5
第一阶段:
    网卡接收 -> DMA COPY -> SOCKET缓冲区 (内核态)

第二阶段:
    数据 COPY 至用户态 -> 处理数据

数据准备阶段: 在这个阶段,网络数据包到达网卡,通过DMA 的方式将数据包拷贝到内存中,然后经过硬中断,软中断,接着通过内核线程ksoftirqd经过内核协议栈的处理,最终将数据发送到内核Socket的接收缓冲区中。

数据拷贝阶段: 当数据到达内核Socket的接收缓冲区中时,此时数据存在于内核空间中,需要将数据拷贝用户空间中,才能够被应用程序读取。

阻塞&非阻塞

在第一阶段过程中, 如果处于等待状态, 称之为阻塞, 反之为非阻塞

同步&异步

在第二阶段过程中,如果处于等待状态,称之为同步, 反之为异步

主要的 IO 模型

在阻塞&非阻塞 同步&异步的概念下, 衍生了几类 IO 模型, 其中包括:

  • 阻塞 IO 模型: 会一直等待数据传输完成(读和写都等待, 一直等待),期间无法执行其他任务。
  • 非阻塞 IO 模型: 用户态要陷入内核态去查询数据状态, 如果缓冲区未准备好,则立即返回错误状态(读或写, 都是能读多少读多少, 能写多少写多少),不等待数据传输完成,可执行其他任务。
    • 相对于阻塞 IO, 实现了使用尽可能少的线程去处理更多的连接
  • 多路复用 IO 模型: 允许一个线程同时管理多个 I/O 连接,适用于高并发、低延迟和高吞吐量场景,减少线程数量和上下文切换开销。
    • 多路复用 解决了用户态和内核态不断切换带来的性能损耗, 多路复用需要操作系统级别的特性支持
    • select 的弊端是需要不断的在用户和内核之间传输文件描述符数组, 每次修改都需要在用户和内核层面来回传递, 而 select 会阻塞到内核态, 当有数据变动时, 中断回用户态并把文件传递至用户态, (上下文来回两次的切换和文件的来回两次传递带来了巨大的开销, select 在处理大量的客户端连接和反复连接频率高的情况下, 效率会越来越低)
    • poll 知识改进了 select 只能监听 1024 个 FD 的问题, 同样面临 C10K 问题
    • epoll 改进了上述两种机制, 由内核维护 全量连接 等待队列就绪队列 , 线程在被唤醒后, 遍历 就绪队列, 拿到就绪的 FD, 进行 IO 操作, 使用红黑树结构来管理所有 socket 连接, 提升了管理的性能
  • 信号驱动: 依赖信号通知应用程序 I/O 事件,适用于低并发、低延迟和低吞吐量场景,需要为每个 I/O 事件创建信号和信号处理函数。
  • 异步 IO: 应用程序发起 I/O 操作后,内核负责数据传输过程,完成后通知应用程序。应用程序无需等待数据传输,可执行其他任务。

除了异步 IO, 其它四种模型均为同步 IO, 因为数据的 copy 需要等待

类比

阻塞 IO: 自己点单(多线程), 点完单之后, 在等餐区等待后厨做好饭, 做好之后去取餐(同步)

非阻塞 IO: 找服务员点单(极少线程), 点完单之后, 可以刷一会手机, 去隔壁买个饮料, 但是每隔一会都要回来问问餐厅, 做没做好, 做好之后去取餐(同步)

多路复用: 找服务员点单(极少线程), 点完之后关注通知大屏上的通知, 如果大屏上显示你的号码, 在去取餐(同步), 其余时间可以干别的事情

信号驱动: 找服务员点单(极少线程), 点完之后, 会给你个响铃, 如果铃响了, 在去取餐(同步), 其余时间可以干别的事情

异步 IO: 找服务员点单(极少线程), 点完之后, 不用关心餐, 干什么都可以, 餐好了会送到你面前(异步)

netty

Netty 是基于 NIO 实现的异步事件驱动的网络框架, 其基于的技术栈为 NIO (多路复用) + Reactor

Netty 既是多路复用,也是异步 IO 的框架,它利用了 Java NIO 的 API 和 Reactor 模式的设计,来构建高性能的网络应用程序。 它并不需要使用 AIO 等技术,因为它已经实现了异步的 IO 操作,通过 Future-Listener 机制来通知用户线程 IO 的结果。Netty 的 IO 模型是一种主从多线程模型, 它有两类线程池:BossGroup 和 WorkerGroup。BossGroup 负责接收客户端的连接请求,WorkerGroup 负责处理 IO 事件和业务逻辑。 每个线程池都有一个或多个 NioEventLoop,每个 NioEventLoop 都有一个 Selector,用于监听多个 Channel 上的 IO 事件。当有 IO 事件发生时, NioEventLoop 会调用 ChannelPipeline 上的 ChannelHandler 来处理 IO 事件和业务逻辑。ChannelHandler 可以是同步的,也可以是异步的, 如果是异步的,就需要返回一个 ChannelFuture,用于表示异步操作的结果。ChannelFuture 可以添加一个或多个 ChannelFutureListener, 用于在异步操作完成后执行回调函数。这样,Netty 就实现了多路复用和异步 IO 的结合,提供了高效的网络编程框架。

在 Netty 中,将数据从操作系统的 socket 缓冲区复制到用户态内存的过程通常是同步的,但它是在异步 I/O 机制的背景下进行的。具体而言:

操作系统级的同步性

同步操作:操作系统在将数据从内核空间(socket 缓冲区)复制到用户空间内存缓冲区的过程中是同步的。即,程序在发起读取操作时会阻塞,直到数据被成功复制到用户态内存中。

Netty 的异步 I/O 机制

虽然底层的 socket 缓冲区到用户态内存的复制是同步的,但 Netty 的异步 I/O 机制通过以下方式使得整个过程对应用程序透明:

异步读取:在 Netty 中,应用程序发起读取操作时,Netty 会将该操作提交到事件循环中,并不会立即阻塞。Netty 内部会处理与操作系统的交互,并在数据到达后通过事件驱动的方式将数据交给应用程序。

事件驱动:Netty 使用事件循环(Event Loop)来处理 I/O 操作,网络读取数据的过程是非阻塞的,因为事件循环可以在数据准备好之前继续处理其他任务。当数据到达并被同步地复制到用户态内存时,Netty 会触发相应的事件,将数据传递给应用程序处理。

总结

底层:在底层,操作系统从 socket 缓冲区复制到用户态内存是同步的。

Netty 层:Netty 的异步 I/O 模型使得应用程序可以以非阻塞的方式处理网络数据,事件循环和回调机制帮助实现高效的 I/O 操作和事件处理。

redis

网络 IO:redis 的网络 IO 主要是指与客户端的通信,包括接收请求,解析命令,返回结果等。redis 使用了 IO 多路复用的技术, 也就是 NIO+Selector,来实现高效的网络 IO 处理。IO 多路复用是一种让一个线程可以同时监控多个 IO 事件的机制, 当某个 IO 事件发生时,就通知该线程进行处理。这样可以避免为每个连接创建一个线程的开销,提高并发性能。但是,IO 多路复用也有一些局限性, 比如它还是使用了同步 IO,也就是说,用户线程需要参与数据从内核空间拷贝到用户空间的过程,这个过程是会阻塞用户线程的。如果数据量很大, 或者网络延迟很高,那么这个数据拷贝的过程就会很长,影响用户线程的处理效率。因此,网络 IO 可能会成为 redis 的瓶颈,尤其是在并发量非常大的情况下。 为了解决这个问题,redis 从 6.0 版本开始,引入了多线程 IO 的特性,也就是说,可以使用多个线程来处理网络 IO,提高网络请求的并行度。 但是,这个特性并不是默认开启的,需要手动配置,而且只是用来处理网络 IO,对于键值对的读写操作,redis 仍然使用单线程来处理。

磁盘 IO:redis 的磁盘 IO 主要是指与持久化相关的操作,包括 RDB 和 AOF 两种方式。RDB 是指定时生成数据快照的方式,AOF 是记录每个写操作的日志的方式。 redis 的持久化操作都是由额外的线程或者进程来完成的,不会阻塞主线程。但是,持久化操作也会消耗一定的 CPU 和磁盘资源,如果持久化的频率很高,或者数据量很大, 那么持久化操作可能会成为 redis 的瓶颈,影响 redis 的性能和可用性。因此,持久化操作需要根据具体的需求和环境来选择合适的方式和参数。

底层的同步复制

数据复制:当 Redis 从 socket 缓冲区读取数据时,这个过程涉及将数据从内核空间(socket 缓冲区)复制到用户空间内存。这一复制操作是同步的,即程序需要等待数据被成功复制到用户态内存中,才能继续处理。

阻塞与非阻塞:虽然底层的复制操作是同步的,但 Redis 使用事件驱动的非阻塞 I/O 模型来处理这些操作。Redis 的事件循环允许它在等待数据复制的同时继续处理其他 I/O 操作和请求,从而提高整体吞吐量和响应能力。