La asincronía no es concurrencia
(kristoff.it)- La asincronía y la concurrencia son conceptos que a menudo se confunden, pero tienen significados distintos
- La asincronía se refiere a la posibilidad de que las tareas puedan ejecutarse sin importar el orden
- La concurrencia se refiere a la capacidad del sistema de avanzar en varias tareas al mismo tiempo
- La falta de una distinción clara entre ambos conceptos en los ecosistemas de lenguajes y bibliotecas genera ineficiencia y complejidad
- En Zig, separar asincronía y concurrencia permite la convivencia de código síncrono y asíncrono sin duplicación de código
Introducción: por qué hay que distinguir entre asincronía y concurrencia
La famosa charla de Rob Pike hizo muy conocida la frase “la concurrencia no es paralelismo”, pero hay un punto aún más importante en la práctica: la necesidad del concepto de “asincronía”. Según la definición de Wikipedia,
- Concurrencia: capacidad de un sistema para procesar varias tareas al mismo tiempo, ya sea por división temporal o en paralelo
- Computación paralela: ejecución simultánea de varias tareas a nivel físico real
Además de esto, hay otro concepto clave que solemos pasar por alto: la “asincronía”.
Ejemplo 1: guardar dos archivos
Si se van a guardar dos archivos (A y B) y el orden no importa,
io.async(saveFileA, .{io})
io.async(saveFileB, .{io})
- Da igual guardar primero A o primero B, e incluso alternar entre ambos durante el proceso
- Incluso si se termina de guardar por completo el archivo A antes de empezar con B, el código sigue siendo correcto
Ejemplo 2: dos sockets (servidor y cliente)
Cuando dentro del mismo programa se debe crear un servidor TCP y conectar un cliente,
io.async(Server.accept, .{server, io})
io.async(Client.connect, .{client, io})
- En este caso, ambas tareas necesitan avanzar de forma superpuesta
- Es decir, mientras el servidor acepta la conexión, el cliente también debe intentar conectarse
- Si se procesan en serie, como en el primer ejemplo de archivos, no se obtiene el comportamiento esperado
Aclaración conceptual
Los conceptos de asincronía, concurrencia y paralelismo se definen así
- Asincronía (asynchrony): propiedad por la cual el resultado sigue siendo correcto aunque las tareas se ejecuten fuera de orden
- Concurrencia (concurrency): capacidad de desarrollar varias tareas al mismo tiempo, ya sea en paralelo o por ejecución fragmentada
- Paralelismo (parallelism): capacidad física de ejecutar varias tareas realmente al mismo tiempo
Los ejemplos de guardar archivos y de conectar sockets son ambos asíncronos, pero en el segundo (servidor-cliente) la concurrencia es obligatoria
La utilidad práctica de distinguir entre asincronía y concurrencia
Si no se hace esta distinción, aparecen problemas como estos
- Los autores de bibliotecas tienen que escribir dos veces el código: una versión asíncrona y otra síncrona (por ejemplo, redis-py vs asyncio-redis)
- Para el usuario, el código asíncrono se vuelve “contagioso”: basta con depender de una sola biblioteca asíncrona para verse obligado a convertir todo el proyecto a asíncrono, lo que resulta incómodo
- Para evitarlo, surgen atajos poco elegantes, que a menudo provocan *deadlocks* e ineficiencia
Por eso, separar claramente ambos conceptos aporta grandes ventajas tanto a quienes crean bibliotecas como a quienes las usan
Zig: separación entre asincronía y concurrencia
El lenguaje Zig usa io.async para la asincronía, pero eso no garantiza concurrencia
- Es decir, incluso usando
io.async, internamente puede ejecutarse en modo bloqueante y con un solo hilo - Por ejemplo,
este código, en un entorno bloqueante, puede comportarse igual queio.async(saveFileA, .{io}) io.async(saveFileB, .{io})saveFileA(io) saveFileB(io) - En otras palabras, aunque el autor de la biblioteca use
io.async, el usuario conserva la flexibilidad de ejecutarlo como I/O bloqueante secuencial si así lo desea
Introducción de concurrencia y mecanismo de cambio de tareas (scheduling)
Cuando se necesita concurrencia, para que el comportamiento sea realmente efectivo hace falta
- usar I/O basado en eventos y no bloqueante (epoll, io_uring, etc.)
- usar primitivas de cambio de tarea (switching), como
yield
- Como ejemplo, Zig usa la técnica de stack swapping en entornos de hilos verdes para cambiar entre tareas
- De forma similar al scheduling de hilos a nivel del sistema operativo, se guardan y restauran estados como registros de CPU y stack para cambiar entre múltiples tareas
- Solo con este mecanismo de cambio se puede programar concurrentemente código asíncrono de verdad
- Las implementaciones de corrutinas stackless (por ejemplo,
suspend,resume) siguen el mismo principio
Convivencia de código síncrono y asíncrono
Si se ejecutan dos llamadas a saveData con io.async como en este ejemplo,
io.async(saveData, .{io, "a", "b"})
io.async(saveData, .{io, "c", "d"})
- Como ambas tareas son asíncronas entre sí, incluso funciones escritas internamente de forma síncrona pueden programarse de manera natural dentro de un contexto concurrente
- Ni el usuario ni el autor de la biblioteca tienen problemas para mezclar funciones síncronas y asíncronas sin duplicar código
Expresar cuándo la concurrencia es “obligatoria”
Hay funciones concretas (por ejemplo, accept en un servidor TCP) donde es necesario expresar en el código que la concurrencia es un requisito de ejecución
- En Zig, esto se distingue con funciones explícitas como
io.asyncConcurrent - Este enfoque permite generar un error si el entorno de ejecución no soporta concurrencia para esa tarea
- A diferencia de
io.async, cuyo propósito es la asincronía, aquí la garantía de concurrencia es obligatoria, por lo que se implementa como una función que puede fallar
Conclusión
- La asincronía y la concurrencia son conceptos completamente distintos y deben diferenciarse con claridad
- Es posible hacer que código síncrono y código asíncrono convivan
- El modelo de asincronía/concurrencia de Zig permite aprovechar ambos mundos sin duplicación de código
- Esta estructura también se ha aplicado en otros lenguajes como Go, y ofrece una vía para superar el carácter contagioso de
async/await - Con el nuevo diseño de async I/O de Zig, se puede esperar un entorno de programación concurrente y asíncrona más intuitivo en el futuro
1 comentarios
Opiniones de Hacker News
definir
asyncsiempre se siente dificilísimo; yo también fui una de las varias personas que diseñaronasyncen JavaScript, y no estoy de acuerdo con la definición que propone este texto; no funciona correctamente solo por serasync; incluso en códigoasyncpueden seguir ocurriendo varios tipos de condiciones de carrera a nivel de usuario, tenga o no el lenguaje soporte paraasync/await; la definición a la que he llegado últimamente es queasynces “código estructurado explícitamente para la concurrencia”; aun así, esta perspectiva todavía necesita pulirse más; también tengo un texto sobre esto: Quite a few words about asynccreo que es importante distinguir entre el concepto abstracto de asincronía y su implementación práctica; esta última incluye tanto abstracciones a nivel de lenguaje como mecanismos de coordinación mecánicos; en el nivel más alto de abstracción, la asincronía es simplemente lo opuesto a la sincronía; normalmente, cuando varios agentes tienen que operar juntos —por ejemplo, cuando una tarea debe terminar para que otra continúe—, la esencia de lo asíncrono es que no se sabe o no está definido cuándo ocurrirá eso; esa definición en sí no es difícil; el problema es la carga cognitiva que aparece al diseñar esta abstracción a nivel de lenguaje
no soy experto en este tema, pero en mi opinión el código
asyncconsiste en convertir operaciones originalmente bloqueantes en no bloqueantes para que otras tareas puedan avanzar al mismo tiempo; en mi caso esto se vuelve especialmente evidente en loops embebidos, donde el código que bloquea durante mucho tiempo puede arruinar el I/O y provocar fallas visibles o audiblesincluso dudo que realmente haya que definir
async; si es tan difícil definirlo, en la práctica es porque no hay nada que encaje del todo en un solo concepto; también dudo que sea necesario definirasyncoevent loop; seguro hay muchísimos conceptos en el terreno físico del chip donde sí existe procesamiento realmente paralelo que yo desconozco; a mí me basta con entender “user finger” (toques del dedo del usuario, etc.), “quickies” (tareas muy cortas), lajob queuey las API bloqueantes/no bloqueantes; para lograr lo que quiero, prefiero las API no bloqueantes, porque las tareas que toman tiempo se las dejo al subsistema inferior y yo solo escribo “quickies”, como guardar los datos que quiero, además de definir quickies distintos para éxito o error; la distinción entresyncyasyncno me ayuda mucho; claro, sí necesito entender el concepto cuando otros lo mencionan; en esencia, para míasynces una API no bloqueante; un modelo de programaciónasyncconsiste básicamente en escribir operaciones bloqueantes pequeñas y atómicas —en términos de tiempo de ejecución— para responder a eventos “caóticos y no deterministas”; haga lo que haga el sistema internamente, confío en que el navegador, el OS o el propio dispositivo me dan múltiples unidades de ejecución y un buen scheduler; para míasynces un concepto definido de forma ambigua, y aunque pudiera definirse, no sé si eso sería realmente útil; en cambio, conceptos como eventos, la naturaleza bloqueante de las tareas que escribo, los closures de funciones y qué trabajo se divide en otros jobs al usar una API son mucho más prácticos; incluso el término “callback” me confundió muchísimo al principio; yo creía que el código se detenía ahí, pero en realidad tenía que entender con precisión qué código corre cuando luego se invoca el “callback” y qué información puede ver; sinceramente, esto es a la vez caos y genialidad; más que “async”, el modelo fundamental —eventos, trabajo bloqueante, cola de trabajos y API no bloqueantes— es mucho más simple; y también es muy importante entender qué hago yo y qué hacen el navegador o el OS; por ejemplo, C++ declara un modelo concurrente, pero el OS se encarga de la ejecución real; en JS, mediante API no bloqueantes, le declaras “tal vez” al navegador o a Node que hay concurrencia, y ellos la manejan internamente de forma concurrente; lo más importante es mantener cada tarea corta (<50ms) y poder expresar la intención con API no bloqueantes; C++ o Rust le piden al OS ejecutar tareas de forma concurrente, así que incluso si físicamente solo hay un hilo, la UI sigue respondiendo; al final, lo que debe hacer el programadorasynces crear un “gran modelo de UX” y mapear bien los eventos a quickiesme parece que el autor sacó la idea de “ceder la ejecución (
yield)” de la definición de concurrencia y la metió en un término nuevo, “asincronía”, y además afirma que sin esa idea toda la concurrencia se rompe; yo creo que en la concurrencia la capacidad de ceder ejecución ya es algo esencial, está implícita en el propio concepto; es una idea importante, sí, pero separarla como término nuevo solo agrega confusiónyo consideraría el paralelismo 1:1 como una forma de concurrencia sin cesión de ejecución; fuera de eso, toda concurrencia no paralela debe ceder ejecución de alguna manera, aunque sea a nivel de instrucción; por ejemplo, en CUDA los hilos divergentes dentro del mismo warp terminan intercalando instrucciones, así que una rama puede bloquear a la otra
de hecho, el texto citado dice explícitamente que “ceder la ejecución es un concepto de concurrencia”
la concurrencia no implica necesariamente ceder ejecución; la lógica síncrona necesita sincronización explícita, y ceder ejecución es solo un medio de sincronización; cuando hablo de lógica asíncrona me refiero a concurrencia que opera sin sincronización ni
yield; desde una perspectiva práctica, ni la concurrencia ni la lógica asíncrona existen plenamente en una máquina de von Neumannen este contexto, asincronía es la abstracción que separa la preparación/envío de una solicitud de la recolección de su resultado; eso permite enviar varias solicitudes y revisar sus resultados después; puede permitir una implementación concurrente, pero no la exige; aun así, el objetivo de esta abstracción es lograr concurrencia; sin concurrencia, tampoco se obtiene el beneficio buscado; algunas abstracciones asíncronas ni siquiera pueden implementarse sin un mínimo de concurrencia; por ejemplo, los callbacks pueden imitarse en un solo hilo, pero tienen límites, como deadlocks cuando se sostiene un
mutexno recursivo; o sea, una abstracción asíncrona sin concurrencia está destinada a fallar; si quien hace la solicitud la lanza mientras sostiene elmutexy el callback corre antes delunlock, eseunlockpodría no llegar nunca; hace falta al menos un hilo separado para que quien solicita pueda avanzar hasta liberar el lockasyncsiempre hay que garantizar concurrencia“el multitasking cooperativo no es preventivo”; el término “asincronía” suele significar “un solo hilo, multitasking cooperativo (
yieldexplícito) y basado en eventos”, donde además las operaciones externas se ejecutan concurrentemente y reportan sus resultados mediante eventos; en modelos multihilo o de ejecución concurrente,asyncpierde gran parte de su sentido, porque aunque ese hilo se bloquee, el programa sigue avanzando; ya no hace falta que los puntos deyieldsean explícitosasync; los hilos del OS sirven para trabajo CPU-bound yasyncpara trabajo IO-bound; la mayor ventaja deasync, o del scheduler M:N estilo Go, es que si tienes memoria suficiente puedes aumentar libremente la cantidad de tareas/goroutines; con hilos del OS aparecen problemas de costo de context switch y falta de hilos o memoria, así que más allá de cargas IO-bound uno puede terminar enfrentando deadlocksla nueva idea de I/O en Zig me parece novedosa para el desarrollo de apps en general; es ideal para quien no necesita corrutinas stackless; pero al escribir librerías probablemente introduzca muchos errores; al autor de una librería le resulta difícil saber si el I/O dado es de hilo único o múltiples hilos, o si es I/O basado en eventos; el código relacionado con concurrencia/asincronía/paralelismo ya es difícil incluso conociendo por completo el stack de I/O, y se vuelve mucho más complicado cuando el I/O llega desde afuera; si la interfaz de I/O se vuelve tan amplia como un “pequeño OS”, también explota la cantidad de escenarios a probar; no me queda claro si solo con las primitivas
asyncque expone la interfaz se podrán manejar todos los edge cases reales; para soportar varias implementaciones de I/O, el código tendría que ser muy “defensivo” y asumir siempre el I/O más paralelo posible; sobre todo, mezclar corrutinas stackless con este enfoque no parece sencillo; para reducir spawns innecesarios de corrutinas probablemente haga falta polling explícito de corrutinas, y no creo que la mayoría de los desarrolladores escriba ese tipo de código a mano; al final, me parece que va a terminar en una estructura parecida al código normal deasync/await; considerando también el dynamic dispatch y la tendencia de diseño bottom-up de Zig, parece que acabará siendo un lenguaje bastante de alto nivel; como todavía no hay casos reales de uso, llamarlo un enfoque “sin concesiones” suena demasiado ambicioso; solo después de unos años de uso podrá evaluarse de verdadlas corrutinas stackless igual están planeadas, porque son necesarias para soportar el target WASM, así que van a entrar sí o sí; el dynamic dispatch solo se usa cuando hay más de una implementación de I/O; si usas una sola, se reemplaza por llamadas directas; como todavía no está probado en producción, también me parece prematuro hablar de algo “sin concesiones”; escuché que en Jai se usa con éxito un modelo parecido —con la diferencia de un contexto de I/O implícito en vez de paso explícito de contexto—, pero tampoco diría que eso ya cuente como validación en el mundo real
coincido en que, si se quiere soportar tanto ejecución síncrona como asíncrona, el código siempre tiene que asumir el I/O más paralelo posible; pero si la asincronía está bien implementada en los event handlers de I/O de nivel inferior, entonces basta con aplicar el mismo principio en todas partes; en el peor caso, el código simplemente correrá de manera secuencial —más lenta—, pero no caerá en problemas de race o deadlock
me parece muy buena la idea de Zig de no obligarte a usar dos librerías separadas; aun así, siempre me preocupa cómo probar código asíncrono; no sé cómo asegurar que un test que hoy pasa realmente reprodujo todos los escenarios/órdenes posibles que pueden ocurrir en producción; con programas con hilos pasa lo mismo, pero el código multihilo siempre es más difícil de escribir y depurar; yo evito usar hilos salvo que sea necesario; el problema real es “hacer que los desarrolladores entiendan con precisión el entorno asíncrono/con hilos”; hace poco trabajé con un equipo que en un sistema Python usaba mitad JS y mitad Python, y había convertido código grande a
asyncythreaded; pero ni siquiera sabían qué era el Global Interpreter Lock (GIL); lo que yo decía solo les sonaba a regaño; peor aún, sus tests siempre pasaban incluso si rompías el código; mangum fuerza la finalización de trabajos background yasynccuando termina el HTTP request, y ellos ni enterados; y aunque les expliques estas cosas, a muchos simplemente no les importa; no es solo importante saberlo, también importa que a otros les importe revisarloen Zig planean introducir una implementación de prueba de
Io; con eso quieren habilitar fuzz testing y otros stress tests bajo modelos de ejecución paralela; pero el punto clave es que la mayoría del código de librería probablemente nunca necesite llamar directamente aio.asyncoio.asyncConcurrent; por ejemplo, la mayoría de las librerías de base de datos pueden escribirse completamente como código síncrono puro; luego el desarrollador de la aplicación puede volverlo asíncrono fácilmente con algo comoio.async(writeToDb),io.async(doOtherThing); así se vuelve menos propenso a errores y mucho más fácil de entender que andar esparciendoasync/awaitpor todo el códigototalmente de acuerdo: en código asíncrono y multihilo es notoriamente difícil probar todas las intercalaciones; incluso usando fuzzers o frameworks de testing de concurrencia, cuesta tener confianza sin las lecciones que solo da la operación real; en sistemas distribuidos esto empeora todavía más; por ejemplo, al diseñar infraestructura de webhooks no solo lidias con el
asyncde tu propio código, sino también con retries de red, timeouts y fallas parciales externas; en entornos de alta concurrencia, garantizar retries, deduplication e idempotency se vuelve un problema de ingeniería por derecho propio; por eso surge la necesidad de usar servicios especializados como Vartiq.com (trabajo ahí); estos servicios abstraen parte de la complejidad operativa de la concurrencia y reducen el blast radius, pero los problemas de testeo delasyncdentro de mi propio código siguen ahí; en conclusión,async, threading y concurrencia distribuida se potencian mutuamente los riesgos, así que la comunicación y el diseño del sistema importan más que cualquier sintaxis o libreríacreo que el autor está confundiendo la definición de concurrencia; vale la pena revisar el paper de Lamport
no dejes solo el enlace del paper, por favor explícalo; a mí me parecía que la definición estaba bien; por ejemplo: asincronía = si el trabajo sigue siendo correcto aunque no se ejecute en orden, eso es asincronía; concurrencia = propiedad de un sistema que puede avanzar varias tareas al mismo tiempo, ya sea con paralelismo o cambio de tareas; paralelismo = dos o más trabajos ejecutándose realmente a la vez a nivel físico
por eso mismo dejé de usar estos términos por completo; con cualquier persona que hables la interpretación cambia, así que las palabras en sí dejan de servir para comunicarse
el autor también sabe que ya existían definiciones previas para ese término; en su blog está proponiendo deliberadamente una definición nueva, y mientras sea consistente dentro de su marco, eso basta; la diferencia está en si los lectores la aceptan o no
la mitad del paper de Lamport ni siquiera puede expresarse conceptualmente en la mayoría de los lenguajes; crear hilos no significa que uno vaya a discutir órdenes totales y parciales; ese tipo de discusión se necesita más bien al diseñar protocolos con TLA+; no hace falta elevar a “nueva teoría” el hecho de que en la API
asyncde Zig una función falle en compilación por “solo funcionar en un entorno de ejecución asíncrono”una buena forma de medir si el término “asincronía” realmente hace falta es ver si también resulta útil en muchos modelos de concurrencia distintos, y no solo en un lenguaje o modelo; por ejemplo, si sirve de forma común en Haskell, Erlang, OCaml, Scheme, Rust, Go, etc., entonces tiene valor; por lo general, cuando entra un scheduler cooperativo, todo el sistema requiere más cuidado porque un solo problema en un fragmento de código puede trabarlo o introducir latencia; con un scheduler preventivo, muchos de esos problemas desaparecen; como ya no es posible bloquear por completo el sistema, el conjunto de problemas se reduce bastante
“asincronía” es una palabra inadecuada aquí; ya existe un término matemático bien definido: “conmutatividad”; hay operaciones en las que el orden no importa —como suma o multiplicación— y otras en las que sí importa —como resta o división—; normalmente el orden de las operaciones en el código se representa por el número de línea, de arriba hacia abajo, pero en código
asyncese orden se rompe; escrito así,asyncConcurrent(...)inevitablemente resulta bastante confuso; si no entendiste por completo el contenido del blog, cuesta mucho saber qué significa; parece una de esas aproximaciones “hipster” que Zig —y Rust, que también me gusta— suelen sacar; sería mejor implementar este sistema procedimental de conmutatividad/orden basado enasyncde una forma parecida a los lifetimes de Rust, o simplemente usar algo con lo que la gente ya esté familiarizadano estoy de acuerdo con que “
asyncConcurrent(...)sea confuso”; si interiorizas la idea central del blog, ya no resulta confuso en absoluto; otra pregunta distinta es si vale la pena aprender esa idea; en la práctica, mucha gente va a experimentar con ello, y con el tiempo se verá en el mundo real si la idea era buena o no; además, si reemplazas ese concepto por “conmutatividad”, en Zig eso podría confundir todavía más, porque ya hay operadores que sí son conmutativos; conf() + g(), como la suma es conmutativa, alguien podría pensar que Zig puede ejecutar ambas en paralelo, y eso sería un malentendido; el orden de ejecución y la conmutatividad son cosas completamente distintas y conviene mantenerlas separadasestrictamente hablando, la conmutatividad es una propiedad de operaciones (binarias); si dices que dos sentencias
asynccomoconnect/acceptson conmutables, surge la pregunta: “¿respecto de qué operación?”; por ahora el operadorbind(>>=), o algo como.then(...), es lo más cercano a cumplir ese papel, pero todavía sigue en el terreno de la intuiciónla asincronía también permite órdenes parciales; incluso si dos operaciones deben retirarse en el mismo orden, eso es independiente del orden real de ejecución; por ejemplo, la resta no es conmutativa, pero aun así podrías calcular el saldo y el monto a descontar en paralelo con dos queries, y luego aplicar el resultado en el orden apropiado
que otro término abarque esta idea no significa automáticamente que sea mejor palabra que “asynchrony”; “commutativity” es una palabra torpe para leer, escuchar y escribir;
asynchronyresulta mucho más familiarla idea de que esto es conmutatividad también tiene límites; si A y B conmutan por separado con C, entonces
ABC = CAB, pero eso no implica necesariamente queACBsea igual; en asincronía, en cambio,ABC = ACB = CABtendría que valer en todos los casos (si ya existe un término matemático para eso, yo no lo conozco)como programador de redes he escrito muchísimo código concurrente, paralelo y asíncrono, y este texto me parece algo confuso; se siente como si intentara encontrar respuestas sobre una abstracción llena de agujeros; si la herramienta o la implementación misma están mal, el hecho de que pueda “romperse” tan fácilmente ya es el problema; la verdad, depurar código multihilo también puede ser bastante divertido; ver que otras personas le tienen tanto miedo a ese monstruo del multihilo hasta me resulta entretenido