线程模型之 Reactor 模型
线程模型
目前存在的线程模型:
- 传统阻塞 IO 服务模型。
- Reactor 模型。
传统阻塞 IO 服务模型的特点:
- 采用阻塞 IO 模式获取输入的数据。
- 每个连接都需要独立的线程完成
read -> 业务逻辑 -> send
。
传统阻塞 IO 服务模型的缺点:
- 当并发数很大,就会创建大量的线程,占用很大系统资源,所以它是没办法处理高并发的。
- 连接创建后,如果当前线程暂时没有数据可读,该线程会阻塞在读操作,造成线程资源浪费。
|
|
Reactor 简介
Reactor 模式也叫分发者(Dispatcher)模式,又叫反应器模式,又称通知者模式(Notifier),即 IO 多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程 / 线程。
Reactor 模型的特点:
- Reactor 中的 Selector 是可以实现应用程序通过一个阻塞对象监听多路连接请求。
- Reactor 对象通过 Selector 监控客户端请求事件,收到事件后通过 Dispatch 进行分发。
- Acceptor 通过
accept
来处理建立连接请求事件,然后创建一个 Handler 对象处理连接完成后的后续业务处理。 - Handler 会完成
read -> 业务逻辑 -> send
。
Reactor 模型的优点:
- 响应快,不必为单个同步事件所阻塞,虽然 Reactor 本身依然是同步的。
- 可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程 / 进程的切换开销。
- 扩展性好,可以方便的通过增加 Reactor 实例个数来充分利用 CPU 资源。
- 复用性好,Reactor 模型本身与具体事件处理逻辑无关,具有很高的复用性。
- 模型简单,没有多线程、进程通信、竞争的问题,全部都在一个线程中完成。
Reactor 模型的缺点:
- 性能问题:只有一个线程,无法完全发挥多核 CPU 的性能。Handler 在处理某个连接上的业务时,整个进程无法处理其他连接事件,很容易导致性能瓶颈。
- 可靠性问题:线程意外终止或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障。
Reactor 模型的核心是 Reactor 加上对应的处理器 Handler,Reactor 在一个单独的线程中运行,负责监听和分发事件,将接收到的事件交给不同的 Handler 来处理,Handler 是处理程序执行 IO 事件的实际操作,它俩负责的事情如下:
- Reactor:负责监听和分发事件,事件类型包含连接事件、读写事件。
- Handler:负责处理 Reactor 分发的事件,如
read -> 业务逻辑 -> send
。
Reactor 模式是灵活多变的,可以应对不同的业务场景,灵活在于:
- Reactor 的数量可以只有一个,也可以有多个。
- Handler 可以是单个进程 / 线程,也可以是多个进程 /线程。
将上面的两个因素排列组合一下,理论上就可以有 4 种方案选择:
- 单 Reactor 单进程 / 线程,通俗点就是「接待员和服务员是同一个人,全程为顾客服务」。
- 单 Reactor 多进程 / 线程,通俗点就是「一个接待员,多个服务员,接待员只负责接待顾客,打个招呼就让服务员上」。
多 Reactor 单进程 / 线程,通俗点就是「多个接待员,一个服务员,暂时还没见过这么变态的」。- 多 Reactor 多进程 / 线程,通俗点就是「多个接待员,多个服务员」。
其中,「多 Reactor 单进程 / 线程」实现方案相比「单 Reactor 单进程 / 线程」方案,不仅复杂而且也没有性能优势,因此实际中并没有应用。
剩下的 3 个方案都是比较经典的,且都有应用在实际的项目中:
- 单 Reactor 单进程 / 线程。
- 单 Reactor 多线程 / 进程。
- 多 Reactor 多进程 / 线程。
方案具体使用进程还是线程,要看使用的编程语言以及平台有关:
- Java 语言一般使用线程,比如 Netty。
- C 语言使用进程和线程都可以,例如 Nginx 使用的是进程,Memcache 使用的是线程。
单 Reactor 单进程 / 线程
单 Reactor 单进程有 Reactor、Acceptor、Handler 这三个对象:
- Reactor:监听和分发事件。
- Acceptor:获取连接。
- Handler:处理业务。
所以,单 Reactor 单进程的方案不适用 计算密集型(CPU 密集型) 的场景,只适用于业务处理非常快速的场景。
Redis 是由 C 语言实现的,它采用的正是「单 Reactor 单进程」的方案,因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的方案。
单 Reactor 单进程 / 线程处理过程:
- Reactor 对象通过
select
(IO 多路复用接口)监听事件,收到事件后通过dispatch
进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型。 - 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过
accept
方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件。 - 如果不是连接建立事件,则交由当前连接对应的 Handler 对象来进行响应。
- Handler 对象通过
read -> 业务处理 -> send
的流程来完成完整的业务流程。
单 Reactor 单进程 / 线程方案的缺点:
- 只有一个进程,无法充分利用多核 CPU 的性能。
- Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,那么就造成响应的延迟。
单 Reactor 多线程 / 多进程
单 Reactor 多线程 / 多进程处理过程:
- Reactor 对象通过
select
(IO 多路复用接口)监听事件,收到事件后通过dispatch
进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型。 - 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过
accept
方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件。 - 如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应。Handler 对象不再负责业务处理,只负责数据的接收和发送,Handler 对象通过
read
读取到数据后,会将数据发给子线程里的 Processor 对象进行业务处理。 - 子线程里的 Processor 对象就进行业务处理,处理完后,将结果发给主线程中的 Handler 对象,接着由 Handler 通过
send
方法将响应结果发送给client
。
单 Reator 多线程的方案优势在于能够充分利用多核 CPU 的能,那既然引入多线程,那么自然就带来了多线程竞争资源的问题。要避免多线程由于竞争共享资源而导致数据错乱的问题,就需要在操作共享资源前加上互斥锁,以保证任意时间里只有一个线程在操作共享资源,待该线程操作完释放互斥锁后,其他线程才有机会操作共享数据。
单 Reactor 多进程相比单 Reactor 多线程实现起来很麻烦,主要因为要考虑子进程 <-> 父进程的双向通信,并且父进程还得知道子进程要将数据发送给哪个客户端。
而多线程间可以共享数据,虽然要额外考虑并发问题,但是这远比进程间通信的复杂度低得多,因此实际应用中也看不到单 Reactor 多进程的模式。
单 Reactor 多线程 / 多进程优点:可以充分地利用多核 CPU 的处理能力。
单 Reactor 多线程 / 多进程缺点:一个 Reactor 对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。
多 Reactor 多进程 / 线程
多 Reactor 多进程 / 线程处理过程:
- 主线程中的 MainReactor 对象通过
select
监控连接建立事件,收到事件后通过 Acceptor 对象中的accept
获取连接,将新的连接分配给某个子线程。 - 子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入
select
继续进行监听,并创建一个 Handler 用于处理连接的响应事件。 - 如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应。
- Handler 对象通过
read -> 业务处理 -> send
的流程来完成完整的业务流程。
多 Reactor 多线程的方案虽然看起来复杂的,但是实际实现时比单 Reactor 多线程的方案要简单的多,原因如下:
- 主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理。
- 主线程和子线程的交互很简单,主线程只需要把新连接传给子线程,子线程无须返回数据,直接就可以在子线程将处理结果发送给客户端。
多 Reactor 多进程 / 线程优点:
- 父线程与子线程的数据交互简单职责明确, 父线程只需要接收新连接, 子线程完成后续的业务处理。
- 父线程与子线程的数据交互简单, Reactor 主线程只需要把新连接传给子线程, 子线程无需返回数据。
多 Reactor 多进程 / 线程缺点:编程复杂度较高。
惊群效应(
Thundering Herd
)是指多进程(多线程)在同时阻塞等待同一个事件的时候(休眠状态),如果等待的这个事件发生,那么他就会唤醒等待的所有进程(或者线程),但是最终却只能有一个进程(线程)获得这个时间的「控制权」,对该事件进行处理,而其他进程(线程)获取「控制权」失败,只能重新进入休眠状态,会产生不必要的 CPU 空转,导致的这种现象和性能浪费就称惊群效应或惊群现象。
开源软件 Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案。而采用了「多 Reactor 多进程」方案的开源软件是 Nginx,不过方案与标准的多 Reactor 多进程有些差异。具体差异表现在主进程中仅仅用来初始化 socket
,并没有创建 MainReactor 来 accept
连接,而是由子进程的 Reactor 来 accept
连接,通过锁来控制一次只有一个子进程进行 accept
(防止出现惊群现象),子进程 accept
新连接后就放到自己的 Reactor 进行处理,不会再分配给其他子进程。