Lua协程——setjmp跳远



在不久前的一篇博客文章中,Scott Vokes描述了一个与lua使用C函数setjmplongjmp实现协同程序相关的技术问题:

Lua协程的主要限制是,由于它们是用setjmp(3)和longjmp(3)实现的,因此不能使用它们从Lua调用回调到Lua(再回调到C)的C代码,因为嵌套的longjmp会破坏C函数的堆栈帧。(在运行时检测,而不是静默失败。)

我在实践中没有发现这是一个问题,我不知道有什么方法可以在不损害Lua的可移植性的情况下修复它,这是我最喜欢Lua的一点——它可以在任何有ANSI C编译器和适度空间的东西上运行。使用Lua意味着我可以轻装出行。:)

我已经使用了相当数量的协程,我认为我大致了解发生了什么,setjmplongjmp做什么,但是我在某个时候读到这篇文章,意识到我并没有真正理解它。为了弄清楚这个问题,我试着根据描述编写一个我认为会导致问题的程序,结果它似乎工作得很好。

然而,在其他一些地方,我看到人们似乎声称存在问题:

  • http://coco.luajit.org/
  • http://lua-users.org/lists/lua-l/2005-03/msg00179.html

问题是:

  • 在什么情况下,lua协程无法工作,因为C函数堆栈帧被破坏?
  • 结果到底是什么?"在运行时检测到"是否意味着lua panic?还是别的什么?
  • 这是否仍然影响最新版本的lua(5.3)或这实际上是一个5.1问题或什么?

这是我生成的代码。在我的测试中,它与lua 5.3.1链接,编译为C代码,测试本身在c++ 11标准下编译为c++代码。

extern "C" {
#include <lauxlib.h>
#include <lua.h>
}
#include <cassert>
#include <iostream>
#define CODE(C) 
case C: { 
std::cout << "When returning to " << where << " got code '" #C "'" << std::endl; 
break; 
}
void handle_resume_code(int code, const char * where) {
switch (code) {
CODE(LUA_OK)
CODE(LUA_YIELD)
CODE(LUA_ERRRUN)
CODE(LUA_ERRMEM)
CODE(LUA_ERRERR)
default:
std::cout << "An unknown error code in " << where << std::endl;
}
}
int trivial(lua_State *, int, lua_KContext) {
std::cout << "Called continuation function" << std::endl;
return 0;
}
int f(lua_State * L) {
std::cout << "Called function 'f'" << std::endl;
return 0;
}
int g(lua_State * L) {
std::cout << "Called function 'g'" << std::endl;
lua_State * T = lua_newthread(L);
lua_getglobal(T, "f");
handle_resume_code(lua_resume(T, L, 0), __func__);
return lua_yieldk(L, 0, 0, trivial);
}
int h(lua_State * L) {
std::cout << "Called function 'h'" << std::endl;
lua_State * T = lua_newthread(L);
lua_getglobal(T, "g");
handle_resume_code(lua_resume(T, L, 0), __func__);
return lua_yieldk(L, 0, 0, trivial);
}
int main () {
std::cout << "Starting:" << std::endl;
lua_State * L = luaL_newstate();
// init
{
lua_pushcfunction(L, f);
lua_setglobal(L, "f");
lua_pushcfunction(L, g);
lua_setglobal(L, "g");
lua_pushcfunction(L, h);
lua_setglobal(L, "h");
}
assert(lua_gettop(L) == 0);
// Some action
{
lua_State * T = lua_newthread(L);
lua_getglobal(T, "h");
handle_resume_code(lua_resume(T, nullptr, 0), __func__);
}
lua_close(L); 
std::cout << "Bye! :-)" << std::endl;
}
我得到的输出是:
Starting:
Called function 'h'
Called function 'g'
Called function 'f'
When returning to g got code 'LUA_OK'
When returning to h got code 'LUA_YIELD'
When returning to main got code 'LUA_YIELD'
Bye! :-)

非常感谢@ nicolbolas非常详细的答案!
在阅读了他的回答,阅读了官方文档,阅读了一些电子邮件并再玩了一些之后,我想完善这个问题/问一个具体的后续问题,无论你想看它。

