c++ c lua coroutine longjmp

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? Si lua_yieldk llamará a longjmp entonces lua_yieldk nunca volverá de todas lua_yieldk , ¿entonces no debería importar si regreso entonces? Entonces eso no puede ser lo que está pasando, ¿verdad?
  • lua_yieldk cambio, que lua_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 que lua_yieldk vuelve a la función de continuación, no a la lua_yieldk llama lua_yieldk , y lua_yield aparentemente se reanuda a la persona que llama de la llamada api actual. , no a la lua_yield llama lua_yield .

Y, la pregunta más importante:

Si utilizo lua_yieldk en el formulario return lua_yieldk(...) especificado en los documentos, volviendo de una función lua_CFunction que se pasó a lua, ¿todavía es posible activar el attempt to yield across a C-call boundary error de attempt 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 de lua_yieldk , parece que de hecho siempre longjmp , 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 no nny ) asociado al estado lua, y cuando se llama lua_call o lua_pcall desde una función C api (a lua_CFunction que antes presionó a lua), nny se incrementa y solo disminuye cuando esa llamada o llamada vuelve. Cuando nny no es cero, no es seguro ceder, y obtienes este yield across C-api boundary error de yield 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! :-)