Skip to content

IO 线程模型

前言

《从内核角度看 IO 模型》中,我们学习了网络数据包的接收和发送过程,并通过介绍五种 IO 模型来了解内核如何读取网络数据并通知用户线程。

IO 模型 vs IO 线程模型

  • I/O 模型(Input/Output Model)描述了操作系统如何管理数据在网络通信中的传输方式,即内核与应用程序在进行 I/O 操作时的交互方式,主要涉及数据的阻塞同步异步等方面。
  • I/O 线程模型指的是应用程序在处理 I/O 请求时的线程管理方式,特别是如何分配和调度线程来完成网络请求的处理。不同的线程模型在多路 I/O 处理时具有不同的效率,决定了系统在高并发下的响应能力。
特性I/O 模型I/O 线程模型
定义指操作系统在传输数据时的工作方式指应用程序在处理 I/O 时的线程管理方式
关注点数据传输的同步/异步、阻塞/非阻塞应用程序如何分配线程处理多个连接
类型阻塞 I/O、非阻塞 I/O、I/O 多路复用、异步 I/O 等单线程、多线程、I/O 多路复用、事件驱动模型等
侧重点如何管理数据传输和等待如何高效管理线程资源,避免资源瓶颈
应用场景影响数据传输方式,适用于不同的性能要求影响并发处理能力,适合高并发的服务器端设计
典型应用readselectepollReactor、Master-Worker、Node.js 的事件模型等
优缺点影响系统 I/O 操作的延迟,直接决定应用 I/O 性能合理选择线程模型可以有效提高并发处理能力

《从内核角度看 IO 模型》中,内容是以内核空间(IO模型)的视角剖析网络数据的收发模型。本文则从用户空间(IO线程模型)的角度,介绍如何对网络数据进行收发。

INFO

《从内核角度看 IO 模型》 中已详细讲解了阻塞 IO 和非阻塞 IO 的这两种经典线程模型。因此,本文将着重讲解 IO 多路复用异步 IO 对应的线程模型。

相比于内核用户空间的 IO 线程模型相对简单一些。这里的用户空间 IO 线程模型主要讨论多线程协作时,如何分配角色,即谁负责接收连接谁负责 IO 读写谁负责计算、以及谁负责数据的发送和接收。这些模型只是对用户 IO 线程的不同分工方式。

演进

如果你看完了 《阻塞与非阻塞IO 的线程模型》,就能明白阻塞 I/O 和非阻塞 I/O 的关键问题在于:

阻塞 I/O 通过主动等待内核缓冲区的数据准备完毕,非阻塞 I/O 通过主动查询内核缓冲区的状态来确定 I/O 操作是否准备好。

这两者都是应用程序主动去与内核打交道。

如果你继续阅读 《IO 多路复用》,你会发现:

I/O 多路复用采用事件驱动方式,通过内核生成的事件通知来高效管理多个 I/O 操作,避免了主动查询。在数据从网卡拷贝到 socket 缓冲区后触发相应事件,从而提高了效率和响应性。


所以当下开源软件能实现网络高性能的原因之一就是 I/O 多路复用。但是使用 I/O 多路复用接口编写网络程序的同学,肯定知道这是一种面向过程的编码方式,这样的开发效率并不高。

因此,大佬们基于面向对象的思想,对 I/O 多路复用进行了封装,让使用者不必考虑底层网络 API 的细节,而只需关注应用代码的编写。这种模式被称为 Reactor 模式

Reactor 翻译过来的意思是“反应堆”。这里的“反应”指的是“对事件的反应”,即当事件到来时,Reactor 会作出相应的反应。

事实上,Reactor 模式也称为 Dispatcher 模式,这个名称更贴合该模式的含义。Reactor 通过 I/O 多路复用监听事件,收到事件后,会根据事件类型将其分配(Dispatch)给某个进程或线程。

