单服务器高性能的关键之一就是服务器采取的并发模型,并发模型有如下两个关键设计点:
- 服务器如何管理连接。
- 服务器如何处理请求。
以上两个设计点最终都和操作系统的 I/O 模型及线程模型相关。
- I/O 模型:阻塞、非阻塞、同步、异步。
- 线程模型:单线程、多线程、多线程。
1. 网络编程模型
1.1. 基于线程模型
在早期并发数不多的场景中,有一种One Request One Thread的架构模式。
该模式下每次接收一个新请求就创建一个处理线程,线程虽然消耗资源并不多,但是成千上万请求打过来,性能也是扛不住的。
这是一种比较原始的架构,思路也非常清晰,创建多个线程来提供处理能力,模式实现简单,比较适合服务器的连接数没那么多的情况,例如数据库服务器。但在高并发生产环境中几乎没有应用。因为这种模型支持的并发连接数量有限:如果每个连接存活时间比较长,而且新的连接又源源不断的进来,则线程数量会越来越多,操作系统线程调度和切换的频率也越来越高,系统的压力也会越来越大。因此,一般情况下,这种模型能处理的并发连接数量最大也就几百。
1.2. 基于事件驱动模型
当前流行的是基于事件驱动的IO复用模型,相比多线程模型优势很明显。
事件驱动编程是一种编程范式,程序的执行流由外部事件来决定,它的特点是包含一个事件循环,当外部事件发生时使用回调机制来触发相应的处理。
通俗来说就是:有一个循环装置在一直等待各种事件的到来,并将到达的事件放到队列中,再由一个分拣装置来调用对应的处理装置来响应。
1.3. Reactor模式
Reactor(反应堆)是个核物理的概念。 核反应堆是核电站的心脏 ,它的工作原理是这样的:原子由原子核与核外电子组成,原子核由质子与中子组成。
当铀235的原子核受到外来中子轰击时,一个原子核会吸收一个中子分裂成两个质量较小的原子核,同时放出2-3个中子。
这裂变产生的中子又去轰击另外的铀235原子核,引起新的裂变,如此持续进行就是裂变的链式反应。
结合这种核裂变的图,好像是一个请求打过来,服务器内部瞬间延伸出很多分支来完成响应,一变二,二变四,甚至更多,确实有种反应堆的感觉。
2. Reactor模式详解
从本质上理解,无论什么网络框架都要完成两部分操作:
- IO操作:数据包的读取和写入
- CPU操作:数据请求的处理和封装
Reactor 模式的核心组成部分包括 Reactor 和处理资源池(线程池或线程池),其中 Reactor 负责监听和分配事件,处理资源池负责处理事件。初看 Reactor 的实现是比较简单的,但实际上结合不同的业务场景,Reactor 模式的具体实现方案灵活多变,主要体现在:
- Reactor 的数量可以变化:可以是一个 Reactor,也可以是多个 Reactor。
- 资源池的数量可以变化:以线程为例,可以是单个线程,也可以是多个线程(线程类似)。
Reactor模式根据处理IO环节和处理数据环节的数量差异分为如下几种:
- 单Reactor线程
- 单Reactor线程和线程池
- 多Reactor线程和线程池
2.1. 单Reactor线程
这种模式最为简洁,一个线程完成了连接的监听、接收新连接、处理连接、读取数据、写入数据全套工作。
由于只使用了一个线程,对于多核利用率偏低,但是编程简单。
- Reactor 对象通过 select 监控连接事件,收到事件后通过 dispatch 进行分发。
- 如果是连接建立的事件,则由 Acceptor 处理,Acceptor 通过 accept 接受连接,并创建一个 Handler 来处理连接后续的各种事件。
- 如果不是连接建立事件,则 Reactor 会调用连接对应的 Handler(第 2 步中创建的 Handler)来进行响应。
- Handler 会完成 read-> 业务处理 ->send 的完整业务流程。
单 Reactor 单线程的模式优点就是很简单,没有线程间通信,没有线程竞争,全部都在同一个线程内完成。但其缺点也是非常明显,具体表现有:
- 只有一个线程,无法发挥多核 CPU 的性能;只能采取部署多个系统来利用多核 CPU,但这样会带来运维复杂度,本来只要维护一个系统,用这种方式需要在一台机器上维护多套系统。
- IO操作和CPU操作是没有分开的,Handler 在处理某个连接上的业务时,整个线程无法处理其他连接的事件,很容易导致性能瓶颈。
因此,单 Reactor 单线程的方案在实践中应用场景不多,只适用于业务处理非常快速的场景,目前比较著名的开源软件中使用单 Reactor 单线程的是 Redis,因为在Redis中由于都是内存操作,速度很快,这种瓶颈虽然存在但是不够明显。
需要注意的是,C 语言编写系统的一般使用单 Reactor 单线程,因为没有必要在线程中再创建线程;而 Java 语言编写的一般使用单 Reactor 单线程,因为 Java 虚拟机是一个线程,虚拟机中有很多线程,业务线程只是其中的一个线程而已。
2.2. 单Reactor线程和线程池
为了解决IO操作和CPU操作的不匹配,也就是IO操作和CPU操作是在一个线程内部串行执行的,这样就拉低了CPU操作效率。
一种解决方法就是将IO操作和CPU操作分别由单独的线程来完成,各玩各的互不影响。单Reactor线程完成IO操作、复用工作线程池来完成CPU操作就是一种解决思路。
- 主线程中,Reactor 对象通过 select 监控连接事件,收到事件后通过 dispatch 进行分发。
- 如果是连接建立的事件,则由 Acceptor 处理,Acceptor 通过 accept 接受连接,并创建一个 Handler 来处理连接后续的各种事件。
- 如果不是连接建立事件,则 Reactor 会调用连接对应的 Handler(第 2 步中创建的 Handler)来进行响应。
- Handler 只负责响应事件,不进行业务处理;Handler 通过 read 读取到数据后,会发给 Processor 进行业务处理。
- Processor 会在独立的子线程中完成真正的业务处理,然后将响应结果发给主线程的 Handler 处理;Handler 收到响应后通过 send 将响应结果返回给 client
在这种模式种由Reactor线程完成连接的管理和数据读取&写回,完全掌管IO操作。工作线程池处理来自上游分发的任务,对其中的数据进行解码、计算、编码再返回给Reactor线程和客户端完成交互。
单 Reator 多线程方案能够充分利用多核多 CPU 的处理能力,但同时也存在下面的问题:
- 多线程数据共享和访问比较复杂。例如,子线程完成业务处理后,要把结果传递给主线程的 Reactor 进行发送,这里涉及共享数据的互斥和保护机制。以 Java 的 NIO 为例,Selector 是线程安全的,但是通过 Selector.selectKeys() 返回的键的集合是非线程安全的,对 selected keys 的处理必须单线程处理或者采取同步措施进行保护。
- Reactor 承担所有事件的监听和响应,只在主线程中运行,瞬间高并发时会成为性能瓶颈。连接实在太多了,一个Reactor线程忙不过来建立新连接和响应旧连接这些事情。
2.3. 多Reactor线程和线程池
水平扩展往往是提供性能的有效方法
我们将Reactor线程进行扩展,一个Reactor线程负责处理新连接,多个Reactor线程负责处理连接成功的IO数据读写。
也就是进一步将监听&创建连接 和 处理连接 分别由两个及以上的线程来完成,进一步提高了IO操作部分的效率。
- 父线程中 mainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 接收,将新的连接分配给某个子线程。
- 子线程的 subReactor 将 mainReactor 分配的连接加入连接队列进行监听,并创建一个 Handler 用于处理连接的各种事件。
- 当有新的事件发生时,subReactor 会调用连接对应的 Handler(即第 2 步中创建的 Handler)来进行响应。
- Handler 完成 read→业务处理→send 的完整业务流程。
多 Reactor 多线程 / 线程的方案看起来比单 Reactor 多线程要复杂,但实际实现时反而更加简单,主要原因是:
- 父线程和子线程的职责非常明确,父线程只负责接收新连接,子线程负责完成后续的业务处理。
- 父线程和子线程的交互很简单,父线程只需要把新连接传给子线程,子线程无须返回数据。
- 子线程之间是互相独立的,无须同步共享之类的处理
3. Preactor模式
前面提到Reactor模式其中非常重要的一环就是调用read/write函数来完成数据拷贝,这部分是应用程序自己完成的,内核只负责通知监控的事件到来了,所以本质上Reactor模式属于非阻塞同步IO。
而Preactor模式,借助于系统本身的异步IO特性,由操作系统进行数据拷贝,在完成之后来通知应用程序来取就可以,效率更高一些,但是底层需要借助于内核的异步IO机制来实现。
底层的异步IO机制可能借助于DMA和Zero-Copy技术来实现,理论上性能更高。
4. 参考资料
https://mp.weixin.qq.com/s/5QMfkclFc_UrbLHr1-Fymg
《从0开始学架构》