再谈Lua热更新(终)

在写这篇文章之前, 我特意在标题前加了个"终"字。因为我相信,这就是生产环境中热更新的最终出路。

大约在4年前,我实现过一版热更新

但是这个版本并不理想。在一些简单场景下工作良好,场景稍一复杂,就显得捉襟见肘。

比如下面代码, 我的热更新实现就无法很好的进行更新。

--M1.lua
local M1 = {}
local case = {
[1] = function() --[[xx]] end
[2] = function() --[[xx]] end
}
function M1.foo(x)
    case[x]()
end
return M1
--M2.lua
local foo = require "M1.foo"
local M2 = {}
function M2.foo(x)
    foo(x)
end
return M

它即不能很好的更新M1.foo函数,因为M2直接持有了M1.foo函数的引用。也不能很好的更新M1中的case引用的函数, 因为case中的函数不是任何一个函数的直接上值。

当然,我们可以加入一些强制规范来解决问题。比如:不允许一个模块持有其他模块的函数引用,不允许一个函数只被引用而不被函数引用,所有上值全部入表,等等。

不允许一个模块持有其他模块的函数这个问题不大,毕竟可以增加代码可读性。但是,限制总是有或多或少增加实现者的负担,也会限制实现者的创造性。

热更新作为一种非常规修复Bug的方案,我不希望他对实现者做过多的干扰。

在这之后我一度认为进程级滚动更新才是未来,不仅可以修复Bug, 还可以进行不停机功能更新。


直到前两天,和一位同学再次聊起热更新。

我突然想到,进程级滚动更新有一个致命的弱点,他是有损的(当然在WEB领域是无损了,因为WEB服务器无状态)。某个进程在升级期间,会有短暂的服务不可用。

进程级滚动升级和停服升级相比,服务不可用的时间会大大降低。但是就临时修Bug而言,热更新可能还是更为合适。

那么,还是想办法改造一下热更新的实现吧。

我又重新思考了一下整个程序的构成,程序是由模块组成的,而模块与模块的交互是通过模块的导出函数进行的。

这样如果我们以模块为粒度进行热更新,就不太会造成模块撕裂的现象。比如下面代码:

--M1.lua
local M1 = {}
local function foobar()
        print("1")
end
function M1.foo()
        foobar()
        print("foo1")
end

function M1.bar()
        foobar()
        print("bar1")
end
return M1
--M2.lua
local M2 = {}
local functin foobar()
        print("2")
end
local function M2.foo()
        foobar()
        print("foo2")
end
local function M2.bar()
        foobar()
        print("bar1")
end
return M2

如果仅仅只使用M2.foo去更新M1.foo, 就会造成新的M1.foo调用的foobar和旧的M1.bar调用的foobar不是同一个函数。

但是以模块为粒度进行更新就不会有这种问题,我很快便实现了第二版

随后发现,第二版除了解决了模块撕裂问题外,和第一版没有本质区别。第一版存在的问题在第二版依然存在。

问题并不是解决不了,但是这些方案都代表着超高的代价,我认为在生产环境这是不可接受的。

case表中引用函数问题,可以递归遍历上值中的Table来解决。但这里有一个潜在的风险,不小心引用了一个超大Table, 可能会有遍历整个LuaVM的风险。

一个模块持有其他模块引用的函数问题, 可以通过遍历整个LuaVM中函数的上值来修正。


种种迹象表明,不可能存在一种方案,即智能又高效又正确。

即然如此,只好采用一种更灵活,但是复杂的方案了。虽然很复杂,但是能解决问题

这个复杂的方案就是:不管是以函数为单位还是模块为单位,都不再实现一键热更功能。取而代之的是,每次Bug的修复,都需要重新编写修复脚本

修复脚本中,我们可以使用Lua原生的debug.upvaluejoin来正确的将修复函数引用到被修复的函数的上值,然后使用修复函数替换被修复函数

针对一个模块持有另一个模块的导出函数引用的情况,我们也可以使用debug.setupvalue来进行修正。

与此同时。我观察到,模块撕裂在某种程度上是不会有副作用的。

比如下面代码:

M.foo和M.bar虽然调用的不是同一个foobar函数,但是由于foobar1和foobar2逻辑相同,并且共享上值。这种情况下,模块撕裂现象虽然会增加内存,但却并不会有副作用。

local M = {}
local a, b = 0,0
local function foobar1()
    a = a + 1
    b = b + 1
end
local function foobar2()
    a = a + 1
    b = b + 1
end
local function M.foo()
        foobar1()
        print("foo2")
end
local function M.bar()
        foobar2()
        print("bar1")
end
return M

修复脚本的方案下,修复逻辑可以是千变万化。但是一个共通点,我们在编写修复脚本时总是需要先定位到一个函数,然后对两个或两组函数进行上值的join。

在这种思路下,我实现了第三版热更新。共提供三个接口:

  • sys.patch.create 用于创建一个上下文, 用于在随后的修复操作中做一些必要的缓存。
  • sys.patch.collectupval(f_or_t)用于收集一个或一组函数的上值信息, 该函数会递归遍历所有上值为函数的上值。
  • sys.patch.join(f1, f2)/sys.patch.join(f1, up1, f2, up2)用于将f1函数的上值以上值名字为关联信息引用f2的上值, 如果f1的某个上值名字不存在于f2的上值列表中,则这个上值的路径将会被存储到路径列表中, sys.patch.join在执行完之后, 总是会返回一个路径列表, 即使这个列表为空。

我们应该如何利用上面这组接口定位一个函数呢。

通过观察,我们可以得知一个事实。函数与函数的引用关系其实是一张有向有环图。这也就意味着不管我们想定位哪一个函数,它一定就在某一个函数的上值或者上值的Table中。

因此,sys.patch.collectupval收集来的上值,不但可以辅助我们做新旧函数的上值引用,还可以辅助定位我们需要更新的函数。

最后以一个简单的热更新例子来结束:

--run.lua
local M = {}
local a, b = 1, 1
function M.foo()
    a = a + 1
end
function M.bar()
    b = b + 1
end
function M.dump()
    return a, b
end
return M
--fix.lua
local M = {}
step = 3
local a, b = 1, 1
function M.foo()
    step = 4
    a = a + step
    b = b + step
end
function M.bar()
    b = b + 1
end
function M.dump()
    return a, b