Reactor 模式主要由 Reactor处理资源池两个核心部分组成,二者负责的工作如下:

  • Reactor 负责监听和分发事件,事件类型包括连接事件、读写事件;
  • 处理资源池 负责处理事件,例如:read -> 业务逻辑 -> send

Reactor 模式灵活多变,可以应对不同的业务场景,灵活性体现在以下两个方面:

  1. Reactor 的数量:可以是一个或多个;
  2. 处理资源池:可以是单个进程/线程,也可以是多个进程/线程。

常见的三个方案都是经典且在实际项目中应用广泛:

  • 单 Reactor 单进程/线程
  • 单 Reactor 多线程/进程
  • 多 Reactor 多进程/线程

具体使用进程还是线程,通常与所使用的编程语言和平台有关。例如:

  • Java 语言一般使用线程,如 Netty
  • C 语言则可以使用进程或线程,例如 Nginx 使用进程,而 Memcached 使用线程。

接下来,将分别介绍这三个经典的 Reactor 方案。

Reactor

无论是 C++ 还是 Java 编写的网络框架,大多数都是基于 Reactor 模型 进行设计和开发。Reactor 模型基于事件驱动,特别适合处理海量的 I/O 事件。

Reactor 模型中的角色

  1. Reactor:负责监听和分配事件,将 I/O 事件分派给相应的 Handler。新的事件包括连接建立就绪、读就绪、写就绪等。
  2. Acceptor:处理客户端的新连接,并将请求分派到处理器链中。
  3. Handler:将自身与事件绑定,执行非阻塞的读/写任务,完成 channel 的读取,并在处理业务逻辑后将结果写出到 channel。可以使用资源池来管理。

Reactor 处理请求的流程

  • 读取操作
    1. 应用程序注册读就绪事件和相关联的事件处理器。
    2. 事件分离器等待事件的发生。
    3. 当发生读就绪事件时,事件分离器调用第一步注册的事件处理器。
  • 写入操作:与读取操作类似,只不过第一步注册的是写就绪事件。

单 Reactor 单线程

在单 Reactor 单线程模型中,Reactor 线程负责多路分离套接字,接受新连接,并将请求分派给 Handler。比如 Redis 就采用了单 Reactor 单进程的模型。

image-20241029194312003

消息处理流程

  1. Reactor 对象通过 select 监控连接事件,收到事件后通过 dispatch 进行转发。
  2. 如果是连接建立的事件,由 Acceptor 接受连接,并创建 Handler 处理后续事件。
  3. 如果不是建立连接事件,Reactor 会分发调用 Handler 进行响应。
  4. Handler 会完成 read -> 业务处理 -> send 的完整业务流程。

虽然单 Reactor 单线程模型在代码上进行了组件的区分,但整体操作仍然是单线程,无法充分利用硬件资源,且 Handler 的业务处理部分没有异步化。

对于小容量应用场景,单 Reactor 单线程模型是可行的;但对于高负载、大并发的应用场景,则显得不够合适,原因如下:

  1. 即使 Reactor 线程的 CPU 负荷达到 100%,也无法满足海量消息的编码、解码、读取和发送。
  2. 当 Reactor 线程负载过重时,处理速度减慢,可能导致大量客户端连接超时,超时后往往会进行重发,进一步加重 Reactor 线程负载,最终导致消息积压和处理超时,成为性能瓶颈。
  3. 一旦 Reactor 线程意外中断或进入死循环,整个系统通信模块将不可用,无法接收和处理外部消息,导致节点故障。

为了解决这些问题,演进出了 单 Reactor 多线程模型

单 Reactor 多线程

在这个模型中,事件处理器(Handler)部分采用多线程(线程池)。

image-20241029194526982

