可恢复虚拟机补丁 |
|
特别是,您现在可以
__gc)进行 yield(暂停)。for x in func)进行 yield。table.foreach(), dofile(), ...)进行 yield。pcall(), xpcall(), ...)进行 yield。这是解决此问题的一种全新且完全可移植的方法。它结合了 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 final 版]。
更新 2006-04-16 VeLoSo:针对 Lua 5.1 final 版的补丁。无重大更改。为清晰起见,我移除了未促进可恢复虚拟机目标的某些性能补丁。我已非常仔细地更新了此补丁以适应 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 堆栈。无需在每个函数调用的返回时都检查 yield 条件,直到 C 堆栈的底部。
这有助于最大程度地减少 VM 核心的更改,并使转换现有 C 函数变得容易。对标准控制流(即不 yield 的情况)没有性能影响。
顺便说一下:为了避免 longjmp 的开销,在标准 coroutine.yield() 的情况下不使用 yield 抛出机制。
已更改受保护调用机制,以避免在使用 setjmp 包装器时,如果 C 堆栈中已存在此类包装器。展开 C 堆栈和 Lua 堆栈现在是两个独立的问题。这使得 pcall() 与函数调用一样便宜:在 x86 上快约 10-15%,在具有许多寄存器的 CPU 上更快,例如 IA64 (Itanium) 上快约 30%。
我对指令和缓存未命中进行了大量的性能分析,并对 Lua 核心进行了几项微小的优化。例如,协程需要约 10% 的内存。
我还修复了一些错误/不当之处
coroutine.yield() 返回到 Lua 函数时,一个间歇性的“call”钩子调用已被移除。coroutine.resume() 销毁。lua_resume 返回 ok 后)。但目前仅限于 C API(只需在堆栈上推送新函数和参数,然后使用 lua_resume 恢复它)。请注意,由于许多单行更改和 ldo.c 中一些需要移动的代码,补丁看起来有点长。实际上,除了新的堆栈展开机制和 VM 操作码 resume 处理之外,新代码并不多。
只有一个新函数
epcall(err, f, arg1, arg2, ...)它旨在成为奇特的
xpcall() API 的更合理的替代品,该 API 在您想要设置错误处理程序时不允许您将参数传递给受保护的函数。这意味着您需要人为地创建闭包以与 xpcall() 一起使用。epcall() 没有此类限制。
epcall() 在所有方面都与 pcall() 行为相同,只是它设置了一个错误处理函数,该函数将在 Lua 堆栈展开之前调用(类似于 xpcall())。
xpcall() 仅为兼容性而保留。
另一个小改动是,当 yield 失败时,您会收到不同的错误消息
这现在(如果发生的话)更好地反映了错误发生时的具体情况。
一组新的 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 函数与非 NULL/非零上下文参数一起使用,表示 Lua 核心您的 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 函数会再次被调用。但这次上下文保证为非 NULL/非零,并反映 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 函数进行的更改才能使其可恢复(已更改/新标记为**/++)。
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_call() 替换为 lua_icall() 并设置一个简单的标志(1)就足够了。另外请注意,goto 可以安全地跳过初始检查,因为当您的 C 函数恢复时,堆栈内容保证保持不变。这省略了冗余检查(例如,检查 userdata 元表的标准机制很慢)。
如果您真的、真的讨厌 goto,那么您当然也可以使用 if/switch 结构。但您必须意识到它们会模糊“标准”控制流。这是使用 goto 的完美情况之一。如果忘记从上下文中获取循环计数器,您的编译器会打印一个很大的警告。
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() 确保这一点),要么仅使用相对索引
或在检索上下文后进行补偿(参见上文)。
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 并将其分配给 resume 时的 status。这允许一个简单的检查 > 0(错误)或 <= 0(成功)。您不能使用零,因为它标记了一个不可恢复的 pcall。
上述代码解决了 lua_vpcall/lua_ipcall **可能**将调用函数及其参数留在堆栈上的事实(但这仅发生在调用设置期间发生错误时)。显式编码两种可能的结局比使用内联条件(如之前)更容易。
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 */
}
这是一个典型的 userdata 方法示例。大多数时候,您可以将所有需要的上下文存储在 userdata 中,并将 userdata 指针本身用作上下文参数。当然,userdata 仍然在堆栈上(否则可能会被垃圾回收),所以您也可以再次检索指针。但这会更慢,而且上下文参数已经存在,为什么不充分利用它呢?userdata 方法也是 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 操作。该操作不是阻塞,而是返回一个特殊的 status 标志(BLOCKING)。这允许您保存上下文并 yield(例如,返回到协程调度器),从而避免阻塞 I/O 操作。当函数恢复时,您只需重试、继续或完成 I/O 操作。如果 I/O 操作可能反复阻塞,则需要在循环中执行此操作。
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。这里使用状态机并将当前状态(控制流中的位置)保存在上下文中可能会很有益。您可以手动分配状态以满足简单需求(如上所示,但可能使用 define 而不是数字)。对于更复杂的需求,您可以使用宏甚至预编译器(当然是用 Lua 编写的?)来自动从您的代码生成状态机。
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,在哪里不能。
请注意,大多数剩余的“不行”在实践中都不相关。
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 代码执行此类操作(始终可恢复)。
lua_resume(L, -1) 具有意义)。但我仍然不知道一个好的 Lua API,除了引入一个新的 coroutine.eresume(co, err) 函数。
debug.sethook() 继承。C 钩子函数和钩子掩码已继承。但当前实现并未意识到这一点,并且未复制 Lua 钩子函数(存储在锚定在注册表中的特殊表中)。
lua_gettable/lua_settable 来处理具有元方法的对象。否则 Lua 函数的 PC 会被销毁。建议将任何此类活动包装在通过 lua_call() (这完全没问题)调用的函数中,或使用 lua_rawget/lua_rawset 代替。(这会影响所有 Lua 5.1-work 版本,并非由于我的补丁。)