likes
comments
collection
share

【技术·真相】手把手带你梳理:高性能服务之单服模型

作者站长头像
站长
· 阅读数 36

人们忘掉了在学校所学的东西之后,剩下的就是教育。

郑重说明:本文适合对游戏开发感兴趣的初级及中级开发和学习者,本人力图将技术用简单的语言表达清楚。鉴于水平有限,能力一般,文章如有错漏之处,还望批评指正,谢谢。

一、高性能计算服务

熟悉服务器开发的小伙伴一般都知道,从横向分层职责来说,后台服务可以分为两大类:逻辑计算型服务与数据存储型服务。

【技术·真相】手把手带你梳理:高性能服务之单服模型

在逻辑计算型服务中,服务器进程主要进行逻辑处理,主要消耗的资源一般是CPU和内存;数据存储型服务器主要是做数据缓存或永久存储,主要消耗的资源是内存或磁盘;

在服务器服务大量用户时,高性能是一个很重要的考量指标。更高的性能意味着相对更少的资源消耗。

在具体实施时,相应的分为两类高性能的要求:

  • 计算高性能
  • 存储高性能

本文我们来谈一谈计算高性能。(存储高性能也是一个很大的话题,我们留作以后再谈。)

我们知道,逻辑计算型服务一般是由一个集群组成,包含很多种类型的服务,每种类型的服务又可以分多组,整个集群协同配合完成逻辑处理。因此,高性能可以从单服高性能和集群高性能两个维度来说明。

本文,我们先从单个服务这一维度谈起,看如何做到高性能;之后的文章我们再研究一下从集群的维度,又是如何使用策略来更进一步,提高服务器整体的计算性能的。

二、单服高性能关键点:网络模型设计

单服在做逻辑请求处理时,主要做的是两件事情:

  1. 管理连接及收发数据
  2. 分配及处理请求

如何管理连接及收发数据的解决方案是:操作系统的网络IO模型;

如何分配及处理请求则对应:进程模型;

我们分别来看一看。

2.1 IO模型

【技术·真相】手把手带你梳理:高性能服务之单服模型

上图中,IO 模型有很多种,他们是

  • 阻塞 IO
  • 非阻塞 IO
  • IO 复用
  • 信号驱动 IO
  • 异步 IO

无论哪种类型,都分为2个阶段:

  1. 等待网卡数据到达
  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
评论
请登录