首页 » 技术文章 » 【源码解读】lua源码分析之编译器部分

【源码解读】lua源码分析之编译器部分

 

lua虚拟机简介

lua作为嵌入式脚本语言,具有轻量、性能高,与宿主语言无缝接入等优点,现已广泛应用于各个领域,包括游戏服务器、甚至web服务器部分组件也已接入lua,例如nginx+lua作为接入服务器portal,已经应用的较为成熟了。

两个阶段

lua虚拟机在实现上可以分为两部分:

  • 编译部分;
  • 解释执行部分;

编译部分负责将lua代码编译成lua指令,而解释执行部分则读取lua编译器编译成的lua指令,并执行相应的操作。

lua编译部分介绍

当我们编译一个lua文件时,lua将整个文件中的所有lua代码当成一个代码块,调用lparser.c中的chunk函数来编译该代码块,编译完成后,会生成一个函数原型树,生成的原型树如下图所示:

1

该原型树最顶层proto1,即最外层的LUA代码,proto2 proto3 proto4则是最外层定义的function,而proto5则是定义在第proto3中的一个FUNCTION,对应lua源码如下:

print('begin')
    funtion proto2()
        //do something
    end
    ....
    funtion proto3()
        //do something
        funtion proto5()
            //do something
        end   
    end
    ...
    
    funtion proto4()
        // do something
    end

proto1则是整个lua文件代码所有代码生成的一个proto。proto是lua中一个重要的数据结构,其意义为函数原型(并非可直接调用的函数),函数原型中包含了指令数组,调用使用该函数原型生成的函数对象时需要传递的参数列表,该函数原型生成的函数对象描述的引用外部函数变量、常量表等信息。根据函数原型,生成对应的函数对象,就可以被调用了,生成的函数对象,在LUA代码中,使用closure表示,即闭包。在闭包中,引用了对应的proto,因此也就包含了该函数调用需要执行的指令集合,同时在闭包中,有独立的空间来引用外部变量,从而在外部函数返回后,能够继续使用外部函数的变量,以实现闭包的功能(后续详细解释如何实现)。 整个lua的编译过程都围绕着生成上述的proto树,生成proto树后,再使用该树生成闭包,就可以被执行了。下面介绍下proto树中的内容如何生成。当lua碰到一行lua代码(这里假设一行代码即一个表达式)时,会判断该表达式的内容,主要分为下述几类:

  • 1、local变量赋值,如local a = 1, 当然也包含对local f = functon()....end的处理;
  • 2、普通表达式, 如a = 3 * 4,t["a"] = 5等;
  • 3、function定义(注意这里不以local开头,lua编译器区分对待);
  • 4、其他代码块(block),如for while do if等;
  • 5、其他(非关键不做具体介绍);
  • 当碰到1类表达式时,此时会更新proto的local变量数信息,会将该变量名也存储在proto的相关数据结构中,方便该proto的下面的代码引用局部变量时,根据名字来索引该变量,同时为该变量分配一个寄存器ID,后续生成机器指令时,均使用ID来访问该局部变量。注意:这里的寄存器ID,每个PROTO开始时都以0开始,当需要新分配寄存器时,ID自增,回收时ID减1即可,lua解释器在执行时,会根据base+ID值来访问变量,而发生函数调用时,base值会调整,因此每个proto可以从0开始计数,其实lua的寄存器指的是lua_state数据结构中的L->base ~ L->top这一块内存区的某个存储slot。
  • 当碰到2类表达式时,先解释变量a,判定其是全局变量、局部变量或外部变量(UPVAL),然后计算=后的表达式的值,最后生成OP_MOVE指令、OP_SETGLOBAL或OP_SETUPVAL指令等,具体生成指令依据变量索引时找到的是全局变量、局部变量或UPVAL信息。同时计算=后的表达式的值时,也会根据不同情况进行处理,假设如果是代码 a = a * 4,此时也在表达式中访问变量a,根据a的类型生成OP_GETGLOBAL或OP_GETUPVAL等指令。与此同时在计算表达式的值时,很可能需要用到临时变量,临时变量也使用寄存器来存储,当该表达式计算完毕时,将这些临时使用的寄存器释放掉即可(释放即将当前的可分配的寄存器ID做减法);
  • 当碰到3类表达式时,会生成proto,同时这里会生成一条生成OP_CLOSURE的指令,也就是说,当我们定义local xx = function(a)...end时,变量xx获取到的其实是一个以后续函数对象生成的闭包;
  • 当碰到4类表达式时,会根据条件生成不同的跳转指令,同时if do while for包含的所有指令包含在一个block中,在block中定义的局部变量(会占用寄存器),在这些block被编译完成后,会释放被再度使用(因此这里面定义的局部变量出了block后会消失);

阅读整个编译过程(当然还没有全部细读),同时结合lvm.c文件中对部分lua指令的执行逻辑,感觉最大的收获在于弄明白了闭包究竟如何实现的(其他部分暂认为大概了解其逻辑即可)。接下来重点介绍下这部分。假设我们写了下述lua代码:

function A()
   local c = 1
   function B()
       print(c)
       c = c+1
   end
   return B
end

local d = A()
local e = A()
d()
d()
e()

该段代码输出结果是1 2 1
d和e都是调用A生成的一个闭包,d每调用一次,里面的变量值就增1,因此第二次调用时会变为2

当我们执行该段代码时,lua先编译该段代码,三个PROTO,如下图所示:

2

