« 如何优雅的实现一个 lua 调试器 | 返回首页 | 用 Ascii 画关系图 »

如何让 lua 做尽量正确的热更新

很多项目采用 lua 的一大原因是 lua 可以方便的做热更新。

你可以在不中断进程运行的情况下,把修改过的代码塞到进程中,让随后的过程运行新版本的代码。这得益于 lua 的 function 是 first class 对象,换掉代码不过是在让相应的变量指向新的 function 对象而已。

但也正因为 lua 的这种灵活性,想把热更新代码这件事做的通用,且 100% 做对,又几乎是不太可能的。

首先,你很难准确的定义出,什么叫做更新,哪些数据需要保留,哪些需要替换成新版本。光从源代码和运行时的元信息上去分析是远远不够的。

lua 只有一种通用数据结构 table ,这方便了我们做数据更新;但同时也制造了一些模糊性难题。比如,如果在代码中有一些常量配置数据表,写死在源代码中,通常你是希望跟着新版本一起更新的;而有一些表,记录着运行时的状态,你又不希望在代码更新后状态清空。

所以一般做热更新方案的时候,都会人为加一些约束,在遵循约束条件的前提上,尽量让更新符合预期。

比如在 skynet 中就提供了一种简单的热更新方法

最近在给公司的项目做一些技术指导,同时也做一些工具来提高开发效率。对于开发期(而不是生产环境),或许提供一个更灵活的热更新方案更方便一些。我们不太需要结果 100% 正确,但是需要减少一些约束。

在开发期,最好就是改两行代码,能立刻让进程刷新成新版本的代码,并不中断运行。如果更新结果和预期不符,最坏的后果也不过是关掉程序重新来而已。但是如果仅仅是改两行代码,或加几行 log ,则基本不会出错,但开发效率却可以极大的提高。


下面来讨论一下,在约束条件足够少的情况下,如何设计一个尽量完备的热更新方案。

热更新的关键是:找到更新的代码模块和在内存中运行的对应模块,到底有什么差异,以及如何处理这些差异。

如果我们以模块为单位来更新,第一步就是要把要处理的数据约束在一个足够小的范畴,而不能扩大到整个虚拟机。

所以我先实现了一个沙盒,让 require 新模块的过程在沙盒中运行。我们只给沙盒注入有限的几个不会产生副作用的函数,比如 print ,require,pairs 等;只允许在模块初始化流程中调用这些无副作用的函数。那么,当模块初始化好之后,整个模块内部的数据(函数也是数据),都不会溢出沙盒(不会有引用指向沙盒外)。

由于 lua 的数据结构很简单,所以我们可以认为沙盒中放着一张只有 function 和 table 两种复杂数据类型构成的图。这是因为 coroutine.* setmetatable io.* 等这些可能产生其它类型数据的函数都不能在初始化阶段调用,这是一个合理的限制条件。

这个限制条件同样也能规范模块的开发方法。比如若有复杂的初始化流程必须提供一个模块的初始化函数,由外部驱动,而不能直接写在模块的加载流程中,这也回避了更新模块代码时的重复初始化过程。

在制作沙盒时,可以建立一个访问全局变量和其它模块内容的 dummy 方法。一些 lua 常用写法就可以支持,比如:

local debug = require "debug"
local tinsert = table.insert
local getinfo = debug.getinfo

这类常见的写法是可以支持的,只不过这些 local 变量在沙盒中运行时,指向的是一个 dummy 对象;当更新模块成功后,可以后续替换成真正的变量。但是,在模块初始化过程调用它们会失败。

第二步,我们可以分析沙盒中的数据图。为了简化实现,我们要求数据图的初始状态 key 都必须是 string 或 number 等值类型。这应该也算一个合理的要求。虽然 key 是 table 或 function 也能实现,但代码会复杂很多,不值得放宽这个限制。

接下来,沙盒中每个 table 和 function 都可以表达为一个简单值类型序列索引到的东西。