end
return M
--修复脚本 将使用fix.lua来修复run.lua中的逻辑
local patch = require "sys.patch"
local run = require "run"
local ENV = setmetatable({}, {__index = _ENV})
local fix = loadfile("fix.lua", "bt", ENV)()
local P = patch:create()
local up1 = P:collectupval(run)
local up2 = P:collectupval(fix)
local absent = P:join(up2, up1)
print(absent[1], absent[2])
debug.upvaluejoin(up2.foo.val, up2.foo.upvals.b.idx, up2.dump.val, up2.dump.upvals.b.idx)
print("hotfix ok")
for k, v in pairs(fix) do
    run[k] = v
end
for k, v in pairs(ENV) do
    if type(v) == "function" or not _ENV[k] then
        _ENV[k] = v
    end
end

在这个例子中,我们可以看到fix.lua中的M.foo函数引用的上值b, 是不能被正确引用的。因为run.lua中的M.foo函数没有上值b, 这时我们就需要手动调用debug.upvaluejoin来修正fix.lua中的M.foo函数的上值b的引用。

GC竞争问题

阅读Lua GC源码中,我们就提到过一个细节,所有带有__gc函数的对象,在第一轮GC循环中只会执行__gc函数,直到第二轮GC才真正清除。

一直没有找到必须这样做的场景,直到最近我发生了一例GC竞争的bug之后,才恍然大悟。回过头想想,其实在我之前翻译Barry Hayes大神的一篇论文里也早都提到过,只不过当时例子是释放OS资源,而场景也太过抽象,才没有引起我的注意。下面来看一个MWE。

我已经尽可能的精简代码,然而还是需要170+LOC。

在这个例子里,实现了一个链表管理,所有的link和node结构均交由Lua GC来自动管理内存。

由于Lua GC不能分析C结构之间的引用关系,因此所有的node(userdata)必须通过一个Table来保持引用,以防GC误回收。这也是为什么在luaopen_link函数中,我们为所有函数绑定了一个相同的UpVal(Table)。

下面来分析为什么会有竞争的发生。

首先这个UpVal是全局的,也就是会与luaVM同生共死。所以`UpVal中node对象的生命周期` >= `link的生命周期`。

因此竞争问题只会出现在UpVal的生命周期与link的生命周期一同结束时。假设UpVal和link的生命周期都在GC 循环C1中结束。没有机制能保证UpVal先于或后于link死亡,因此node对象的__gc和link.free在这一周期是竞争执行的。这就是RACE1和RACE2都需要将buff置为NULL的原因,只要有一方不置为NULL另一方就有可能出现double free。

之所以置NULL不会有memory corruption问题. 是因为在Lua GC实现中,所有带__gc函数的对象,在当前GC循环死亡后并不会立即释放内存,而是会等到下一轮GC循环才会真正释放。换句话说只要在本轮GC循环中,不管什么时间访问操作node指针都是有效的。

反过来讲,由于在释放过程中可能存在竞争或释放过程中循环依赖的情况。GC模块要保证在执行__gc函数过程中,所有需要的数据都是有效的,就必须要延迟一个GC循环来回收内存。

ps.其实上面的竞争问题可以通过其他手段解决,比如为link设置一个__gc函数来释放buff, 移除掉node对象中__gc函数的释放buff行为,但是不管怎么样竞争场景确实存在,比如两个user data相互引用,并被同一个Table持有等。

又一个lua调试器

最初,我并没有打算为Silly提供一个lua调试器,因为我本人不是一个重度调试器使用者。在开发期间出bug,看一下代码,打几条log可能比使用调试器会更快的找到问题,尤其是服务端程序,在很多时候其实只有log这一原始工具可以使用。但就我周围的人来推断,应该还是有很多重度调试器使用者。

因此,最终还是计划为Silly增加一个lua调试器作为基本组件存在。但是这个调试器到底以哪种方式提供调试功能,我一直在纠结中。

就我个人而言,我用过至少两种完全不同的调试器,gdb和dtrace(严格来讲dtrace可能不算是调试器)。

gdb主要用于开发期间进行阻塞式调试,虽然也可以写调试脚本用在生产环境中调试,但是在gdb加载符号文件时,延迟比较明显。

dtrace提供的是动态跟踪手段,在程序中放置相应的探针,当程序执行到探针的位置时,就执行这个探针上的函数。在放置探针时几乎没有卡顿延迟的现象,而且在放置探针后其对程序的整体执行性能影响甚小。因此在特殊时期可以用于生产环境调试使用。

最开始我觉得也许应该为Silly增加一个类似dtrace的调试器。这个调试器只提供一个probe接口,probe有两个参数,参数1是代码位置(文件名与行号),参数2是一个探针函数。当调用probe注册一个探针之后,代码执行到指定行时,就会执行调用probe时注册的探针函数。这样当线上出问题时,就可以像dtrace一样编写一段代码注入到程序中,将我们所关心的信息全部dump出来,而不会影响程序的正常执行。

但是,最近重新仔细思考发现,dtrace之所以可以如此高效,是因为他可以有各种加速手段。比如在增加探针时,可以通过调试信息快速定位到要增加的探针在CPU指令的哪个位置,还可以通过int 3中断被动触发探针函数等(这些只是大概的猜测,具体实现也许不同,但是至少可以证明的是,他可以非常高效的确定探针函数是否应该被执行)。而在lua中,即使仅仅确定什么时间探针函数需要被执行,也至少需要在全局hook事件”call”,在每次触发”call”事件时,遍历检测是否有探针触发。这会极大的拖慢程序的整体性能。因此即使实现了探针机制也不适合在生产环境做调试使用。

既然不可以在生产环境使用,那么做成类dtrace的方式也就没有必要了。如果是在开发期间使用,还是Gdb那种阻塞交互式调试最方便易用。

这不是第一次实现lua调试器了,两年前我就实现了一个非常简易并且低效的阻塞式调试器

当时我们的客户端代码采用lua编写,偶尔会出现一些偶发性bug,并且出错的地方总是一些没有提前预料到的地方,因此没有任何log。而当我们在出错的地方加上详细的log再重启客户端后,问题又不能被重现了。

这个调试器就是这种情况下产生的,他的作用仅仅是当出现偶发性bug时,能够在不重启客户端的情况下,打印出我们想要的上下文。因此在实现时一切从简,只有s(单步步入)、c(继续运行)、 b(设置断点)和 d(删除断点功能)的功能,并且没有考虑整个调试器的效率。


