可恢复的虚拟机补丁

lua-users home
wiki

这是一个使 Lua 虚拟机完全可恢复的补丁。它解决了 [Lua 5.1] 中协程存在的大多数实际问题。

特别是,您现在可以

这是一种新的且完全可移植的方法来解决这个问题。它结合了 Eric Jacobs 的“改进的协程”补丁中的想法,以及从我的“真正的 C 协程”补丁工作中获得的经验。对 Lua 核心进行的更改尽可能少,这得益于灵活的 yield 机制。一个副作用是更快的 pcall() 和总体上略微更快的 Lua 虚拟机。

许可证声明:我特此声明,我对 Lua 核心所做的所有贡献都将受与 Lua 核心本身相同的许可证约束。-- MikePall

Greg Falcon 又名 VeLoSo 已自愿成为该补丁的新维护者。请将有关该补丁的所有问题直接向他提出。

我决定继续研究解决跨 C yield 问题的另一种方法。请也查看 [Coco]。这两种方法都有其优缺点。有关更多信息,请在 [Lua 邮件列表存档] 中搜索“Coco”和/或“RVM”。-- MikePall

我同意。与该补丁相比,[Coco] 具有许多优势。它是一种更优雅的方法,测试更充分,不需要修改您的 Lua C 函数以使其可恢复,它可以在许多平台上运行,并且是 [LuaJIT 1.x] 所必需的。如果您需要改进的协程支持,Coco 可能是您项目的正确选择。

也就是说,我认为该补丁很有价值,因为它采用了 ANSI C 方法,这使其可以在 Lua 构建的所有平台上运行,并且可能有助于在未来的 Lua 核心版本中实现更好的协程行为。-- VeLoSo

LuaFiveTwo 实现了一种类似于 [1] 的方法。

下载

点击下载补丁 [与 5.1 最终版相比]

更新 2006-04-16 VeLoSo: 针对 Lua 5.1 最终版的补丁。没有重大更改。我删除了一些不符合可恢复 VM 目标的性能补丁(为了清晰起见)。我更新了这个针对 5.1 的补丁,并非常小心地进行操作,但有可能我遗漏了一些细微之处。购买者需谨慎。

更新 2005-05-24 MikePall: 针对 Lua 5.1-work6 的补丁。没有重大功能更改,但有一些内部重组。现在可以从已死协程获取回溯信息(就像在基线中一样)。work6 中的新运算符 (*s 和 a%b) 当然也是可恢复的。

补丁中包含了许多针对 Lua 5.1-work6 基线的修复:MSVC number2int 修复、*s 性能改进、删除未定义的 lua_checkpc 断言。

兼容性

Lua API 没有更改,不需要更改任何现有的 Lua 代码。特别是,您现在可以将使用 pcall() 的代码放在协程中,并且仍然可以在受保护的函数中 yield,无需任何代码更改。

