服务器并发设计
- 并发与并行
- 并发技术选型
- 结构并发
- 状态并发
- 集群和负载均衡
并发(Coccurrency)和并行(Parallelism)
并行用来描述程序的一个状态,有多个程序在同时执行,在技术实现上指的是多线程或多进程 。并发是指程序的多个操作可以在同样的时间段内重叠进行。并行在物理上是有多个执行流在操作,并发重点是在同一时间段内多个操作是在重叠执行的。 一个执行流也可以模拟出并发的效果。综合来说,并行是并发的一个子集,并行是可以用来实现并发。
朴素的服务器模型
# 伪代码
main()
{
# 绑定服务器地址和端口并监听
fd = find(server_addr);
listen(fd);
while(1)
{
# 接收客户端连接 (堵塞式调用,等待连接)
client_fd = accept(fd);
# 接收客户端正常的上行数据
while(recv(client_id, data) > 0)
{
# 处理数据
handle(data);
# 发送响应
send(response);
}
# 连接断开,关闭TCP连接
close(client_fd);
}
}
并发的概念
问题1:串行堵塞式,不能满足游戏服务器的实时性的要求
问题2:单核性能有限,不能满足游戏服务器高并发要求
解决之道:让多个操作在重叠的时间段内进行(并发)
并发技术选型
例如有两个操作,可通过时间分片的方式实现结构并发,另一种方式是使用多线程多进程模型即状态并发,也就是给它分配不同的执行流 。在实际应用中,更多地会采用混合并发的模式。
并发技术选型结构并发
角色登录过程一个登录操作包含了3个远程调用
注意阻塞等待返回的形式成为同步调用,即时返回的形式成为异步调用,异步调用需要进行时间分片设计。
时间分片设计
每个异步调用称为一个时间分片点,n个远程调用需要n+1个时间分片。得到的效果是不同操作的时间分片交叉执行,单个操作的时间分片顺序执行。异步操作涉及操作返回后的回调操作。
上下文指处理回调必须的数据及状态信息异步操作的最大问题是异步返回后上下文会被清理掉然后去执行下一个操作,那么上下文应该如何管理呢?上下文指的是处理回调必须的数据以及状态信息。
异步回调流程
异步回调流程请求方先将回调时所需要的上下文保存起来,然后打包请求,将请求打包成一个消息发送给服务方。此时请求方的第一阶段的工作已经完成了,也就可以返回了。等到服务方收到请求消息后,服务方解析消息并处理请求,处理完毕后将结果以消息的形式返回给请求方。 此时请求方收到刚才发出请求的回复消息,此消息不是马上能处理的,请求方会去上下文管理的模块中找到刚才的上下文。如果能找到这个上下文,则调用一个callback的回调函数,继续下一步流程的处理。这流程的思路是把直接的请求转化为两个模块之间消息的处理。
这里思考一个问题:上下文管理的实现方式是什么样的呢?
上下文是程序继续执行时必须的一块数据,这块数据应该如何管理呢?一种方式是专门有一个模块在请求者的本地通过一个key去管理,另一种比较简单的方式是打包发送请求时将上下文一起发送并在返回时一并带回。
异步回调示例
# 伪代码
// 发送请求
int SendRequest(int userid, Request request)
{
//声明上下文
LoginContext context;
// fill up context
//存入数据
save_context(userid, context);
//声明消息
Request_Message message;
//将消息打包
request_to_msg(message, request);
// 发送消息给服务方
return send_msg(message);
}
//当请求方接收到操作的回复时执行
void OnResponse(int userid, Response_Message &message)
{
//通过userid找到上下文
LoginContext *context = get_context(userid);
//声明回复
Response response;
//将message反序列化为response
msg_to_response(response, message);
//调用callback回调函数继续执行
context.cb_func(context, response, context);
}
此种做法的优点是实现成本低,但缺点是逻辑分散且反人性。实际登录流程中可能涉及几十上百个异步操作,此方式将一个操作完全碎片化了,到后期是完全无法维护的。
比较高端的方式是使用协程
协程即用户态线程,是以当前进程帧作为上下文的管理单位,实现机制是使用独立的栈和寄存器组以保存机器指令地址。协程的优点是异步逻辑同步化且容易维护,而缺点则是协程上下文切换会带来额外的CPU开销,约2400时钟周期。如果使用密集的话,需要考虑性能上的问题。
//GNU C提供的一组完全是用户态的使用线程
//核心数据结构是ucontext_t
#include <ucontext.h>
data structure;
/* userlevel context*/
typedef struct ucontext
{
unsigned long int uc_flags;
struct ucontext *uc_link;
stack_t uc_stack;//私有栈指针
mcontext_t uc_mcontext;//寄存器
__sigset_t uc_sigmask;
struct _libc_fpstate __fpregs_mem;
} ucontext_t;
//api 保存当前上下文uc,将当前的一个栈针和寄存器组全都保存到ucontextd中。
getcontext(ucontext_t *uc)
//api 切换上下文uc
setcontext(ucontext_t *uc)
//等价于getcontext(ouc) + setcontext(uc)
swapcontext(ucontext_t *ouc, ucontext_t *uc)
简单协程示例
#include <stdio.h>
#include <ucontext.h>
#include <unistd.h>
// 每隔1秒输出一个hello world
int main(int args, char *argv[])
{
//制作镜像
ucontext_t context;
getcontext(&context);
//镜像内容
puts("hello world");
sleep(1);
//恢复镜像
setcontext(&context);
return 0;
}
结构并发为避免阻塞则采用分时间片的方式,具体实现方式有2种“异步+回调”和协程。
状态并发
程序状态并发可采用多线程或多进程的结构来提高处理能力。
由于现在摩尔定律已经失效,游戏服务器常用的E5-2 640。处理器的发展导致我们必须使用多线程。
Intel至强处理器E5-2640 v4
- 处理器基本频率 2.50GHz
- 最大睿频频率 3.00GHz
多线程与多进程的选择
多线程与多进程的选择随着软硬件技术的提升,多进程的缺点不再成为核心问题。
多线程与多进程选型原则在游戏开发中多线程使用的非常少,而在高频计算或高频通信或公共组件中会使用到多线程。 大部分情况会根据高内聚低耦合的原则,将业务逻辑拆分为多个进程去处理, 原则上业务逻辑尽量不要使用多线程,只用在一些公共组件上,如接入、存储、打解包等场景下。
进程的划分依据
简单游戏服务器
在简单的游戏服务器中,即具有三层架构“接入-逻辑-存储”。最好将三层拆分为三个进程。对于逻辑,随着业务复杂度不断提升,一个进程可能有些吃不消。 可以根据高内聚低耦合的原则,进一步划分可细分为“场景-聊天-组队-社交”。这些进程之间,它负责的功能就不能直接使用函数调用的方式,就需要使用异步调用或协程。也就是说它们之间从一个调用关系转变为一个消息发送的关系。
简单游戏服务器进程划分进程的拆分要符合高内聚低耦合的原则用以控制工程的复杂度。内聚主要是从功能的角度来度量模块内的一个联系,也就是说一个模块内部应该只是做好一件事情。如果发现模块内部做了好几件事情,你应该把它进行拆分。低耦合其实是各模块之间相互连接的一种度量,模块间的耦合强弱取决于模块间接口的复杂程度。也就是说如何设计好你的接口,让它的内部逻辑最小地对外暴露。例如聊天是需要组队信息的,聊天时需要给全队发送,那就需要去组队的服务器上去获取队伍的信息,此时应该通过一个设计良好的协议或接口获得。而不是直接去看组队模块里面的数据结构。
复杂的游戏服务器
一个典型的MMORPG服务器,如天涯明月刀,代码量高达100~200w行。单服同时承载4W人,应该如何设计呢?(集群和负载均衡)
线程的使用场景在协议打解包、接入服务、存储服务。那么思考下,一个玩家对应一个处理线程的模型如何呢?
集群和负载均衡
进程集群
将同一个业务多个实例部署到多台服务器上,对外提供统一的服务。
进程集群进程的数量变多之后,那么怎么知道那个玩家在哪一个进程上进行处理呢?一般会从两个角度入手:
- 数据切割
例如1w个用户,10个接入进程,也就是1个进程处理1000个。 这就是数据切割,也就是从数据上将其直接分割开来。 - 功能切割
按照一些基础功能,例如组队、聊天...主要用在逻辑进程这部分。
既然一个进程会有多个实例,就会涉及到一个玩家发请求时该发到哪台服务器上的问题。另外,10个进程是不是负载均衡,不要出现发送10请求最后都跑到一个进程上去了。 这都集群需要考虑的负载均衡。
集群的负载均衡
集群的负载均衡当有多个进程时需要一个中心节点去管理请求应该发往哪里去。如何让中心节点保证它的负载均衡呢?最简单那的方式是通过哈希的方式,如果直接哈希算法可能导致某个进程宕掉就不再提供服务,进而损失了此部分的计算能力,那么可采用分布式哈希。逻辑中很多会根据业务来进行一个负载均衡,例如MMORPG中有不同区域的地图,可以将各种地图放到不同的进程实例上,也就是说不要将两个大地图同时放在同一个进程上。
与此同时,中心节点会成为系统中的单点,单点的意思就是一旦这个点挂掉整个系统都不可用了,所以集群负载均衡是必须考虑好怎么对中心节点进行容灾。
负载均衡的方式
- 业务:如游戏地图场景
- 轮询
- 随机
- 最小响应时间
- 最小并发数量
- 最小承载
- 哈希:如接入或存储
- 分布式哈希
...
例如根据那个进程的最小响应时间最短则将请求发送给谁...
天涯明月刀进程集群示意图