我们可以根据每条路径去对比内存中同样路径上的对象,找到它的老版本。对于 function 变成 table 或 table 变成 function 这种情况,通通认为是有二义性的,可以简单拒绝热更新。我们的原则是,在约束条件下尽量不出错,如果做就做对。

这样,问题就变简单了:找到了对象之间的新老版本对后,就是怎么替换的问题。如果新版本中有对象不存在,那么不用删除老版本,因为如果老版本无人使用,那么就随它去好了;如果新版本对老版本有新增,直接加入新对象。

对于同类对象替换:函数当然是用新版本替换老版本,但是要小心的处理 upvalue ,这个下面展开说;对于 table ,我的建议是直接把新版本的 k/v 插入老版本的 table ,取并集。这种合并 table 的规则最为简单。

upvalue 如何合并呢?

upvalue 其实是 lua 中的一个隐式类型,大致相当于 C++ 的引用类。比如:

local a = {}

function foo()
  return a
end

a 是 foo 的一个 upvalue ,在 lua 中 a 的类型是 table ,但 upvalue 的实际类型却不是。因为你可以在别处修改 a ,foo 返回值也会跟着改变。

lua 提供了两个 api :debug.upvalueid 和 debug.upvaluejoin 来操作 upvalue 。

我建议的规则是,当我们需要把 f1 替换成 f2 时,应该取出 f2 的所有 upvalue ,按名字 join 给 f1 。不过这里存在的问题是, f1 如果新增了 upvalue 该怎么办?。

我们不能简单的保留 f1 新增的 upvalue ,比如:

--老版本
local a,b

function foo()
  return a
end

function foo2()
  return b
end

-- 新版本
local a,b

function foo()
  return a,b
end

function foo2()
  return b
end

这里老版 foo 只有一个 upvalue a ,但新版 foo 有两个 a 和 b 。我们在处理 foo 更新的时候,可以把老版的 a 关联给新版的 foo ;但在老版的 foo 中,却无法找到 b 。

如果我们不理会新增的 b 让其保留在 foo 上,那么接下来就会出现 foo2 的 b 被关联走;结果在新版本中, foo 和 foo2 原本共享的 b 变成了两个。

所以正确的做法是,把一个模块中所有的函数对象一起处理,所有处理过的 upvalue 以upvalueid 为索引记录在一起,这样在推导 upvalue 的归属时,可以保持同一版本中的关联性。

如果推导产生了歧义,例如新版本中两个函数共享一个 upvalue ,而老版中是分离的,那么都认为代码有二义性,拒绝更新。

再考虑这种情况:

local mod = {}

local pool = {}

function mod.foo()
    return function()
        return pool
    end
end

这里,mod.foo() 返回一个函数,引用了 pool 。这点,在初始化 mod 的时候,该函数是不存在的,所以无法被分析到。所以在合并对象,处理 upvalue 的时候,必须将新版 upvalue 的值合并到老版 upvalue 中,再将新版 function 的 upvalue join 到老版的 upvalue ;不可以反过来做。

注意:这里返回的那个匿名函数 function() return pool end ,如果一旦被持有,旧版本是无法被更新成新版本的。


由于我们可以先在沙盒中尽量把有效性检查全部做完,所以不会出现更新了一半的状态出错,让内存中的状态处于中间状态的情况。

最后,我们只需要遍历整个 VM ,把所有引用老版本函数的地方,修改为新版本对应函数。整个工作就完成了。


说起来容易,做起来还是很麻烦的。我花了两天的时间才把实现基本完成。放在 github 上

目前还没有仔细测试,有时间我会再 review 一遍。正如文章前面所说,在我们的应用场合(用于开发期调试),正确性暂时没有太高的要求,所以可以先将就着用用吧。 :) 如果你想在你的项目利用它做更多的事情,欢迎使用,但请留意可能出现的 bug 。欢迎修正 bug 后提交一个 pull request 。


11 月 25 日补:

关于前面提到的限制,如果旧有模块中有函数返回了一个函数,这个返回的函数虽然属于这个模块,但是却是无法更新的。只能做到,新的版本再调用函数生成它时,新的版本被更新,已有的的函数则只能保持原样。

这个限制的根源在于,重新加载的模块新版本,在加载完成后,对应的函数并不存在。因为对于 lua ,function 是 first class 的。过去的运行中,可能已构建了很多这样的函数对象,每个都是不同的(如果 upvalue 不同),而如果更换成新版本的函数,必须通过调用已有的函数才能构建出来,而且每调用一次才能生成一个新对象。

如果想解决这个问题,需要给 lua 增加一种能力:可以根据一个函数的 prototype 来直接构建出 function 对象。

prototype 是 lua 内部的一种数据类型,可以简单理解成一个函数去掉 upvalue 后的部分。当你写:

function foo(a)
  return function()
    return a
  end
end

时,每次对 foo(a) 的调用都会生成一个不同的匿名函数对象,它们引用的 a 都是不同的(每个分别指向不同的 a )。但是这些不同的匿名函数对象拥有相同的 prototype 对象。

如果使用 lua 的内部 c api 可以实现这样一组 api ,根据一个函数的 prototype 来构建新的函数,构建的函数的 upvalue 全部为 nil ,但你可以再使用 debug.upvaluejoin 来连接到正确的 upvalue 上。

#include 
#include 

#include 
#include 
#include 

static int
lclone(lua_State *L) {
    if (!lua_isfunction(L, 1) || lua_iscfunction(L,1))
        return luaL_error(L, "Need lua function");
    const LClosure *c = lua_topointer(L,1);
    int n = luaL_optinteger(L, 2, 0);
    if (n < 0 || n > c->p->sizep)
        return 0;
    luaL_checkstack(L, 1, NULL);
    Proto *p;
    if (n==0) {
        p = c->p;
    } else {
        p = c->p->p[n-1];
    }

    lua_lock(L);
    LClosure *cl = luaF_newLclosure(L, p->sizeupvalues);
    luaF_initupvals(L, cl);
    cl->p = p;
    setclLvalue(L, L->top++, cl);
    lua_unlock(L);

    return 1;
}

static int
lproto(lua_State *L) {
    if (!lua_isfunction(L, 1) || lua_iscfunction(L,1))
        return 0;
    const LClosure *c = lua_topointer(L,1);
    lua_pushlightuserdata(L, c->p);
    lua_pushinteger(L, c->p->sizep);
    return 2;
}

int
luaopen_clonefunc(lua_State *L) {
    luaL_checkversion(L);
    luaL_Reg l[] = {
        { "clone", lclone },
        { "proto", lproto },
        { NULL, NULL },
    };
    luaL_newlib(L, l);
    return 1;
}

以上,clone 这个 api 可以接收两个参数,第一个必须为一个 lua function ,第二个参数为 0 时,复制自身;为 1~n 时,以内部定义好的 prototype 来构建新的函数副本。

proto 可以返回函数的内部prototype 的指针,和 debug.upvalueid() 类似,它可以帮助你判断两个不同的函数是否有相同的 prototype 。

有了这两个新 api 的帮助,我们就可以完善前面的 reload 库了。我将实现放在里 proto 这个分支上。同时希望这组新的 api 可以进入 lua 下个版本的标准中去。


如果只想更替函数 prototype ,如果我们限制新老模块的 prototype 接口必须完全相同,那么利用我上面提供的 api 可以更简单的实现更新。见 proto 分支上的 hardreload.lua 。

Comments

是指这种情况吗?
local count=1
function test.f()
local function()
count = 2
end
end
的确是如果这种方式传递count出去就会出问题

@Cloud 关于local count和test.count,我没明白,我的机制是保证Reload前后,除了function类型的会变,别的值都维持原样,test.count和count是number类型的,也就保持不变了,问题在哪里呢?能否再给我一个具体的例子?

5.1 没有 upvaluejoin 是一个缺陷,不过可以自己给 lua 加这个东西。