不需要更改现有的 C 代码。但当然,现有的函数不是完全可恢复的,因为旧的 C API 没有提供这样的功能。这在您的 C 函数需要回调(lua_call)或受保护的调用(lua_pcall并且您想要从被调用函数中 yield 时才是一个问题。

有一些新的 C API 函数允许您使您的 C 函数可恢复。转换非常简单。例如,我只需要更改几行代码,就可以使标准 Lua 库函数可恢复。有关教程,请参见下文。

工作原理

由于新的 yield 机制中一个特殊的技巧,对 Lua 核心所做的更改是非侵入性的:只要存在中间(但可恢复的)C 调用边界,yield 机制就会抛出一个“yield 错误”以快速展开 C 栈。无需在每个函数调用返回时一直检查到 C 栈底部,以检查 yield 条件。

这有助于最大程度地减少对 VM 核心所做的更改,并使现有 C 函数的转换变得容易。对标准控制流(即没有 yield 的情况)没有性能影响。

顺便说一下:yield 抛出机制不用于标准的 `coroutine.yield()` 情况,以避免 `longjmp` 的开销。

受保护的调用机制已更改,以避免在 C 栈中已存在 setjmp 包装器时使用 setjmp 包装器。展开 C 栈和 Lua 栈现在是两个独立的问题。这使得 `pcall()` 与函数调用一样便宜:在 x86 上快约 10-15%,在具有多个寄存器的 CPU 上更快,例如在 IA64(Itanium)上快约 30%。

我已经做了相当多的指令分析和缓存未命中分析,并对 Lua 核心进行了几个微小的优化。例如,协程需要的内存减少了约 10%。

我还修复了一些错误/错误功能。

请注意,由于许多单行更改以及必须在 ldo.c 中移动的一些代码,补丁看起来有点长。实际上,除了新的栈展开机制和 VM 操作码恢复处理之外,并没有那么多新代码。

Lua API 扩展

只有一个新函数

  epcall(err, f, arg1, arg2, ...)
它旨在作为奇怪的 `xpcall()` API 的更合理的替代方案,该 API 不允许您在要设置错误处理程序时将参数传递给受保护的函数。这意味着您需要人为地创建闭包以供 `xpcall()` 使用。`epcall()` 没有这样的限制。

`epcall()` 在各个方面都与 `pcall()` 相似,只是它会设置一个错误处理程序函数,该函数将在 Lua 栈展开之前调用(就像 `xpcall()` 所做的那样)。

`xpcall()` 仅出于兼容性目的而保留。

另一个微小的变化是,当 yield 失败时,您会收到不同的错误消息。

这更好地反映了现在发生此错误的情况(如果有的话)。

C API 扩展

一组新的 lua_v*lua_i* 函数增强了 lua_call()lua_pcall()lua_yield()

  void   lua_call (lua_State *L, int nargs, int nresults);
| void  lua_vcall (lua_State *L, int nargs, int nresults, void *ctx);
| void  lua_icall (lua_State *L, int nargs, int nresults, int ictx);
  void  lua_pcall (lua_State *L, int nargs, int nresults, int ef);
| void lua_vpcall (lua_State *L, int nargs, int nresults, int ef, void *ctx);
| void lua_ipcall (lua_State *L, int nargs, int nresults, int ef, int ictx);
  int   lua_yield (lua_State *L, int nargs);
| int  lua_vyield (lua_State *L, int nargs, void *ctx);
| int  lua_iyield (lua_State *L, int nargs, int ictx);
(别担心,大多数只是带有适当类型转换的宏。)

新函数接受一个上下文参数(void *int),可用于保存正在运行的 C 函数的当前状态。使用这些 API 函数并使用非空/非零上下文参数表示您的 C 函数是可恢复的(即允许回调进行 yield)。

经典的不可恢复 API 函数 lua_call()lua_pcall()lua_yield() 只是宏,将 NULL 上下文参数传递给 lua_v* 等效项。

可以使用两个新的 API 函数来检索上下文

| void *lua_vcontext (lua_State *L);
| int   lua_icontext (lua_State *L);
在第一次调用 C 函数时,上下文初始化为 NULL/零。当协程 yield 并再次恢复时,只需再次调用可恢复的 C 函数。但这次保证上下文非空/非零,并反映 C 函数的保存状态(或在 lua_vpcall/lua_ipcall 捕获错误的情况下为错误号)。

您必须意识到,当您使用新的 API 函数进行回调时,包括您的 C 函数在内的 C 栈可能会被展开。这会在回调(或从其调用的任何函数)yield 时发生。在这种情况下,控制流永远不会从新的 API 调用返回到您的 C 函数。相反,当协程恢复时,您的 C 函数将被再次调用

这意味着您必须保存 C 函数保留的所有上下文。可以通过将特定上下文参数传递给 API 调用(标志、索引/计数器或指针)和/或在 Lua 栈上保存上下文(进行 API 调用之前)来实现。有关教程,请参见下文。

当协程恢复时,调用栈中任何更高层的函数都会在恢复(返回)到栈中更低层的函数之前完成。在使用上述任何 API 函数之前或之后,对 Lua 值栈的使用没有限制,因为在 C 函数执行时(与挂起时不同),调用栈中不可能存在活动函数。

所有 lua_*yield() API 函数都是尾调用,这意味着您必须使用 return 语句来使用它们,如下所示

  return lua_vyield(L, na, ctx);
调用可能或可能不会在执行 return 语句之前返回到您的 C 函数,具体取决于使用的是标准 yield 还是 yield 抛出机制。不要尝试通过在 yield 调用和 return 语句之间添加代码来巧妙地绕过此机制。当上下文为 NULL/零(或使用 lua_yield)时,您的函数将不会再次被调用(尾部 yield)。否则,当协程恢复时,它将再次被调用。

lua_vpcall/lua_ipcall 可能回退到在 C 栈上创建 setjmp 包装器的经典行为。当尚未存在此类包装器或当 C 栈中存在一个中间不可恢复的调用边界时(例如,当从钩子或从 __gc 中使用时),就会发生这种情况。当然,生成的调用栈不可恢复,但无论如何,它在之前也不可恢复。您在实践中几乎不会注意到这一点,因为独立的 Lua 可执行文件 (lua.c) 始终使用 lua_pcall() 包装主块,当然 lua_resume() 也会创建 setjmp 包装器。

关于 lua_vpcall/lua_ipcall(但不是 lua_pcall)的另一个需要注意的点是,它们可能在发生错误时将回调函数及其参数留在错误消息下方的栈上(抱歉,但这在 Lua 核心代码中很难解决)。您必须小心,不要在受保护的调用失败时对相对栈级别做任何假设。当然,错误消息保证位于最顶层的栈槽(相对索引 -1),回调函数(绝对索引 1..(func-1))以下的任何内容也仍然完好无损。

我从核心代码中删除了 lua_cpcall(),并用使用标准 API 调用的简单宏替换了它。我建议将其弃用,因为它冗余。

教程:保留上下文

以下是一个简短的教程,展示了您需要对 C 函数进行的更改,以使其可恢复(更改/新增部分用 **++** 标记)。

示例 #1:简单标志 -- table.foreach()

    static int foreach (lua_State *L) {
++    if (lua_vcontext(L)) goto resume;
      luaL_checktype(L, 1, LUA_TTABLE);
      luaL_checktype(L, 2, LUA_TFUNCTION);
      lua_pushnil(L);  /* first key */
      for (;;) {
        if (lua_next(L, 1) == 0)
          return 0;
        lua_pushvalue(L, 2);  /* function */
        lua_pushvalue(L, -3);  /* key */
        lua_pushvalue(L, -3);  /* value */
**      lua_icall(L, 2, 1, 1);
++  resume:
        if (!lua_isnil(L, -1))
          return 1;
        lua_pop(L, 2);  /* remove value and result */
      }
    }
这里,恢复所需的一切都已在 Lua 栈上(上一个键)。因此,用 lua_icall() 替换 lua_call() 并设置一个简单的标志 (1) 是您需要做的全部工作。

还要注意,goto 可以安全地跳过初始检查,因为当您的 C 函数恢复时,栈内容保证保持不变。这省略了冗余检查(例如,检查用户数据元表的标准机制很慢)。

如果您真的、真的讨厌 goto,那么当然也可以使用 if/switch 结构。但是您必须意识到,它们会掩盖“标准”控制流。这是使用 goto 非常有意义的情况之一。当您跳入循环并忘记从上下文中获取循环计数器时,您的编译器也会打印一个大大的警告。

示例 #2:循环计数器 -- print()

    static int luaB_print (lua_State *L) {
      int n = lua_gettop(L);  /* number of arguments */
**    int i = lua_icontext(L);
++    if (i) {
++      n -= 2;  /* compensate for tostring function and result */
++      goto resume;
++    }
      lua_getglobal(L, "tostring");
      for (i=1; i<=n; i++) {
        const char *s;
        lua_pushvalue(L, -1);  /* function to be called */
        lua_pushvalue(L, i);   /* value to print */
**      lua_icall(L, 1, 1, i);
++  resume:
        s = lua_tostring(L, -1);  /* get result */
        if (s == NULL)
          return luaL_error(L, "`tostring' must return a string to `print'");
        if (i>1) fputs("\t", stdout);
        fputs(s, stdout);
        lua_pop(L, 1);  /* pop result */
      }
      fputs("\n", stdout);
      return 0;
    }
这里,我们只是将循环计数器存储在上下文中。您需要小心只使用非零索引,因为零上下文参数表示不可恢复的调用(无论如何,它无法与初始调用区分开来)。

栈级别可能会在函数执行期间发生变化,这会导致恢复时出现许多问题。要么将其保持在固定级别(您可以使用 lua_settop() 来确保这一点),要么只使用相对索引。

或者在检索上下文后对其进行补偿(见上文)。

示例 #3:受保护的调用 -- pcall()

    static int luaB_pcall (lua_State *L) {
++    int status = lua_icontext(L);
++    if (status) goto resume;
      luaL_checkany(L, 1);
**    status = lua_ipcall(L, lua_gettop(L) - 1, LUA_MULTRET, 0, -1);
++  resume:
~~    if (status > 0) {  /* error */
~~      lua_pushboolean(L, 0);
~~      lua_insert(L, -2);  /* args may be left on stack with vpcall/ipcall */
~~      return 2;  /* return status + error */
~~    }
~~    else {  /* ok */
~~      lua_pushboolean(L, 1);
~~      lua_insert(L, 1);
~~      return lua_gettop(L);  /* return status + all results */
~~    }
~~  }
使用 lua_vpcall/lua_ipcall 时,可能出现四种情况。

除非您需要将上下文参数用于自己的目的(避免错误数字范围),否则您可以简单地将上下文设置为 -1 并将其分配给恢复时的状态。这允许简单地检查 > 0(错误)或 <= 0(正常)。您不能使用零,因为这表示不可恢复的 pcall。

上面的代码解决了 lua_vpcall/lua_ipcall **可能** 会将调用函数及其参数留在堆栈上的问题(但仅在调用设置期间抛出错误时)。显式编码两种可能的结果比使用内联条件语句(如前所述)更容易。

示例 #4:用户数据指针

    typedef struct { ... } mytype_t;

    static int my_userdata_method (lua_State *L) {
      mytype_t *ud = (mytype_t *)lua_vcontext(L);
++    if (ud) goto resume;
      ud = (mytype_t *)luaL_checkudata(L, 1, MYTYPE_HANDLE);
      if (ud == NULL) ... /* error handling */
      ... /* check other args here */

      ... /* start processing */
      ... /* be sure to save all context in the userdata structure */
**    lua_vcall(L, na, nr, ud);  /* pass userdata as context */
++  resume:
      ... /* continue processing */
    }
这是一个典型的用户数据方法示例。大多数情况下,您可以将所有需要的上下文存储在用户数据中,并仅使用用户数据指针本身作为上下文参数。当然,用户数据仍然在堆栈上(它必须在堆栈上,否则可能会被垃圾回收),因此您可以再次检索指针。但这会更慢,而上下文参数已经存在,所以为什么不充分利用它呢?

用户数据方法也是 lua_vyield() 的完美示例。

      while (read_operation(ud, ...) == BLOCKING) {
        ... /* do any processing needed after a blocking indication */
**      return lua_vyield(L, na, ud);
++  resume:
        ... /* do any processing needed resuming the read */
      }
这里尝试进行一个可能阻塞的 I/O 操作。该操作不会阻塞,而是返回一个特殊的状态标志(BLOCKING)。这使您可以保存上下文并 yield(例如,返回到协程调度程序),避免阻塞 I/O 操作。当函数恢复时,您只需重试、继续或完成 I/O 操作。如果 I/O 操作可能重复阻塞,则需要在循环中执行此操作。

示例 #5a:使用 switch 的状态机

    static int myfunction (lua_State *L) {
++    int state = lua_icontext(L);
++    switch (state) {
++    case 0:  /* initial call */
        ...
**      lua_vcall(L, na, nr, 1);
++    case 1:
        ...
**      lua_vcall(L, na, nr, 2);
++    case 2:
        ...
**      lua_vcall(L, na, nr, 3);
++    case 3:
        ...
        return n;
      }
    }
有时您需要使用一堆回调或 yield,这些回调或 yield 散布在线性控制流中。在这种情况下,使用状态机并在上下文中保存当前状态(控制流中的位置)可能会有益。对于简单的需求,您可以手动分配状态(如上所示,但可能使用定义而不是数字)。对于更复杂的需求,您可以使用宏甚至预编译器(用 Lua 编写,当然!)从您的代码自动生成状态机。

示例 #5b:带有 goto 的状态机

如果您使用的是 GCC,您会很高兴地听到它有一个非常有用的(但非标准的)扩展,称为 *标签作为值*,也称为 *计算 goto*。在 GCC 信息文档中了解更多信息。

这最好与宏内的局部标签扩展结合使用。以下是上面示例的转换版本,使用此功能(更简洁,速度也快很多)

++  #define rvcall(L, na, nr) \
++    ({ __label__ RR; lua_vcall(L, (na), (nr), &&RR); RR: ; })

    static int myfunction (lua_State *L) {
++    void *cont = lua_vcontext(L);
++    if (cont) goto *cont;
      ...
**    rvcall(L, na, nr);
      ...
**    rvcall(L, na, nr);
      ...
**    rvcall(L, na, nr);
      ...
      return n;
    }

是否要让步

以下表格显示了您的代码可以在哪里让步,以及在哪里不能让步。

请注意,大多数剩余的“否”在实践中是无关紧要的。

Yield across ...  | Yield from ...         | Ok? | Rationale
------------------+------------------------+-----+---------------------------
for x in func     | iterator function      | Yes |
VM operations     | metamethod except __gc | Yes |
(anywhere)        | __gc metamethod        | No  | Difficult, rarely useful
(anywhere)        | count/line hook        | Yes | Only via C API hooks
(anywhere)        | call/return hooks      | No  | Does anyone need this?
error processing  | err. handler/traceback | No  | Not very useful
------------------+------------------------+-----+---------------------------
pcall()           | protected callback     | Yes |
xpcall()          | protected callback     | Yes | [Deprecated]
epcall()      NEW | protected callback     | Yes | Sane API, replaces xpcall
print()           | tostring() callback    | Yes |
tostring()        | __tostring metamethod  | Yes |
dofile()          | chunk                  | Yes | It was simple :-)
table.foreach()   | callback               | Yes |
table.foreachi()  | callback               | Yes |
string.gsub()     | callback               | No  | Tricky, I have no use case
table.sort()      | callback, __lt metam.  | No  | Not very useful
require()         | chunk                  | No  | Not very useful
debug.sethook()   | Lua hook function      | No  | Please use a C hook
load()            | chunk reader           | No  | Parser is not resumable
------------------+------------------------+-----+---------------------------
lua_call()        | callback               | No  | For compatibility (macro)
lua_vcall()   NEW | callback               | Yes |
lua_icall()   NEW | callback               | Yes | (macro)
lua_pcall()       | protected callback     | No  | For compatibility (macro)
lua_cpcall()      | protected callback     | No  | For compatibility (macro)
lua_vpcall()  NEW | protected callback     | Yes |
lua_ipcall()  NEW | protected callback     | Yes | (macro)
lua_load()        | chunk reader           | No  | Parser is not resumable
------------------+------------------------+-----+---------------------------
lua_equal()       | __eq metamethod        | No  | (*)
lua_lessthan()    | __lt metamethod        | No  | (*)
lua_gettable()    | __index metamethod     | No  | (*)
lua_getfield()    | __index metamethod     | No  | (*)
lua_settable()    | __newindex metamethod  | No  | (*)
lua_setfield()    | __newindex metamethod  | No  | (*)
lua_concat()      | __concat metamethod    | No  | (*)
------------------+------------------------+-----+---------------------------
(*) 没有必要。只需使用 lua_vcall/lua_icall 调用元方法。或者使用 Lua 代码执行这些类型的操作(始终可恢复)。

待办事项


最近更改 · 首选项
编辑 · 历史记录
最后编辑于 2010 年 7 月 4 日凌晨 12:32 GMT (差异)