【热更新】游戏热更新方案
游戏热更新方案
游戏热更新方案
热更新演化
进程切换 -> 动态库 -> 脚本语言热更新
热更新方案
【1】 进程切换
1.1 利用fork、exec切换
利用fork、exec函数实现进程切换,原理:fork和exec函数有一个重要的特性,即可以让复刻后的进程和新开启的进程继承原进程的文件描述符,因此新进程也可以直接访问原进程监听的端口的socket。
如上图说明了fork和exec函数实现优雅进程切换的流程,进程1是一个服务端程序,监听8001端口,客户端A正在与服务端进行交互。当需要热更新时,让进程1调用fork函数,系统会复刻一个与进程1一摸一样的进程2,两个进程共同监听8001端口。让进程2调用exec函数运行新版本的程序,新版本程序进程3继承了原有的监听端口。此时,可以让进程1停止接收新连接。客户端A可以继续与进程1进行交互,而新的连接会与进程3交互。待到进程1处理完客户端A的请求后,再让它退出,系统仅剩下进程3。如此便实现了优雅的进程切换。由于进程3和进程1都监听8001端口,因此客户端无须做任何改变。
Nginx热更新
Nginx是一款由C语言编写的Web服务器,它是一个多进程架构的程序。Nginx会开启一个master进程和若干个worker进程(图中开启了1个),其中,master进程负责监听(图中监听了80端口)新连接,当客户端成功连接后,master会把该连接交给某个worker处理。
Nginx采用了进程切换的热更新方式,如图9-18所示,在用户输入热更新指令后,Nginx内部会调用fork和exec函数,开启一组新版本进程,旧连接由旧进程负责处理、新连接由新进程负责处理。在旧进程处理完旧连接后,用户可以输入指令让它们退出。
图9-19是从Linux命令行观察Nginx热更新时的输出,热更新之前,Nginx拥有master和worker两个进程,当输入热更新指令“kill -USR2 127”之后,Nginx将拥有4个进程。
1.2 利用网关切换
除了使用fork和exec函数,利用网关也能够实现优雅的进程切换。如图9-20所示的是一种带有网关的服务端架构,客户端与网关相连,网关再将消息转发给逻辑进程(图中的game1)。
如图9-21所示,需要热更新时,开启一个新版本的逻辑进程(图中的game2),让网关把旧连接的请求转发给game1(图中的①)、把新连接的请求转发给game2(图中的②)。待所有旧连接都处理完毕,再关闭game1。
由于引入了网关,因此在切换进程的过程中,客户端的连接不会中断,从而实现了热更新。
1.3 微服务
进程切换热更新有个特点:旧连接由旧进程负责处理,新连接由新进程负责处理。这意味着进程切换热更新更适合于短连接的应用,这是因为旧连接很快就断开,服务端很快就能够全部演化到新版本。短连接适用于非频繁交互的休闲游戏,不适合于强交互类的游戏,但它依然可以作为强交互游戏架构的一部分。
图9-22所示是一款策略游戏(SLG)的服务端架构,游戏只有一张大地图,每位玩家占据一个角落发展自己的军团,且随时会与其他玩家发生战斗。鉴于玩家之间具有强交互性,服务端用一个进程(场景服务器)处理地图逻辑。玩家控制军队前进,需要采用A寻路算法,但A寻路算法的计算量较大,为了保证性能,该算法不宜放在场景服务器中计算。于是,开发者把徐璐功能做成无状态的微服务,场景服务器向寻路服务器请求”从{100,200}走到{300,400}的路径“,寻路服务器回应路径点。
- 进程切换注意要点
无论是使用fork和exec函数、还是使用网关实现的热更新,都需要借助多个进程的配合,进程切换热更新是一种架构级别的方法;而且需要做到进程级别的无状态,或者能够在重启时恢复整个进程的状态。
伪代码示例:
unordered_map<int, Player*> players;
//进程开启时调用
void onStart(Mode mode){
if(mode == MODE.HOTFIX){
players = LoadFromDB();
}
}
//进程退出时调用
void onExit(){
if(mode == MODE.HOTFIX){
SaveToDB(players);
}
}
【2】 动态库替换
进程切换不仅需要多个进程互相配合,还要实现进程级别的无状态,灵活性很差。如果靠单个进程就能实现热更新,那么程序在开发时就能够灵活很多。使用动态库就能实现单进程的热更新,而且只需要达到”库“级别的无状态。
动态库更新的方式是指把程序的某些变量和方法编写到外部的动态库文件中(.so),在程序运行时再动态地加载它们。这种方式可用于热更新动态库中的内容,只需要把动态库替换掉即可。
示例:
player.h
struct Player{
int x;
int y;
int coin;
};
handle.c
#include "player.h"
void work(struct Player *player){
player->coin = player->coin + 1;
}
main.c
#include <stdio.h>
#include <unistd.h>
#include <dlfcn.h> //该文件声明了处理动态库的方法dlopen、dlsym、dlclose
#include <player.h>
void *handle = NULL;
void (*work)(struct Player *player) = NULL;
//需要热更新时调用
int reload(struct Player *player){
//打开动态库
handle = dlopen("./handle.so", RTLD_LAZY);
//从动态库中获取某个方法的地址指针
work = dlsym(handle, "work");
return 1;
}
void closeHandle(){
//关闭动态库
dlclose(handle);
}
void main(){
struct Player player = {0,0,0};
reload(&player);
while(1){
work(&player);
printf("player x:%d y:%d coin:%d\n", player.x, player.y, player.coin);
sleep(1);
}
closeHandle();
}
编译:
# ls
handle.c main.c player.h
//生成动态库: -shared代表要生成的目标文件类型是动态库
# gcc -shared -o handle.so handle.c
//查看库中的符号(函数、全局变量等)
# nm handle.so
0000000000004028 b completed.7326
w __cxa_finalize@@GLIBC_2.2.5
0000000000001050 t deregister_tm_clones
00000000000010c0 t __do_global_dtors_aux
0000000000003e10 t __do_global_dtors_aux_fini_array_entry
0000000000003e18 d __dso_handle
0000000000003e20 d _DYNAMIC
0000000000001124 T _fini
0000000000001100 t frame_dummy
0000000000003e08 t __frame_dummy_init_array_entry
0000000000002080 r __FRAME_END__
0000000000004000 d _GLOBAL_OFFSET_TABLE_
w __gmon_start__
0000000000002000 r __GNU_EH_FRAME_HDR
0000000000001000 T _init
w _ITM_deregisterTMCloneTable
w _ITM_registerTMCloneTable
0000000000001080 t register_tm_clones
0000000000004028 d __TMC_END__
0000000000001105 T work
# ls
handle.c handle.so main main.c player.h
//生成可执行文件: 因为用到了动态链接库,所以在编译时,需要添加参数"-ldl",编译器才能找到dlopen、dlsym、dlclose这几个方法的具体实现。
# gcc -o main main.c -ldl
//查看可执行文件中所有依赖的共享库(ldd)
# ldd main
linux-vdso.so.1 => (0x00007ffe943ca000)
libdl.so.2 => /lib64/libdl.so.2 (0x00007fcd04139000)
libc.so.6 => /lib64/libc.so.6 (0x00007fcd03d6b000)
/lib64/ld-linux-x86-64.so.2 (0x00007fcd0433d000)
//运行
# ./main
player x:0 y:0 coin:1
player x:0 y:0 coin:2
player x:0 y:0 coin:3
player x:0 y:0 coin:4
player x:0 y:0 coin:5
...
【3】 脚本语言热更新
解释型脚本语言(Lua、Python……)的模块重载功能能很好满足灵活的热更方案。
热更新探究
最简单的实现热更的方法
main.lua
local shop = require("shop")
local players = {} --玩家列表
players[101] = {coin=1000,l bag={}}
--lua热更新
function reload()
package.loaded["shop"] = nil
shop = require("shop")
print("reload succ")
end
--用字符输入模拟网络消息
while true dp
cmd = io.read()
if cmd == "b" then --buy
shop.onBuyMsg(players[101], 1001)
elseif cmd == "r" then --reload
reload()
end
end
shop.lua
local M = {}
local goods = {
[1001] = {name = "金疮药", price = 10},
[1002] = {name = "葫芦", price = 2}
}
M.onBuyMsg = function(player, id)
local item = goods[id]
--扣金币,这里缺少对金币数量书否充足的判定
player.coin = player.coin - item.price
--增加道具计数
player.bag[id] = player.bag[id] or 0
player.bag[id] = player.bag[id] + 1
--...
local tip = string.format("player buy item %d, coin:%d item_num:%d", id, player.coin, player.bag[id])
print(tip)
end
最简单的实现热更的方法的局限性
main.c
local cmdHandle = {
b = shop.onBuyMsg,
--s = shop.onSellMsg, --出售
--w = work.onWorkMsg,
r = reload,
}
function reload()
package.loaded["shop"] = nil
shop = require("shop")
print("reload succ")
end
while true do
cmd = io.read()
cmdHandle[cmd](players[101], 1001)
end
如上代码会导致热更新失败。这是因为在reload方法中,只是用"shop = require(“shop”)" 替换了对shop的引用,尽管shop.onBuyMsg引用了新方法,但cmdHandle.b引用的依然是旧方法。而程序调用的就是cmdHandle.b,因此程序没能实现热更新。如图9-37所示,虚线代表热更新前cmdHandle.b和shop.onBuyMsg的引用指向,实现代表热更新后的引用指向。
若要想成功实现热更新,那么我们还需要在reload方法中添加一句"cmdHandle.b = shop.onBuyMsg",让cmdHandle.b引用新方法。
由此可见,热更新的实现与业务的写法有关。要么就遵循严格的代码规范,禁止在业务层使用回调函数、匿名函数,禁用任何未经验证的设计模式;要么就为每个模块单独编写特定的热更新方法。
热更新全局替换模块方法的局限性
一种针对上面案例的做法是:通过一些小技巧来实现全局替换,在热更新时遍历虚拟机中的所有全局变量、局部变量、上值、元表等,替换掉旧方法。如Skynet的注入补丁就是使用debug.setupvalue替换了本地变量。
但全局替换并不是一个通用的热更新方法。由于程序无法得知哪些值需要进行热更新,哪些值需要保留,因此我们无法使用一个通用的热更新方法,每个项目都要做特殊处理。
下面以shop模块为例说明问题。假设现在需要为商城添加限购功能,每天仅出售100瓶金疮药,添加remain表记录商品的剩余数量,每成功购买一个道具,剩余数量减1。
shop.lua
local M = {}
local goods = {
[1001] = {name = "金疮药", price = 1},
[1002] = {name = "葫芦", price = 2}
}
local remain = {
[1001] = 100, --今日剩余的金创药数量
[1002] = 200, --今日剩余的葫芦数量
}
M.onBuyMsg = function(player, id)
local item = goods[id]
--扣金币,这里缺少对金币数量书否充足的判定
player.coin = player.coin - item.price
--省略对金币和先构数量的判定
remain[id] = remain[id] - 1
--增加道具计数
player.bag[id] = player.bag[id] or 0
player.bag[id] = player.bag[id] + 1
--...
local tip = string.format("player buy item %d, coin:%d remain:%d", id, player.coin, player.remain[id])
print(tip)
end
运行程序将得到如图9-38所示的结果,每购买一次,道具剩余量将减少1。
如果修改商品价格,再执行热更新,则将得到如图9-39所示的失败结果,虽然道具价格从10变成了1,但剩余量却发生了错误的变化。正常情况下,道具剩余量应以99、98、97、96的规律递减,现在却变成了99、98、99、98。也就是说,在热更新后,道具价格成功发生改变,但道具剩余量还原到初始值了。
这是因为,新版本的remain表替换了旧remain表,而remain表的值需要保存起来。如图9-40所示,热更新前后的M.onBuyMsg引用了各自的本地变量goods和remain,新remain的默认值是100,正是因为新的onBuyMsg方法引用了新的remain表,才使得热更新失败。本例中,需要用到新的goods表(因为修改了商品价格),但要保留旧的remain表。goods表和remain表都是普通的本地变量,程序无法自动区分它们。
根源在于代码没有提供足够的信息量,让程序去判断哪些值需要热更新,哪些值需要保留。
工程实现
1. 规范写法以确保模块内无状态
如果开发者清楚哪些值需要热更新,哪些值不需要,则可以把不需要热更新的变量设为全局变量(注意代码中没有local)。
shop.lua
remain = remain or {
[1001] = 100,
[1002] = 200,
}
上面使用了一个小技巧“remain = remain or 默认值”,模块第一次加载时,全局变量remain为空,为它赋予默认值;热更新时,让remain继承旧值。
修改之后,程序就可以正常进行热更新了,运行结果如图9-41所示。
图9-42展示了变量的引用关系,热更新前后,onBugMsg方法引用了不同的goods表,但引用了同一个remain表。
使用全局变量之前必须做好命令的规划。在shop模块的例子中,如果另外的某个模块也用到了全局变量remain,并将其设计成奇怪的值,则将产生不可预料的后果。
main.lua
while true do
cmd = io.read()
if cmd == "b" then --buy
shop.onBuyMsg(players[101], 1001)
elseif cmd == "r" then --reload
reload()
end
remain = p
end
图9-43所示的是由全局变量冲突引起的报错。remain本来是表结构,而代码9-19却把它改成了数值,结果导致shop模块读表时出现报错。
我们可以为各模块分配不同的全局空间,以避免发生全局变量冲突。如图9-44所示,将shop模块的全局变量放到runtime.shop中,将成就模块的全局变量放到runtime.achieve中,以避免冲突。
2. 交给具体模块解决
由于程序无法自动判断哪些值需要保留,因此这部分工作最好是交给具体模块的开发者去处理。如下代码所示,我们可以规定每个模块都必须包含一个reload方法,服务端在热更新该模块时会调用它。开发者需要在reload方法中还原需要保留的值。
shop.lua
local M = {}
...
M.reload = function(old_module)
remain = old_module.get("remain")
end
return M
3. 标注后全局遍历
如下代码,我们规定NEED_PRESERVE是一个特殊的标识,表示该值需要保留。再使用全局替换的方法遍历所有的全局变量、局部变量、上值、元表等,由于特殊标识的存在,因此程序可以分辨出需要保留的内容。
shop.lua
local M = {}
local goods = {
--具体内容略
}
local remain = {
NEED_PRESERVE= true
--具体内容略
}
其实,如果能够明确热更新要替换的内容,那么无论程序有多复杂,我们都能用各种技巧成功实现热更新。
* 选择合适的热更新范围
热更新能力和灵活性就像鱼与熊掌的关系一样,难以兼得。要实现更强的热更新能力,就需要遵循更严格的规范,越严格的规范就意味着越多的培训成本。对于大部分项目,通过少量限制,获取有限的热更新能力是面对实际需求权衡之后的选择。
表9-1列出了服务端热更新能力的五个层次。
一般而言,我们认为实现前3个层次的热更新能力是性价比比较高的一种选择,这样做既能满足大部分热更新需求,又不至于增加太多写法限制。
Skynet热更新
【1】利用独立虚拟机热更新
1.skynet热更新的依赖于架构
skynet实现了Actor模型,每个Lua服务开启了独立的虚拟机(如图9-4所示),这种架构为skynet提供了一些热更新能力。
我们可以这样理解:开启新服务时,虚拟机需要重新加载Lua代码,所以只要先修改Lua代码,再重启(或新建)服务,新开的服务就会基于新代码运行,实现热更新。
2.清除代码缓存
不过,直接修改Lua代码并不能起作用,这是因为skynet使用了修改版的Lua虚拟机,它会缓存代码。所以在修改Lua代码之后,要先登录调试控制台(debug_console)执行清除缓存的指令clearcache(skynet调试台的clearcache指令虽然被称为“清缓存”,但它并不是真的执行“清理”操作,而是额外加载一份代码,所以频繁执行clearcache会加大内存开销)。
3.完成热更新需求
每个客户端对应于一个代理服务,每个代理服务的存活时间是客户端从连接到断开的时间。对于一些休闲类手游,玩家每次游玩的时间不会很长,可以让已在线的玩家按照旧的规则玩(如每次增加1金币),同时让新上线的玩家按照新的规则玩(如每次增加2金币)。
热更新范例程序,仅需执行如下两步操作:
1)修改代码,如将“coin = coin + 1” 改成 “coin = coin + 2”
2)登录调试控制台,执行clearcache指令。
热更新后,旧客户端依然只增加1金币,但新连接的客户端会增加2金币,如图9-6所示。
4.适用场景
skynet独立虚拟机热更新的方式适合在“一个客户端对应一个代理服务”的架构下热更新代理服务,以及在“开房间型”游戏中热更新战斗服务。
如图9-7所示的是一种典型的Actor服务端架构,每个客户端对应一个代理服务,每场战斗对应于一个战斗服务(battle)。图中灰色底纹的服务即表示可以通过此方案热更新的服务;白色底纹的网关(gateway)、登陆服务(login)、匹配服务(match)是“固定”的服务,难以通过此方式进行热更新。
虽然旧客户端执行的是依然是旧代码,但重新登录就能运行新的版本;虽然旧的比赛执行的依然是旧代码,但新开的比赛就能运行新的版本。由于每个客户端的登录时长有限、每场战斗的持续时间也有限,程序最终会趋向于运行新版本。
【2】注入补丁
1.注入补丁热更新方案
skynet还提供一种称为inject(可翻译为“注入”)的热更新方案,如图9-9所示,写一份补丁文件,把它注入某个服务,就可以单独修复这个服务的Bug。
2.编写补丁文件
虽然skynet提供了“注入”的热更新方案,却没有给予足够的支持,补丁文件的写法颇具技巧性。
skynet/examples/hinject.lua
local oldfun = _P.lua._ENV.onMsg
_P.lua._ENV.onMsg = function(data)
local _,skynet = debug.getupvalue(oldfun, 1)
local _,coin = debug.getupvalue(oldfun, 2)
skynet.error("agent recv" .. data)
--消息处理
if data == "work\r\n" then
coin = coin + 2
debug.setupvalue(oldfun, 2, coin)
return coin .."\r\n"
end
return "err cmd\r\n"
end
代码中,“_P”是skynet提供的变量,用于获取旧代码的内容,“_P.lua._ENV.onMsg”即原先的onMsg方法,重新为它赋值,即可换成新的方法。因为新、旧方法的运行环境不同,新方法不能直接读取skynet、coin等外部变量,所以这里还需要依靠一些小技巧,如上代码是通过Lua的调试模块(debug)来获取外部值的。
图9-10是如上代码的简化示意图,将hagent.lua中的onMsg替换为hinject中的newfun,newfun中的skynet、coin依然引用旧代码。其中,newfun代表_P.lua._ENV.onMsg。
3.完成热更新需求
写完补丁文件,在调试控制台中输入inject a examples/hinject.lua即可完成热更新。其中
“a”是代理服务的id,可从服务端的输出日志中获取;“examples/hinject.lua”是补丁文件的路径。
4.适用场景
“注入”热更新方案适合于需要紧急修复Bug的情况。补丁文件的写法比较诡异,容易出错,需要在开发环境中做严密测试;Lua调试模块(debug)的运行效率较低,还会破坏语言封装的整体性,因此若不是危机情况则尽量不要使用;skynet只提供了针对某个服务的注入功能,若要热更某类服务(如图9-7中的全部代理服务或战斗服务),则还需自行实现。
【3】Lua脚本热更新方案
如上的Lua脚本热更新方案,共有3种:
- 规范写法以确保模块内无状态(需要保留的变量保存在全局变量里)
- 交给具体模块解决(每个模块单独实现reload保留什么变量,覆盖什么变量)
- 标注后全局遍历
- skynet方法(1.直接替换lua脚本,在skynet控制台执行clearcache,那么新运行的skynet服务就是新的方案。2.注入,编写补丁lua脚本,在skynet控制台输入inject a examples/hinject.lua)
更多推荐
所有评论(0)