我认为这个术语"重击"不适合描述这个问题,这是我困惑的一部分-没有什么被"重击"在被写入两次和第一个值丢失的意义上,问题完全是,正如@Nicol Bolas指出的那样,longjmp丢弃了C堆栈的一部分,如果你希望以后恢复堆栈,太糟糕了。

这个问题实际上在lua 5.2手册的4.7节中有很好的描述,在@Nicol Bolas提供的链接中。

奇怪的是,lua 5.1文档中没有相应的章节。但是,lua 5.2对lua_yieldk: 有这样的说明:

生成一个协程。

该函数只能作为C函数的返回表达式调用,如下所示:

return lua_yieldk (L, n, i, k);

Lua 5.1手册也说了类似的话,关于lua_yield:

生成一个协程。

该函数只能作为C函数的返回表达式调用,如下所示:

return lua_yieldk (L, n, i, k);

一些自然的问题:

  • 为什么我在这里是否使用return很重要?如果lua_yieldk将调用longjmp,那么lua_yieldk无论如何都不会返回,所以如果我返回,它应该无关紧要?所以这不可能是真的,对吧?
  • 假设lua_yieldk只是在lua状态中记录当前C api调用已经声明它想要放弃,然后当它最终返回时,lua将找出接下来发生的事情。那么这就解决了保存C堆栈帧的问题,不是吗?因为在我们正常返回lua之后,这些堆栈帧已经过期了——所以@Nicol Bolas图片中描述的复杂性被绕过了?其次,至少在5.2中,语义从来没有说我们应该恢复C堆栈帧,似乎——lua_yieldk恢复到一个延续函数,而不是lua_yieldk调用者,lua_yield显然恢复到当前api调用的调用者,而不是lua_yield调用者本身。

最重要的问题是:

如果我一直使用lua_yieldk形式的return lua_yieldk(...)在文档中指定,从lua_CFunction传递给lua返回,是否仍然有可能触发attempt to yield across a C-call boundary错误?

最后,(但这不是很重要),我想看到一个具体的例子,当一个天真的程序员"不小心"触发attempt to yield across a C-call boundary错误时,它是什么样子的。我得到的想法是,setjmplongjmp抛出堆栈帧可能会有问题,我们稍后需要,但我想看到一些真正的lua/lua c api代码,我可以指出并说"例如,不要这样做",这是令人惊讶的难以捉摸。

我发现了这封邮件,有人报告了一些lua 5.1代码中的错误,我试图在lua 5.3中重现它。然而,我发现,这看起来只是来自lua实现的糟糕的错误报告——实际的bug是由于用户没有正确设置他们的协程而引起的。加载协程的正确方法是,创建线程,将函数推入线程堆栈,然后在线程状态下调用lua_resume。相反,用户在线程堆栈上使用dofile,它在加载函数后执行该函数,而不是恢复它。所以它是有效的yield outside of a coroutineiiuc,当我修补这个,他的代码工作得很好,在lua 5.3中使用lua_yieldlua_yieldk

