本文来自于《极客时间》- 从0开始学架构
本文内容:
单服务器高性能模式:PPC与TPC
应对高并发场景的单服务器高性能架构模式:Reactor和Proactor。
高性能架构设计主要集中在两方面:
1.提升单服务器的性能,将单服务器的性能发挥到极致。
2.若单服务器无法支撑性能,设计服务器集群方案。
3.架构设计决定了系统性能的上限(基础),实现细节(编码)决定了系统性能的下限。
单服务器高性能的关键之一就是服务器采取的并发模型,并发模型有如下两个关键设计点:
1.服务器如何管理连接。
2.服务器如何处理请求。
以上两个设计点最终都和操作系统的I/O模型及进程模型相关。
1.I/O模型:阻塞、非阻塞、同步、异步。
2.进程模型:单进程、多进程、多线程。
单服务器高性能模式:PPC与TPC
PPC
指每次有新的连接就新建一个进程去专门处理这个连接的请求,统的UNIX网络服务器采用的模型。
基本的流程图是:
注意,图中有一个小细节,父进程“fork”子进程后,直接调用了close,看起来好像是关闭了连接,其实只是将连接的文件描述符引用计数减一,真正的关闭连接是等子进程也调 用close后,连接对应的文件描述符引用计数变为0后,操作系统才会真正关闭连接,更多细节请参考《UNIX网络编程:卷一》。
PPC模式实现简单,比较适合服务器的连接数没那么多的情况.
例如数据库服务器。对于普通的业务服务器,在互联网兴起之前,由于服务器的访问量和并发量并没有那么大,这种 模式其实运作得也挺好.
世界上第一个web服务器CERN httpd就采用了这种模式(具体可以参考https://en.wikipedia.org/wiki/CERN_httpd)。
互联网兴起后,服务器的并发和访问量从几十剧增到成千上万,这种模式弊端就凸显出来了。
主要体现在这几个方面:
1).fork代价高:站在操作系统的角度,创建一个进程的代价是很高的,需要分配很多内核资源,需要将内存映像从父进程复制到子进程。即使现在的操作系统在复制内存映像时用到 了Copy on Write(写时复制)技术,总体来说创建进程的代价还是很大的。
2).父子进程通信复杂:父进程“fork”子进程时,文件描述符可以通过内存映像复制从父进程传到子进程,但“fork”完成后,父子进程通信就比较麻烦了,需要采 用IPC(Interprocess Communication)之类的进程通信方案。
例如,子进程需要在close之前告诉父进程自己处理了多少个请求以支撑父进程进行全局的统计,那么子进程和 父进程必须采用IPC方案来传递信息。
3).支持的并发连接数量有限:如果每个连接存活时间比较长,而且新的连接又源源不断的进来,则进程数量会越来越多,操作系统进程调度和切换的频率也越来越高,系统的压力也 会越来越大。因此,一般情况下,PPC方案能处理的并发连接数量最大也就几百。prefork 提前创建进程(pre-fork)
PPC模式中,当连接进来时才fork新进程来处理连接请求,由于fork进程代价高,用户访问时可能感觉比较慢,prefork模式的出现就是为了解决这个问题。
系统在启动的时候就预先创建好进程,然后才开始接受用户的请求,当有新的连接进来的时候,就可以省去fork进程的操作, 让用户访问更快、体验更好。
prefork的基本示意图是:
- prefork的实现关键就是多个子进程都accept同一个socket,当有新的连接进入时,操作系统保证只有一个进程能最后accept成功。
但这里也存在一个小小的问题:“惊群”现象,就是指虽然只有一个子进程能accept成功,但所有阻塞在accept上的子进程都会被唤醒,这样就导致了不必要的进程调度和上下文切换了。
幸运的是,操作系统可以解决这个问题,例 如Linux 2.6版本后内核已经解决了accept惊群问题。
prefork模式和PPC一样,还是存在父子进程通信复杂、支持的并发连接数量有限的问题,因此目前实际应用也不多。Apache服务器提供了MPM prefork模式,推荐在需要可靠性或 者与旧软件兼容的站点时采用这种模式,默认情况下最大支持256个并发连接。
TPC
TPC是Thread Per Connection的缩写,其含义是指每次有新的连接就新建一个线程去专门处理这个连接的请求。与进程相比,线程更轻量级,创建线程的消耗比进程要少得多;
同时多线程是共享进程内存空间的,线程通信相比进程通信更简单。
因此,TPC实际上是解决或者弱化了PPC fork代价高的问题和父子进程通信复杂的问题。
和PPC相比,主进程不用“close”连接了。
原因是在于子线程是共享主进程的进程空间的,连接的文件描述符并没有被复制,因此只需要一次close即可。
TPC虽然解决了fork代价高和进程通信复杂的问题,但是也引入了新的问题。
具体表现在:
1)创建线程虽然比创建进程代价低,但并不是没有代价,高并发时(例如每秒上万连接)还是有性能问题。
2)无须进程间通信,但是线程间的互斥和共享又引入了复杂度,可能一不小心就导致了死锁问题。
3)多线程会出现互相影响的情况,某个线程出现异常时,可能导致整个进程退出(例如内存越界)。
除了引入了新的问题,TPC还是存在CPU线程调度和切换代价的问题。因此,TPC方案本质上和PPC方案基本类似,在并发几百连接的场景下,反而更多地是采用PPC的方案,因为PPC方案不会有死锁的风险,也不会多进程互相影响,稳定性更高。
- prethread
TPC模式中,当连接进来时才创建新的线程来处理连接请求,虽然创建线程比创建进程要更加轻量级,但还是有一定的代价,而prethread模式就是为了解决这个问题。
prethread模式会预先创建线程(和prefork类似),然后才开始接受用户的请求,当有新的连接进来的时候,就可以省去创建线程的操作,让用户感觉更快、体验更好。 由于多线程之间数据共享和通信比较方便,因此实际上prethread的实现方式相比prefork要灵活一些,
常见的实现方式有下面几种:
1)主进程accept,然后将连接交给某个线程处理。
2)子线程都尝试去accept,最终只有一个线程accept成功,基本示意图如下:
Apache服务器的MPM worker模式本质上就是一种prethread方案,但稍微做了改进。
Apache服务器会首先创建多个进程,每个进程里面再创建多个线程,这样做主要是为了考虑 稳定性,即:即使某个子进程里面的某个线程异常导致整个子进程退出,还会有其他子进程继续提供服务,不会导致整个服务器全部挂掉。
prethread理论上可以比prefork支持更多的并发连接,Apache服务器MPM worker模式默认支持16 × 25 = 400 个并发处理线程。
- PPC和TPC能够支持的最大连接数差不多,都是几百个。适用的场景也是差不多的。
接着再从连接数和请求数来划分,这两种方式明显不支持高连接数的场景。 - 常量连接海量请求。比如数据库,redis,kafka等等
- 海量连接常量请求。比如企业内部网址(运营系统),管理系统
应对高并发场景的单服务器高性能架构模式:Reactor和Proactor。
单服务器高性能的PPC和TPC模式,优点是实现简单,缺点是都无法支撑高并发的场景,尤其是互联网发展到现在,各种海量用户业务的出现,PPC和TPC完全无能为力。
Reactor
PPC模式(以PPC和进程为例,换成TPC和线程,原理一样),最主要的问题就是每个连接都要创建进程连接结束后进程就销毁了,这样做其实是很大的浪费。
为解决这个问题,就是资源复用,即不再单独为每个连接创建进程,而是创建一个进程池,将连接分配给进程,一个进程可以处理多个连接的业务。引入资源池的处理方式后,会引出一个新的问题:进程如何才能高效地处理多个连接的业务?
当一个连接一个进程时,进程可以采用“read -> 业务处理 -> write”的处理流程,如果当前连接没有数据可以读,则进程就阻塞在read操作上。
这种阻塞的方式在一个连接一个进程的场景下没有问题,但如果一个进程处理多个连接,进程阻塞在某个连接的read操作 上,此时即使其他连接有数据可读,进程也无法去处理,很显然这样是无法做到高性能的。
解决这个问题的最简单的方式是将read操作改为非阻塞,然后进程不断地轮询多个连接。
这种方式能够解决阻塞的问题,但解决的方式并不优雅。
首先,轮询是要消耗CPU的;
其次,如果一个进程处理几千上万的连接,则轮询的效率是很低的。
为了能够更好地解决上述问题,很容易可以想到,只有当连接上有数据的时候进程才去处理,这就是I/O多路复用技术的来源。I/O多路复用技术归纳起来有两个关键实现点:
1).当多条连接共用一个阻塞对象后,进程只需要在一个阻塞对象上等待,而无须再轮询所有连接,常见的实现方式有select、epoll、kqueue等。
2). 当某条连接有新的数据可以处理时,操作系统会通知进程,进程从阻塞状态返回,开始进行业务处理。I/O多路复用结合线程池(Reactor),完美地解决了PPC和TPC的问题.
Reactor,中文是“反应堆”,“事件反应”的意思,通俗理解为“来了一个事件我(Reactor)就有相应的反应”.具体的反应是我们写的代码.
Reactor会根据事件类型来调用相应的代码进行处理。
Reactor模式也叫Dispatcher模式(就是实现Reactor模式的),更贴近模式本身的含义,即I/O多路复用统一监听事件,收到事件后分配(Dispatch)给某个进程。Reactor模式的核心组成部分包括Reactor和处理资源池(进程池或线程池),其中Reactor负责监听和分配事件,处理资源池负责处理事件。初看Reactor的实现是比较简单的,但实际上结合不同的业务场景,Reactor模式的具体实现方案灵活多变,主要体现在:
1)Reactor的数量可以变化:可以是一个Reactor,也可以是多个Reactor。
2)资源池的数量可以变化:以进程为例,可以是单个进程,也可以是多个进程(线程类似)。
最终Reactor模式有这三种典型的实现方案:
1)单Reactor单进程/线程。
2)单Reactor多线程
3)多Reactor多进程/线程。
以上方案具体选择进程还是线程,更多地是和编程语言及平台相关.
例如,Java语言一般使用线程(例如,Netty),C语言使用进程和线程都可以。
例如,Nginx使用进程,Memcache使用线程。
- 单Reactor单进程/线程。
因此,单Reactor单进程的方案在实践中应用场景不多,只适用于业务处理非常快速的场景.
目前比较著名的开源软件中使用单Reactor单进程的是Redis。
需注意,C语言编写系统的一般使用单Reactor单进程,因为没有必要在进程中再创建线程;
而Java语言编写的一般使用单Reactor单线程,因为Java虚拟机是一个进程,虚拟机中有很多线程,业务线程只是其中的一个线程而已。
- 单Reactor多线程
为了克服单Reactor单进程/线程方案的缺点,引入多进程/多线程.
产生了第2个方案:单Reactor多线程。
图中只列出了“单Reactor多线程”方案,没有列出“单Reactor多进程”方案,这是什么原因呢?
主要原因在于如果采用多进程,子进程完成业务处理后,将结果返回给父进程,并通知父进程发送给哪个client,这是很麻烦的事情。
因为父进程只是通过Reactor监听各个连接上的事件然后进行分配,子进程与父进程通信时并不是一个连接。
如果要将父 进程和子进程之间的通信模拟为一个连接,并加入Reactor进行监听,则是比较复杂的。
而采用多线程时,因为多线程是共享数据的,因此线程间通信是非常方便的。虽然要额外考虑线程间共享数据时的同步问题,但这个复杂度比进程间通信的复杂度要低很多。
- 多Reactor多进程/线程。
为了解决单Reactor多线程的问题,最直观的方法就是将单Reactor改为多Reactor,这就产生了第3个方案:多Reactor多进程/线程。
案例:
开源系统Nginx采用的是多Reactor多进程,采用多Reactor多线程的实现有Memcache和Netty。
Proactor
Peactor是非阻塞同步网络模型,因为真正的read和send操作都需要用户进程同步操作。
这里的“同步”指用户进程在执行read和send这类I/O操作的时候是同步的,如果把I/O操作 改为异步就能够进一步提升性能,这就是异步网络模型Proactor。
Reactor可以理解为“来了事件我通知你,你来处理”。
而Proactor可以理解为“来了事件我来处理,处理完了我通知你”。
这里的“我”就是操作系统内核,“事件”就是有新连接、有数据可读、有数据可写的这些I/O事 件,“你”就是我们的程序代码。
理论上Proactor比Reactor效率要高一些,异步I/O能够充分利用DMA特性,让I/O操作与计算重叠,但要实现真正的异步I/O,操作系统需要做大量的工作。
目前Windows下通 过IOCP实现了真正的异步I/O,而在Linux系统下的AIO并不完善,因此在Linux下实现高并发网络编程时都是以Reactor模式为主。
所以即使Boost.Asio号称实现了Proactor模 型,其实它在Windows下采用IOCP,而在Linux下是用Reactor模式(采用epoll)模拟出来的异步模型。