Lua 协程 -- setjmp longjmp 破坏?

Lua coroutines -- setjmp longjmp clobbering?

在不久前的 blog post 中,Scott Vokes 描述了与 lua 使用 C 函数 setjmp 和 [=17= 实现协程相关的技术问题]:

The main limitation of Lua coroutines is that, since they are implemented with setjmp(3) and longjmp(3), you cannot use them to call from Lua into C code that calls back into Lua that calls back into C, because the nested longjmp will clobber the C function’s stack frames. (This is detected at runtime, rather than failing silently.)

I haven’t found this to be a problem in practice, and I’m not aware of any way to fix it without damaging Lua’s portability, one of my favorite things about Lua — it will run on literally anything with an ANSI C compiler and a modest amount of space. Using Lua means I can travel light. :)

我使用了相当多的协程,我认为我大致了解发生了什么以及 setjmplongjmp 做了什么,但是我在某个时候读到这篇文章并意识到我没有'不是很懂。为了解决这个问题,我尝试编写一个我认为应该根据描述引起问题的程序,但它似乎运行良好。

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

问题是:

这是我生成的代码。在我的测试中,它是 linked with 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! :-)

非常感谢@Nicol Bolas 的详细回答!
在阅读了他的回答、阅读了官方文档、阅读了一些电子邮件并进行了更多尝试之后,我想完善问题/提出一个具体的后续问题,但是你想看看它。

我认为这个术语 'clobbering' 不适合描述这个问题,这是让我感到困惑的部分原因 -- 从被写入两次和第一次的意义上来说,没有什么是 "clobbered"值丢失,问题只是,正如@Nicol Bolas 指出的那样,longjmp 扔掉了 C 堆栈的一部分,如果您希望稍后恢复堆栈,那就太糟糕了。

在@Nicol Bolas 提供的 link 中 section 4.7 of lua 5.2 manual 中实际上很好地描述了这个问题。

奇怪的是,lua 5.1 文档中没有对应的部分。然而,lua 5.2 有 this to say 关于 lua_yieldk:

Yields a coroutine.

This function should only be called as the return expression of a C function, as follows:

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

Lua 5.1 手册说 something similar,关于 lua_yield 而不是:

Yields a coroutine.

This function should only be called as the return expression of a C function, as follows:

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

然后是一些自然的问题:

还有,最重要的问题:

If I consistently use lua_yieldk in the form return lua_yieldk(...) specified in the docs, returning from a lua_CFunction that was passed to lua, is it still possible to trigger the attempt to yield across a C-call boundary error?

最后,(但这不太重要),我想看一个具体的例子,说明天真的程序员 "isn't careful" 并触发 attempt to yield across a C-call boundary 错误时的情况。我的想法是可能存在与 setjmplongjmp 相关的问题,我们稍后需要扔堆栈帧,但我想看到一些真正的 lua / lua c api 代码,我可以指着它说 "for instance, don't do that",这出奇地难以捉摸。

我发现 this email 有人用一些 lua 5.1 代码报告了这个错误,我试图在 lua 5.3 中重现它。然而,我发现,这看起来像是来自 lua 实现的错误报告——实际的错误是由于用户没有正确设置他们的协程而引起的。加载协程的正确方法是,创建线程,将函数压入线程堆栈,然后在线程状态上调用 lua_resume。相反,用户在线程堆栈上使用 dofile,它在加载函数后在那里执行函数,而不是恢复它。所以它实际上是 yield outside of a coroutine iiuc,当我修补它时,他的代码工作正常,在 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: %s\n", lua_tostring(L, -1));
  }
  return 0;
}

int lua_finish(lua_State *L) {
  running = false;
  printf("%s called\n", __func__);
  return 0;
}

int trivial(lua_State *, int, lua_KContext) {
  printf("%s called\n", __func__);
  return 0;
}

int lua_sleep(lua_State *L) {
  printf("%s called\n", __func__);
#ifdef USE_YIELDK
  printf("Calling lua_yieldk\n");
  return lua_yieldk(L, 0, 0, trivial);
#else
  printf("Calling lua_yield\n");
  return lua_yield(L, 0);
#endif
}

