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 ,线程中断和恢复部分的代码已经给协程实现打下了良好的基础。

参考资料