比如例子里你可以传递 test.count ,但是如果直接写 local count 就瞎了。

thread 并不指你的 coroutine ,mainthread 也是 thread 。从完备性来说,就是调用 reload 的地方的上下文里的 local 变量是需要修改的。

module 的 require 过程理论上什么都可以做,所以获得 module 所产生的所有函数的方法有三个:

1. 在 reload 前拍一个快照,reload 后比较快照。这个成本很高。

2. 我的实现中用到的方法,在 reload 前建立一个沙盒。让 reload 过程不要溢出沙盒。一旦有这种情况至少调用者可以知道。

3. 我上文后面提到的方法,扩展 lua 可以提取出一个 function 中所有的 prototype 。

@cloud非常感谢反馈,我一一解答一下:
1.lua5.1没有upvaluejoin啊,所以我是通过名字识别并拷贝upvalue的,在gif例子里就有一个添加了function类的upvalue,可以运行啊,table类的upvalue也可以修改的,不过只有针对函数的修改才会生效,因为我们只更新函数逻辑。
2.这个问题的确是存在的,因为我们是用lua来做游戏上的逻辑,没有用到userdata和thread的功能,所以就忽略了,这部分我再尝试加上。
3.那请问怎么样才能得到一个moudle相关的全部的函数呢?我也遍历了它的元表以及所有函数的upvalue了。


云风大大可否留个QQ?我想再向你讨教一下具体的问题,谢谢。

@lichking

1. lua 有比 C# 强的多的反射机制。

2. lua 的 metatable 可以用来构建比 C# 更强的运行时类型系统。

3. 如果需要静态类型帮助开发期分析,可以使用 typed lua 。

4. lua 和 C 的交互比 C# 高效的多,性能敏感的地方可以比 C# 更方便的用 C 写。而一般业务,无论是 lua 和 C# 都不会在性能上出问题。

5. C# 的 gc 在内存受限环境基本不可控,mono 环境更加严重。

@asqbtcupid

你的实现有几个明显的问题(没有仔细看可能的潜在问题)

1. 不用 upvaluejoin 是不能将 upvalue 关联对的。只有 upvalue 是 table 且运行时不会修改 upvalue 才可以正确运行。

2. 遍历 VM 不周全。没有遍历 userdata ,没有遍历 thread 调用栈。针对 5.1 来说,还需要遍历函数的 env 。

3. 简单遍历 module table 是不能保证找到所有 module 相关的函数的。

一年前做过类似的热更新机制,很好的运用到公司的项目上了,我们的约束比较简单,就是只更新函数,不更新除函数以外的东西。https://github.com/asqbtcupid/lua_hotupdate 有个简单的GIF演示,只用在lua5.1+windows上。

也可以说一个没有复杂类型系统,没有元数据反射,而且性能捉急的语言,谈不上高级,
C#就语言设施来说,是比lua丰富得多,是从工程出发非常有用的特性

to medky:
首先假定这里说的"热更新"只是为了不重新安装应用的更新,而不是不影响当前运行状态的实时更新.
C#在ios未越狱的情况下无法热更新.越狱和安卓平台虽然可以热更新,但坑也很多,unity的一些资源也假定程序是无法更新的,某些程序修改会没法正常跑.
除非想办法解释执行C#代码或字节码,这与把Lua翻译成C#本质是一样的.难点在于C#语法特性都很复杂,即使没有Lua那么高级,也很难做准确的翻译,目前也没有看到很好的开源实现.

在我看来 lua 是比 C# 高级的语言 :)

一个没有完备的 coroutine ,function 不是 first class , gc 细节不可控的语言谈不上高级。

个人觉得项目完全可以用C#这样的高级语言实现,发布的时候将C#编译成lua字节码就可以了,这样lua热更新的优势和高级语言的抽象能力都有了,不过真这样做了何不直接用C#的字节码做热更新呢?

Post a comment

非这个主题相关的留言请到:留言本