前言
前面讲了一些Lua对象的实现细节,这一节要从总体上看Lua虚拟机是怎么创建出来的。
全局状态
一个Lua虚拟机所涉及的各种状态和数据,主要是由两个结构来管理的,一个是global_State
,另一个是lua_State
。global_State负责全局的状态,比如GC相关的,注册表,内存统计等等信息。而lua_State对应于一个Lua线程,当创建一个Lua虚拟机时会自动创建一个“主线程”,默认Lua代码就在这个主线程中执行。而通过协程库可以创建多个“线程”,并使Lua代码执行在不同的“线程”中,这一篇先忽略协程的东西,也就是只关注全局状态和主线程。
因为与虚拟机相关的状态都放在global_State或lua_State中,所以虚拟机的API是可重入的,可以多个系统线程中并行执行多个虚拟机,只要确保每个虚拟机一个时刻只在一个系统线程执行即可。
global_State的声明如下所示,这里我去掉与GC相关的东西(占了主要部分),等到以后说到GC时才列出来。
/*
** 'global state', shared by all threads of this state
全局状态,所有线程共享这个状态
*/
typedef struct global_State {
// 内存分配函数,以及关联的用户数据
lua_Alloc frealloc; /* function to reallocate memory */
void *ud; /* auxiliary data to 'frealloc' */
// 短字符串哈希表
stringtable strt; /* hash table for strings */
// 全局注册表
TValue l_registry;
// 随机函数种子
unsigned int seed; /* randomized seed for hashes */
// 终止函数
lua_CFunction panic; /* to be called in unprotected errors */
// 主线程
struct lua_State *mainthread;
// 版本号
const lua_Number *version; /* pointer to version number */
// 错误消息
TString *memerrmsg; /* memory-error message */
// 元方法名,初始化在luaT_init
TString *tmname[TM_N]; /* array with tag-method names */
// 基本类型的元方法,表和userdata之外的元表放这儿
struct Table *mt[LUA_NUMTAGS]; /* metatables for basic types */
// 零结尾的字符串缓存
TString *strcache[STRCACHE_N][STRCACHE_M]; /* cache for strings in API */
} global_State;
frealloc是设置给Lua的内存分配函数,从这可看出Lua是高度可定制的,你可以在调用lua_newstate
时转入自己的分配函数,也可以调用luaL_newstate
使用Lua提供的默认分配函数,代码如下:
// 默认的分配器:
// nsize == 0 : 行为和free一样
// nsize != 0: 行为和realloc一样,当ptr==NULL时,realloc和malloc一样;否则重分配内存,注意返回的地址和ptr可能不一样。
static void *l_alloc (void *ud, void *ptr, size_t osize, size_t nsize) {
(void)ud; (void)osize; /* not used */
if (nsize == 0) {
free(ptr);
return NULL;
}
else
return realloc(ptr, nsize);
}
后面Lua将只调用frealloc,而不会使用如malloc这样的C函数。lmem.h|c
基于frealloc提供了更上层的分配函数,Lua代码通常只用lmem来分配对象或其他数据结构。
strt 为短字符串缓存,这在前面已经有描述。
l_registry 注册表,是一个Table对象,用于保存全局的Lua值,比如主线程对象,全局环境等等。
seed 是用于计算哈希的随机种子
panic 为终止函数,当代码出现错误且未被保护时,会调用panic函数并终止宿主程,通过lua_atpanic
可设置终止函数。panic在luaD_throw
中被调用,随后会调用abort结束程序。你有最后一个机会不让结束程序,就是在panic函数里调用longjmp,使panic永远不会返回,不过这种做法应该也很少用到。
mainthread 主线程。
线程和栈
在调用lua_newstate
时创建global_State和一个lua_State,此即为线程状态。lua_State是一个Lua对象,代表一个线程,它里面最重要的的数据就是一个用于存放Lua值的栈,和函数的调用信息的链表(CallInfo),去掉和GC相关的以及调试相关的字段后,lua_State结构如下:
// 线程对象
struct lua_State {
// CallInfo数量,一个CallInfo代表一层函数调用
unsigned short nci; /* number of items in 'ci' list */
// 线程状态:LUA_OK...
lu_byte status;
// 栈顶地址
StkId top; /* first free slot in the stack */
// 全局状态
global_State *l_G;
// 当前函数的调用信息
CallInfo *ci; /* call info for current function */
// [stack, stack_last]这个范围的栈槽位可用
StkId stack_last; /* last free slot in the stack */
// 栈的起始地址
StkId stack; /* stack base */
// 错误处理链表:当前的恢复点,看luaD_rawrunprotected
struct lua_longjmp *errorJmp; /* current error recover point */
// 初始调用信息
CallInfo base_ci; /* CallInfo for first level (C calling Lua) */
// 栈大小
int stacksize;
};
StkId的类型为TValue*,实际上stack就是一个TValue数组。
statck, top, stack_last, stacksize
这几个字段的含义用下图说明:
![11fee04888cfb8ec03e40961419fc033.png](https://i-blog.csdnimg.cn/blog_migrate/64c8c84795451b3fd09fa1752a30de2c.png)
一开始线程创建一个大小为BASIC_STACK_SIZE
的TValue数组,statck指向这个数组的首地址,statcksize为这个栈的大小,top为当前栈顶,向栈压值后top会往下移(增长)。stack_last为最后可用的位置,即正常的栈操作可以在[stack, stack_last]之间。剩下的EXTRA_STACK个槽位预留,用于元表调用或错误处理的栈操作,也就是这些扩展槽位可以让某些操作不用考虑栈空间是否足够,而导致要重分配栈空间的行为。
对栈的原始操作并不会自动增长栈空间,那样每次都要检查空间,对性能比较有影响。对于每个C函数的调用,Lua确保一开始有LUA_MINSTACK(20)
个空闲槽位可以用,一般情况是非常足够了,对于需要在循环里不断压入元素的操作,应该调用lua_checkstack
:
// 检查当前线程的栈空间是否足够,如果不够会扩大整个栈,
// 同时如果当前函数的栈范围不够,也会扩大
LUA_API int lua_checkstack (lua_State *L, int n) {
int res;
// 当前的函数调用信息
CallInfo *ci = L->ci;
lua_lock(L);
api_check(L, n >= 0, "negative 'n'");
if (L->stack_last - L->top > n) /* stack large enough? */
res = 1; /* yes; check is OK */
else { /* no; need to grow stack */
// 计算出正在使用的大小,EXTRA_STACK也认为是使用的部分
int inuse = cast_int(L->top - L->stack) + EXTRA_STACK;
if (inuse > LUAI_MAXSTACK - n) /* can grow without overflow? */
res = 0; /* no */
else /* try to grow stack */
// 在保护模式下调用growstack
res = (luaD_rawrunprotected(L, &growstack, &n) == LUA_OK);
}
// 调整当前CI的栈顶
if (res && ci->top < L->top + n)
ci->top = L->top + n; /* adjust frame top */
lua_unlock(L);
return res;
}
这个函数有几个重要的信息:
- 栈有一个最大的尺寸
LUAI_MAXSTACK
,超过这个最大尺寸则不能增长栈,这个值很大,在int为32位以上的机器上,它是100万个。 - 实际增长空间的函数是
growstack
,它是在保护模式下调用的,关于保护模式的实现这里先略过。 - 增长完毕后,还要调整当前CallInfo的栈使用范围,这个下面会说。
growstack
调用的是luaD_growstack
,它会尝试以2倍的大小扩充栈,最终扩充栈的是luaD_reallocstack
函数:
// 重新分配栈空间
void luaD_reallocstack (lua_State *L, int newsize) {
TValue *oldstack = L->stack;
int lim = L->stacksize;
lua_assert(newsize <= LUAI_MAXSTACK || newsize == ERRORSTACKSIZE);
lua_assert(L->stack_last - L->stack == L->stacksize - EXTRA_STACK);
luaM_reallocvector(L, L->stack, L->stacksize, newsize, TValue);
// 对多出来的槽位填充为nil
for (; lim < newsize; lim++)
setnilvalue(L->stack + lim); /* erase new segment */
// 调整大小字段
L->stacksize = newsize;
L->stack_last = L->stack + newsize - EXTRA_STACK;
// 分配完之后,可能L->stack和oldstack为不同的地址,所以要矫正依赖于栈地址的其他数据
correctstack(L, oldstack);
}
前面的代码都好理解,主要是correctstack这个,它的作用是矫正依赖于栈地址的其他数据,因为当调用luaM_reallocvector
之后可能会重新分配内存地址,所以必须对那些依赖的地方作调整:
// 重新分配栈之后,可能L->stack和oldstack为不同的地址,所以要矫正依赖于栈地址的其他数据
static void correctstack (lua_State *L, TValue *oldstack) {
CallInfo *ci;
UpVal *up;
// 矫正栈顶
L->top = (L->top - oldstack) + L->stack;
// open upvalue
for (up = L->openupval; up != NULL; up = up->u.open.next)
up->v = (up->v - oldstack) + L->stack;
// 调用栈帧
for (ci = L->ci; ci != NULL; ci = ci->previous) {
ci->top = (ci->top - oldstack) + L->stack;
ci->func = (ci->func - oldstack) + L->stack;
if (isLua(ci))
ci->u.l.base = (ci->u.l.base - oldstack) + L->stack;
}
}
这里主要调整3个地方,一个是线程的栈顶,打开的upvalue,和CallInfo链表。
这里说一点个人见解,对于依赖于栈的数据,能否保存偏移,而不是直接保存地址?比如上面的L->top,或ci中的top, func,如果它们都是基于L->stack的偏移值,那么当栈扩充后,这些变量就完全不需要调整。取栈元素时变成这样:StkId e = L->stack + L->top
,这里看起来虽然是多了一个相对寻址,但我认为性能应该不会影响多少,相反代码上肯定会简洁得多。
创建虚拟机
Lua创建一个虚拟机很简单,只需要下面的代码:
// 创建一个虚拟机,L为虚拟机的主线程
lua_State *L = luaL_newstate();
// 打开标准库,如果不需要标准库,下面这一行都可以不要。
luaL_openlibs(L);
luaL_newstate是一个上层封装,主要是调用lua_newstate,并指定默认的内存分析函数,和panic函数:
// 创建虚拟机,并设置panic回调
LUALIB_API lua_State *luaL_newstate (void) {
lua_State *L = lua_newstate(l_alloc, NULL);
if (L) lua_atpanic(L, &panic);
return L;
}
lua_newstate才是真正创建虚拟机的地方,在里面创建glocal_state和主线程lua_State并对它们初始化,不过它的创建手法有点奇妙,它调用分配函数创建这个结构:
typedef struct LG {
LX l;
global_State g;
} LG;
typedef struct LX {
lu_byte extra_[LUA_EXTRASPACE];
lua_State l;
} LX;
也就是一性次把lua_State和global_State一起创建出来了,这样释放主线程,全局状态也跟着回收掉,可见Lua对空间的利用是多么紧凑。释放的相关代码是:
#define fromstate(L) (cast(LX *, cast(lu_byte *, (L)) - offsetof(LX, l)))
LX *l = fromstate(L1);
luaM_free(L, l);
lua_State的前面还有一点附加空间,可以容纳一个void*指针,这个附加空间Lua并没有使用,外部可以用它来保存和虚拟机相关的数据。
创建好虚拟机后,在保护模式下初始化一些其他的数据,在f_luaopen
函数中:
static void f_luaopen (lua_State *L, void *ud) {
global_State *g = G(L);
UNUSED(ud);
// 初始化主线程的栈
stack_init(L, L); /* init stack */
// 注册表
init_registry(L, g);
// 字符串
luaS_init(L);
// 元表
luaT_init(L);
// 标识符
luaX_init(L);
// 开启GC
g->gcrunning = 1; /* allow gc */
// 版本号
g->version = lua_version(NULL);
luai_userstateopen(L);
}
初始化注册表的代码可以看一下:
static void init_registry (lua_State *L, global_State *g) {
TValue temp;
/* create registry */
// 创建注册表
Table *registry = luaH_new(L);
sethvalue(L, &g->l_registry, registry);
luaH_resize(L, registry, LUA_RIDX_LAST, 0);
/* registry[LUA_RIDX_MAINTHREAD] = L */
// 保存主线程
setthvalue(L, &temp, L); /* temp = L */
luaH_setint(L, registry, LUA_RIDX_MAINTHREAD, &temp);
/* registry[LUA_RIDX_GLOBALS] = table of globals */
// 创建全局环境
sethvalue(L, &temp, luaH_new(L)); /* temp = new table (global table) */
luaH_setint(L, registry, LUA_RIDX_GLOBALS, &temp);
}
在创建注册表的时候,会把主线程保存在LUA_RIDX_MAINTHREAD字段,同时创建一个全局环境,后面的标准库模块都保存在这个全局环境中。