演进
如果要让服务器服务多个客户端,那么最直接的⽅式就是为每⼀条连接创建线程。
其实创建进程也是可以的,原理是⼀样的,进程和线程的区别在于线程⽐较轻量级些,线程的创建和线程间切换的成本要⼩些,为了描述简述,后⾯都以线程为例。
处理完业务逻辑后,随着连接关闭后线程也同样要销毁了,但是这样不停地创建和销毁线程,不仅会带来性能开销,也会造成浪费资源,⽽且如果要连接⼏万条连接,创建⼏万个线程去应对也是不现实的。
要这么解决这个问题呢?我们可以使⽤「资源复⽤」的⽅式。
也就是不⽤再为每个连接创建线程,⽽是创建⼀个「线程池」,将连接分配给线程,然后⼀个线程可以处理多个连接的业务。
不过,这样⼜引来⼀个新的问题,线程怎样才能⾼效地处理多个连接的业务?
当⼀个连接对应⼀个线程时,线程⼀般采⽤「read -> 业务处理 -> send」的处理流程,如果当前连接没有数据可读,那么线程会阻塞在 read 操作上( socket 默认情况是阻塞 I/O),不过这种阻塞⽅式并不影响其他线程。
但是引⼊了线程池,那么⼀个线程要处理多个连接的业务,线程在处理某个连接的 read 操作时,如果遇到没有数据可读,就会发⽣阻塞,那么线程就没办法继续处理其他连接的业务。
要解决这⼀个问题,最简单的⽅式就是将 socket 改成⾮阻塞,然后线程不断地轮询调⽤ read 操作来判断是否有数据,这种⽅式虽然该能够解决阻塞的问题,但是解决的⽅式⽐较粗暴,因为轮询是要消耗 CPU的,⽽且随着⼀个 线程处理的连接越多,轮询的效率就会越低。
上⾯的问题在于,线程并不知道当前连接是否有数据可读,从⽽需要每次通过 read 去试探。
那有没有办法在只有当连接上有数据的时候,线程才去发起读请求呢?答案是有的,实现这⼀技术的就是I/O 多路复⽤。
I/O 多路复⽤技术会⽤⼀个系统调⽤函数来监听我们所有关⼼的连接,也就说可以在⼀个监控线程⾥⾯监控很多的连接。
我们熟悉的 select/poll/epoll 就是内核提供给⽤户态的多路复⽤系统调⽤,线程可以通过⼀个系统调⽤函数从内核中获取多个事件。
select/poll/epoll 是如何获取⽹络事件的呢?
在获取事件时,先把我们要关⼼的连接传给内核,再由内核检测:
-
如果没有事件发⽣,线程只需阻塞在这个系统调⽤,⽽⽆需像前⾯的线程池⽅案那样轮训调⽤ read操作来判断是否有数据。
-
如果有事件发⽣,内核会返回产⽣了事件的连接,线程就会从阻塞状态返回,然后在⽤户态中再处理这些连接对应的业务即可。
当下开源软件能做到⽹络⾼性能的原因就是 I/O 多路复⽤吗?
是的,基本是基于 I/O 多路复⽤,⽤过 I/O 多路复⽤接⼝写⽹络程序的同学,肯定知道是⾯向过程的⽅式写代码的,这样的开发的效率不⾼。
于是,⼤佬们基于⾯向对象的思想,对 I/O 多路复⽤作了⼀层封装,让使⽤者不⽤考虑底层⽹络 API 的细节,只需要关注应⽤代码的编写。
⼤佬们还为这种模式取了个让⼈第⼀时间难以理解的名字:Reactor 模式。
这⾥的反应指的是「对事件反应」,也就是来了⼀个事件,Reactor 就有相对应的反应/响应。
事实上,Reactor 模式也叫 Dispatcher 模式,我觉得这个名字更贴合该模式的含义,即 I/O 多路复⽤监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程 / 线程。
Reactor 模式主要由 Reactor 和处理资源池这两个核⼼部分组成,它俩负责的事情如下:
- Reactor 负责监听和分发事件,事件类型包含连接事件、读写事件;
- 处理资源池负责处理事件,如 read -> 业务逻辑 -> send;
Reactor 模式是灵活多变的,可以应对不同的业务场景,灵活在于:
-
Reactor 的数量可以只有⼀个,也可以有多个;
-
处理资源池可以是单个进程 / 线程,也可以是多个进程 /线程;
将上⾯的两个因素排列组设⼀下,理论上就可以有 4 种⽅案选择:
- 单 Reactor 单进程 / 线程;
- 单 Reactor 多进程 / 线程;
- 多 Reactor 单进程 / 线程;
- 多 Reactor 多进程 / 线程;
其中,「多 Reactor 单进程 / 线程」实现⽅案相⽐「单 Reactor 单进程 / 线程」⽅案,不仅复杂⽽且也没有性能优势,因此实际中并没有应⽤。
剩下的 3 个⽅案都是⽐较经典的,且都有应⽤在实际的项⽬中:
- 单 Reactor 单进程 / 线程;
- 单 Reactor 多线程 / 进程;
- 多 Reactor 多进程 / 线程;
⽅案具体使⽤进程还是线程,要看使⽤的编程语⾔以及平台有关:
- Java 语⾔⼀般使⽤线程,⽐如 Netty;
- C 语⾔使⽤进程和线程都可以,例如 Nginx 使⽤的是进程,Memcache 使⽤的是线程。
接下来,分别介绍这三个经典的 Reactor ⽅案。
Reactor
单 Reactor 单进程 / 线程
⼀般来说,C 语⾔实现的是「单 Reactor 单进程 」的⽅案,因为 C 语编写完的程序,运⾏后就是⼀个独⽴的进程,不需要在进程中再创建线程。
⽽ Java 语⾔实现的是「单 Reactor 单线程 」的⽅案,因为 Java 程序是跑在 Java 虚拟机这个进程上⾯的,虚拟机中有很多线程,我们写的 Java 程序只是其中的⼀个线程⽽已。
我们来看看「单 Reactor 单进程」的⽅案示意图:
可以看到进程⾥有 Reactor、Acceptor、Handler 这三个对象:
- Reactor 对象的作⽤是监听和分发事件;
- Acceptor 对象的作⽤是获取连接;
- Handler 对象的作⽤是处理业务;
对象⾥的 select、accept、read、send 是系统调⽤函数,dispatch 和 「业务处理」是需要完成的操作,其中 dispatch 是分发事件操作。
接下来,介绍下「单 Reactor 单进程」这个⽅案:
- Reactor 对象通过 select (IO 多路复⽤接⼝) 监听事件,收到事件后通过 dispatch 进⾏分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型;
- 如果是连接建⽴的事件,则交由 Acceptor 对象进⾏处理,Acceptor 对象会通过 accept ⽅法 获取连接,并创建⼀个 Handler 对象来处理后续的响应事件;
- 如果不是连接建⽴事件, 则交由当前连接对应的 Handler 对象来进⾏响应;
- Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。
单 Reactor 单进程的⽅案因为全部⼯作都在同⼀个进程内完成,所以实现起来⽐较简单,不需要考虑进程间通信,也不⽤担⼼多进程竞争。
但是,这种⽅案存在 2 个缺点:
- 第⼀个缺点,因为只有⼀个进程,⽆法充分利⽤ 多核 CPU 的性能;
- 第⼆个缺点,Handler 对象在业务处理时,整个进程是⽆法处理其他连接的事件的,如果业务处理耗时⽐较⻓,那么就造成响应的延迟;
所以,单 Reactor 单进程的⽅案不适⽤计算机密集型的场景,只适⽤于业务处理⾮常快速的场景。
Redis 是由 C 语⾔实现的,它采⽤的正是「单 Reactor 单进程」的⽅案,因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的⽅案。
单 Reactor 多线程 / 多进程
如果要克服「单 Reactor 单线程 / 进程」⽅案的缺点,那么就需要引⼊多线程 / 多进程,这样就产⽣了单Reactor 多线程 / 多进程的⽅案。
闻其名不如看其图,先来看看「单 Reactor 多线程」⽅案的示意图如下:
详细说⼀下这个⽅案:
- Reactor 对象通过 select (IO 多路复⽤接⼝) 监听事件,收到事件后通过 dispatch 进⾏分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型;
- 如果是连接建⽴的事件,则交由 Acceptor 对象进⾏处理,Acceptor 对象会通过 accept ⽅法 获取连接,并创建⼀个 Handler 对象来处理后续的响应事件;
- 如果不是连接建⽴事件, 则交由当前连接对应的 Handler 对象来进⾏响应;
上⾯的三个步骤和单 Reactor 单线程⽅案是⼀样的,接下来的步骤就开始不⼀样了:
- Handler 对象不再负责业务处理,只负责数据的接收和发送,Handler 对象通过 read 读取到数据后,会将数据发给⼦线程⾥的 Processor 对象进⾏业务处理;
- ⼦线程⾥的 Processor 对象就进⾏业务处理,处理完后,将结果发给主线程中的 Handler 对象,接着由 Handler 通过 send ⽅法将响应结果发送给 client;
单 Reator 多线程的⽅案优势在于能够充分利⽤多核 CPU 的能,那既然引⼊多线程,那么⾃然就带来了多线程竞争资源的问题。
例如,⼦线程完成业务处理后,要把结果传递给主线程的 Reactor 进⾏发送,这⾥涉及共享数据的竞争。
要避免多线程由于竞争共享资源⽽导致数据错乱的问题,就需要在操作共享资源前加上互斥锁,以保证任意时间⾥只有⼀个线程在操作共享资源,待该线程操作完释放互斥锁后,其他线程才有机会操作共享数据。
聊完单 Reactor 多线程的⽅案,接着来看看单 Reactor 多进程的⽅案。
事实上,单 Reactor 多进程相⽐单 Reactor 多线程实现起来很麻烦,主要因为要考虑⼦进程 <-> ⽗进程的双向通信,并且⽗进程还得知道⼦进程要将数据发送给哪个客户端。
⽽多线程间可以共享数据,虽然要额外考虑并发问题,但是这远⽐进程间通信的复杂度低得多,因此实际应⽤中也看不到单 Reactor 多进程的模式。
另外,「单 Reactor」的模式还有个问题,因为⼀个 Reactor 对象承担所有事件的监听和响应,⽽且只在主线程中运⾏,在⾯对瞬间⾼并发的场景时,容易成为性能的瓶颈的地⽅。
多 Reactor 多进程 / 线程
要解决「单 Reactor」的问题,就是将「单 Reactor」实现成「多 Reactor」,这样就产⽣了第 多Reactor 多进程 / 线程的⽅案。
⽼规矩,闻其名不如看其图。多 Reactor 多进程 / 线程⽅案的示意图如下(以线程为例):
⽅案详细说明如下:
- 主线程中的 MainReactor 对象通过 select 监控连接建⽴事件,收到事件后通过 Acceptor 对象中的accept 获取连接,将新的连接分配给某个⼦线程;
- ⼦线程中的 SubReactor 对象将 MainReactor 对象分配的连接加⼊ select 继续进⾏监听,并创建⼀个Handler ⽤于处理连接的响应事件。
- 如果有新的事件发⽣时,SubReactor 对象会调⽤当前连接对应的 Handler 对象来进⾏响应。
- Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。
多 Reactor 多线程的⽅案虽然看起来复杂的,但是实际实现时⽐单 Reactor 多线程的⽅案要简单的多,原因如下:
- 主线程和⼦线程分⼯明确,主线程只负责接收新连接,⼦线程负责完成后续的业务处理。
- 主线程和⼦线程的交互很简单,主线程只需要把新连接传给⼦线程,⼦线程⽆须返回数据,直接就可以在⼦线程将处理结果发送给客户端。
⼤名鼎鼎的两个开源软件 Netty 和 Memcache 都采⽤了「多 Reactor 多线程」的⽅案。
采⽤了「多 Reactor 多进程」⽅案的开源软件是 Nginx,不过⽅案与标准的多 Reactor 多进程有些差异。
Proactor
Proactor 采⽤了异步 I/O 技术,所以被称为异步⽹络模型。
现在我们再来理解 Reactor 和 Proactor 的区别,就⽐较清晰了。
Reactor 是⾮阻塞同步⽹络模式,感知的是就绪可读写事件。在每次感知到有事件发⽣(⽐如可读就绪事件)后,就需要应⽤进程主动调⽤ read ⽅法来完成数据的读取,也就是要应⽤进程主动将socket 接收缓存中的数据读到应⽤进程内存中,这个过程是同步的,读取完数据后应⽤进程才能处理数据。
Proactor 是异步⽹络模式, 感知的是已完成的读写事件。在发起异步读写请求时,需要传⼊数据缓冲区的地址(⽤来存放结果数据)等信息,这样系统内核才可以⾃动帮我们把数据的读写⼯作完成,这⾥的读写⼯作全程由操作系统来做,并不需要像 Reactor 那样还需要应⽤进程主动发起 read/write来读写数据,操作系统完成读写⼯作后,就会通知应⽤进程直接处理数据。
因此,Reactor 可以理解为「来了事件操作系统通知应⽤进程,让应⽤进程来处理」,⽽ Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应⽤进程」。这⾥的「事件」就是有新连接、有数据可读、有数据可写的这些 I/O 事件这⾥的「处理」包含从驱动读取到内核以及从内核读取到⽤户空间。
举个实际⽣活中的例⼦,Reactor 模式就是快递员在楼下,给你打电话告诉你快递到你家⼩区了,你需要⾃⼰下楼来拿快递。⽽在 Proactor 模式下,快递员直接将快递送到你家⻔⼝,然后通知你。
⽆论是 Reactor,还是 Proactor,都是⼀种基于「事件分发」的⽹络编程模式,区别在于 Reactor 模式是基于「待完成」的 I/O 事件,⽽ Proactor 模式则是基于「已完成」的 I/O 事件。
接下来,⼀起看看 Proactor 模式的示意图:
介绍⼀下 Proactor 模式的⼯作流程:
- Proactor Initiator 负责创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过Asynchronous Operation Processor 注册到内核;
- Asynchronous Operation Processor 负责处理注册请求,并处理 I/O 操作;
- Asynchronous Operation Processor 完成 I/O 操作后通知 Proactor;
- Proactor 根据不同的事件类型回调不同的 Handler 进⾏业务处理;
- Handler 完成业务处理;
可惜的是,在 Linux 下的异步 I/O 是不完善的,aio 系列函数是由 POSIX 定义的异步操作接⼝,不是真正的操作系统级别⽀持的,⽽是在⽤户空间模拟出来的异步,并且仅仅⽀持基于本地⽂件的 aio 异步操作,⽹络编程中的 socket 是不⽀持的,这也使得基于 Linux 的⾼性能⽹络程序都是使⽤ Reactor ⽅案。
⽽ Windows ⾥实现了⼀套完整的⽀持 socket 的异步编程接⼝,这套接⼝就是 IOCP ,是由操作系统级别实现的异步 I/O,真正意义上异步 I/O,因此在 Windows ⾥实现⾼性能⽹络程序可以使⽤效率更⾼的Proactor ⽅案。
总结
常⻅的 Reactor 实现⽅案有三种。
第⼀种⽅案单 Reactor 单进程 / 线程,不⽤考虑进程间通信以及数据同步的问题,因此实现起来⽐较简单,这种⽅案的缺陷在于⽆法充分利⽤多核 CPU,⽽且处理业务逻辑的时间不能太⻓,否则会延迟响应,所以不适⽤于计算机密集型的场景,适⽤于业务处理快速的场景,⽐如 Redis 采⽤的是单 Reactor 单进程的⽅案。
第⼆种⽅案单 Reactor 多线程,通过多线程的⽅式解决了⽅案⼀的缺陷,但它离⾼并发还差⼀点距离,差在只有⼀个 Reactor 对象来承担所有事件的监听和响应,⽽且只在主线程中运⾏,在⾯对瞬间⾼并发的场景时,容易成为性能的瓶颈的地⽅。
第三种⽅案多 Reactor 多进程 / 线程,通过多个 Reactor 来解决了⽅案⼆的缺陷,主 Reactor 只负责监听事件,响应事件的⼯作交给了从 Reactor,Netty 和 Memcache 都采⽤了「多 Reactor 多线程」的⽅案,Nginx 则采⽤了类似于 「多 Reactor 多进程」的⽅案。
Reactor 可以理解为「来了事件操作系统通知应⽤进程,让应⽤进程来处理」,⽽ Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应⽤进程」。
因此,真正的⼤杀器还是 Proactor,它是采⽤异步 I/O 实现的异步⽹络模型,感知的是已完成的读写事件,⽽不需要像 Reactor 感知到事件后,还需要调⽤ read 来从内核中获取数据。
不过,⽆论是 Reactor,还是 Proactor,都是⼀种基于「事件分发」的⽹络编程模式,区别在于 Reactor模式是基于「待完成」的 I/O 事件,⽽ Proactor 模式则是基于「已完成」的 I/O 事件。
学自小林coding,侵删