这次实现,希望除了要具备上一个调试器所有功能以外,还要支持gdb中命令n(单步步过)的功能。此外,它还应该是高效的。

在Silly中,所有的代码都是被包在某一个coroutine中被执行的,整个程序运行期间,可能会有数十或上百个coroutine并发执行。因此在调试某一个coroutine中运行的代码时,应该尽可能的保证其他coroutine应该可以不受调试器的干扰而正常执行。

这样就不能使用之前调试器中的方式(直接阻塞等待命令输入)来实现阻塞式调试器。因为那样会塞住整个Silly进程,所有的coroutine都会被卡住。因此,整个lua调试器的命令输入均来源于socket, 这样可以充分利用Silly自身的高并发能力而减少阻塞。

我把整个调试器实现成了一个调试状态机,共分为三个状态,分别是:checkcall, checkline、checkbreak。

程序运行时,整个调试器处于checkcall状态。在checkcall状态,调试器会检测所有的coroutine的”call”事件。只检测“call”事件可以最大程度保证程序的运行效率,而之所以会检测所有的coroutine,是因为这时还不知道被断点的代码将会在哪个coroutine上被执行。

每次触发”call”事件后,都会检测此函数中是否被设置断点。如果被设置断点,则进入checkline状态。

进入checkline后,将程序中所有的coroutine的事件检测取消,只保留当前coroutine的”call/return/line”事件,并且此后,所有针对事件检测范围的修改均只影响此coroutine。

在每个line事件被触发后,都要检测此行代码是否被设置断点,如果有则进入checkbreak状态。

需要说明的这里有一个可能存在的优化,比如下面代码。

function foo(x)
...
end

function bar()
local x = 3
...
foo(x)
local y = 10
...
end

假设在checkcall状态下触发”call”事件的是bar函数,并且有断点设置在bar函数内。这时调试状态机会进入checkline状态,如果foo函数中没有断点的存在,理论上我们只需要检测bar函数中所有行是否触发断点即可,也就是在执行foo函数时,不必触发”line”事件。这也是在checkline状态,我们需要检测”call”和”return”事件的原因。

checkline状态中,如果触发了断点,则挂起当前coroutine并进入checkbreak状态。

checkbreak状态主要处理用户命令输入比如b(设置断点)、d(删除断点)、s(单步步入),n(单步步过)、c(继续运行)等命令。

b/d/c命令都没什么好说的,无非就是向断点列表中增加删除断点信息,及将因为触发断点而被挂起的coroutine唤醒。

命令s的实现是参照Intel x86 CPU架构中的TF标志的思路,即如果单步标志位被置起,则每执行一行都会有触发断点的效果(当前coroutine被挂起等待,用户下一次命令输入)。

命令n在实现时,其实费了颇多周折。如果这一行的代码是调用一个函数,命令s会直接进入到这个被调用的函数的上下文并暂停执行并等待用户输入命令。而命令n则是直到这个被调用的函数返回才会暂停执行。

最开始是通过设置临时断点的方式来实现的,每执行一行,就为下一行设置一个临时断点,后来发现当函数执行到最后一行时,下一行的临时断点就失效了。

经过多次分析总结之后,发现命令n和s惟一的差别仅在于函数调用这里,其余情况下,n与s的作用一模一样。

有了这个关系,问题就简单多了。引入一个变量calllevel代表调用层数,每当call事件触发时,calllevel加1,每当return事件触发时,calllevel则减1。这样calllevel为0时,命令n就可以和命令s的处理方式相同。

这里同样有优化的空间,在“call”事件响应中,如果calllevel大于1,说明我只关心”return”事件,就可以把“line”事件的检测取消。

虽然我前面说要做一个高效的调试器,然而这个版本其实还是有很多可以优化的空间,比如可以重新设置断点列表的数据结构,以使在checkcall状态时可以尽快检测当前函数中是否有断点等。不过,至少这个版本,在使用lua debug hook上,我已经做到了尽可能的高效。

给silly增加热更新

最新抽了点时间给silly增加了一个silly.patch模块,用于对热更新提供一些有限的支持。

热更新最麻烦之处莫过于“数据迁移”, 即怎么使新函数(要更新的函数)以“运行时数据”的状态运行。

其实http这类无状态协议是最为简单的,因为他们不需要“数据迁移”的过程。http的这种架构,使得所有的函数都是无副作用的,所有的数据在请求结束给出Response的同时, 数据就已经存入了数据库。当需要热更新函数时,根本就不需要考虑数据的问题,直接替换就可以完美解决。

与此相对的是,通常的服务器或应用程序都会有“非局部变量”的存在(所有生命周期不是在函数被call时建立,ret时销毁的变量都可以认为是非局部变量,比如lua中的全局变量或上值)。

对于这类程序,在热更新时,就必须要小心处理这类数据,使热更新之后的函数安全的以“运行时数据”所代表的状态运行。当涉及数据结构或功能变化较大时,这种“数据迁移”的安全性很难面面俱道,也很难提出一个通用的解决方案。

再考虑一下解bug的场景,大多数情况下,bug可能只会出现在某几行代码或某几个函数之中,一般会延续之前的设计而不太会有大量数据结构或大量代码的改变。

在这种情况下,热更新实现的复杂度就可以降低不止一个数量级。在实现上也可以有更好的保证。

因此,这次新增的silly.patch模块也仅仅对热更新bugfix做了一些支持。


silly.patch模块只提供一个功能,即将a函数中的所有“非局部变量” patch 到b函数中去,以便b函数以a当前的运行时状态继续运行。

借助luaVM提供了一组调试函数,使得我们可以方便的对a,b两个函数进行“数据迁移”。

比如,使用debug.getinfo来遍历出a,b两个函数的所有上值,然后使用debug.getuservalue和debug.upvaluejoin将b函数的所有上值均引用至a函数的上值。这样就可以使逻辑以b函数的代码以a函数的数据去运行。

但是这里面有些问题需要处理.

a,b两个函数必然不会相同(不然也不会去更新了),那也就不能保证a函数的第一个上值意义与b函数的第一个上值意义完全相同。比如下面代码:

local foo = "hello"
local bar = "world"
local function f1()
	print(foo)
	print(bar)
end
local function f2()
	print(bar)
	print(foo)
end

如果a函数是f1, b函数是f2。f1的第二个上值是foo(因为使用了print,所以第一个上值是_ENV),而f2的第二个上值是bar。如果按上值的id去patch,上述代码就会出现很诡异的bug。