其中proto1对应整个lua文件,proto2对应function A , proto3是被包含在proto2的一个子proto。那么变量A究竟是什么?当我们写function A时(我们忽略掉参数传递以及函数内部代码指令生成部分),那么A最后会是一个函数对象,即一个闭包(生成闭包可以参考OP_CLOSURE指令码的生成)。当调用A闭包时,每次调用,都生成了一个局部变量c,同时生成了一个B,注意这里B也是一个函数对象(即闭包)(该生成过程不能认为每次进入A运行时,都会重新编译function B,因为lua是先编译后执行的,当调用A的时候,编译部分早已完成,这里返回函数对象B是因为生成的OP_CLOSURE指令,lvm.c中在处理OP_CLOSURE指令时,会生成一个闭包)。而函数B中当碰到对c变量的访问时,编译器查找变量时,发现变量在上一层proto中(lua编译器在实现时通过递归来查找,查找变量名通过字符串是否为同一个),会在proto3(B对应的proto)中保存upval信息,当A被调用时,生成一个闭包,此时会根据proto3的中的upval信息,在closure中使用upval来存储局部变量c的指针,这里的存取方式如下图所示,其实closure中的upval引用的是存储在lua_state的openupval数组的某个元素,该元素被调整为指向A中的c变量指针,当A返回时,会将A中的c中的内容拷贝到lua_state中的openupval数组的对应位置,因此A返回后还可以继续使用变量。如下图所示:

3

A返回前后唯一的不同在于(线条1)在返回前存在,在返回后不再存在,closure 3直接引用lua_state中的某个变量。而lua_state在在整个执行期都是全局的,因此不会在A返回后还可以正常访问。而A返回时,其OP_RETURN指令被解释执行时,会将线条1去掉,即拷贝局部变量的值到upval中,从而实现了闭包功能。 从上述实现,我们可以看到,若A中定义了多个function,这些function对应的proto的closure会共用同一个upval。若不通过upval存储在lua_state中,而直接存储在closure中,则不仅会在函数返回时造成可能调整对个upval,且会导致各个闭包都访问自己的变量,这不符合语义。

function A()
   local c = 1
   function B()
       print(c)
       c = c+1
   end

   function F()
       c = c + 3
   end
   return B, F
end

local d,f = A()
f()
d()

这个输出最终结果是4

而在closure生成对lua_state的upval指向的具体代码是在lvm.c中对OP_CLOSURE指令进行解释执行时实现的。在OP_CLOSURE指令生成的时候,会在尾部插入部分指令,用以实现这一功能。

除了闭包如何实现外,还了解到对全局变量的访问代码与局部变量存在近20%的性能差异,下面以一个小实验来做介绍(后续逐步将一些可能优化的点弄成一个小系列):

function globalF()
   //do something....
end

globalF()
......
globalF()
...
...
globalF()

该示例表达的是定义了一个全局函数globalF(因为其并未写成local globalF=function的形式),那么lua将其编译成LUA操作码时,会将其设置为在全局表中操作,globalF的访问也会被生成OP_GETGLOBAL指令,进而会调用lua_gettable在table查找变量,增加了操作,但若写成下述形式,则会节省这部分损耗。

function globalF()
   //do something....
end

local f = globalF
......
f()
...
...
f()

将上述两种写法在系统中做相关实验,得到的数据对比如下:

使用第一种形式时 global
real    0m5.739s
user    0m5.738s
sys     0m0.001s

第二种形式时:
real    0m4.227s
user    0m4.226s
sys     0m0.001s

从上可以看出,一个小小的改动,可以节省近20%的CPU。 对比测试使用的两个文件为:

第一种形式:
function gf()
   local a = 1
end
for i=100000000,1,-1
do
   gf()
end

第二种形式:
function gf()
   local a = 1
end
local ff = gf  //唯一的不同点
for i=100000000,1,-1
do
   ff() //这里
end

这部分的编译原理为:lua编译器在编译这部分代码时,首先碰到了function 关键字,此时会生成一个函数,而该函数名在其上下文中并未碰到局部变量定义等,因此会将其当成全局变量,即将gf存储在全局表中。当在for循环中调用gf函数时,此时会变为从全局表中获取gf,因此多了一步从table查找字符串"gf"的value(该value是一个闭包)的操作,然后再执行。而第二种形式实现时,使用local ff=gf,该表达式会被lua编译为 OP_MOVE指令,将从全局表中获取"gf"的value操作在这里执行了,而for循环中再调用ff时,则直接访问局部变量ff即可,访问局部变量,则直接从寄存器中获取其value(也就是gf的闭包),因此省去了频繁从table中获取"gf"的value这一部操作,从而节省了CPU执行时间。

我们在使用lua实现一些较为复杂的项目时,一般来说会存在多个lua文件,且文件之间还存在包含关系,有的时候为了共享,会将脚本的执行环境设置元表,同时会设置Include方法,使得一个脚本可以去引用另一个脚本,在C语言中通过元表方法及Include引用关系,在不同的lua文件中实现变量跨域访问,此时外部索引的代价会更高,如果妥善使用局部变量来缓存外部全局变量,规避频繁的索引获取,能带来性能提升。但实现也需要考虑热更新,因为被缓存后,如果被缓存的变量发生变化,不会重新得到新索引,可能会造成错误。对于不会热更新的部分则完全可以使用该方法进行优化。

本文作者:马良

原文链接:【源码解读】lua源码分析之编译器部分,转载请注明来源!

0