【问题标题】:Lua coroutines -- setjmp longjmp clobbering?Lua 协程——setjmp longjmp 崩溃?
【发布时间】:2016-03-22 02:04:54
【问题描述】:

在不久前的blog post 中,Scott Vokes 描述了一个与 lua 使用 C 函数 setjmplongjmp 实现协程相关的技术问题:

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

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

我已经使用了相当多的协程,并且我认为我大致了解发生了什么以及 setjmplongjmp 做了什么,但是我在某些时候读到了这篇文章并意识到我并没有真正理解它。为了弄清楚这一点,我尝试制作一个我认为应该根据描述导致问题的程序,但它似乎工作正常。

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

问题是:

  • 在什么情况下 lua 协程会因为 C 函数栈帧被破坏而无法工作?
  • 结果到底是什么? “在运行时检测到”是否意味着 lua 恐慌?还是别的什么?
  • 这是否仍会影响最新版本的 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! :-)

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

我认为“破坏”这个词不适合描述这个问题,这是让我感到困惑的部分原因 - 没有什么被“破坏”,因为它被写入两次并且第一个值丢失,这个问题正如@Nicol Bolas 指出的那样,仅仅是longjmp 扔掉了C 堆栈的一部分,如果您希望稍后恢复堆栈,那就太糟糕了。

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

奇怪的是,lua 5.1 文档中没有等效的部分。然而,lua 5.2 有this to say 大约lua_yieldk

产生一个协程。

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

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

Lua 5.1 手册说something similar,而不是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 调用者本身。

还有,最重要的问题:

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

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

我发现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! :-)

【问题讨论】:

  • AFAIK,整个延续功能就是解决该问题的修复。如果您使用的是 Lua 5.3 或 5.2,则没有问题。
  • @immibis:不,你仍然有问题。只是你有一个潜在的解决方案。但这几乎不是自动的。
  • 好的,你现在问的是一个全新的问题。您最初是在询问该帖子在谈论跳过堆栈的内容。现在你在问 Lua 5.2/3 是如何解决这个问题的。这是一个很好的问题,但它是一个新问题,应该使用“”按钮提出。
  • @NicolBolas:所以我知道你是从哪里来的,但请听我说完。当程序员就这样的问题提出问题时,他们通常想知道“在高层次上是什么问题,有哪些例子说明它是如何发生的,当我遇到问题时我需要做什么,实际上”。例如,在这个站点上,您可以看到诸如“什么是悬空指针”之类的问题,他们将通过代码示例给出非常详细的答案。我希望为协程提供这样的答案。我花了一段时间才弄清楚在此过程中我需要问什么问题。
  • 如果你认为我需要重写整个东西来浓缩它,那是合理的。如果您认为我应该将其拆分为两个问题“协程跳过堆栈的问题是什么”和“如何解决某些示例代码中的协程跳过堆栈的问题”,我的意思是这也是合理的。但我认为将这两条信息放在同一个地方可能很有用。我的意思是通常在编程中我想要高水平以及“实际上这意味着什么”

标签: c++ c lua coroutine setjmp


【解决方案1】:

想想当协程执行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 的堆栈。 Lua 的内部堆栈,在 yield 即将被调用时,看起来是这样的:

-- 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 协程会发生什么?

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

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

当协程中的 Lua 代码调用您的 C 代码,而您的 C 代码调用可能产生的 Lua 代码时,您可以使用 lua_callklua_pcallk 调用可能产生的 Lua 函数。这些调用函数需要一个额外的参数:一个“延续”函数。

如果您调用的 Lua 代码确实产生了,那么 lua_*callk 函数将永远不会真正返回(因为您的 C 堆栈将被破坏)。相反,它将调用您在 lua_*callk 函数中提供的延续函数。顾名思义,延续函数的工作就是从前一个函数中断的地方继续。

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

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

注意Coco 让 Lua 5.1 能够解决这个问题。它能够(尽管操作系统/程序集/等魔法)在屈服操作期间保留 C 堆栈。 LuaJIT 2.0 之前的版本也提供了这个功能。


C++ 笔记

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

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

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

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

【讨论】:

  • 两件事:Lua 5.1 在你“尝试跨越元方法/C 调用边界”时也会引发错误,而当前的 LuaJIT (2.0/2.1beta) 确实有类似的问题(尽管您可以从元方法和 for 循环迭代器中产生)。 LuaJIT 1.x 中使用了 Coco。
  • @NicolBolas:这是次要的,但我认为应该在您的图表中交换“顶部”和“底部”行,不是吗?这样“顶部”就在“屈服点”旁边
  • @ChrisBeck:“屈服点”表示协程将返回的位置。堆栈自下而上增长。
  • 那么 Lua 是使用静态跳转缓冲区还是什么? C setjmp() 允许您同时维护多个跳转目标,据我了解这个问题,似乎可以通过 Lua 为从 C 到 Lua 的每个调用维护一个单独的跳转缓冲区来解决。我还认为这很自然地会从 C 调用堆栈中消失。
  • @JohnBollinger:Lua 可以做到这一点,但它不能正常工作。或者至少,相对于 Lua 想要的不正确。 coroutine.yield 应该返回到发出 resume 调用的代码。您的建议将导致yield 返回到resume 调用调用Lua 的最新C 函数。那只是错误的行为。
【解决方案2】:

将此作为补充@Nicol Bolas 答案的答案发布,这样 我可以有空间写下我是如何理解原文的 问题,以及次要问题的答案/代码清单。

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

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

【讨论】:

  • return lua_yieldk(...) 问题是 Lua 5.1 的遗留问题,它没有使用 longjmp 进行屈服,而是使用特殊的返回值。现在(使用 Lua 5.2+)它只是提醒当前 C 函数的执行在调用 lua_yieldk 时结束。
猜你喜欢
  • 2014-06-07
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2020-08-21
相关资源
最近更新 更多