因此silly.patch模块做了一个简单的约定,如果要拿b函数去修复a函数,就必须保证a函数中使用的所有上值在b函数中必须不得改变其意义。

有了这个约定,silly.patch就可以根据“非局部变量”的名字去进行“数据迁移”。 比如f1使用了上值foo, f2中只要foo的意义不变,不管他属于f2的第几个上值,都可以保证“数据迁移”的正确性。

还需要注意的是,如果a和b函数的上值变量是函数时,需要递归对其上值函数进行“数据迁移”。


silly.patch仅仅是对热更新做了支持,他并不是一个完整的热更新模块。还需要对silly.patch进行一定的封装才可以使用。

在进行封装时一般的步骤为,生成新函数b,找到等修复函数a, 执行silly.patch, 将a的数据迁移到b函数上, 然后使用b函数替换为a函数。

生成新函数b,一般是通过load/loadfile来生成一个chunk, 然后调用chunk来生成.

为了避免在调用chunk函数时有副作用,一般在调用load/loadfile时,会传入一个新的_ENV表作来将chunk置于沙盒之中,如果有顾虑新的函数b会使用新的全局变量(即函数a从没使用过的全局变量),可以在整个热更新的最后, 将load/loadfile时传入的_ENV表有选择性的合并运行时环境中(只合并运行时环境不存在的变量)。

比较麻烦的是怎么找到要修复的函数并进行替换,lua中提供的debug接口中并不能获取一个chunk中的所有函数。

当然就算提供了这样一个接口也很难使用。在lua中,function是first class, 这就意味着当你定义两个变量指向同一个函数时,这个函数就拥有了两个名字。

因此在我们约定不给可能会热更新的函数起别名的情况下,有两种实现方式。

一种不太通用的实现是,在每个chunk中实现两个函数,一个函数提供通过名字对chunk中的任意函数进行定位,一个函数提供通过名字对chunk中任意函数进行替换。这种方法比较麻烦,而且容易出错。

另一种方式依赖于一个事实,一般每一个lua模块都会导出一些接口函数供其他函数使用,那么就从这些接口上做文章。

比如我们想要将module模块中a函数热更新为b函数,我们可以直接require “module”得到module模块的接口函数表,然后再根据名字定位到相应的函数,如果替换则直接将module模块函数表中的相应字段重新赋值就可以了。

如果想要热更module中某个local函数,就比较很麻烦,但是也可以办到。在上文silly.patch的实现中可以得知,如果一个函数有上值,而且上值是一个函数的情况下,silly.patch同样会对上值函数进行“数据迁移”,这也意味着,上值函数同时也会被热更新到最新。所以,在需要热更新某个local函数时,可以通过热更调用他的模块接口函数来实现。

但是需要注意的是,这里有一个坑。如果函数module.a1和函数module.a2同时引用了module中的一个名为foo的局部函数。如果只热更module.a1的话,module.a2将依然会使用旧的foo函数。

基于通用考虑,在silly的console模块提供了一个patch命令, 这个命令正是基于方式2来实现的。

实现了一个lualint

在使用动态语言的过程中,由于其运行时检查特性,很多手误并不能在刚运行时暴露出来。虽然并不会导致程序个程序crash掉,但总归是很麻烦的。

网上找了一个lualint试了试,效果还不错。这个工具的原理就是基于在写lua代码时并不会使用全局变量。在检测到全局变量时直接报警即可。

仔细研究了一下这个lualint的原理。

lua自带的工具luac是可以把编译后的OPCODE打印出来的,更关键的是自带注释。

利用OPCODE和注释就可以分析出在哪一行哪些变量是使用了什么全局变量。只要我们在代码中不使用全局变量这一事实成立,那么所有使用了全局变量的地方都可能是手误打错了。

但对于我来说这个lualint有2个缺陷。

1. 但这这个lualint是并不支持lua5.3版本。因此在使用如’//’这类lua5.3新支持的特性时,这个lualint工具就不能很好的工作了。
2. 如果我require了一个表,并且使用了这个表中不存在的字段,那么他并不会报警,这对我来说很不习惯。因为大部分模块调用都是使用通过使用module.member的方式来调用的,如果member的名字打错(如A.l1打成了A.ll), 这个工具并不会报警出来。

基于以上原因,我基于lua53重新实现了一个lua分析工具修复了上述缺陷。虽然依然叫lualint, 但为了简化实现复杂度,这个lua检查工具在部分地方使用了动态分析。

在lua53中,对全局变量的作法做了些改动。

所有全局变量都是放在_ENV表中存放的,而一个代码块的所有代码是共享一个upvalue的。而访问全局变量是通过类似 ‘GETTABUP 0 0 -1 ; _ENV “print”‘的OPCODE来完成的。因此检测访问全局变量的OPCODE要改为监测使用GETTABUP/SETTABUP 对于_ENV表中的字段进行操作的代码。

考虑如下代码:

local b = require "testb"
--code1
print(b.CONST_A)

function hello()
        --code2
        print(b.CONST_A)
end

对于b.CONAT_A中的访问检测则稍微有些麻烦。

在code1处对模块testb的成员CONST_A的访问OPCODE和在code2处的OPCODE并不相同,因为code1属于main chunk而code2属于function chunk, 变量b与code1是平级的,即相当于code1的局部变量,而对于code2来说b变量为函数hello的upvalue, 因此在main chunk中访问模块testb的成员CONST_A使用的是GETTABLE而在function chunk使用的是GETTABUP. 顺便插一句,lua之所以能够实现的如此简洁,应该与其这种设计的统一性不无关系。

如果通过分析OPCODE来检测模块B是否有CONST_A变量虽然也可以做到,但是会很麻烦。因此这里取了个巧,采用了动态加载的方式。模拟require的行为,然后将模块testb的返回值接收下来并保存,当访问testb的成员时直接查看testb返回的表中是否有这个成员即可。

当然事情不可能这么简单,在函数中访问testb中CONST_A时会生成的OPCODE如’GETTABUP 1 1 -3 ; b “CONST_B”‘。 这里存在两个麻烦:一个是如果b是某个模块的别名,那么怎么确认他是哪个模块的别名,,因为生成require的OPCODE并不会有别名b的信息。一个是怎么找到这个b到底是模块testb的别名还是此函数的另外一个上值。

