AlexiaChen.github.io
AlexiaChen.github.io copied to clipboard
架构设计----服务端编程之网络高性能
网络高性能
高性能是所有程序员的追求,无论是做底层系统还是做应用App,我们都希望在成本内尽可能的优化性能。它是最复杂的一环,磁盘,操作系统,CPU,内存,缓存,网络,编程语言,架构都有可能影响系统整体达到高性能呢个,一行不恰当的debug日志就可能把一个高性能服务器拖慢成龟速。一个服务器配置的参数,比如tcp_nodelay就可能将响应时间从2ms延长到40ms。
站在架构的角度,当然需要关注高性能的架构,关注在两个方面:
-
尽量提升单机服务器的性能,将单服务器的性能发挥到极致。
-
如果单服务器无法支撑性能, 设计服务器集群方案。
架构设计是高性能的基础,如果架构设计没有做到高性能,则后面的具体实现和编码能提升的空间有限。简单一句话就是,架构决定了系统的性能上限,具体实现细节决定了性能下限。
这里主要讲服务器端的网络高并发的性能
单服务器网络高性能
关键之一就是服务端采取的网络编程模型,有以下两个设计关键点:
-
服务器如何管理连接
-
服务器如何处理请求
以上两个设计点最终都和操作系统的I/O模型及进程模型相关。
-
I/O模型:阻塞,非阻塞,同步,异步
-
进程模型: 单进程,多进程,多线程
one Process per connection
标题的字面意思就是,一个连接对应一个进程处理。每次有新的连接过来就新建一个进程专门处理这个连接的请求。大概是以下步骤:
-
父进程接受连接(accept)
-
父进程fork出子进程
-
子进程处理连接的读写请求(read, write,业务逻辑处理)
-
子进程关闭连接(close)
同一个文件描述符多进程访问是有引用计数的,计数到0才会真正关闭连接。
该模型非常简单,最早的服务器网络模型大部分是这么搞的,但是随着互联网兴起,这模型才没落了,因为代价太高。
-
fork代价高:操作系统创建一个进程的代价很高,需要分配很多内核资源,需要将内存镜像从父进程复制到子进程。即使现代操作在复制内存镜像的时候用到了写时复制技术,但是代价还是太大。
-
父子进程通信复杂: 父进程fork子进程时,文件描述符可以通过内存镜像复制从父进程传到子进程,但是fork完成后,父子通信就比较麻烦了,需要采用IPC(Interprocess Communication)之类的进程通信方案。
-
进程数量增大对操作系统压力大,进程切换,调度等等。所以,一般这种方案能处理的并发连接数最大也就几百。
prefork
也就是服务器启动的时候,预先fork出多个子进程,这样连接过来的时候就不需要fork了,这样用户端的感受就是,速度快了些。
prefork实现的关键就是,多个子进程都accept同一个socket,当有新的连接进入时,操作系统保证只有一个进程能最后accept成功。不必担心引入额外的性能开销。
当然,问题也存在,就是父子通信复杂,支持的并发连接数有限。但Apache服务器提供了MPM prefork模式。现在很少用了,除非是一些老系统。
one thread per connection
就是一个新连接,就建立一个线程去处理这个连接请求(write read 业务逻辑处理)。与进程相比,当然会更轻量些,创建线程的消耗要比进程少得多。同时多线程是共享同一个进程的内存空间,通信成本会低些。
-
父进程接受连接(accept)
-
父进程创建出子线程(pthread)
-
子线程处理连接的读写请求(read, write,业务逻辑处理)
-
子线程关闭连接(close)
该模型确实是一个进步,但是也有问题:
-
高并发时,还是有性能问题
-
线程间的互斥和共享又引入了复杂度,可能一不小心就导致了死锁问题
-
因为多线程共享同一个内存地址空间,一个线程crash,那么整个进程就崩溃了,互相影响太多。稳定性差点。
-
CPU线程调度和切换代价。
该模型处理近一千个并发连接还是有问题的,如果是几百个并发,还不如用one Process per connection的模型,因为稳定性更高。
prethread
预先创建处理请求的线程,和prefork类似。让用户体验更快。
由于是同一进程内的多线程,通信方便,prethread的实现要比prefork灵活些。大概有下面两种方式:
-
主进程accept,然后将连接交给某个线程处理。
-
所有子线程都尝试去accept,最终只有一个线程accept成功
Apache服务器的MPM worker模式本质就是一种prethread方案,但是稍微做了改进,服务器创建多个进程,每个进程又有多个线程,主要是考虑稳定性,因为其中某个线程异常崩溃,不至于导致整个服务器挂掉,还有其他进程提供继续提供服务。
Reactor
重点来了,该模型是现代互联网高并发的基础模型。
前面说的用多线程,多进程或者两者结合,或者引入线程池就会引入一个新的问题,进程如何才能高效的处理多个连接的业务?当一个连接一个线程时,线程可以采用"read -> 业务逻辑处理 -> write" 这种处理流程,如果当前连接没有数据可以读,则进程就阻塞在read操作上。这种阻塞的方式在一个连接一个线程的场景下没问题,但是如果一个线程处理多个连接,线程阻塞在某个连接的read操作上,即使此时此刻其他连接有数可以读,线程也无法去处理,很显然这是无法做到高性能的。
解决这个问题就最简单的就是将read操作改为非阻塞,然后线程不断地while去轮询多个连接。这种方式能解决阻塞带来的低效问题,但是不优雅,首先,轮询是要消耗CPU的,其次一个线程处理几千上万个连接,则轮询的效率就很低了。
为了能更好的解决问题,一种自然而然的想法就是只有当连接上有数据的时候,线程才去处理,这就是I/O多路复用技术的来源。
上面提到的“多路”就是多条连接,“复用”就是多条连接复用同一个阻塞对象,这个阻塞对象和具体的实现有关。在Linux上如果使用select,那么这个公共阻塞对象就是select用到的fd_set,如果是epoll,就是epoll_create创建的文件描述符。
I/O多路复用有两个关键实现点:
-
当多条连接共用一个阻塞对象后,进程(I/O线程)只需要在一个阻塞对象上等待,而无须再轮询所有连接。
-
当某条连接有新的数据可以处理时,操作系统就会通知进程(I/O线程),线程从阻塞状态返回,开始进行业务处理
I/O多路复用结合线程池,完美的解决了之前所提到的模型的所有问题。而且有个很酷的名字-----Reactor,就是反应堆。实质就是事件反应的意思,就是来了一个事件,我就有相应的反应。有些开源系统里面叫Dispatcher模式,即I/O多路服用统一监听事件,收到事件后分配(Dispatch)给某个线程处理。
Reactor模式的核心组成部分包括Reactor和线程池,Reactor负责监听和分配事件,线程池负责处理事件。感觉起来简单嘛,但是不是,Reactor模式具体实现方案灵活多变。主要体现以下两点:
-
Reactor的数量可以变化: 可以是一个Reactor,也可以是多个Reactor。也就是陈硕经常说的one loop per thread。一个loop一个线程,loop就是所谓的事件循环监听。
-
线程池的数量也可以变化:可以是单线程,也可以是多线程。当然也可以是单进程或多进程。
最终Reactor模式有以下三种典型实现方案:
-
单Reactor 单进程/单线程
-
单Reactor 多线程
-
多Reactor 多进程/多线程 (陈硕最喜欢这个)
以上方案具体选择进程还是线程,更多的是个人口味。Java的高性能网络库Netty用的是线程,Nginx使用进程,memcached使用线程。
单Reactor 单进程 or 单线程
-
Reactor对象通过select或者epoll监控连接事件,收到事件后通过dispatch进行分发
-
如果是连接建立的事件,则由Acceptor处理,Acceptor通过accept接受连接,并创建一个Handler来处理连接后续的各种事件。
-
如果不是连接建立的事件,则Reactor会调用连接对应的Handler来进行响应
-
Handler会完成read -> 业务逻辑处理 -> send的完整业务流程
该模式优点简单,没有进程/线程间通信,没有竞争,全部在同一个进程内完成,但是缺点也明显:
-
只有一个进程/线程,无法发挥多核CPU的性能,只能采取部署多个系统来利用多核CPU。
-
Handler在处理某个连接上的业务时,整个进程无法处理其他连接的事件,很容易导致性能瓶颈
因此,单Reactor单进程的方案在实践中应用场景不多,只适用业务处理非常快速的场景,业界的Redis用的就是这个模型,因为它是数据结构服务器,修改各种数据结构一般不会太耗时。
单Reactor 多线程
为了避免单Rector 单进程/单线程方案的缺点,由此改进的模型
-
主I/O线程中,Reactor对象通过select监控连接事件,收到事件后通过dispatch进行分发。
-
如果是连接建立的事件,则由Acceptor处理,Acceptor通过accept接受连接,并创建一个Handler来处理连接后续的各种事件。
-
如果不是连接建立事件,则Reactor会调用连接对应的Handler来进行响应。
-
Handler只负责响应事件,不进行业务处理。Handler通过read读取到数据后,会发给Worker进行业务处理
-
Worker会在独立的子线程中完成真正的业务处理,然后将响应结果发给主I/O线程的Handler处理,Handler收到响应后,通过send将响应结果返回给client。
该模型能够充分利用多核多CPU的处理能力,但是也有问题存在:
单个Reactor承担所有事件的监听和响应,只在主I/O线程中运行,难以应对瞬时高并发访问,也就是突发高并发事件。这样会有单点性能瓶颈。
多Reactor 多进程 or 多线程
这个方案在保证上一个方案模型优点的同时,也解决了应对瞬时高并发访问的问题。
-
父I/O线程中rootReactor对象通过select或epoll监控连接建立事件,收到事件后通过Acceptor接收,将新的连接分配给某个子线程。
-
子I/O线程的subReactor将rootReactor分配的连接加入连接队列进行监听,并创建一个Handler用于处理连接的各种事件。
-
当有新的事件发生的时候,subReactor会调用连接对应的Handler来进行响应。
-
Handler完成read -> 业务处理 -> send的完整业务流程
看起来这个模型比单Reactor多线程复杂,但是实际实现更简单,而且优点多多:
-
父I/O线程和子I/O线程的职责非常明确,父线程只负责接收(accept)新的连接(只处理连接建立事件),子线程完成后续的各种事件和业务处理。
-
父线程和子线程的互交简单,父线程需要把新连接传给子线程,子线程无须返回数据
-
子线程之间也是互相独立的,无须同步共享select,read send之类的网络处理,我自己读,自己发数据。
目前Nginx是采用多Reactor多进程,memcahed和Netty是多Reactor多线程。
Proactor
Reactor是非阻塞同步网络模型,因为真正的read,send网络操作都需要用户进程同步操作,简而言之就是read和send这样的I/O操作是同步的(epoll也是同步的),如果把I/O操作换成异步就能够进一步提升性能,这就是异步网络模型Proactor。
Proactor翻译为--前摄器模式,它与Reactor有啥区别呢?
-
Reactor是,来了I/O事件(Event),操作系统内核来通知用户,用户自己来处理(用户自己read)。
-
Proactor是,来了I/O事件,操作系统内核来处理,处理完成内核主动通知用户(内核read到的数据直接通过回调给到用户)
其实用大白话说就是Reactor是被动,Proactor是主动。
Proactor的主要处理步骤如下:
-
Proactor Initiator负责创建Proactor和Handler,并将Proactor和Handler都通过异步I/O操作注册到操作系统内核
-
异步I/O操作负责处理注册请求,并完成I/O操作(read send)
-
异步I/O操作完成I/O操作后,通知Proactor
-
Proactor根据不同的事件类型回调不同的Handler进行业务处理
-
Handler完成业务处理,Handler也可以注册新的Handler到内核
理论上,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)模式之上模拟出来一个Proactor异步模型。
集群高性能
主要是负载均衡了,包括DNS,硬件负载均衡,软件负载均衡。
DNS成本低,负载均衡基本上交给DNS服务器处理,无须自己开发维护负载均衡设备,就近原则,提高访问速度,缺点也多多,DNS缓存的时间比较长,修改配置后,用户还会访问修改前的IP,达到负载失败,DNS的负载均衡控制权在域名商那里,无法根据业务特点做定制扩展。(一般用于地理级别的负载均衡)
硬件负载均衡成本高,但是功能强大,全面支持各个层级,各个负载均衡算法,支持全局负载均衡。软件负载均衡支持到100K级别已经非常厉害,但是硬件负载均衡可以支持1000k并发。然后稳定性又高,还具备防火墙,防DDoS的攻击的安全功能。缺点就是扩展性差,价格普通公司承受不起。比如F5。(一般用于机房集群级别的负载均衡,比如北京机房有多个集群,这一个机房用一台F5设备,来负载集群与集群之间的负载)
软件负载均衡有Nginx和LVS。Nginx是7层负载均衡,LVS是4层负载均衡。Nginx支持http等协议,LVS因为太底层,与协议无关,所有几乎所有应用都可以做,比如聊天和数据库等。(一般用于集群内部机器之间的负载均衡)
负载均衡算法也就是网络上罗列的那几样,参考着选择就行。