消息处理流程

  1. Reactor 对象通过 select 监控客户端请求事件,收到事件后通过 dispatch 进行分发。
  2. 如果是建立连接请求事件,由 Acceptor 通过 accept 处理连接请求,并创建 Handler 对象处理连接后的各种事件。
  3. 如果不是建立连接事件,Reactor 会分发调用对应的 Handler 进行响应。
  4. Handler 只负责响应事件,不进行具体业务处理。通过 Read 读取数据后,会分发给 Worker 线程池进行业务处理。
  5. Worker 线程池分配独立的线程完成实际的业务处理,并将响应结果发送给 Handler 进行处理。
  6. Handler 收到响应结果后通过 send 将结果返回给 Client。

相对于第一种模型,单 Reactor 多线程模型在处理业务逻辑时,将 I/O 的读写事件交由线程池处理,从而降低了 Reactor 的性能开销,使其更专注于事件分发,提升了整体应用的吞吐量。

存在的问题

  1. 多线程数据共享和访问比较复杂。如果子线程完成业务处理后需要将结果传递给主线程 Reactor 进行发送,就会涉及共享数据的互斥和保护机制。
  2. Reactor 只在主线程中运行,可能会存在性能问题。例如,在处理百万客户端连接时,可能会对性能产生影响,特别是涉及安全认证等耗时操作。

为了解决这些性能问题,产生了 多 Reactor 多线程模型

多 Reactor 多线程

相较于第二种模型,多 Reactor 多线程模型 将 Reactor 分为两部分:

  1. mainReactor:负责监听 server socket,处理网络 I/O 连接的建立操作,将建立的 socketChannel 注册给 subReactor。
  2. subReactor:主要进行与建立的 socket 进行数据交互和事件业务处理操作。通常,subReactor 的数量可以与 CPU 数量相同。

许多高性能的开源软件如 Nginx、Swoole、Memcached 和 Netty 都采用这种实现。

image-20241029194601543

消息处理流程

  1. 从主线程池中随机选择一个 Reactor 线程作为 Acceptor 线程,用于绑定监听端口,接收客户端连接。
  2. Acceptor 线程接收客户端连接请求后,创建新的 SocketChannel,将其注册到主线程池的其它 Reactor 线程上,由其负责接入认证、IP 黑白名单过滤、握手等操作。
  3. 完成上述操作后,业务层的链路正式建立,将 SocketChannel 从主线程池的 Reactor 线程的多路复用器上摘除,重新注册到 Sub 线程池的线程上,并创建一个 Handler 用于处理各种连接事件。
  4. 当有新的事件发生时,SubReactor 会调用连接对应的 Handler 进行响应。
  5. Handler 通过 Read 读取数据后,会分发给 Worker 线程池进行业务处理。
  6. Worker 线程池分配独立的线程完成实际的业务处理,并将响应结果发给 Handler 进行处理。
  7. Handler 收到响应结果后通过 send 将响应结果返回给 Client。

总结

Reactor 模型具有如下优点:

  1. 响应快:不必为单个同步时间所阻塞,虽然 Reactor 本身依然是同步的;
  2. 编程相对简单:可以最大程度避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销;
  3. 可扩展性:可以方便地通过增加 Reactor 实例的数量来充分利用 CPU 资源;
  4. 可复用性:Reactor 模型本身与具体事件处理逻辑无关,具有很高的复用性。

Proactor

前面提到的 Reactor 是非阻塞同步网络模式,而 Proactor 是异步网络模式

关于 同步IO和异步IO,请参阅《从内核角度看 IO 模型》

  • Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发生(比如可读就绪事件)后,应用进程需要主动调用 read 方法来完成数据的读取,也就是说,应用进程需要主动将 socket 接收缓存中的数据读到应用进程内存中。这个过程是同步的,读取完数据后,应用进程才能处理数据。

  • Proactor 是异步网络模式,感知的是已完成的读写事件。在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才能自动完成数据的读写工作。这里的读写工作全程由操作系统来处理,并不需要像 Reactor 那样还需应用进程主动发起 read/write 来读写数据。操作系统完成读写工作后,会通知应用进程直接处理数据。