对于第一个问题,其实在分析require的过程中,我会重新再解析一遍当前代码文本,require中的那一行找到相关模块’testb’的别名即为b,做一个反向映射,如果GETTABUP指令访问b就把b当做别名找出真正的模块名’testb’,从而找出模块’testb’的表引用。由于需要分析require,因此顺带着就把整个project的依赖关系给解析了,在使用时也就不需要使用如find . -name ‘*.lua’ | xargs grep ./lualint 之类的命令来使用了。设置完ENTRY之后,直接使用./lua lualint.lua就可以自动分析完整个project是否警告信息。

对于第二个问题出于对实现的简化,采用了鸵鸟政策,碰到此类情况均当做是对模块’xxx’的变量的访问。当然做了一点小小的优化,如果从别名找不到真正的模块名,就认为访问的不是模块的成员变量,直接忽略。

ps.由于急用,实现的有些丑陋,因此附一篇文章大致说一下思路:D

使用lua过程中需要注意事项

最近一段时间写了不少lua代码,由于这一次的代码量比以往都多,因此也就遇到了以前没有遇到问题, 需要说明的是,这就是一篇lua问题大集合, 并不涉及其他。

先说说遇到的问题及解决方案。

在lua中没有多余的数据结构,全靠一张表,这也是他简洁的根本所在。

当索引为全为数字时即可当数组使用,然而需要注意的是lua假设数组的索引一定是连续的,在此基础上lua尽可能快的优化了取数据长度的算法,因此lua对‘#’操作符的描述是“当table[i]不为nil, 而table[i+1]为nil时,则数组的长度为i”.

因此如果table = {1, nil, 2, 3}, 那么使用#table得到的结果会是1.因为table[1]不为nil, 而table[1+1]为nil。其实这一点在”Programming in Lua”是有明确说明的,只不过在写代码时我忘记了。

如果想删除数组中的元素而又保证#table的正确性,可以使用table.remove函数来删除指定位置的值,table.remove所做的事情其实就是把所有元素向前移动一位,因此在使用table.remove时一定要估算好移动元素带来的开销是否超出了预期。如果超出了预期就要考虑其他方案,而其他方案也与‘#’引起的问题没有关系了。

‘#’的语义带来的麻烦还不仅仅于此。考虑下面一段代码:

        function test1(...)
                local param = {...}
                local p1, p2, p3, p4 = table.unpack(param)
        end

当调用test(1, nil, 2, 3)之后,得到的结果是p1 = 1, p2 = nil, p3 = nil, p4 = nil。

