Lua热更新机制(上)

ronald1年前职场5410

Lua热更新机制

一个Lua热更新demo

    Lua在游戏开发中能广泛使用不仅由于其轻量易嵌入的特性,还有一个重要的点是易于热更新,设想在产品线上运营过程中,出现bug需要修复,频繁停机对于产品体验影响大,也影响口碑;所以实际运营我们是希望能尽量避免停止服务进行代码更新的操作,下面先从一段比较简单的代码看Lua的热更新机制:

require "mymodule"
local last_file = io.popen("stat -c %Y mymodule.lua")
local last_update_time = last_file:read()
local old_str = string
while true do
    local file = io.popen("stat -c %Y mymodule.lua")
    local update_time = file:read()
    os.execute("sleep 1")
    if update_time ~= nil then
        if tonumber(update_time) - tonumber(last_update_time) > 5 then
            last_update_time = update_time
            package.loaded["mymodule"] = nil
            require("mymodule")
            local new_str = string
            print(old_str,new_str)
        end
    end
end

从上述代码可以看到:

    当前代码段require了mymodule模块,轮询mymodule.lua这个文件,当发现文件更新了之后,则将package.loaded的对应项设置为nil,并重新require,就可以实现将mymodule最新的代码加载进来。

    这是最朴素的的热更新机制,中间还会有很多问题,无法实际用于现网环境,在此之前,我们先看看Lua的require机制的原理。


Lua require机制

Lua中的Module

    所谓module,即为了实现代码复用的模块,类似与C++的.so,在Lua中module是通过table实现的,可以是一个lua文件,也可以是一个代码的chunk

关于require函数

    我们在用lua编写了一个module之后,别的地方需要使用这个module的代码,可以通过require函数将这个module加载进来,reuqire函数原型如下:

static int ll_require (lua_State *L) {
  const char *name = luaL_checkstring(L, 1);
  lua_settop(L, 1);  /* LOADED table will be at index 2 */
  lua_getfield(L, LUA_REGISTRYINDEX, LUA_LOADED_TABLE);
  lua_getfield(L, 2, name);  /* LOADED[name] */
  if (lua_toboolean(L, -1))  /* is it there? */
    return 1;  /* package is already loaded */
  /* else must load package */
  lua_pop(L, 1);  /* remove 'getfield' result */
  findloader(L, name);
  lua_rotate(L, -2, 1);  /* function <-> loader data */
  lua_pushvalue(L, 1);  /* name is 1st argument to module loader */
  lua_pushvalue(L, -3);  /* loader data is 2nd argument */
  /* stack: ...; loader data; loader function; mod. name; loader data */
  lua_call(L, 2, 1);  /* run loader to load module */
  /* stack: ...; loader data; result from loader */
  if (!lua_isnil(L, -1))  /* non-nil return? */
    lua_setfield(L, 2, name);  /* LOADED[name] = returned value */
  else
    lua_pop(L, 1);  /* pop nil */
  if (lua_getfield(L, 2, name) == LUA_TNIL) {   /* module set no value? */
    lua_pushboolean(L, 1);  /* use true as result */
    lua_copy(L, -1, -2);  /* replace loader result */
    lua_setfield(L, 2, name);  /* LOADED[name] = true */
  }
  lua_rotate(L, -2, 1);  /* loader data <-> module result  */
  return 2;  /* return module result and loader data */
}

    当我们调用require函数本质上是被映射到ll_require接口,执行的是加载模块的逻辑,所有已加载模块都被注册在表LUA_LOADED_TABLE中,所以调用时会先去查这个tablelua_getfield,若是这个table没有,则先找到对应加载器findloader,加载器则从指定路径去搜索这个模块,通过接口loader去加载,并在package.loaded这个表进行注册。在这里用package.loaded表进行记录,一个是效率考虑,一个是避免相互require,导致死循环。

    loader接口加载时,加载路径记录在package.path下面,从本例可以看到:

require "mymodule"
print(package.path)

    执行结果如下所示:

