Corrutinas en C (2000)
(chiark.greenend.org.uk)- Cuando el código productor y el consumidor intercambian datos, si uno de los dos se reescribe invertido como callee, la estructura del algoritmo que antes era visible puede quedar enterrada en transiciones de estado
- Las corrutinas al estilo Knuth son un modelo en el que dos rutinas guardan su posición de ejecución y se pasan el control, pero en la estructura de llamadas basada en pila de C es difícil implementarlas directamente de forma portable
- El truco central de este texto consiste en usar la sintaxis de C que permite poner
casedentro de subbloques de unswitch, junto con la macro__LINE__, para crear una máquina de estados implícita que reingresa en la posición posterior a unreturn - Con las macros
crBegin,crReturnycrFinishse puede conservar la estructura original de bucles de un descompresor y un parser, pero las variables locales que deban preservarse deben serstatic, ycrReturnno debe colocarse dentro de unswitchexplícito ni dos veces en la misma línea - En código real, por restricciones de reentrancia y multithreading, se necesita una versión mejorada que pase una estructura de contexto;
coroutine.hofrece tanto macros simplesscrcomo macros reentrantesccr
El problema estructural al conectar productores y consumidores
- En programas grandes, es común que un código produzca datos y otro los consuma; en ese caso, decidir cuál será el caller y cuál el callee complica el diseño
- El ejemplo consta de dos rutinas pequeñas
- El código de descompresión run-length lee la entrada con
getchar()y emite caracteres uno por uno conemit() - El código del parser lee caracteres con
getchar()y trata las secuencias alfabéticas comoWORD, y los demás caracteres comoPUNCT
- El código de descompresión run-length lee la entrada con
- Vistas por separado, ambas rutinas son naturales, pero para que la salida
emit()del descompresor alimente directamente la entradagetchar()del parser, hace falta una estructura que las conecte - También se puede resolver con un pipe entre dos procesos o dos threads
- El
emit()del descompresor escribe en el pipe, y elgetchar()del parser lee del otro extremo - Este enfoque es simple y robusto, pero pesado y poco portable, por lo que a menudo no se quiere dividir en threads una tarea sencilla
- El
Pérdida de legibilidad por reescritura de funciones
- La solución tradicional es reescribir un extremo del canal de comunicación como una función invocable
- Si el descompresor se convierte en una función que devuelve un carácter en cada llamada, el parser existente puede llamar a
decompressor()en lugar degetchar() - A la inversa, si el parser se convierte en una función que se llama cada vez que recibe un carácter, el código de descompresión existente puede llamar a
parser()en lugar deemit() - No hace falta cambiar ambos: con modificar solo un lado ya se pueden conectar, pero el código reescrito se vuelve mucho más difícil de leer que el original
- En el descompresor y el parser originales, el flujo del algoritmo aparece de forma natural dentro de los bucles
- La forma reescrita depende de variables de estado
staticy transiciones de estado conswitch, lo que dificulta leer en el código el formato de compresión o la gramática del parser
- El objetivo es conectar ambos sin invertir ninguno como una máquina de estados explícita
Corrutinas al estilo Knuth y los límites de C
- La solución con corrutinas de Donald Knuth descarta la distinción entre caller y callee, y trata los dos procesos como entidades cooperativas en igualdad de condiciones
- El principio de llamada de este modelo difiere de una llamada de función común
- La posición de ejecución actual se guarda en un lugar separado, no en la pila
- Se salta a la posición de ejecución que la otra rutina guardó por última vez
- Cuando el descompresor emite un carácter, guarda su propio contador de programa y se mueve a la posición guardada del parser
- Cuando el parser necesita el siguiente carácter, guarda su propio contador de programa y se mueve a la posición guardada del descompresor
- El control va y viene entre las dos rutinas tantas veces como sea necesario
- Este enfoque es bueno en teoría, pero en la práctica solo es posible en ensamblador
- Los lenguajes de alto nivel como C dependen de una estructura basada en pila, así que al transferir control entre funciones una parte debe ser caller y la otra callee
- En código C portable, el enfoque de corrutinas puras es tan poco práctico como la solución con pipes de Unix
Simular “return and continue” en C
- En C, el comportamiento necesario es que una función callee haga
returny, en la siguiente llamada, continúe ejecutándose justo después de esereturn: return and continue - Por ejemplo, sería ideal que una función de la forma
for (i = 0; i < 10; i++) return i;, al llamarse 10 veces, devolviera en orden los valores de 0 a 9 - La primera implementación usa una variable de estado y
goto- Se colocan labels al inicio de la función y después de cada
return - Una variable
stateque se conserva entre llamadas apunta al siguiente label de reanudación - Al inicio de la función,
switch(state)salta al label adecuado - Justo antes del
return, se guarda enstateel label al que se volverá en la próxima llamada
- Se colocan labels al inicio de la función y después de cada
- Este enfoque funciona, pero administrar labels manualmente carga el mantenimiento
- Cada vez que se agrega un
return, hay que crear un nuevo label y sumarlo también alswitchinicial - Al quitar un
return, también hay que quitar su label correspondiente - Hay que mantener sincronizados todo el tiempo el cuerpo de la función y la lista del
switch
- Cada vez que se agrega un
Una máquina de estados oculta con Duff’s device
- El famoso Duff’s device de C aprovecha la sintaxis que permite colocar sentencias
casecorrespondientes a unswitchtambién dentro de sus subbloques - Si esta propiedad se aplica al truco de corrutinas, en vez de que el
switchelija quégotoejecutar, el propioswitchfunciona como un salto de reingreso - La forma básica es la siguiente
static int stateguarda el siguiente punto de reanudación- Al inicio de la función se entra con
switch(state) { case 0: ... } - Justo antes del
return, se guarda el siguiente valor decaseenstate - Justo después del
return, se coloca ese labelcase
- Si se envuelve esto en macros, queda una interfaz que parece una corrutina
crBegin: ocultastatic int state=0; switch(state) { case 0:crReturn: guardastate, devuelve un valor y luego coloca un labelcaseen la misma posicióncrFinish: cierra el bloque abierto
crReturnestá envuelta endo ... while(0), así que incluso si se usa sin llaves entre unify unelse, no causa problemas de sintaxis- Al principio hay que pasar explícitamente el número de estado, como en
crReturn(1, i), pero usando la macro__LINE__de ANSI C se puede usar el número de línea actual del código fuente como valor de estado - Después de esta mejora, basta con escribir
crReturn(x), pero se agrega la regla de no poner doscrReturnen una misma línea
Reglas de uso de las macros y ejemplos
- Las corrutinas basadas en macros asumen algunas reglas
- Envolver el cuerpo de la función con
crBeginycrFinish - Declarar como
staticlas variables locales que deban preservarse más allá de uncrReturn - Nunca poner
crReturndentro de una sentenciaswitchexplícita - En la implementación basada en
__LINE__, no poner doscrReturnen la misma línea
- Envolver el cuerpo de la función con
- El ejemplo del descompresor conserva la estructura original de bucles y, al emitir un carácter, usa
crReturn(c)en lugar deemit(c) - El ejemplo del parser vuelve al caller con
crReturn()cuando necesita un nuevo carácter, y en la siguiente llamada continúa ejecutándose con el nuevo carácter ya recibido en el parámetroc - En el parser hay un pequeño cambio estructural
- Como el primer carácter ya está en
cal entrar en la función, elcrReturncorrespondiente algetchar()del inicio del bucle original se mueve al final del bucle - Si se quiere, también se puede definir que el parser necesite una llamada de inicialización
- Como el primer carácter ya está en
- No hace falta convertir ambas rutinas a macros de corrutina; se puede cambiar solo una y dejar la otra como caller
- En definitiva, combinando ANSI C, el preprocesador y una sintaxis poco usada de
switch, se maneja el paso de datos entre productor y consumidor sin reescribirlos como máquinas de estados explícitas
Conflicto entre estándares de código y claridad del algoritmo
- Esta técnica viola fuertemente los estándares de codificación habituales
- Dentro de las macros hay llaves que no encajan entre sí
- Usa
casedentro de subbloques crReturnocultaswitch,returnycasedentro de una sola macro
- Desde el punto de vista de los estándares de codificación, las macros que ocultan la estructura sintáctica pueden considerarse perjudiciales para la claridad
- Sin embargo, una función reescrita como máquina de estados explícita también se compone de pequeños bloques
case STATEy transiciones de estado, y su estructura visual no difiere mucho de una función que enumera bloques con labelsgoto - Cuanto más larga es la función, más daña la reescritura como máquina de estados la estructura original del algoritmo
- Esta técnica es un compromiso que oculta parte de la estructura sintáctica para exponer mejor la estructura del algoritmo
Versión reentrante mejorada y código provisto
- Una implementación simple de juguete depende de variables
static, por lo que no es reentrante ni adecuada para multithreading - En aplicaciones reales, debe ser posible llamar a la misma función desde varios contextos y que cada contexto continúe ejecutándose después de su último
return - La mejora consiste en pasar un puntero a una estructura de contexto como parámetro adicional
- Tanto el estado local como la variable de estado de la corrutina se guardan como miembros de la estructura
- Variables como contadores de bucle también deben accederse como
ctx->ien lugar dei - El código queda un poco más feo, pero elimina el problema de reentrancia y mantiene la estructura general de la rutina
- Los usuarios de C++ pueden convertir la corrutina en un miembro de clase y poner dentro de la clase el estado equivalente a las variables locales, para manejar el scope de forma más natural
- El archivo
coroutine.hprovisto implementa este truco de corrutinas como un conjunto de macros predefinidas- Las macros con prefijo
scrson la versión simple que usa variablesstatic - Las macros con prefijo
ccrson la versión avanzada reentrante - La documentación detallada está incluida en los comentarios del archivo header
- Las macros con prefijo
- Visual C++ 6 rechaza este truco porque con la configuración de debug predeterminada “Program Database for Edit and Continue” trata de forma extraña la macro
__LINE__- Para compilar un programa que use corrutinas en VC++ 6, hay que desactivar Edit and Continue
- En la configuración del proyecto, en la pestaña “C/C++”, categoría “General”, opción “Debug info”, hay que elegir una opción que no sea “Program Database for Edit and Continue”
- El header se ofrece bajo licencia MIT
Referencias relacionadas y uso real
- The Art of Computer Programming, Volumen 1, Sección 1.4.2, de Donald Knuth, trata las corrutinas en su forma pura
- La discusión de Tom Duff sobre Duff’s device contiene indicios de que pudo haber concebido de forma independiente un truco de corrutinas similar, y en una actualización del 2005-03-07 Tom Duff lo confirmó en un comentario de blog
- El código del protocolo SSH de PuTTY usa este truco de corrutinas en la práctica
- El caso de PuTTY representa un nivel fuerte de hackeo en C poco común en código serio de producción
1 comentarios
Comentarios en Hacker News
Al intentar reducir la complejidad de la API en un proyecto en C, terminé volviendo a esta página varias veces, y me parece excelente la explicación del flujo de control
También me ayudó a pensar con más claridad sobre cómo se guarda el estado dentro y fuera de la pila, y sobre las diferencias de legibilidad según el enfoque de acceso
Por ahora, mi conclusión es que lo mejor es que sean los usuarios de la biblioteca quienes decidan si quieren usar corrutinas en C. Por ejemplo, Mongoose(https://github.com/cesanta/mongoose) maneja la asincronía con callbacks de eventos, y para bibliotecas así es mucho más agradable envolverlas con las primitivas nativas de hilos/tareas de cada sistema, en lugar de intentar portarlas a unas corrutinas en C míticamente multiplataforma o, peor aún, a
std::thread[1] https://www.chiark.greenend.org.uk/~sgtatham/putty/
[2] https://www.chiark.greenend.org.uk/~sgtatham/puzzles/
Las corrutinas son un concepto realmente genial, y también es muy entretenido ver videos de corrutinas de C++ en CppCon, sobre todo los presentados por gente de Microsoft. El gancho de “abstracción de costo negativo” también está bastante bueno
Hace unos años, amigos en Meta empezaron a usar corrutinas de C++, pero me dijeron que al final fue un gran error. Tuvieron que enfrentarse a bugs en la implementación del compilador, y seguramente fue bastante sucio de rastrear. En Google, sigo esperando a que la gente brillante que sabe integrarlas bien en google3/ diga que ya se pueden usar
Este artículo explica una estrategia para implementar corrutinas en C con
gotoestructurado basado en macros, usando el dispositivo de Duff [1]. La clave es que puedes poner sentenciascasecasi en cualquier parte dentro de un bloqueswitch; entonces envuelves toda la función en unswitch, guardas la última posición de retorno de la corrutina en una variablestaticy etiquetas cadacoReturncomo uncaseEl artículo de Sustrik sobre corrutinas en C también puede ser interesante [2]
[1] https://en.wikipedia.org/wiki/Duff%27s_device
[2] https://250bpm.com/blog:48/index.html
co_yield,co_returnyco_awaittiene tanto ventajas como desventajasLa ventaja frente al enfoque interno de google3 es que, al leer el código, queda claro qué partes son asíncronas. Algunos programadores en Google llegaban a trabajar sin entender el modelo de hilos más allá de una sola rama, y luego terminaban creando bugs serios
La desventaja es más simple. Como mucho código “puede ser asíncrono”, con el tiempo todo termina volviéndose asíncrono solo porque los programadores escriben en ese modo. Elegir entre un spinlock y un mutex con cesión debería depender del tamaño de la sección crítica y de la situación de hilos en ese momento, pero al intentar mantener legibilidad y consistencia, es fácil que todo el proyecto se incline hacia un solo lado
Me gustaría conocer más implementaciones de lenguajes con threading que no fijen un valor predeterminado hacia ninguno de los dos lados, sino que optimicen mejor la siguiente ejecución según el perfil de las anteriores, sin cambios de código ni bugs
lc-addrlabels.hTambién usé la función de etiquetas locales de GCC para evitar por completo el uso de
__LINE__, y así incluso se podían poner varioscoReturnen una misma líneacasecasi en cualquier parte dentro de un bloqueswitch, pero esa capacidad en sí casi con seguridad fue una función intencionalComo se menciona al final del artículo, Duff también se dio cuenta de que encima de eso se podían construir corrutinas, pero esa idea le pareció “repugnante”
Si piensas en el
switchde C como una especie de pattern matching poco expresivo, es fácil que el “fallthrough” parezca un bug, pero no lo es. Es algo de la misma familia que elgotocalculado de Fortran, y como no requiere que los valores sean contiguos ni que todas las etiquetas estén listadas arriba, resulta más cómodo. Ahora que lo escribo, quizá se parece más a unCOMEFROMcalculadoLa afirmación de que “los lenguajes de alto nivel de uso general no soportan corrutinas” pudo haber sido cierta en 2000, pero hoy muchos lenguajes sí las soportan, como C++20, Lua, Python y Ruby
yieldapareció por esa época o no mucho despuésLa mejora propuesta al final del artículo —“agregar un puntero a una estructura de contexto como argumento adicional de la función, y declarar todo el estado local y las variables de estado de la corrutina como miembros de esa estructura”— se parece a una implementación de closures. Sería como convertir el lado llamado en una lambda y hacer que use variables/contexto/estado externos para decidir qué hacer o con qué valores hacerlo; me pregunto si lo estoy entendiendo bien
El enfoque con
switchno es rarísimo, pero normalmente se usa un puntero de estado que se pasa a una función de inicialización y a la función de la corrutinaUsé mucho este método en proyectos embebidos: una corrutina se encargaba de la aceleración/desaceleración del motor y otra solo indicaba hacia qué dirección ir. También lo usé en una biblioteca de red[1]. Incluso la biblioteca estándar tiene funciones corrutina como
strtok()[2]Para que sea manejable no hace falta llegar al infierno de macros, pero nunca me ha resultado agradable leer el flujo de
switch/case[1]: https://github.com/REONTeam/libmobile/blob/master/relay.c#L3...
[2]: https://manpages.debian.org/bookworm/manpages-dev/strtok.3.e...
También está Simon Tatham's Portable Puzzle Collection, del mismo autor
https://www.chiark.greenend.org.uk/~sgtatham/puzzles/
Si esto te parece magia negra de C, también vale la pena leer el texto del mismo autor sobre cómo crear estructuras de control arbitrarias con macros: https://www.chiark.greenend.org.uk/~sgtatham/mp/
Las corrutinas son interesantes, pero en código real también conviene considerar el uso de hilos reales. Los procesadores modernos tienen muchos núcleos, pero las corrutinas a menudo usan solo un núcleo
Es un problema real. Hasta hace poco, qemu, que usa muchas corrutinas, enviaba buena parte del I/O de dispositivos de bloque a un solo hilo, y eso causaba problemas de rendimiento. Kevin Wolf y otras personas lo corrigieron durante años, y el qemu moderno ya usa varios hilos para I/O; ese trabajo está previsto para RHEL 9.4
Las corrutinas son una forma de estructurar la ejecución monohilo, y eso ya es útil por sí mismo. El patrón productor-consumidor del ejemplo principal es un buen caso, y conectar un stream a un parser no es un algoritmo paralelo, así que los hilos no sirven para escribir eso
Si usas un paradigma monohilo para trabajo que sí puede ejecutarse en paralelo, naturalmente será ineficiente, pero las corrutinas no son paralelismo para pobres sino una estructura de control con valor propio. También pueden combinarse productivamente con hilos, como en un servidor web donde el event loop, junto con el dispatcher, va entretejiendo corrutinas entre varios eventos bloqueantes; y si el runtime lanza un hilo por núcleo para paralelizar, la coordinación entre hilos puede reducirse a revisar la profundidad de cada cola de trabajo y pasar solicitudes a la menos cargada
La idea de las corrutinas es que tienes algún trabajo local y datos síncronos, y te resulta más fácil expresarlo no con el paradigma funcional donde el llamador “tira” del loop interno, sino con la forma invertida donde la función recorre algo en un loop y “empuja” resultados hacia un consumidor abstraído que está en otra parte
Luego casi nunca se mueven corrutinas entre planificadores, y también casi nunca se comparten datos entre corrutinas de distintos planificadores
Las corrutinas, mediante planificación cooperativa, permiten un estilo cómodo de programación concurrente que no necesita locks en absoluto. Normalmente la latencia de planificación es mayor, pero al desaparecer el overhead de operaciones atómicas/locks y al no interrumpirse constantemente la ejecución por temporizadores de planificación preventiva, el throughput puede ser bastante alto
La versión en C++ de este enfoque: https://www.codeproject.com/Tips/29524/Generators-in-C
Yo también lo uso en mi Sciter por si acaso, y funciona bastante bien; es cómodo
La forma modular y segura de lograr esto probablemente sean los manejadores de efectos. Se parecen al
yieldde Python, pero pueden devolver valores y no están limitados a llamadas de función: capturan el scope como las excepciones. Si no te resultan familiares, este texto da una buena motivaciónCada función escrita en estilo directo puede realizar un “efecto” cuando el control tiene que pasar a otro lugar. Aquí
c=getchar()yemit(c)serían casos asíEntonces el control pasa al manejador de efectos y, en este caso, probablemente el llamador de las dos funciones decide qué hacer después. Cuando el descompresor emite un carácter, se reanuda el código del parser pasándole ese carácter; se sigue hasta que el parser dice que necesita más, y entonces se reanuda otra vez el descompresor
Los efectos pueden implementarse eficientemente, en especial si se restringe la continuation para que solo pueda llamarse una vez. Ese es el caso de OCaml. Permiten código en estilo directo junto con seguridad de tipos y de memoria, y además son muy útiles en entornos concurrentes
Aquí hay un ejemplo: https://effekt-lang.org/docs/casestudies/lexer
No coincido en absoluto con la parte de “este truco obviamente viola todos los estándares de codificación… yo diría que los estándares de codificación están mal”.
No está mal que un estándar de codificación rechace este código; este código es solo un truco simpático. La ingeniería de software a gran escala consiste en eliminar sorpresas y en hacer código que también pueda leer alguien falto de sueño, depurando a las 3 de la mañana después de que lo llamaron. No se puede esperar que los programadores recuerden siempre cuatro reglas básicas.
Tampoco me convence la idea de que, al ocultar elementos importantes como
switch,returnycasedentro de macros “ofuscadoras”, se haya difuminado la estructura sintáctica pero revelado la estructura algorítmica. Un buen programa debe dejar claras tanto la estructura sintáctica como la estructura algorítmica, y este enfoque no llega a eso. Creo que aquí el modelo debería ser la forma en que Rust crea implícitamente una máquina de estados en las funcionesasync.Esa actitud de rebajar todo indefinidamente “hasta el mínimo común denominador” es la causa de gran parte de la calidad actual del software, o de su ausencia. Si se evita el conocimiento y la formación, al final se termina pagando el precio.
Al pasar de C a C++, vi que hay una gran diferencia entre ambas comunidades en qué consideran código fácil de leer, es decir, en qué esperan que entienda quien lo lea en el futuro.
En el mundo de C, hasta el operador condicional ternario parece demasiado provocador, y C99 todavía se trata como si fuera una novedad. En el mundo de C++, la única razón para desalentar la metaprogramación con plantillas es que en el estándar que se use se pueda hacer lo mismo con
constexpr.