c++ - Lua coroutines-setjmp longjmp clobbering?
(2)
En una publicación de blog de no hace mucho tiempo, Scott Vokes describe un problema técnico asociado a la implementación de corrutinas de lua utilizando las funciones C setjmp
y longjmp
:
La principal limitación de Lua coroutines es que, dado que se implementan con setjmp (3) y longjmp (3), no puede usarlos para llamar desde Lua a un código C que devuelve la llamada a Lua que devuelve la llamada a C, porque el longjmp anidado bloqueará los cuadros de pila de la función C. (Esto se detecta en tiempo de ejecución, en lugar de fallar silenciosamente).
No he encontrado esto como un problema en la práctica, y no estoy al tanto de ninguna forma de solucionarlo sin dañar la portabilidad de Lua, una de mis cosas favoritas sobre Lua: se ejecutará literalmente cualquier cosa con un compilador ANSI C y una modesta cantidad de espacio. Usar Lua significa que puedo viajar ligero. :)
He usado coroutines bastante y pensé que entendía ampliamente lo que estaba pasando y lo que setjmp
y longjmp
hacen, sin embargo, leí esto en algún momento y me di cuenta de que realmente no lo entendía. Para tratar de resolverlo, traté de crear un programa que pensé que debería causar un problema basado en la descripción, y en su lugar parece funcionar bien.
Sin embargo, hay algunos otros lugares que he visto personas que parecen alegar que hay problemas:
La pregunta es:
- ¿En qué circunstancias no funcionan las cuerdas de lua debido a que los marcos de pila de la función C se desgastan?
- ¿Cuál es exactamente el resultado? ¿Significa "detectado en tiempo de ejecución" lua pánico? ¿O algo mas?
- ¿Esto todavía afecta las versiones más recientes de lua (5.3) o es realmente un problema 5.1 o algo así?
Aquí estaba el código que produje. En mi prueba, está vinculado con lua 5.3.1, compilado como código C, y la prueba en sí misma se compila como código C ++ en C ++ 11 estándar.
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;
}
El resultado que obtengo es:
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! :-)
¡Muchas gracias a @ Nicol Bolas por la respuesta detallada!
Después de leer su respuesta, leer los documentos oficiales, leer algunos correos electrónicos y jugar con ellos un poco más, quiero refinar la pregunta / hacer una pregunta de seguimiento específica, como quiera que la mire.
Creo que este término "aplastamiento" no es bueno para describir este tema y esto fue parte de lo que me confundió: nada está siendo "golpeado" en el sentido de que se escribió dos veces y se perdió el primer valor, el problema es únicamente, como señala @Nicol Bolas, longjmp
arroja parte de la pila C, y si esperas restaurar la pila más tarde, es una lástima.
El problema se describe muy bien en la sección 4.7 del manual de lua 5.2 , en un enlace proporcionado por @Nicol Bolas.
Curiosamente, no hay una sección equivalente en la documentación de lua 5.1. Sin embargo, lua 5.2 tiene esto que decir sobre lua_yieldk
:
Rinde una corutina.
Esta función solo debe llamarse como la expresión de retorno de una función C, de la siguiente manera:
return lua_yieldk (L, n, i, k);
El manual de Lua 5.1 dice algo similar , sobre lua_yield
:
Rinde una corutina.
Esta función solo debe llamarse como la expresión de retorno de una función C, de la siguiente manera:
return lua_yieldk (L, n, i, k);
Algunas preguntas naturales entonces:
- ¿Por qué importa si utilizo la
return
aquí o no? Silua_yieldk
llamará alongjmp
entonceslua_yieldk
nunca volverá de todaslua_yieldk
, ¿entonces no debería importar si regreso entonces? Entonces eso no puede ser lo que está pasando, ¿verdad? -
lua_yieldk
cambio, quelua_yieldk
solo hace una nota dentro del estado lua de que la llamada C api actual ha declarado que quiere ceder, y luego, cuando finalmente regrese, lua descubrirá qué sucederá después. Entonces, esto resuelve el problema de guardar cuadros C stack, ¿no? Dado que después de regresar a lua normalmente, esos marcos de pila han expirado de todos modos, por lo que las complicaciones descritas en la imagen de @Nicol Bolas están rodeadas. Y en segundo lugar, en 5.2, al menos, la semántica nunca dice que deberíamos restaurar los fotogramas C stack, parece quelua_yieldk
vuelve a la función de continuación, no a lalua_yieldk
llamalua_yieldk
, ylua_yield
aparentemente se reanuda a la persona que llama de la llamada api actual. , no a lalua_yield
llamalua_yield
.
Y, la pregunta más importante:
Si utilizo
lua_yieldk
en el formularioreturn lua_yieldk(...)
especificado en los documentos, volviendo de una funciónlua_CFunction
que se pasó a lua, ¿todavía es posible activar elattempt to yield across a C-call boundary
error deattempt to yield across a C-call boundary
?
Finalmente, (pero esto es menos importante), me gustaría ver un ejemplo concreto de lo que parece cuando un programador ingenuo "no tiene cuidado" y desencadena el attempt to yield across a C-call boundary
error de attempt to yield across a C-call boundary
Me da la idea de que podría haber un problema asociado a setjmp
y longjmp
lanzando cuadros de pila que más tarde necesitamos, pero quiero ver algún código de API real que pueda señalar y decir "por ejemplo, no lo haga". eso ", y esto es sorprendentemente difícil de alcanzar.
Encontré este correo electrónico donde alguien reportó este error con algún código lua 5.1, e intenté reproducirlo en lua 5.3. Sin embargo, lo que encontré fue que esto parece un error de informe de la implementación de lua: el error real se debe a que el usuario no está configurando correctamente su corutina. La forma correcta de cargar la corutina es crear el hilo, insertar una función en la pila de hilos y luego llamar a lua_resume
en el estado del hilo. En cambio, el usuario estaba usando dofile
en la pila de hilos, que ejecuta la función allí después de cargarla, en lugar de reanudarla. Por lo tanto, es efectivamente yield outside of a coroutine
usuario, y cuando remito esto, su código funciona bien, usando tanto lua_yield
como lua_yieldk
en lua 5.3.
Aquí está el listado que produje:
#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;
}
Aquí está la salida cuando USE_YIELDK
está comentada:
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! :-)
Aquí está el resultado cuando USE_YIELDK
está definido:
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! :-)
Piensa en lo que sucede cuando una corutina yield
. Deja de ejecutar, y el procesamiento regresa a quien sea que llamó resume
en esa coroutine, ¿correcto?
Bueno, digamos que tienes este código:
function top()
coroutine.yield()
end
function middle()
top()
end
function bottom()
middle()
end
local co = coroutine.create(bottom);
coroutine.resume(co);
En el momento de la llamada a yield
, la pila Lua se ve así:
-- top
-- middle
-- bottom
-- yield point
Cuando llamas a yield
, la pila de llamadas Lua que es parte de la coroutine se conserva. Cuando resume
, la pila de llamadas conservadas se ejecuta nuevamente, comenzando donde lo dejó antes.
OK, ahora digamos que el middle
no era una función Lua. En cambio, era una función C, y esa función C llama a la función Lua top
. Así que, conceptualmente, tu pila se ve así:
-- Lua - top
-- C - middle
-- Lua - bottom
-- Lua - yield point
Ahora, tenga en cuenta lo que dije antes: así es como se ve su pila conceptualmente .
Debido a que su pila de llamadas actual no se parece en nada a esto.
En realidad, en realidad hay dos stacks. Existe la pila interna de Lua, definida por lua_State
. Y está la pila de C La pila interna de Lua, en el momento en que el yield
está a punto de ser llamado, se ve más o menos así:
-- top
-- Some C stuff
-- bottom
-- yield point
Entonces, ¿cómo se ve la pila de C? Bueno, se ve así:
-- arbitrary Lua interpreter stuff
-- middle
-- arbitrary Lua interpreter stuff
-- setjmp
Y ese ahí es el problema. Mira, cuando Lua yield
, llamará a longjmp
. Esa función se basa en el comportamiento de la pila C. A saber, va a volver a donde estaba setjmp
.
La pila Lua se conservará porque la pila Lua está separada de la pila C. Pero la pila C? ¿Todo entre longjmp
y setjmp
? Ido. Kaput. Perdido para siempre
Ahora puedes ir, "espera, ¿la pila de Lua no sabe que entró en C y regresó a Lua"? Un poco. Pero el stack de Lua es incapaz de hacer algo que C no puede hacer. Y C simplemente no es capaz de preservar una pila (bueno, no sin bibliotecas especiales). Entonces, mientras que la pila Lua es vagamente consciente de que algún tipo de proceso C ocurrió en el medio de su pila, no tiene forma de reconstituir lo que estaba allí.
Entonces, ¿qué sucede si reanudas este yield
ed coroutine?
Demonios nasales Y a nadie le gustan esos. Afortunadamente, Lua 5.1 y superior (al menos) generará un error siempre que intente ceder el paso a C.
Tenga en cuenta que Lua 5.2+ tiene formas de solucionar esto . Pero no es automático; requiere una codificación explícita de tu parte.
Cuando el código Lua que está en una coroutine llama a su código C, y su código C llama al código Lua que puede ceder, puede usar lua_callk
o lua_pcallk
para llamar a las funciones Lua que posiblemente lua_pcallk
. Estas funciones de llamada toman un parámetro adicional: una función de "continuación".
Si el código de Lua que llamas cede, entonces la función lua_*callk
nunca regresará (ya que tu stack C habrá sido destruido). En su lugar, llamará a la función de continuación que proporcionó en su función lua_*callk
. Como puede adivinar por el nombre, el trabajo de la función de continuación es continuar donde dejó su función anterior.
Ahora, Lua conserva la pila para su función de continuación, por lo que coloca la pila en el mismo estado en que estaba su función C original. Bueno, excepto que la función + argumentos que llamó (con lua_*callk
) se eliminaron, y los valores de retorno de esa función se insertan en su pila. Fuera de eso, la pila es todo lo mismo.
También hay lua_yieldk
. Esto permite que su función C regrese a Lua, de modo que cuando se reanude la corrutina, llame a la función de continuación proporcionada.
Tenga en cuenta que Coco le da a Lua 5.1 la capacidad de resolver este problema. Es capaz (a pesar de la magia de OS / assembly / etc) de preservar la pila C durante una operación de rendimiento. Las versiones LuaJIT anteriores a 2.0 también proporcionaron esta característica.
Nota de C ++
Marcó su pregunta con la etiqueta C ++, así que supongo que eso está involucrado aquí.
Entre las muchas diferencias entre C y C ++ está el hecho de que C ++ es mucho más dependiente de la naturaleza de su callstack que Lua. En C, si descarta una pila, puede perder recursos que no se limpiaron. Sin embargo, C ++ es necesario para invocar destructores de funciones declaradas en la pila en algún momento. El estándar no te permite tirarlos.
Entonces, las continuaciones solo funcionan en C ++ si no hay nada en la pila que necesite tener una llamada al destructor. O más específicamente, solo los tipos que son trivialmente destructibles pueden estar en la pila si llama a cualquiera de las API de Lua de la función de continuación.
Por supuesto, Coco maneja C ++ muy bien, ya que en realidad está preservando la pila de C ++.
Publicar esto como una respuesta que complementa la respuesta de @Nicol Bolas, y para que pueda tener espacio para escribir lo que me llevó comprender la pregunta original, y las respuestas a las preguntas secundarias / una lista de códigos.
Si lees la respuesta de Nicol Bolas pero aún tienes preguntas como las que hice, aquí hay algunos consejos adicionales:
- Las tres capas en la pila de llamadas, Lua, C, Lua, son esenciales para el problema. Si solo tienes dos capas, Lua y C, no obtienes el problema.
- Al imaginar cómo se supone que funciona la llamada corutina, la pila lua se ve de cierta manera, la pila C se ve de cierta manera, la llamada cede (longjmp) y luego se reanuda ... el problema no ocurre inmediatamente cuando es reanudado.
El problema ocurre cuando la función reanudada luego intenta regresar, a su función C.
Porque, para que la semántica corutina funcione, se supone que vuelve a una llamada a función C, pero los marcos de pila para eso desaparecen y no se pueden restaurar. - La solución para esta falta de capacidad para restaurar esos marcos de pila es usar
lua_callk
,lua_pcallk
, que le permite proporcionar una función sustituta a la que se puede llamar en lugar de esa función C cuyos marcos se borraron. - El problema con el
return lua_yieldk(...)
parece no tener nada que ver con nada de esto. Del descremado de la implementación delua_yieldk
, parece que de hecho siemprelongjmp
, y puede que solo regrese en algún caso oscuro que involucre hooks de depuración de lua (?). - Lua internamente (en la versión actual) realiza un seguimiento de cuándo no se debe permitir el rendimiento, manteniendo una variable de contador
nny
(número nonny
) asociado al estado lua, y cuando se llamalua_call
olua_pcall
desde una función C api (alua_CFunction
que antes presionó a lua),nny
se incrementa y solo disminuye cuando esa llamada o llamada vuelve. Cuandonny
no es cero, no es seguro ceder, y obtienes esteyield across C-api boundary
error deyield across C-api boundary
si tratas de ceder de todos modos.
Aquí hay una lista simple que produce el problema e informa los errores, si usted es como yo y le gusta tener ejemplos de códigos concretos. Demuestra la diferencia en el uso de lua_call
, lua_pcall
y lua_pcallk
dentro de una función llamada por una corutina.
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;
}
Ejemplo de salida:
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! :-)