因此,Reactor 可以理解为“来了事件,操作系统通知应用进程,让应用进程来处理”,而 Proactor 可以理解为“来了事件,操作系统来处理,处理完再通知应用进程”。这里的“事件”包括有新连接、有数据可读、有数据可写的这些 IO 事件,这里的“处理”包含从驱动读取到内核以及从内核读取到用户空间。

举个实际生活中的例子,Reactor 模式就像快递员在楼下给你打电话告诉你快递到了你家小区,你需要自己下楼来拿快递。而在 Proactor 模式下,快递员直接将快递送到你家门口,然后通知你。

无论是 Reactor,还是 Proactor,都是一种基于“事件分发”的网络编程模式,区别在于 Reactor 模式是基于“待完成”的 I/O 事件,而 Proactor 模式则是基于“已完成”的 I/O 事件。

接下来,一起看看 Proactor 模式的示意图:

img

介绍一下 Proactor 模式的工作流程:

  1. Proactor Initiator 负责创建 ProactorHandler 对象,并将 ProactorHandler 都通过 Asynchronous Operation Processor 注册到内核。
  2. Asynchronous Operation Processor 负责处理注册请求,并处理 I/O 操作。
  3. Asynchronous Operation Processor 完成 I/O 操作后,通知 Proactor
  4. Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理。
  5. Handler 完成业务处理。

可惜的是,在 Linux 下的异步 I/O 是不完善的。aio 系列函数是由 POSIX 定义的异步操作接口,并不是真正的操作系统级别支持,而是在用户空间模拟出来的异步操作,并且仅支持基于本地文件的异步 I/O,网络编程中的 socket 是不支持的。这也使得基于 Linux 的高性能网络程序大多使用 Reactor 方案。

而在 Windows 中,实现了一套完整支持 socket 的异步编程接口,这套接口就是 IOCP,是由操作系统级别实现的真正意义上的异步 I/O。因此,在 Windows 中实现高性能网络程序可以使用效率更高的 Proactor 方案。

总结

常见的 Reactor 实现方案有三种:

  1. 单 Reactor 单进程/线程 这种方案不需要考虑进程间通信和数据同步的问题,因此实现起来相对简单。缺陷在于无法充分利用多核 CPU,且处理业务逻辑的时间不能太长,否则会导致响应延迟。因此,这种方案不适用于计算密集型的场景,而更适合于业务处理快速的场景,比如 Redis(6.0 之前)采用的就是单 Reactor 单进程的方案。
  2. 单 Reactor 多线程 通过多线程的方式,解决了第一种方案的缺陷,但仍然离高并发有一定距离。问题在于只有一个 Reactor 对象来承担所有事件的监听和响应,并且只在主线程中运行,因此在面对瞬间高并发的场景时,容易成为性能瓶颈。
  3. 多 Reactor 多进程/线程 通过多个 Reactor 来解决第二种方案的缺陷,主 Reactor 只负责监听事件,而将响应事件的工作交给从 ReactorNettyMemcache 都采用了“多 Reactor 多线程”的方案,而 Nginx 则采用了类似于“多 Reactor 多进程”的方案。

Reactor 可以理解为“来了事件,操作系统通知应用进程,让应用进程来处理”;而 Proactor 则可以理解为“来了事件,操作系统来处理,处理完再通知应用进程”。

因此,真正的大杀器还是 Proactor,它是采用异步 I/O 实现的异步网络模型,感知的是已完成的读写事件,而不需要像 Reactor 一样在感知到事件后,还需要调用 read 来从内核中获取数据。

不过,无论是 Reactor 还是 Proactor,它们都是一种基于“事件分发”的网络编程模式。区别在于 Reactor 模式是基于“待完成”的 I/O 事件,而 Proactor 模式则是基于“已完成”的 I/O 事件。

参考

基于 MIT 许可发布