Lua热更新机制(上)

ronald1年前职场5450

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热更新机制(下)

Lua热更新机制(下)

Lua热更新机制怎么解决热更的这些问题场景拆分    Lua代码的实体可拆分出三类:配置、逻辑和运行时状态,其中逻辑分为常规的接口和闭包内的逻辑,运行时状态又分为全局变量和闭包内的局部变量,这里结合skynet的解决方案进行描述。热更的核心问题    1)新老版本的差异是什么?   &nb...

发表评论    

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