下面是我生成的清单:
#include <cassert>
#include <cstdio>
extern "C" {
#include "lua.h"
#include "lauxlib.h"
}
//#define USE_YIELDK
bool running = true;
int lua_print(lua_State * L) {
if (lua_gettop(L)) {
printf("lua: %sn", lua_tostring(L, -1));
}
return 0;
}
int lua_finish(lua_State *L) {
running = false;
printf("%s calledn", __func__);
return 0;
}
int trivial(lua_State *, int, lua_KContext) {
printf("%s calledn", __func__);
return 0;
}
int lua_sleep(lua_State *L) {
printf("%s calledn", __func__);
#ifdef USE_YIELDK
printf("Calling lua_yieldkn");
return lua_yieldk(L, 0, 0, trivial);
#else
printf("Calling lua_yieldn");
return lua_yield(L, 0);
#endif
}
const char * loop_lua =
"print("loop.lua")n"
"n"
"local i = 0n"
"while true don"
"  print("lua_loop iteration")n"
"  sleep()n"
"n"
"  i = i + 1n"
"  if i == 4 thenn"
"    breakn"
"  endn"
"endn"
"n"
"finish()n";
int main() {
lua_State * L = luaL_newstate();
lua_pushcfunction(L, lua_print);
lua_setglobal(L, "print");
lua_pushcfunction(L, lua_sleep);
lua_setglobal(L, "sleep");
lua_pushcfunction(L, lua_finish);
lua_setglobal(L, "finish");
lua_State* cL = lua_newthread(L);
assert(LUA_OK == luaL_loadstring(cL, loop_lua));
/*{
int result = lua_pcall(cL, 0, 0, 0);
if (result != LUA_OK) {
printf("%s error: %sn", result == LUA_ERRRUN ? "Runtime" : "Unknown", lua_tostring(cL, -1));
return 1;
}
}*/
// ^ This pcall (predictably) causes an error -- if we try to execute the
// script, it is going to call things that attempt to yield, but we did not
// start the script with lua_resume, we started it with pcall, so it's not
// okay to yield.
// The reported error is "attempt to yield across a C-call boundary", but what
// is really happening is just "yield from outside a coroutine" I suppose...
while (running) {
int status;
printf("Waking up coroutinen");
status = lua_resume(cL, L, 0);
if (status == LUA_YIELD) {
printf("coroutine yieldingn");
} else {
running = false; // you can't try to resume if it didn't yield
if (status == LUA_ERRRUN) {
printf("Runtime error: %sn", lua_isstring(cL, -1) ? lua_tostring(cL, -1) : "(unknown)" );
lua_pop(cL, -1);
break;
} else if (status == LUA_OK) {
printf("coroutine finishedn");
} else {
printf("Unknown errorn");
}
}
}
lua_close(L);
printf("Bye! :-)n");
return 0;
}

下面是USE_YIELDK被注释掉后的输出:

Waking up coroutine
lua: loop.lua
lua: lua_loop iteration
lua_sleep called
Calling lua_yield
coroutine yielding
Waking up coroutine
lua: lua_loop iteration
lua_sleep called
Calling lua_yield
coroutine yielding
Waking up coroutine
lua: lua_loop iteration
lua_sleep called
Calling lua_yield
coroutine yielding
Waking up coroutine
lua: lua_loop iteration
lua_sleep called
Calling lua_yield
coroutine yielding
Waking up coroutine
lua_finish called
coroutine finished
Bye! :-)

下面是定义USE_YIELDK时的输出:

Waking up coroutine
lua: loop.lua
lua: lua_loop iteration
lua_sleep called
Calling lua_yieldk
coroutine yielding
Waking up coroutine
trivial called
lua: lua_loop iteration
lua_sleep called
Calling lua_yieldk
coroutine yielding
Waking up coroutine
trivial called
lua: lua_loop iteration
lua_sleep called
Calling lua_yieldk
coroutine yielding
Waking up coroutine
trivial called
lua: lua_loop iteration
lua_sleep called
Calling lua_yieldk
coroutine yielding
Waking up coroutine
trivial called
lua_finish called
coroutine finished
Bye! :-)

想想当协程执行yield时会发生什么。它停止执行,处理返回到那个协程上调用resume的人,对吗?

嗯,假设你有这样的代码:

function top()
coroutine.yield()
end
function middle()
top()
end
function bottom()
middle()
end
local co = coroutine.create(bottom);
coroutine.resume(co);

在调用yield的时刻,Lua堆栈看起来像这样:

-- top
-- middle
-- bottom
-- yield point

当您调用yield时,作为协程一部分的Lua调用堆栈将被保留。当您执行resume时,保留的调用堆栈将再次执行,从它之前停止的地方开始。

好,现在我们说middle实际上不是一个Lua函数。相反,它是一个C函数,该C函数调用Lua函数top。从概念上讲,你的堆栈是这样的:

-- Lua - top
-- C   - middle
-- Lua - bottom
-- Lua - yield point

现在,请注意我之前所说的:这是你的堆栈在概念上的样子

因为你实际的调用堆栈看起来不是这样的

实际上,有两个堆栈。有Lua的内部堆栈,由lua_State定义。这是C的堆栈。当yield即将被调用时,Lua的内部堆栈看起来像这样:

-- top
-- Some C stuff
-- bottom
-- yield point