[root]# lua test.lua 
./?.lua;/usr/share/lua/5.1/?.lua;/usr/share/lua/5.1/?/init.lua;/usr/lib64/lua/5.1/?.lua;/usr/lib64/lua/5.1/?/init.lua

    可见默认搜索路径包括当前目录,也包括lua库所在的文件,且模块名会替换调路径中的?

    若是我们想引用其他路径下的文件,可以通过如下方式将搜索路径放到package.path中:

require "mymodule"
package.path=package.path..";./test/?.lua"                                               
require "module2"

    执行结果如下:

[root /lua_test/hotfix_test]# lua test.lua 
./?.lua;/usr/share/lua/5.1/?.lua;/usr/share/lua/5.1/?/init.lua;/usr/lib64/lua/5.1/?.lua;/usr/lib64/lua/5.1/?/init.lua;./test/?.lua

    可以看到test/?.lua被添加到路径之后可以正常找到test/module2.lua这个文件并加载。


关于模块的加载器

    在require接口的实现我们看到,当去加载一个module时,发现这个module不在表LUA_LOADED_TABLE中时,会先调用接口findloader去查找对应的加载器,这个findloader实现如下:

static void findloader (lua_State *L, const char *name) {
  /* push 'package.searchers' to index 3 in the stack */
  lua_getfield(L, lua_upvalueindex(1), "searchers");
  /*  iterate over available searchers to find a loader */
  for (i = 1; ; i++) {
    if (l_unlikely(lua_rawgeti(L, 3, i) == LUA_TNIL)) {  /* no more searchers? */
      lua_pop(L, 1);  /* remove nil */
      luaL_buffsub(&msg, 2);  /* remove prefix */
    }
    lua_pushstring(L, name);
    lua_call(L, 1, 2);  /* call it */
  }
}

    这个findloader也是先拿到searchers这个table的指针,这个table里面有如下内容:

static void createsearcherstable (lua_State *L) {
  static const lua_CFunction searchers[] =
    {searcher_preload, searcher_Lua, searcher_C, searcher_Croot, NULL};
  ...
}

    则findloader依次执行如下接口:

    1)searcher_preload:此接口首先去查表LUA_PRELOAD_TABLE,这里面实际村的是函数指针,不为空的话就是一个函数加载器,使用此加载器加载文件;

    2)searcher_Lua:在本例实际searcher_preload为空,是通过接口searcher_Lua进行文件的加载。

static int searcher_Lua (lua_State *L) {
  const char *filename;
  const char *name = luaL_checkstring(L, 1);
  filename = findfile(L, name, "path", LUA_LSUBSEP);
  if (filename == NULL) return 1;  /* module not found in this path */
  return checkload(L, (luaL_loadfile(L, filename) == LUA_OK), filename);
}

    在searcher_Lua中,

    1)findfile:是在path路径进行指定lua文件的查找;

    2)luaL_loadfile:找到之后本质上是执行了luaL_loadfile将加载的文件重新加载到全局表中。

上面demo的一些问题

    从分析加载器源码我们可以看到上述demo的一些问题:

    1)对于定义在模块内的全局变量,重新加载时由于会重新执行lua模块,模块内全局变量若是在其他地方被修改,则这种修改会被丢失,源码如下:

-- test.lua
require "mymodule"
local last_file = io.popen("stat -c %Y mymodule.lua")
local last_update_time = last_file:read()
local old_str = string
string2 = "cccc"
print("string2:"..string2)
while true do
    local file = io.popen("stat -c %Y mymodule.lua")
    local update_time = file:read()
    os.execute("sleep 1")
    if update_time ~= nil then
        if tonumber(update_time) - tonumber(last_update_time) > 5 then
            last_update_time = update_time
            package.loaded["mymodule"] = nil 
            require("mymodule")
            local new_str = string
            print("string2:"..string2)
            print(old_str,new_str)                                                                          
        end
    end 
end
--mymodule.lua
string = "aaabbbcdmmmccqqq"
string2 = "bbbcdmmmcc"

    执行结果如下:

[root lua_test/hotfix_test]# lua test.lua 
string2:cccc
string2:bbbcdmmmcc
aaabbbcdmmmcc   aaabbbcdmmmccqqq

    string2的修改,在热加载过程被重置了。

    2)闭包里面的upvalue在热更过程中不会修改,如下所示:

--upvalue_test.lua
mod1 = require "module2"
foo_closure = mod1.foo()
local old_file = io.popen("stat -c %Y module2.lua")
local old_time = old_file:read()
while true do
    foo_closure()
    os.execute("sleep 4")                             
    local file = io.popen("stat -c %Y module2.lua")
    local update_time = file:read()
    if tonumber(update_time) - tonumber(old_time) > 5 then
        package.loaded["module2"] = nil 
        mod1 = require "module2"
        old_time = update_time
        foo_clo2 = mod1.foo()
    end 
    foo_closure()
    if foo_clo2 ~= nil then
        foo_clo2()
    end 
end

--module2.lua(修改前)
local M = {}
function M.foo()
    local foo_count = 1 
    local function add_foo()
        foo_count = foo_count+1                             
        print(foo_count)
    end 
    return add_foo
end
return M
-- module2.lua(修改后)
local M = {}
function M.foo()
    local foo_count = 1 
    local function add_foo()
        foo_count = foo_count+2                            
        print(foo_count)
    end 
    return add_foo
end
return M

    执行结果如下:

[root /lua_test/hotfix_test]# lua upvalue_test.lua 
2
3
4
5
6
7
8
9
3
10
11
5
12

    可以看到foo_closure这个闭包是在更新前创建了,更新后闭包里面局部变量状态被保留了,但是逻辑也是没法更新的。



参考资料

    https://john.js.org/2020/10/27/Lua-Runtime-Hotfix/

    云风的 BLOG: 如何让 lua 做尽量正确的热更新 (codingnow.com)

    https://blog.51cto.com/bosswanghai/1832133

    https://blog.csdn.net/Yueya_Shanhua/article/details/52241544

    https://github.com/zhyingkun/lua-5.3.5/blob/master/


相关文章

几种Lua和C交叉编程的程序写法

Lua程序调用C接口//另一个待Lua调用的C注册函数。 static int sub2(lua_State* L) {     double op1 = luaL_checknumber(L,1);     double op2 ...

LUA数据结构(三)

Lua数据结构userdata    Lua官方的介绍:userdata是一种用户自定义数据,用于表示一种由应用程序或者C/C++语言库创建的类型,可以将任意C/C++类型的数据(通常是struct、指针)存储到Lua变量中调用。    在实际应用过程中,C/C++接口调用LuaL_newuserdata就会分配指定大...

LUA数据结构(二)

LUA数据结构(二)

Lua数据结构thread    Lua中,最主要的线程是协程,它和线程差不多,拥有独立的栈、局部变量和指令指针;和线程区别在于线程可以同时运行多个,而协程同一时刻只能有一个运行    Lua协程接口都放在table coroutine里面,主要包括以下几个:coroutine.create,创建一个协程corouti...

协程-无栈协程(下)

无栈协程库——protothread    ProtoThread源码如下所示:#define LC_INIT(s) s = 0; #define LC_RESUME(s) switch(s) { case 0: #define LC_SET(s)...

关于LUA(下)

关于LUA(下)

Lua与OOP    Lua是面向过程的语言,不提供面向对象的特性,但是我们可以利用Lua的元表和元方法模拟面向对象的效果。OOP的特性封装    所谓封装,是隐藏对象的属性和细节,仅对外暴露公共的访问方式。本质上分为两层:    1)成员变量和成员方法,提升代码的内聚性,降低模...

Lua和C

Lua和C

一.背景    在实际游戏业务运营过程中,经常会出现一些紧急的配置修改、bug修复等;这种规划外的变更行为希望能做到用户无感知,否则对于游戏体验和产品口碑有很大影响,在此背景下,热更新能力成为成熟上线游戏的标准配置。    一般情况下,后台逻辑热更新能力的技术方案有两种:    ...

发表评论    

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。