前文送分来了,华为一面,介绍下五种 IO 模型 我们解释过,操作系统系统如何获取输入和输出的数据,就是 I/O 模型干的事。
那怎么通过输入数据得到的输出数据的?换句话说,操作系统是怎么处理输入数据的(怎么处理请求的)?这就是线程模型(或者说进程模型)的事了。
所以,当我们想要设计一个服务端的并发模型时,主要有如下两个关键点:
- 服务器如何管理连接,获取输入输出数据:基于 「I/O 模型」管理连接
- 服务器如何处理请求:基于「线程/进程模型」处理请求
值得说明的是,具体选择线程还是进程来处理请求,更多是与平台及编程语言相关,例如 Nginx 使用进程,Memcached 使用线程,而 C 语言使用线程和进程都可以,Java 语言一般使用线程(例如 Netty),为方便行文,下文统一用线程模型
主要有三类线程模型:
- 阻塞 I/O 模型(Blocking I/O Model)
- Reactor 模型
单 Reactor 单线程
单 Reactor 多线程
主从 Reactor 多线程
- Proactor 模型
Blocking I/O Model
方案说明
阻塞 I/O 模型也被称为同步 I/O 模型。在这种模型下,为每一个请求分配一个线程,并且当一个线程执行一个 I/O 操作时,它会一直等待,直到 I/O 操作完成并返回结果。在这个过程中,该线程会被阻塞,无法执行其他任务。
废话不多说,看图就明白了:
阻塞 I/O 模型原理示例图
特点
- 采用「阻塞 I/O 模型」获取输入数据,实现简单
- 每个连接都需要独立的线程完成数据输入、业务处理、数据返回的完整操作
缺点
- 当并发数较大时,需要创建大量线程来处理连接,系统资源占用较大
- 连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在 read 操作上,造成线程资源浪费,CPU 的利用率非常低,因为线程会被阻塞很长时间,等待 I/O 操作的完成,如果线程阻塞过多甚至会导致系统崩溃
Reactor Model
针对传统阻塞 I/O 模式的 2 个缺点,比较常见的有如下解决方案:
- 基于「I/O 多路复用」模型,多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理
- 基于「线程池」复用线程资源,不必再为每个连接创建线程,将连接完成后的具体处理任务分配给线程进行处理,一个线程可以处理多个连接的业务
I/O 多路复用 + 线程池,这就是 Reactor 模式基本设计思想
Reactor 模式中主要有 2 个关键组成:
- Reactor:负责监听和分发事件,分发给适当的 Handler 来对 IO 事件做出反应
- Handler:处理程序执行 I/O 事件要完成的实际任务
根据Reactor的数量和处理资源池线程的数量不同,有 3 种典型的实现:
- 单 Reactor 单线程:Single Reactor Single Thread (SRST) pattern
- 单 Reactor 多线程:Single Reactor Multi-Thread (SRMT) pattern
- 主从 Reactor 多线程:Master-Slave Reactor Multi-Thread (MSRMT) pattern
单 Reactor 单线程
方案说明
- 一个进程里有 Reactor、Acceptor、Handler 这三个对象:
- Reactor 对象的作用是监听和分发事件;
- Acceptor 对象的作用是获取连接;
- Handler 对象的作用是处理业务;
- Reactor 对象通过 select 监控客户端请求事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型;
- 如果是建立连接请求事件,则由Acceptor通过accept处理连接请求,然后创建一个Handler对象处理连接完成后的后续业务处理
- 如果不是建立连接事件,则 Reactor 会分发调用连接对应的Handler对象来响应
- Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程
单 Reactor 单线程原理示例图
特点
模型简单,所有的 I/O 操作都在一个线程中被执行,Reactor 监听所有的 IO 事件,当有事件发生时,Reactor 会调用对应的 Handler 来处理事件。没有多线程(全部工作都在同一个线程内完成)、线程/进程通信、竞争的问题
缺点
- 性能问题:只有一个线程,无法完全发挥多核 CPU 的性能。Handler 对象在处理某个连接上的业务时,整个进程无法处理其他连接事件,很容易导致性能瓶颈
- 可靠性问题:线程意外跑飞,或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障
适用场景
适用于并发需求不高、处理逻辑简单的情况,比如处理时间不敏感的任务
示例代码
下面是一个简单的示例代码,实现了单 Reactor 单线程模型
单 Reactor 多线程
方案说明
和单 Reactor 单线程模型的区别就在于,
- Handler 只负责响应事件,不做具体业务处理,通过 read 读取数据后,会分发给后面的 Worker 线程池进行业务处理
- Worker 线程池会分配独立的线程完成真正的业务处理,然后将响应结果发给 Handler 进行处理
- Handler 收到响应结果后返回给 client
单 Reactor 多线程原理示例图
特点
单 Reactor 多线程模型中,Reactor 仍然只有一个,但是有多个线程用于处理 I/O 事件。当有事件发生时,Reactor 会将事件分发给空闲的线程处理,可以利用多核 CPU 实现更好的并发性能。
缺点
- 多线程数据共享和访问比较复杂
- Reactor 承担所有事件的监听和响应,在单线程中运行,高并发场景下容易成为性能瓶颈
适用场景
单 Reactor 多线程模型适用于并发需求较高、但是任务处理逻辑简单的情况,可以利用多核 CPU 提高并发性能
示例代码
在下述代码中,Reactor 类代表 Reactor 线程,它通过 Selector(Java NIO 包中的类) 监听和分发事件。Acceptor 类代表事件处理器,它处理连接请求事件,并创建新的 Handler 对象处理读写事件。Handler 类代表具体的业务逻辑处理器,它处理读写事件并返回响应。
在 Reactor 类的 run 方法中,通过调用 Selector 的 select 方法等待事件的发生,并获取已经就绪的事件。然后遍历事件集合,将每个事件分发给一个 Worker 线程处理。Worker 线程采用线程池来实现。
主从 Reactor 多线程
方案说明
针对单 Reactor 多线程模型中,Reactor 在单线程中运行,高并发场景下容易成为性能瓶颈,主从 Reactor 模型可以让 Reactor 在多线程中运行:
- Reactor 主线程(MainReactor)通过 select 监控建立连接事件,收到事件后通过 Acceptor 接收,处理建立连接事件
- Accepto 处理建立连接事件后,MainReactor 将连接分配给 Reactor 子线程(SubReactor)进行处理
- SubReactor 将连接加入 select 进行监听,并创建一个 Handler 用于处理该连接的响应事件
- 当有新的事件发生时,SubReactor 会调用连接对应的 Handler 进行响应
- Handler 通过 read 读取数据后,会分发给后面的 Worker 线程池进行业务处理
- Worker 线程池会分配独立的线程完成真正的业务处理,如何将响应结果发给 Handler 进行处理
- Handler 收到响应结果后通过 send 将响应结果返回给 Client
主从 Reactor 多线程原理示意图
特点
- Reactor 父线程与子线程的数据交互简单职责明确,Reactor 父线程只需要接收新连接,Reactor 子线程完成后续的业务处理
- 父线程与子线程的数据交互简单,Reactor 主线程只需要把新连接传给子线程,子线程无需返回数据
缺点
- 实现较为复杂:主从 Reactor 模式需要同时处理多个 Reactor 线程之间的协作和同步,实现起来比较复杂,需要处理更多的细节和边界情况。
- 可能存在的性能瓶颈:在主从 Reactor 模式中,主 Reactor 线程和从 Reactor 线程之间需要通过队列等方式传递事件,可能会存在性能瓶颈。
- 调试和维护困难:主从 Reactor 模式中包含多个线程和多个 Reactor 对象,调试和维护都比较困难,需要付出更多的时间和精力。
适用场景
主从 Reactor 多线程模型适用于并发需求非常高,任务处理逻辑复杂,可以通过多个从 Reactor 和线程池来提高并发性能。
示例代码
在下述示例代码中,主 Reactor 线程负责监听连接事件(SelectionKey.OP_ACCEPT),从 Reactor 线程负责监听读事件(SelectionKey.OP_READ)和写事件(SelectionKey.OP_WRITE)。当有新连接到达时,主 Reactor 线程会创建一个新的 Handler 对象,并将其注册到从 Reactor 线程的 Selector 上。在 Handler 中,读和写事件会被提交给线程池
Proactor Model
由于 Reactor 是基于 I/O 多路复用的,所以 Reactor 其实还是同步的。
而 Proactor 是「异步」的,把 I/O 操作改为异步来进一步提升性能
在 Proactor 线程模型中,应用程序通过调用异步 I/O 操作向系统内核发起 I/O 请求,内核将请求放入 I/O 队列中,并向应用程序返回一个 I/O 请求标识符。应用程序可以继续执行其他任务,不必等待 I/O 操作完成。当内核完成 I/O 操作时,会向应用程序发送一个通知,并将操作结果放入 I/O 完成队列中。应用程序可以通过轮询或者回调方式获取完成队列中的操作结果,并进行后续的处理。
Reactor 和 Proactor 的区别:
- Reactor 是同步网络模式,感知的是【就绪可读写事件】。在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据。
- Proactor 是异步网络模式, 感知的是【已完成的读写事件】。在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,内核完成读写工作后,就会通知应用进程直接处理数据。
因此,Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,而 Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」
理论上 Proactor 比 Reactor 效率更高,但是有如下缺点:
- 编程复杂性:由于异步操作流程的事件的初始化和事件完成在时间和空间上都是相互分离的,因此开发异步应用程序更加复杂。应用程序还可能因为反向的流控而变得更加难以Debug
- 内存使用:缓冲区在读或写操作的时间段内必须保持住,可能造成持续的不确定性,并且每个并发操作都要求有独立的缓存,相比 Reactor 模式,在 Socket 已经准备好读或写前,是不要求开辟缓存的
- 操作系统支持:Windows 下通过 IOCP 实现了真正的异步 I/O,而在 Linux 系统下,Linux2.6 才引入,目前异步 I/O 还不完善