对C来说堆栈是什么样子的呢?它看起来是这样的:

-- arbitrary Lua interpreter stuff
-- middle
-- arbitrary Lua interpreter stuff
-- setjmp

这就是问题所在。当Lua执行yield时,它会调用longjmp。该函数基于C堆栈的行为。也就是说,它将返回到setjmp所在的位置。

Lua堆栈将被保留,因为Lua堆栈与C堆栈分开。但是C语言栈呢?在longjmpsetjmp之间的所有东西?一去不复返了。坏了的。永远失去了

现在你可能会说,"等等,Lua栈不知道它进入C语言并返回到Lua吗?"一点。但是Lua栈不能做C不能做的事情。而且C根本无法保存堆栈(当然,如果没有特殊的库的话)。因此,虽然Lua堆栈模糊地意识到在其堆栈中间发生了某种C进程,但它无法重建那里的内容。

那么如果您恢复这个yielded协程会发生什么呢?

鼻恶魔。没有人喜欢这些。幸运的是,Lua 5.1及以上版本(至少)会在您尝试跨c时出错。

注意Lua 5.2+确实有解决这个问题的方法。但这不是自动的;它需要你自己明确的编码。

当协同程序中的Lua代码调用C代码,而C代码调用可能产生结果的Lua代码时,您可以使用lua_callklua_pcallk来调用可能产生结果的Lua函数。这些调用函数接受一个额外的形参:一个"continuation"函数。

如果你调用的Lua代码确实产生了,那么lua_*callk函数实际上永远不会返回(因为你的C堆栈将被销毁)。相反,它将调用您在lua_*callk函数中提供的延续函数。正如您可以从名称中猜到的那样,延续函数的工作是继续上一个函数停止的地方。

现在,Lua确实为你的延续函数保留了堆栈,所以它使堆栈处于与原始C函数相同的状态。好吧,除了您调用的函数+参数(使用lua_*callk)被删除,并且该函数的返回值被压入堆栈。除此之外,堆栈都是一样的

还有lua_yieldk。这允许您的C函数返回到Lua,这样当协程被恢复时,它调用提供的延续函数。

注意Coco给了Lua 5.1解决这个问题的能力。它能够(尽管OS/assembly/etc魔法)在yield操作期间保留C堆栈。2.0之前的LuaJIT版本也提供了这个特性。

c++注意

你用c++标记了你的问题,所以我假设这里涉及到c++。

在C和c++之间的众多差异中,有一个事实是c++比Lua更依赖于调用栈的性质。在C语言中,如果丢弃堆栈,可能会丢失未清理的资源。然而,c++需要在某些时候调用在堆栈上声明的函数的析构函数。标准不允许你把它们扔掉。

所以在c++中只有当没有时,延续才有效在需要有析构函数调用的堆栈上。或者更具体地说,如果你调用任何延续函数的Lua api,只有那些平凡的可析构的类型可以放在堆栈上。

当然,Coco可以很好地处理c++,因为它实际上保留了c++堆栈。

将此作为回复补充@Nicol Bolas的回复,等等我可以有空间写下我理解原文所花的时间问题,以及次要问题的答案/代码清单。

如果你读了nicolbolas的回答,但仍然有像我一样的问题,这里是一些额外提示:

  • 调用栈上的三个层,Lua, C, Lua,对这个问题至关重要。如果你只有两个层,Lua和C,你就不会遇到这个问题。
  • 在想象协程调用应该如何工作时——lua堆栈看起来以某种方式,C堆栈看起来是某种方式,调用产生(longjmp)和后来又恢复了……问题不会在发生时立即发生恢复。
    当恢复的函数稍后试图返回到C函数。
    因为,协程语义的工作,它应该返回转换成一个C函数调用,但是它的堆栈帧已经消失了,而且不可能了恢复。
  • 这个缺乏恢复这些堆栈帧的能力的解决方法是使用lua_callklua_pcallk,它们允许您提供替代函数,它可以用来代替C函数,它的框架是消灭。
  • 关于return lua_yieldk(...)的问题似乎与这些都可以。从略读lua_yieldk的实现来看它确实总是longjmp,并且它可能只在一些模糊的情况下返回包含lua调试钩子(?).
  • Lua内部(在当前版本)跟踪yield不应该是什么时候允许,通过保持一个计数器变量nny(number non-yield)相关联到lua状态,以及从C api调用lua_calllua_pcall时函数(之前推入lua的lua_CFunction),nny是递增,只有在调用或pcall返回时才递减。当nny是非零的,如果你试图屈服,你会得到这个yield across C-api boundary错误。