之所以这样是因为table.unpack(param) 等价于table.unpack(param, 1, #param), 而’#’的语义导致了#param得到的结果是1,所以table.unpack其实只unpack出了第一个值而已。

即然存在此类问题,显然也存在着解决方案。lua的table库中提供了一个函数table.pack。table.pack(…)与{…}惟一不一样的地方就是,table.pack会自动识别…中有几个参数,并把这个值赋给返回的表中的”n”字段。

所以上述代码要想支持传递nil就必须改成如下:

        function test1(...)
                local param = table.pack(...)
                local p1, p2, p3, p4 = table.unpack(param, 1, param.n)
        end

再说说优化问题。

3R在很多文章上都有提到,就不再多说什么了,但是对于Reuse需要注意的是,并不是所有的值都值得Reuse,需要考虑这个表的Reuse的代价有多大。

考虑一个表从产生到销毁的过程(不包含对元表的__gc函数的调用过程)。

local xx = {} 就代表着要构造一个新表,这就代表要调用malloc, 当这个表不再被使用时,就会被gc回收free掉。

如果我们可能重用xx表,那么就可以省去malloc/free的开销,要知道malloc的开销是相当大的。

但是这里需要注意的一个问题是,xx已经有了预分配槽位,假设之前xx表中被设置过xx[1]~xx[100]个数据,那么就意味着重用xx表后,向xx[1]~xx[100]的位置赋值是是不会触发malloc进行扩容的。

之所以说这是一个问题而不是一个优点,是因为在不同的情况下,这种重用有时有利有时有害。这取决于被重用的xx表的用途,如果xx几乎是固定大小,那么显然这种不需要扩容就是有利的,反之如果xx大部分情况下都很小,就会浪费很多空间。

当重用的表是一个模块的upvalue时,还需要考虑的是当你为这个表的每一个槽位都赋为nil的代价与构造一个表的代价哪个更高,如果无法评估则应该选取语言更简单的模式“直接构造一个表”。

next函数常用来进行无序遍历整张表,同样你必须知道他的底层实现,才能够很好的使用它。从lua源码中可以找到next函数具体实现,当传递给next的key为nil时,事实上next函数会从table的第一个空槽位开始遍历,直接找到第一个值为非nil的key并返回。因此在使用next进行遍历时,必须对整个table中的布局做到心中有数。

考虑一种极端情况,有一个table有1M个数组空间,只有在索引接近1M时,值才不为nil,其他值都为nil,如果这时候需要频繁的调用next来遍历整个表,势必会造成程序性能急剧下降。这种情况下就需要在表的基础上构造出相应的数据结构来提高程序性能,比如记录第一个非nil的起始索引。

在lua中有很多基本库和函数是存在于_ENV表中,比如table库,math库等,如果调用这些函数很频繁,可以考虑将函数局部化,能显著提高程序性能。

下面对比一下两段代码,和编译后的OPCODE

--使用全局函数
local tbl = {}
for i = 1, 100 do
        table.insert(tbl, i)
end
--OPCODE
main <a.lua:0,0> (12 instructions at 0x7fc748c043f0)
0+ params, 8 slots, 1 upvalue, 5 locals, 4 constants, 0 functions
	1	[1]	NEWTABLE 	0 0 0
	2	[3]	LOADK    	1 -1	; 1
	3	[3]	LOADK    	2 -2	; 100
	4	[3]	LOADK    	3 -1	; 1
        --for i = 1, 100 do
	5	[3]	FORPREP  	1 5	; to 11
	6	[4]	GETTABUP 	5 0 -3	; _ENV "table"
	7	[4]	GETTABLE 	5 5 -4	; "insert"
	8	[4]	MOVE     	6 0
	9	[4]	MOVE     	7 4
	10	[4]	CALL     	5 3 1
	11	[3]	FORLOOP  	1 -6	; to 6
        -- end
	12	[5]	RETURN   	0 1

--全局函数局部化

local tinsert = table.insert
local tbl = {}
for i = 1, 100 do
        tinsert(tbl, i)
end
--OPCODE
main <b.lua:0,0> (13 instructions at 0x7fb5a94043f0)
0+ params, 9 slots, 1 upvalue, 6 locals, 4 constants, 0 functions
	1	[1]	GETTABUP 	0 0 -1	; _ENV "table"
	2	[1]	GETTABLE 	0 0 -2	; "insert"
	3	[2]	NEWTABLE 	1 0 0
	4	[3]	LOADK    	2 -3	; 1
	5	[3]	LOADK    	3 -4	; 100
	6	[3]	LOADK    	4 -3	; 1
        -- for i = 1, 100 do
	7	[3]	FORPREP  	2 4	; to 12
	8	[4]	MOVE     	6 0
	9	[4]	MOVE     	7 1
	10	[4]	MOVE     	8 5
	11	[4]	CALL     	6 3 1
	12	[3]	FORLOOP  	2 -5	; to 8
        -- end
	13	[5]	RETURN   	0 1

对比可以看出使用局部变量tinsert代替table.insert之后在for循环内少了两条对table的操作(GETTABUP, GETTABLE) 效率大概提高了10%

当语言内置操作和标准库一样时,应该优先选择语言内置操作。其实这很好理解语言内置操作执行一起一般比调用标准库要生成更少的OPCODE,在功能一样的情况下一般OPCODE数目越少,应该就越高效才对。
在循环100W次的情况下table[#table+1]=i要比table.insert(table, i)快30%左右。

btw, 在看lvm.c的过程中,越来越觉得脚本语言是比C/C++更高层次的抽象,不过也只有这样才能达到简少代码的目的。然而高层次抽象就意味着我们失去了很多优化的能力。

lua gc的使用

主流的垃圾回收一般都是基于引用计数和标记清除算法.

从内存占用量上来讲, 引用计数无疑是有优势的, 当引用计数为0时, 直接就会将相应的对象清除, 典型的应用就是C++的智能指针. 但是基于引用计数的gc有一个坏处, 它无法解决循环引用问题. 如果A引用B会导致B的引用计数+1, B引用A也会导致A的引用计数+1, 这样A,B对象永远也不会删除. 记得在OC中似乎使用了弱引用来解决这个问题, 但总感觉这样会给代码中埋下坑.

标记清楚算法可以完美的解决循环引用问题, 其做法是从root遍历所有可达的指针, 然后将不可达的区域回收. 这样即使A和B循环引用, 只是没有其他对象来引用A和B, 那么A和B对象即是不可达. 此时A和B对象即可删除. 但是标记清除算法有一个致命缺陷, 就是在gc时需要停顿整个程序来进行mark-sweep.

最近抽了点时间研究了下go语言, 果然可能是由于太年轻, go也有这个坑. 在搜索资料时发现很多人都在吐槽go的gc部分, go的主要应用领域就是服务器部分,然而据说go的gc会造成不可预知的停顿,这对于服务器来讲是非常致命的.

看到这, 我突然非常担心lua也有此问题, 而在silly中也是以lua来写上层逻辑的, 很担心lua中的gc是否也会造成stop-the-world. 于是找来手册重新认真的读了一下, 发现以前对于lua的gc的工作方式太想当然了, 以致于造成了对lua的错误使用.

lua中的gc算法是采用增量标记清除算法, 通过一次执行一小步来将一次完整的gc时间分摊在每一步上, 提高了整个程序的实时性.(具体如何做的,还要等看完源码才知道:D)

在lua中是分为手动gc和自动gc的.

通过设置垃圾收集器间歇率和垃圾收集器步进倍率来控制自动gc的运行方式.

有某些特殊的条件下, 如果我们需要手动控制gc的时机, 这时就可以LUA_GCSTOP来停止自动垃圾回收器. 然后在恰当的时机手动调用LUA_GCSTEP来发起一次增量垃圾回收.

这样看来, 至少在gc的策略上, lua应该做的比go要好. 当然具体情况还是要等看完实现才能更确切的比较.

btw, 作为一门现代语言, gc已经是一个不可缺少的部件. 然而gc的问题在于, 在平时开发时完全体会不到问题, 一旦在大量数据面前就有可能会出现各种情况. 因此熟悉一门语言绝不仅仅是熟悉其语法就好了, 还必须要深刻了解该语言的Gc机制, 才能做到心中有数.

lua的debug_hook接口应用

lua本身并没有提供调试器, 但是提供了一组称为debug_hook的API, 这组API可以用来编写自己的调试器或者一些其他的东西。

这两天的工作主要就是研究了一下debug_hook这组接口, 写了一个极简化的lua调试器和一个lua性能分析器.


lua调试器现在仅支持break point, step, print, coutinue这几个功能。

由于最近写lua已经习惯于不用调试器了, 因为实现的比较简单。

下断点时, 将要断点所在的文件与行号存入表中。

在不考虑效率的前提下, 直接用line mode来hook中每一行的运行,然后在每个line_hook函数中去check当前文件名和行号是否与断点信息中进行匹配, 如果匹配则进入调试模式。

在调试模式下, 可以step/print/continue来控制程序做单步/打印变量/继续运行等操作。

在打印变量时, 首先搜寻局变量是否存在此变量, 如果没有则搜寻上值中是否存在些变量, 如果都没有则提示没有此变量。

然而,作为一个能用的调试器来说, 这个调试器还缺的太多。如:打印表中内容, 修改变量, 支持call stack的切换, 非法断点提示, 运行到指定行, 打印堆栈回溯信息等。

由于平时写代码不太依赖调试器,所以暂时不打算继续完善。这里先把思路记述一下,有需要再继续完善。

在简化版本实现中,仅支持打印如integer/string 这样的非table数据, 如果有table数据的话则只打印一个指针。

可以增加对变量类型的判断,如果是table则递归打印表中内容, 这里可能存在的一个问题是直接去访问一个表会触发__index元方法, 因此一定要根据具体情况考虑是否使用rawget。

修改变量与打印变量类似,修改可能会触发元方法__newindex,因此要具体考虑是否使用rawset来修改。

将一个debug模式下手动输入的表赋于某个变量时, 为了更方便输入, 可以支持将表以字符串的形式输入,然后内部使用load来执行这段字符串,然后将产生的表赋于某个变量。

在call stack切换时使用lua_getstack切换到相应的call stack中,然后调用lua_getinfo就可以得到当前的stack中相应的环境。只是这里仅能支持print变量功能。

如果要增加非法断点提示就比较麻烦了,初步的想法是,为每一个文件维护一个可断行号表, 在对某个文件第一次下断点时,过滤掉这个文件的空白行和注释行, 然后把有效行置入可断行号表,再去查询要下的断点是否是有效行。如果不是有效行, 则可以根据情况进行提示, 或向上/向下偏移断点行号。

运行到指定行则比较简单, 下一个临时断点,到达指定行后删除此断点即可。

打印堆栈回溯信息直接使用luaL_traceback函数即可。

最后就是关于调试器效率的优化了。

直接设置line hook效率比较低,可以首先设置为call hook,每次call hook时判断当前函数是否有被下过断点。如果有断点则设置为line hook。每次进入line hook时除了要判断是否命中断点外,还需要判断当前函数中的断点是否已经全部步过,如果所有断点全部步过,将hook设置为call hook。

由于lua有tailcall支持,因此上述优化在某些情况下并不一定能如想象中的那么顺利, 只有等到实际优化时碰到问题再来解决。


相比极其简单的lua调试器来讲,lua性能分析器我花了将近一天的时间来写。

在linux下有gprof可以在很方便的测度整个程序的性能热点, 在windows下VS自带的性能分析器也非常强大。

但是在写lua想测试性能时,要测哪一个函数的时间开销就得去哪一个函数定义处加时间测量代码。

我希望实现一个非侵入式lua profiler,就像gprof一样。

在《Programming in lua》一书中讲解debug_hook时,演示了一下怎么使用debug_hook(call/ret)来测试函数的调用次数,这给了我很大的启发。

我希望这个profiler可以即统计调用次数,又统计时间开销,。

由于lua自带的debug lua接口时间开销很大,想要精确统计出函数的运行时间就必须用debug_hook的C接口来实现。

大概实现如下:
每一个函数单独维护一张表, 表中存有进入此函数的当前cpu时间, 此函数上次ret时已经运行了多少时间,当函数已经被调用了多少次。

设置call hook和ret hook, 在call hook时更新函数的调用次数及进入此函数的cpu时间,在ret hook时根据当前cpu时间和进入此函数的cpu时间即可更新此函数已经运行的总时间。

实现完之后,放到silly中去用即刻就发现问题了。没错是coroutine和闭包这些高级特性。 silly是用大量coroutine和闭包组成的一整套框架,如果不解决coroutine和闭包在profile中的影响,那这个profiler几乎没法使用。

在《Programming in lua》中, 作者使用函数的指针来作为profile数据的索引, 但是在碰到闭包之后, 这种方法就会显得有些麻烦了。

假如有一个函数A可以生成另一个函数, 如果调用A两次分别生成函数B1和B2, 那么B1和B2的函数地址是不同的,在做profile时表中就会有两个此函数的信息, 但是其实两个函数的定义是一模一样的。而且由于使用了函数指针作为索引,为了不影响被分析代码的GC形为,必须使用weak-table来cache住这些闭包的性能数据。但是闭包函数的生命周期一般较短(这取决于代码作者),因此有可能来不及打印此函数的性能数据,此项数据就被GC干掉了。

为了解决这个问题,我不再使用函数指针作为profile数据的索引,而使用“文件名:行号”来做为函数性能分析数据的索引,这样不管有多少个相同函数定义的闭包,其执行时间都是算到同一个函数定义的性能数据上。

coroutine可以使用yield/resume来让出/恢复运行,但是显然从让出到运行这断时间并不能算在这个函数的执行时间开销上, 所以怎么跳过这断开销非常重要。

想要解决这个问题只能在coroutine.yield和coroutine.resume这两个api上做文章。

由于在C代码中调用yield之后下次再被resume时,会直接走到lua代码中,并不会回到上次调用yield的C代码处。为此我不得不使用lua对C接口实现的lprofiler做了一个wrapper。

在lua实现的profiler.start代码中将coroutine.yield和coroutine.resume备份并换成自定义yield/resume函数。
在执行自定义yiled/resume之前调用lprofiler.yield,更新当前call stack上所有函数的运行总时间。在执行自定义yield/resume之后调用lprofiler.resume来更新当前call stack上的所有函数开始执行的cpu时间。

如果多个coroutine共用一个函数,那么每一个函数定义对应于一个性能分析数据就不能再满足需求了.
如co1和co2共用一个函数, 当co1执行到50%时yield让出,如果co2接着执行就会再次时入此函数的call hook, 会重新刷新此函数的函数进入时间,这样当co1执行完成之后,得到的时间开销仅是实际开销的一半。

为了避免这个问题,也为了可以针对某一个具体coroutine进行profile。

可以为每一个coroutine单独存储每个函数的性能分析数据。 由于使用coroutine来做为索引, 因此一定要使用weak-table来存储与coroutine相关的性能分析数据。

在打印性能分析报告时,可根据实际情况,合并所有coroutine中的相同函数定义的时间开销。

由于coroutine一旦被回收其性能分析数据均会被GC掉,因此打印性能分析报告的时机一定要选择好。

如果最终确定不需要对单个coroutine进行profile,可以仅仅为每个coroutine维护所执行函数的开始执行cpu时间。这样即可避免当coroutine被GC后,相关的profile数据丢失问题。

实现多国语言

大概一般的软件设计都需要至少支持两种以上的语言, 但是上一个软件中的多国语言设计一直是我心中的遗憾。今天刚看好看完“3D数学基础”, 学DX又提不起太大致, 于是决定重新写一个多国语言的demo也算弥补了上次的遗憾。

因为第一次设计多国语言的实现, 在设计中踩了许多坑,甚至于后来只能去切换主UI上的所有按钮的字符, 而所有对话框的UI则永远都英文状态。 除了鸡肋我实在想不到更能形象说明这个功能的形容词了。

设计之初的功能定义是可以让程序在运行过程中自由切换多国语言。

为了实现这个功能, 让每一个非模态对话框(包括主UI)都实现一个函数叫OnChangeLanguage(const char *locale)的函数。 当语言菜单被切换到其他语言时, 主UI循环调用所有已被创建的非模态对话框的OnChangeLanguage函数, 让每个对话框自行去语言文件中找到自己需要的相应的语言字符串。

模态对话框则要更特别一些, 因为只要模态对话框处于active状态,他的父UI则全部处于挂起状态。 于是定义所有的模态对话框都需要提供一个public的变量叫m_szCurrLanguage, 在此模态对话框被调用DoModal之前, 此变量会被其父窗口设置为当前的语言。 然后模态对话框则在Initialize的时候根据当前的m_szCurrLanguage自行去语言文件中取得相应的控件字符串去显示出来。

每个语言文件都采用了一个独立的Windows下的ini文件格式进行存储, 切换不同的语言时, 去不同的ini文件中索引。 ini文件的格式 以id=string的方式存储, id是指需要这个字符串的控件ID, string是这个控件需在显示的字符串。

事实上制定出这样一套规则, 坏的味道已经出来了, 但我还沉浸在多国语言的高大上之处而混然不知。


现在重新看来, 在软件运行过程中, 动态切换语言根本就是不必要的, 这是一个过度设计。在拿掉这个过度设计之后重新去审视这个需要, 就会发现整个实现很简单明了。

首先实现一个多国语言模块, 大概有init, exit, get三个接口。

init函数用于在程序运行时,传入当前语系。
exit函数用于在程序退出时, 释放语言文件的资源。
get函数则用于返回某字符串在当前语系下对应的字符串。

不管是模态对话框还是非模态对话框, 在显示之初肯定都会调用Initialize函数去初始化一些控件等操作, 而且这个Initialize在此对话框的生命周期内只会被一次。

那么只需要在Initialize函数中通过多国语言模块将字符串转换为要显示的字符串, 并用来将控件初始化问题就OK了。

用id=string的方式去定义语言文件会有很大的局限性, 比如重复ID无法处理, 大量重复字符串浪费资料, 某些log无法被翻译等。

所以语言文件应该被定义为类似字典的机制。同样采用每种语言一个语言文件的方式, 那么语言文件的格式可以使用string_src=string_dst的格式去定义。

多国语言模块的init函数的功能其实就是将当前程序所需要的语言文件加载, 并按照某种格式在组织在内存中。 get函数的功能就是去快速从源字符串索引到需要显示的字符串。

因为翻译功能被抽象成了一个单独的模块, 那么一旦发现此模块效率不足, 只需要去重写或优化这个模块即可, 并不需要去动到动到调用此模块的所有对话框。

多国语言模块可以用hash或某种排序算法进行排序以加速查找以及采用atom的方式来存储翻译文本节省空间。

幸运的是,lua虚拟机的全局表的效率已经足够好, 而且lua虚拟机本身并不大, 完全可以对lua虚拟机做一个简答的封装来实现多国语言模块。

试了一下大概只花了几十行代码就将lua虚拟机封装成了一个多国语言模块。

lua编码风格

最近都是在看lua代码, 并在其基础上进行修改和增加功能. 在代码中看到了不少个人感觉很不好的现象, 就忍不住吐槽一下.

lua做为一门动态语言, 其弱类型及灵活性, 的确大大加快了开发和修改的效率. 但是这种自由有时不加以限制的使用, 有时候可能会造成很严重的后果.

先以我有限的理解说一下lua语言对于访问控制的有限支持.

定义变量或函数只有加上local才代表局部变量或函数, 否则只要这个模块被加载就可以被其他模块访问.
require代表要去加载某个模块, 如果两次调用require去调用同一模块并不会造成同一模块的多次加载.
lua5.1之后加入的module(…,seeall)函数可以自动导出lua函数中的非local函数及变量

在我所看到的代码中到处都是大文件(大的都有1W多), 魔数, 全局变量, 循环依赖, 如果某一模块已经加载并不会再去调用rquire函数等各种问题.

全局变量的存在使得看似将程序分了多个模块, 但是由于全局变量可以被所有模块相互访问和修改, 全无依赖层次可言. 最后不得不将这些模块化为一个整体模块来使用.
魔数让看代码的人搞不情作者意图, 还多了许多可能修改错误的机会.
并没有显式调用require会导致实在看不情模块之间的依赖关系, 对于把握整个程序的结果来看非常不利.
单个文件1W行的代码就像是一大滩稀泥放在那, 让你敢看不敢碰.


虽然lua给予的代码访问限制很少, 甚至于还提供了module(…,seeall)这样方便的函数. 但是如果我们不加限制的去使用只会使程序成为一滩无法维护的烂泥.

《C Programming Language》中有这样一句话 “…限制不仅提倡了经济性, 更在某种程序上提倡了设计了优雅”.

在参考了网上大神的lua源码和《lua程序设计》以后, 我觉得可以采用部分限制来提供代码的可读和可维护性.

require函数的作用就是去加载一个没有被加载过的模块, 并将此模块的返回值记录下来, 作为每次调用require函数的返回值来使用. 那么便可以在require函数上来做文章.

首先除非有必要, 应该舍弃module(…,seeall)函数的调用, 采用返回表的方式来返回某个模块的所有导出接口. 代码如下:

--bar.lua

local bar = {}

local v1 = 3
local v2 = 4

function bar.test()
print("----bar.lua---, v1, v2", v1, v2)
end

return bar

--foo.lua
local bar = require("bar")
bar.test()

虽然相比module(…,seeall)函数来讲, 代码中可以要多打几个字. 但是相对于这种方式提供的好处来讲却是巨大的.

因为采用require函数的返回值来调用相应的模块函数, 那么就限制了只要模块有依赖就必须去显式调用require.
就算不小心写漏了local, 只要不会有意在bar表中去赋值, 那么外部模块也并不能去访问bar模块中的v1和v2变量

由于lua中字符串管理是采用类似《C接口与实现》中的atom的管理方式, 因此字符串比较与整数比较的效率几乎是一样的.
那么其实可以通过直接用字符串传入参数或采用下面这种方式来避免魔数.

--foo.bar

local foo = {}
foo.state = {ONE = "the first step of state machine", TWO = "the step is do something"}

foo.test(step)
if(step == foo.state.ONE) then
dosomething()
elesif (step == foo.state.TWO) then
dosomething()
end
end

毫无疑问我更偏爱上面的方式, 即省了注释, 还能有与使用magic number一样的效率, 在调用此函数时, 还可以减少击键次数.

当然这些限制, 只能某种程序上解决一定的问题, 提高了代码的可读性. 但是像循环依赖, 刻意的全局变量等设计问题, 是无法靠变这些限制来解决的.