【技术·真相】手把手带你梳理:高性能服务之单服模型
人们忘掉了在学校所学的东西之后,剩下的就是教育。
郑重说明:本文适合对游戏开发感兴趣的初级及中级开发和学习者,本人力图将技术用简单的语言表达清楚。鉴于水平有限,能力一般,文章如有错漏之处,还望批评指正,谢谢。
一、高性能计算服务
熟悉服务器开发的小伙伴一般都知道,从横向分层职责来说,后台服务可以分为两大类:逻辑计算型服务与数据存储型服务。
在逻辑计算型服务中,服务器进程主要进行逻辑处理,主要消耗的资源一般是CPU和内存;数据存储型服务器主要是做数据缓存或永久存储,主要消耗的资源是内存或磁盘;
在服务器服务大量用户时,高性能是一个很重要的考量指标。更高的性能意味着相对更少的资源消耗。
在具体实施时,相应的分为两类高性能的要求:
- 计算高性能
- 存储高性能
本文我们来谈一谈计算高性能。(存储高性能也是一个很大的话题,我们留作以后再谈。)
我们知道,逻辑计算型服务一般是由一个集群组成,包含很多种类型的服务,每种类型的服务又可以分多组,整个集群协同配合完成逻辑处理。因此,高性能可以从单服高性能和集群高性能两个维度来说明。
本文,我们先从单个服务这一维度谈起,看如何做到高性能;之后的文章我们再研究一下从集群的维度,又是如何使用策略来更进一步,提高服务器整体的计算性能的。
二、单服高性能关键点:网络模型设计
单服在做逻辑请求处理时,主要做的是两件事情:
- 管理连接及收发数据
- 分配及处理请求
如何管理连接及收发数据的解决方案是:操作系统的网络IO模型;
如何分配及处理请求则对应:进程模型;
我们分别来看一看。
2.1 IO模型
上图中,IO 模型有很多种,他们是
- 阻塞 IO
- 非阻塞 IO
- IO 复用
- 信号驱动 IO
- 异步 IO
无论哪种类型,都分为2个阶段:
- 等待网卡数据到达
- 将数据从内核空间拷贝到用户空间
根据这两个阶段,前面5种IO模型又可以分为同步、异步两大类。一般说 IO 是同步/异步是指第二阶段是不是阻塞的,如果是阻塞的(前四种都是)就是同步IO,只有最后的异步IO 是异步的 IO,第二阶段的数据拷贝是在内核空间完成的。
以上各种模型中,异步 IO 是最快的,因为数据读写是在内核空间完成的,应用层可以调用多个 IO 流程,多个 IO 相当于可以同时并发执行,效率高;
如果是前四种模型, 数据读写是阻塞的,相当于串行;
异步IO 除了 windows 上支持的不错(IOCP),其他都不是操作系统原生支持的,使用起来也不是太直观,难于调试,所以目前用的不太多。
2.2 进程模型
谈完了IO模型,我们再来看看进程模型。进程模型我们平时接触的比较多,主要分为三类:
1)多线程
使用多线程,可以很好的利用多核;如果线程之间基本没有交互,独立执行,这是一种很强大的模型;但如果多个线性之间存在交互,难免出现资源交换,则难点在于如何做线程间的资源互斥或者同步,如果处理不好,很容易出bug,且难于调试,这给开发人员带来的挑战比较大。
2)多进程
如果一个进程中只使用单线程,则不存在线程交互问题,编码就相对比较简单,不容易出错。因为单线程不能利用多核,因此就需要开多组进程来利用多核,这其实就上升到集群这一维度了。相对于单进程多线程模型,多进程模型还有个好处,它可以多机器部署,而多线程只限于一台机器上(当然多线程所属的进程也可以分多组,那会更复杂)。
3)协程
协程这种模型其实很早就出现了,只是最近一些年又重新流行起来。它本质上和线程与进程并不是并列的关系,而是前面二者的一种补充。从原理上来说,协程本身不会提高性能,相反还会稍稍降低一些性能(协程切换本身有消耗)。它的大量应用,主要是从编码的便利性上来考虑的,利用协程来编码,可读性及便利性会大大提高。
协程的执行过程如下图所示(我们以单个线程内开启协程为例):
协程的本质还是时分多用:
- 在一个调度单位内(如一个线程),新的协程产生后进入队列,随着时间流失,依次被调度执行;
- 任一时间只有一个协程在占用 CPU,执行一段时间之后该协程可能切换走或者直接执行完毕;
- 接着调度到下一个协程,开始执行,同样的,执行一段时间之后可能切换走或者直接执行完毕;
- 如果协程被切换走,未来在合适的情况下也会重新被切换回来,加入队列,继续执行;
如果协程要切换走,一般是主动让出 CPU(调用了一个关键字语句如yield),这个也是和线程不同的地方,体现了协程之间的协作,而不是由第三方强制切换(如线程切换)。
具体的原理以后再谈。
单线程或者单进程中,可以融合进协程:
对于单线程程序来说,其实就是进程中的主线程中直接开启协程;如果开启了多线程,可以在一个或多个线程中开启协程。
三、网络模型的演进
前面我们了解了IO模型以及进程模型的多个类别,现在,一起来看看这二者会如何组合成多种多样的网络模型,以及他们是如何演进的。
3.1 PPC
PPC 是 Process Per Connection 的缩写,指每次有新的连接过来就新建(fork)一个进程处理这个连接的请求。
PPC 简单,适合连接数很少的情况。
采用的模型是阻塞模型,前面说的IO模型的第一种,非常简单。
新建进程时候 Fork 的代价比较大,即使有 Copy on Write (写时复制) 仍然比较重度。
数据通过 fork 本身的复制得到了传递,如果要额外通信就比较麻烦,需要进程间通信。
【典型应用】如版本管理工具 perforce,其 server (fork mode) 就是使用了这种模型,因为并发使用量一般不会太大,因此能满足需求。
3.2 Prefork
Prefork 是对 PPC 的改进,预先 fork 出多个子进程,不用即时分配资源,提高性能。
Prefork 的特点是子进程来 accept,相对的 PPC 是主进程进行 accept 连接后复制到子进程。
【问题】
问题1:这里为什么不像上一模式 PPC 一样由父进程 accept 之后将连接传递给子进程处理请求?
这里的主要问题在于,子进程通过 Prefork 创建完成之后,已经和原来的主进程独立开来了。之后各自独立的进程之间相互传递连接信息就需要专门的机制来做。而在前面的 PPC 中不存在这种问题,子进程在创建之前,连接就已经被主进程 accept 出来了,创建子进程时可以一起带过来。
如果还不好理解,我们来看一个相似的例子。母亲在怀孕生小孩之前,本身会携带自己的基因信息,如果小孩生下来,这些基因信息就可能传递给小孩。在小孩生下来之后,小孩和母亲各自独立,母亲本体如果发生变化,是不会直接影响到已经独立的小孩的。
独立进程之间传递信息需要额外的机制,如 IPC、网络通信等。而不同进程之间传递网络连接的套接字这种特殊信息则更为复杂,它不是将 fd 数值打包发送就可以的。网络套接字 fd 本身没有任何意义,它只相当于进程网络连接信息(内核中维护)的一个索引,不同进程之间传递这个索引是没有意义的,需要更复杂的手段。
因此,这里 accept 直接放在子进程里做更为方便。
问题2:有多个子进程存在,如果 accept,哪个子进程来响应?
某些操作系统会保证只有一个子进程 accept 成功,但是也有一些系统不支持并发 accept,此时就要加锁(进程锁)。
即使支持多个进程 accept, 一些操作系统也会有 “惊群” 现象,所谓 “惊群”,是指如果有连接过来,所有子进程都会被唤醒,然后大部分又进入阻塞状态,如此反复,造成不必要的进程调度和上下文切换消耗。
部分操作系统的新版本内核已经解决了该问题,但是只是直接调用 accept 的时候解决了,如果一些间接调用 accept 的时候,如使用 IO 复用机制(如epoll)来监听新连接事件然后再 accept 的情况,还没有解决这个同时唤醒的问题。因此,一些跨平台的库如 nginx 还是用锁来避免 “惊群” 现象。
【典型应用】一些 webserver,可以通过 prefork 方式来执行 FastCGI 程序处理业务。
Prefork 只是改进了性能,但是没有解决连接数支持较少的问题。
3.3 TPC
TPC 是 Thread Per Connection 的缩写,在进程模型维度将进程换成了线程。
创建线程性能消耗小,支持的连接数量更多。
主线程和子线程之间不需要传递连接,因为内存本身就是共享的。
但是线程之间数据交互也有复杂度,容易产生死锁问题。
某个线程中处理如果发生异常,会导致宕机,所有线程退出。
3.4 Prethread
预先分配线程池来避免运行时创建线程,进一步提高性能。
这里根据维护连接的方式不同:accept 是在主线程还是子线程又分为两种实现方式:
实现方式1:
实现方式2:
3.5 Reactor
前面限制连接数的原因是什么?
每个连接要消耗一个进程或线程,是对系统资源的很大浪费。
事实上每个连接大部分时间是没有请求需要处理的。如果在维护所有连接(IO)时只由一个进程或者线程来维护,就能节约资源。这相当于通信上的时分复用,我们每个进程或线程要能为多个连接服务。
而在处理连接上的请求时,将进程模型看成资源池,不再与某个连接绑定,有请求来了先从资源池分配资源,再处理请求,如下:
这里有个问题,IO 如何感知到哪个连接有请求,从而获取请求?
如果采用 read -> 读取请求 -> write 的方式,此时选择一个连接开始 read,该连接如果没有请求过来,则会阻塞在该 read 上,无法感知其他连接的请求。
这时候,有几种方案可以解决这个问题:
1)轮询
第一种方案是将 read 设置为非阻塞,对多个链接进行人工轮询,有请求来则处理。这是前面提到的IO模型的第二种(非阻塞IO)。
那这种方式最大的问题是什么?
效率低。
效率为何低呢?因为 read 是系统调用,轮询时候需要不停的在用户空间和内核空间进行切换;此外,轮询本身消耗 CPU,连接数如果成千上万,依次遍历的效率可想而知。
2)IO复用
第二种方式是复用公共的阻塞对象。通过IO多路复用,统一监听所有连接上的事件,收到事件后分配给进程/线程处理。
采用IO复用加进程或线程池的模型称为 Reactor 模型。
3)单 Reactor 单线程
如果将事件直接在本线程处理,其图示如下:
优点:简单,一个线程内,没有复杂的交互。
缺点:单线程,无法使用多核;网络交互与业务处理在一个线程,网络吞吐不佳。
【典型应用】5.0 及之前版本的Redis 就用到了这种模型,而 redis 的业务简单,且是纯内存操作,消耗很低,因此 redis 能达到非常高的性能。
4)单 Reactor 双线程
如果将 IO 和逻辑处理分别放在两个线程:网络线程和逻辑线程中,如
优点:可以部分利用多核,逻辑线程是单线程,保证数据交互的简单;网络线程吞吐高;
缺点:两个线程间需要交换数据,这需要使用一定的机制如无锁队列等,有消耗。
【典型应用】6.0 版本的 Redis;很多游戏的逻辑服务器就是采用这种模式。
5)单 Reactor 多线程
逻辑处理部分拓展为线程池,多线程处理业务:
优点:除了网络吞吐高,逻辑处理性能也很高。
缺点:如果逻辑处理有交互,则要涉及业务上的多线程交互,这和我们前面叙述的一样,多线程交互复杂,容易出错;多线程并行处理,消息处理顺序上有隐患,如果多线程并发存储同一数据,则容易出现数据互相覆盖。
【典型应用】很多 web 服务器,因为其单个业务处理比较独立,交互不多,比较适合。
6)单 Reactor 多进程
进程间交互比多线程更复杂:比如,如果进程间也采用队列来通信,那主进程如何得知队列上有数据(可能是 response 数据)到达?
由于主进程只能阻塞在等待网络事件上,因此这种模式需要将父子进程的数据通知转化成网络事件,借助父子进程的管道通信。
7)一主多从 Reactor 多线程/多进程
极大量连接的高并发情况下,一个 Reactor 负责所有连接的数据收发也可能吃力。
可以采用一主多从 Reactor:
-
主 Reactor 线程只负责 accept 新连接,单一线程 accept 效率比较高;
-
各个从 Reactor 线程负责已 accept 连接的数据收发;
-
还有线程池或进程池做业务处理;
这种方式其实是 单Reactor+单进程或单线程组成一组,同时有多个组的形式;
上述如果将主从Reactor 的线程换成进程也是可以的,只不过 Acceptor 到的连接信息传递要更复杂一些。
典型应用:采用多进程模型的有 Nginx(变种);采用多线程模型的有 Memcache,muduo 和 Java 写的 Netty。
8)便利性扩展:Reactor + 进程/线程 + 协程
前面这么多种模型已经基本将单服的性能提高到很高的水平。如果从业务开发便利性的角度,我们还可以在进程或者线程中引入协程,方便业务程序代码的编写。这里不详细阐述了。
3.6 Proactor
从前面 IO 模型的部分我们可以知道 Reactor 本质上还是非阻塞同步模型(第二阶段的 Read、Write 部分仍然是同步的)。如果把 IO 模型调整为异步IO,大部分网络操作都在内核态完成,则性能会进一步提高,这就是 Proactor 模型。
四、几个常用单服模型
平时游戏服务器在开发时,常用的几种单服模型有:
4.1 多线程
4.2 单 Reactor 单线程
4.3 单 Reactor 双线程 + 协程
这里的双线程是:网络线程 + 逻辑处理线程 在逻辑处理线程中,我们开启协程来处理请求。为了简单,我们这里只画出了逻辑处理线程及协程:
小结
本文我们讲述了高性能计算服务中的单服模型:
- 设计高性能单服模型时候要解决的两类问题
- 解决问题涉及的IO模型及进程模型
- 利用IO模型及进程模型组合而成的解决方案
- 游戏服务器中实际常用的单服模型
希望本文对大家有所帮助!
作者:我是零壹协奏,期待你的关注,不要错过我后续的文章更新。
转载自:https://juejin.cn/post/7219241334925377594