const char * loop_lua =
"print(\"loop.lua\")\n"
"\n"
"local i = 0\n"
"while true do\n"
"  print(\"lua_loop iteration\")\n"
"  sleep()\n"
"\n"
"  i = i + 1\n"
"  if i == 4 then\n"
"    break\n"
"  end\n"
"end\n"
"\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: %s\n", 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 coroutine\n");
    status = lua_resume(cL, L, 0);
    if (status == LUA_YIELD) {
      printf("coroutine yielding\n");
    } else {
      running = false; // you can't try to resume if it didn't yield

      if (status == LUA_ERRRUN) {
        printf("Runtime error: %s\n", lua_isstring(cL, -1) ? lua_tostring(cL, -1) : "(unknown)" );
        lua_pop(cL, -1);
        break;
      } else if (status == LUA_OK) {
        printf("coroutine finished\n");
      } else {
        printf("Unknown error\n");
      }
    }
  }

  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 的任何人处理 returns,对吗?

嗯,假设你有这个代码:

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 的堆栈。 Lua 的内部堆栈,在 yield 即将被调用时,看起来像这样:

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

那么堆栈对于 C 来说是什么样的呢?好吧,它看起来像这样:

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

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

Lua 堆栈将被保留,因为 Lua 堆栈与 C 堆栈分开。但是 C 堆栈? longjmpsetjmp 之间的所有内容?走了。卡普特。永远失去.

现在你可以走了,"wait, doesn't the Lua stack know that it went into C and back into Lua"?一点点。但是 Lua 堆栈不能做 C 不能做的事情。而且 C 根本无法保存堆栈(好吧,不是没有特殊的库)。因此,虽然 Lua 堆栈模糊地意识到某种 C 进程发生在它的堆栈中间,但它无法重建那里的内容。

那么如果你恢复这个 yielded 协同程序会发生什么?

Nasal demons. 没有人喜欢这些。幸运的是,Lua 5.1 及更高版本(至少)会在您尝试跨 C yield 时出错。

请注意 Lua 5.2+ does have ways of fixing this。但这不是自动的;它需要您进行明确的编码。

当协程中的Lua代码调用你的C代码,你的C代码调用Lua可能yield的代码时,你可以使用lua_callklua_pcallk 调用可能产生的 Lua 函数。这些调用函数有一个额外的参数:"continuation" 函数。

如果您调用的 Lua 代码确实产生了,那么 lua_*callk 函数实际上不会 return (因为您的 C 堆栈将被破坏)。相反,它将调用您在 lua_*callk 函数中提供的延续函数。顾名思义,continuation 函数的工作是从上一个函数停止的地方继续。

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

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

请注意 Coco 赋予 Lua 5.1 解决此问题的能力。它能够(尽管 OS/assembly/etc 魔法)在 yield 操作期间 保留 C 堆栈。 Lua2.0之前的JIT版本也提供了这个功能。


C++ 笔记

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

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

因此,如果堆栈上没有 nothing 需要调用析构函数,那么延续仅在 C++ 中有效。或者更具体地说,如果您调用任何延续函数 Lua API,则只有可轻易破坏的类型才能位于堆栈中。

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

将此作为补充@Nicol Bolas 答案的答案发布,这样 我可以 space 写下我理解原文所花费的时间 问题,以及次要问题的答案/代码清单。

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

  • 调用堆栈上的 层,Lua,C,Lua,对问题至关重要。 如果你只有两层,Lua 和 C,你就不会遇到这个问题。
  • 想象协程调用应该如何工作——lua 堆栈看起来 某种方式,C 堆栈看起来某种方式,调用产生 (longjmp) 和 稍后恢复...问题不会立即发生 已恢复。
    当恢复函数稍后尝试 return 到您的 C函数.
    因为,要使协程语义生效,它应该 return 进入 C 函数调用,但是它的堆栈帧已经消失,并且不能 已恢复。
  • 无法恢复这些堆栈帧的解决方法是 使用 lua_callklua_pcallk,这样您就可以提供替代品 可以调用的函数来代替其框架是的 C 函数 消灭了。
  • 关于 return lua_yieldk(...) 的问题似乎与 任何一个。从略读 lua_yieldk 的实现看来 它确实总是 longjmp,并且在一些模糊的情况下它可能只 return 涉及 lua 调试挂钩 (?)。
  • Lua 在内部(当前版本)跟踪 yield 何时不应该 允许,通过保持计数器变量 nny (数字不可屈服)关联 到 lua 状态,当您从 C api 调用 lua_calllua_pcall 时 函数(您之前推送到 lua 的 lua_CFunction),nny 是 递增,并且仅在调用或 pcall returns 时递减。什么时候 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! :-)