Lua 的一大特色就是协程的使用,在解释型语言中,如果不考虑嵌入到较为低级的语言(如 C 语言)中,就只需要操作调用栈并保存好上下文状态即可。但是 Lua 并不是一门纯粹依靠字节码的解释型语言,它常常与 C 语言反复嵌套,甚至出现 C 中调用 Lua ,Lua 中再调用 C 代码,,,的情况。一旦 Lua 调用的 C 库企图中断线程,再想恢复,就会是一个难题。好在 Lua 巧妙地解决了这些问题。
Lua 虚拟机重要结构体
- global_State
global_State 由所有线程共享,其中包含字符串内部化保存的哈希表、垃圾回收、异常处理等信息。
- lua_State
lua_State 表示一个 Lua 线程的执行状态,它有着自己独立的数据栈和函数调用栈。每个 lua_State 都有一个指向 global_State 的指针。
/*
** 'per thread' state
*/
struct lua_State {
CommonHeader;
lu_byte status;
lu_byte allowhook;
unsigned short nci; /* number of items in 'ci' list */
StkId top; /* first free slot in the stack */
global_State *l_G;
CallInfo *ci; /* call info for current function */
StkId stack_last; /* end of stack (last element + 1) */
StkId stack; /* stack base */
UpVal *openupval; /* list of open upvalues in this stack */
StkId tbclist; /* list of to-be-closed variables */
GCObject *gclist;
struct lua_State *twups; /* list of threads with open upvalues */
struct lua_longjmp *errorJmp; /* current error recover point */
CallInfo base_ci; /* CallInfo for first level (C calling Lua) */
volatile lua_Hook hook;
ptrdiff_t errfunc; /* current error handling function (stack index) */
l_uint32 nCcalls; /* number of nested (non-yieldable | C) calls */
int oldpc; /* last pc traced */
int basehookcount;
int hookcount;
volatile l_signalT hookmask;
};
- 数据栈
对数据栈初始化和释放分别调用 stack_init 和 free_stack 函数:
static void stack_init (lua_State *L1, lua_State *L);
static void freestack (lua_State *L);
数据栈初始大小是 Lua 内置栈结构的两倍,随着数据的增多需要调用 growstack 进行栈的扩展。因为有些数据包含了对栈中元素的引用,在栈中元素移位之后,在扩展之中要对栈进行修正。
#define BASIC_STACK_SIZE (2*LUA_MINSTACK)
static void correctstack (lua_State *L, StkId oldstack, StkId newstack);
int luaD_reallocstack (lua_State *L, int newsize, int raiseerror);
int luaD_growstack (lua_State *L, int n, int raiseerror);
通过源代码中的注释可以很清晰知晓这些函数的作用,再结合源码,可以发现这三个函数的调用关系为:
luaD_growstack ---> luaD_reallocstack ---> correctstack
- 调用栈
函数调用栈使用的是一个双向链表结构体 CallInfo :
typedef struct CallInfo {
StkId func; /* function index in the stack */
StkId top; /* top for this function */
struct CallInfo *previous, *next; /* dynamic call link */
union {
struct { /* only for Lua functions */
const Instruction *savedpc;
volatile l_signalT trap;
int nextraargs; /* # of extra arguments in vararg functions */
} l;
struct { /* only for C functions */
lua_KFunction k; /* continuation in case of yields */
ptrdiff_t old_errfunc;
lua_KContext ctx; /* context info. in case of yields */
} c;
} u;
union {
// ...
} u2;
short nresults; /* expected number of results from this function */
unsigned short callstatus;
} CallInfo;
在联合体 u 中:字段 l 用于调用 Lua 函数,字段 c 用于调用 C 函数,callstatus 用于标志是 Lua 函数还是 c 函数;联合体 u2 和保护模式、钩子函数等有关,暂不考虑。
需要注意到的是,既有 func 又有 base 的原因是,Lua 传入一个函数的参数个数可能不定,通过函数位置和栈底位置相减可以计算出参数个数。
Lua 线程
创建线程
在 Lua 中也有线程的概念,但它并不是系统线程,而且由于所有的 lua_State 都共享了 global_State ,因此多线程操作较难。在 Lua 中,协程才是最强大的武器。
Lua 给 C/C++ 保留了很多 API ,其中关于创建线程的 API 很有意思:
LUA_API lua_State *lua_newthread (lua_State *L) {
// ...
/* create new thread */
L1 = &cast(LX *, luaM_newobject(L, LUA_TTHREAD, sizeof(LX)))->l;
//...
}
创建线程结构体时分配空间的大小不是 Lua_State 的大小,而是 LX 的大小,而且将 Lua_State 放在了 LX 所在内存的后一部分,这既是给自定义 Lua 代码留下了空间,也是为了防止向用户暴露 Lua_State 的大小。
举个例子,在多线程操作中,访问 lua_State 结构中注册表是线程不安全的,但是如果你将一些信息放在 lua_State 的前面,就可以既方便访问又在数据竞争中保持安全(不够这也增加了代码的复杂度)。
线程中断及异常处理
Lua 全部源码都由标准 C 写成而且只使用了标准库,因此它的线程中断和错误处理都使用的是标准库中的 setjmp.h 来实现的。
异常
我在之前的一篇博文中讲到过 C语言接口与实现 中实现 C 语言异常处理的方法,也是通过 setjmp.h 来实现的。而 Lua 源码更进一步,通过宏定义将异常处理和 C++ 中的原生异常处理 try/catch 联系起来,也方便了开发者使用 C++ 开发 Lua 程序。
Lua 中的异常是一个单向链表(链栈),它的行为也很好理解:出现异常,将异常串到链表上并抛出,在处理完后再使用 longjmp 跳回来。
/* chain list of long jump buffers */
struct lua_longjmp {
struct lua_longjmp *previous;
luai_jmpbuf b;
volatile int status; /* error code */
};
函数
Lua 的函数调用过程分为 precall 和 poscall 两个部分:对于 C 语言函数来说,precall 调用了它;而对于 Lua 函数来说,precall 是为了它的调用做准备,只是改变了 Lua 虚拟机(lua_State)的状态而已,并没有真正调用。poscall 处理了它的返回,对数据栈进行了调整。
CallInfo *luaD_precall (lua_State *L, StkId func, int nresults) {
retry:
switch (ttypetag(s2v(func))) {
case LUA_VCCL: /* C closure */
// ...
case LUA_VLCF: /* light C function */
// ...
case LUA_VLCL: { /* Lua function */
// ...
}
default: { /* not a function */
func = luaD_tryfuncTM(L, func); /* try to get '__call' metamethod */
/* return luaD_precall(L, func, nresults); */
goto retry; /* try again with metamethod */
}
}
}
void luaD_poscall (lua_State *L, CallInfo *ci, int nres);
StkId luaD_tryfuncTM (lua_State *L, StkId func);
可以看到 precall 里面 switch/case 中的 default 分支,里面调用了 luaD_tryfuncTM 函数,这涉及了 Lua 中的元表(Meta Table),简单来说就是,当在表中找不到元素时就会尝试在表对应的元表中寻找。Lua 就是利用元表实现了类似于 Java 中的接口、C++ 中的虚函数、Rust 中的 Trait,以及重载运算符等操作。
当我们使用 C 语言开发并将 Lua 代码嵌入其中时,可以使用 API luaD_call ,它是 ccall 的一个包装。
void luaD_call (lua_State *L, StkId func, int nResults) {
ccall(L, func, nResults, 1);
}
l_sinline void ccall (lua_State *L, StkId func, int nResults, int inc) {
CallInfo *ci;
L->nCcalls += inc;
if (l_unlikely(getCcalls(L) >= LUAI_MAXCCALLS))
luaE_checkcstack(L);
if ((ci = luaD_precall(L, func, nResults)) != NULL) { /* Lua function? */
ci->callstatus = CIST_FRESH; /* mark that it is a "fresh" execute */
luaV_execute(L, ci); /* call it */
}
L->nCcalls -= inc;
}
参数 inc 代表的是在 C 函数栈中递归函数的个数,而 lua_State 实例 L 中的 nCcalls 代表的是在调用过程中被中断后就不能恢复的 C 函数(non-yielded,在老版本中名字叫做 nny)。
从中断中恢复
终于到了如何解决从中断中恢复这个问题了。Lua 给出的解决方案其实也不难,就是让编写 C 程序的开发者辛苦一点而已,也就是让他们自己提供一个延续函数 k 。
我们可以回顾一下 CallInfo 结构体中的 c 字段:
// ...
struct { /* only for C functions */
lua_KFunction k; /* continuation in case of yields */
ptrdiff_t old_errfunc;
lua_KContext ctx; /* context info. in case of yields */
} c;
// ...
k 就是延续函数,而 ctx 就保存有 yield 时的上下文。
在使用 k 时,只需要把它当作回调函数,将它和虚拟机实例 L 一起传入 API lua_callk 中即可。
在 C 线程需要挂起时就要调用 lua_yieldk 来保存恢复时需要的上下文等信息:
LUA_API int lua_yieldk (lua_State *L, int nresults, lua_KContext ctx, lua_KFunction k);
在 lua_yieldk 中保存信息前要进行 lua_lock(L),之后要 lua_unlock(L) 释放锁,但是在源码中 这两个宏都是不完整的,都被拓展为(void (0))
,在需要同步的时候要注意对它们进行补充。
Lua 协程让出时也需要调用 lua_yieldk ,这放在协程部分讨论。
在恢复时 Lua 内部调用的函数是 resume 函数:
static void resume (lua_State *L, void *ud) {
// ...
if (isLua(ci)) { /* yielded inside a hook? */
L->top = firstArg; /* discard arguments */
luaV_execute(L, ci); /* just continue running Lua code */
}
else { /* 'common' yield */
if (ci->u.c.k != NULL) { /* does it have a continuation function? */
lua_unlock(L);
n = (*ci->u.c.k)(L, LUA_YIELD, ci->u.c.ctx); /* call continuation */
lua_lock(L);
api_checknelems(L, n);
}
luaD_poscall(L, ci, n); /* finish 'luaD_call' */
}
unroll(L, NULL); /* run continuation */
// ...
}
可以看到如果是要恢复一个 Lua 函数就直接继续执行就可以了,如果是一个 C 函数则需要调用延续函数,再使用 poscall 处理返回值。
与 resume 相关的 API 有 lua_resume,只是对 resume 的包装而已,较为简单。
Lua 协程
讨论完了上面的线程问题,现在需要讲的协程就是小菜一碟了。Lua 的协程实际上就是 Lua 线程间的切换(而非系统线程,故性能损失较少),需要考虑的只有错误信息的 move 以及新切换到的线程的 resume 问题,而 resume 的实现在前文已经讨论过了。
协程的创建
对应 Lua 语句: lua coroutine.create()
由于 Lua 协程是不对称的,故创建协程时有一个参数会是创建协程的协程 L ,并从 L 中 move 函数信息到新创建的 NL 中:
static int luaB_cocreate (lua_State *L) {
lua_State *NL;
luaL_checktype(L, 1, LUA_TFUNCTION);
NL = lua_newthread(L);
lua_pushvalue(L, 1); /* move function to top */
lua_xmove(L, NL, 1); /* move function from L to NL */
return 1;
}
luaB_cowrap 是对函数 luaB_coreate 的一层封装:
static int luaB_cowrap (lua_State *L) {
luaB_cocreate(L);
lua_pushcclosure(L, luaB_auxwrap, 1);
return 1;
}
协程的让出
对应 Lua 语句: lua coroutine.yield()
#define lua_yield(L,n) lua_yieldk(L, (n), 0, NULL)
static int luaB_yield (lua_State *L) {
return lua_yield(L, lua_gettop(L));
}
协程的恢复
对应 Lua 语句:lua coroutine.resume()
启动协程和协程的恢复类似,都离不开以下两个函数:
static int luaB_coresume (lua_State *L);
static int auxresume (lua_State *L, lua_State *co, int narg);
函数调用链如下:
luaB_coresume ---> auxresume ---> lua_resume(API) ---> resume
总结
Lua 的异常处理和线程机制为了和 C 语言互相嵌入经过了反复的权衡,代码的复杂度较大,最后使用延续函数的设计其实只能算是一个妥协的结果,但是这其中的设计还是很值得我们去学习。
但 Lua 的协程库的代码非常之短小,总共两百行出头,而核心部分只有接近百行,原因之一就是它只需要调用 ldo.h 中提供的 API ,线程中断和恢复部分的代码已经给协程实现打下了良好的基础。