下面是一个产生问题并报告错误的简单清单,如果你像我一样,喜欢有一个具体的代码例子。它演示了使用lua_calllua_pcalllua_pcallk的一些区别在协程调用的函数内。

extern "C" {
#include <lauxlib.h>
#include <lua.h>
}
#include <cassert>
#include <iostream>
//#define USE_PCALL
//#define USE_PCALLK
#define CODE(C) 
case C: { 
std::cout << "When returning to " << where << " got code '" #C "'" << std::endl; 
break; 
}
#define ERRCODE(C) 
case C: { 
std::cout << "When returning to " << where << " got code '" #C "': " << lua_tostring(L, -1) << std::endl; 
break; 
}
int report_resume_code(int code, const char * where, lua_State * L) {
switch (code) {
CODE(LUA_OK)
CODE(LUA_YIELD)
ERRCODE(LUA_ERRRUN)
ERRCODE(LUA_ERRMEM)
ERRCODE(LUA_ERRERR)
default:
std::cout << "An unknown error code in " << where << ": " << lua_tostring(L, -1) << std::endl;
}
return code;
}
int report_pcall_code(int code, const char * where, lua_State * L) {
switch(code) {
CODE(LUA_OK)
ERRCODE(LUA_ERRRUN)
ERRCODE(LUA_ERRMEM)
ERRCODE(LUA_ERRERR)
default:
std::cout << "An unknown error code in " << where << ": " << lua_tostring(L, -1) << std::endl;
}
return code;
}
int trivial(lua_State *, int, lua_KContext) {
std::cout << "Called continuation function" << std::endl;
return 0;
}
int f(lua_State * L) {
std::cout << "Called function 'f', yielding" << std::endl;
return lua_yield(L, 0);
}
int g(lua_State * L) {
std::cout << "Called function 'g'" << std::endl;
lua_getglobal(L, "f");
#ifdef USE_PCALL
std::cout  << "pcall..." << std::endl;
report_pcall_code(lua_pcall(L, 0, 0, 0), __func__, L);
// ^ yield across pcall!
// If we yield, there is no way ever to return normally from this pcall,
// so it is an error.
#elif defined(USE_PCALLK)
std::cout  << "pcallk..." << std::endl;
report_pcall_code(lua_pcallk(L, 0, 0, 0, 0, trivial), __func__, L);
#else
std::cout << "call..." << std::endl;
lua_call(L, 0, 0);
// ^ yield across call!
// This results in an error being reported in lua_resume, rather than at
// the pcall
#endif
return 0;
}
int main () {
std::cout << "Starting:" << std::endl;
lua_State * L = luaL_newstate();
// init
{
lua_pushcfunction(L, f);
lua_setglobal(L, "f");
lua_pushcfunction(L, g);
lua_setglobal(L, "g");
}
assert(lua_gettop(L) == 0);
// Some action
{
lua_State * T = lua_newthread(L);
lua_getglobal(T, "g");
while (LUA_YIELD == report_resume_code(lua_resume(T, L, 0), __func__, T)) {}
}
lua_close(L); 
std::cout << "Bye! :-)" << std::endl;
}

示例输出:

call

Starting:
Called function 'g'
call...
Called function 'f', yielding
When returning to main got code 'LUA_ERRRUN': attempt to yield across a C-call boundary
Bye! :-)

pcall

Starting:
Called function 'g'
pcall...
Called function 'f', yielding
When returning to g got code 'LUA_ERRRUN': attempt to yield across a C-call boundary
When returning to main got code 'LUA_OK'
Bye! :-)

pcallk

Starting:
Called function 'g'
pcallk...
Called function 'f', yielding
When returning to main got code 'LUA_YIELD'
Called continuation function
When returning to main got code 'LUA_OK'
Bye! :-)

最新更新