Async y event loop
El problema del bloqueo
En el Capítulo 17, aprendiste que tcp_accept bloquea — el programa se detiene y espera hasta que un cliente se conecta. En el Capítulo 18, resolviste esto con threads: crear un nuevo thread para cada cliente.
Pero los threads tienen costos. Cada thread usa memoria para su stack (~8KB mínimo). Con 10,000 conexiones concurrentes, son 80MB solo para stacks. Y cambiar entre threads tiene overhead.
I/O asíncrono es un enfoque diferente: en lugar de bloquear en cada operación, le dices al sistema operativo "avísame cuando haya datos listos" y continúas haciendo otro trabajo. Un solo thread puede manejar miles de conexiones.
Goroutines en Nyx
Nyx proporciona concurrencia ligera con spawn — similar a las goroutines de Go:
fn main() { spawn { print("Hola desde goroutine 1") } spawn { print("Hola desde goroutine 2") } sleep(50) // esperar a que terminen las goroutines print("Main terminado") }
A diferencia de thread_spawn (que crea un thread del sistema operativo), spawn crea una tarea ligera gestionada por el scheduler M:N de Nyx. Muchas goroutines se mapean sobre menos threads del SO.
El scheduler M:N
El scheduler de Nyx mapea N goroutines sobre M threads del SO:
Goroutines: [g1] [g2] [g3] [g4] [g5] [g6] [g7] [g8]
↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
Threads SO: [Thread 1] [Thread 2] [Thread 3] [Thread 4]
El scheduler usa work-stealing: si un thread se queda sin goroutines, roba trabajo de la cola de otro thread. Esto mantiene todos los núcleos de CPU ocupados.
Select en channels
Cuando tienes múltiples channels, select te permite esperar al que tenga datos primero:
fn main() { let ch1: Map = channel_new(4) let ch2: Map = channel_new(4) spawn { sleep(100) channel_send(ch1, 42) } spawn { sleep(50) channel_send(ch2, 99) } select { case ch1 => { let v: int = channel_recv(ch1) print("ch1: " + int_to_string(v)) } case ch2 => { let v: int = channel_recv(ch2) print("ch2: " + int_to_string(v)) } } }
select es como match para channels — elige el primer channel que tenga datos. Si ninguno está listo, bloquea hasta que uno lo esté.
El event loop
Internamente, Nyx usa un event loop basado en epoll (en Linux) para I/O asíncrono. Cuando una goroutine necesita esperar datos de red, en lugar de bloquear todo el thread del SO:
- Registra interés con epoll ("avísame cuando este socket tenga datos").
- Cede la goroutine, permitiendo que otras se ejecuten.
- Se despierta cuando llegan los datos.
Por esto http_serve_mt puede manejar más de 73,000 solicitudes por segundo — combina pools de threads con I/O dirigido por eventos.
Sintaxis async/await
Nyx también proporciona la sintaxis async/await:
async fn obtener_datos() -> String { return "datos cargados" } fn main() { let resultado: String = await obtener_datos() print(resultado) // datos cargados }
Nota importante: En la versión actual, async/await es azúcar sintáctico — no proporciona paralelismo adicional más allá de lo que spawn y threads ofrecen. La async fn crea un closure, y await lo llama. Para paralelismo real, usa spawn o thread_spawn.
Cuándo usar qué
| Herramienta | Mejor para | Overhead |
|---|---|---|
thread_spawn |
Trabajo intensivo de CPU | ~8KB por thread + overhead del SO |
spawn |
Goroutines I/O-bound | ~1KB por goroutine |
channel_new + workers |
Distribución de tareas | Mínimo |
http_serve_mt |
Servidores HTTP | Combina threads + channels |
select |
Esperar en múltiples channels | Cero overhead |
Regla general: Usa spawn para trabajo I/O-bound (red, archivos). Usa thread_spawn para trabajo CPU-bound (computación, compresión). Usa http_serve_mt para servidores HTTP.
Ejemplo práctico: fetcher concurrente
fn main() { let resultados: Map = channel_new(8) // Simular obtener datos de 4 fuentes concurrentemente spawn { sleep(100) // simular latencia de red channel_send(resultados, 1) } spawn { sleep(200) channel_send(resultados, 2) } spawn { sleep(50) channel_send(resultados, 3) } spawn { sleep(150) channel_send(resultados, 4) } // Recolectar todos los resultados var i: int = 0 while i < 4 { let r: int = channel_recv(resultados) print("Resultado recibido: " + int_to_string(r)) i += 1 } print("¡Todo listo!") }
Los resultados llegan en orden de completitud (el más rápido primero), no en orden de creación.
Ejemplo práctico: patrón de timeout
fn main() { let resultado: Map = channel_new(1) let timeout: Map = channel_new(1) spawn { sleep(500) // simular operación lenta channel_send(resultado, 42) } spawn { sleep(200) // timeout después de 200ms channel_send(timeout, 0) } select { case resultado => { let v: int = channel_recv(resultado) print("Resultado obtenido: " + int_to_string(v)) } case timeout => { channel_recv(timeout) print("¡Timeout!") } } }
Si la operación tarda más de 200ms, el timeout se dispara primero.
Ejercicios
- Escribe un programa que cree 10 goroutines, cada una enviando su ID a través de un channel. Recolecta e imprime todos los IDs.
- Implementa un patrón fan-out/fan-in: un productor envía números del 1 al 100 a un channel, 4 workers elevan cada número al cuadrado, y un recolector suma los resultados.
- Usa
selectpara implementar un sistema simple de prioridades: channels de alta y baja prioridad, donde el de alta prioridad siempre se verifica primero.
- Escribe una cuenta regresiva concurrente: crea 5 goroutines que cada una cuente de 5 a 1 con diferentes retrasos, todas imprimiendo su progreso.
- Implementa un wrapper de timeout: una función que ejecuta una computación en una goroutine y devuelve un valor por defecto si tarda demasiado.
Resumen
spawn { }crea goroutines ligeras gestionadas por el scheduler M:N.- El scheduler usa work-stealing para mantener todos los núcleos de CPU ocupados.
select { case ch => { } }espera en múltiples channels.- Nyx usa un event loop basado en epoll para I/O asíncrono eficiente.
async/awaites azúcar sintáctico (no paralelismo adicional).- Usa
spawnpara trabajo I/O-bound,thread_spawnpara trabajo CPU-bound. - Channels + goroutines habilitan patrones: fan-out/fan-in, timeouts, colas de prioridad.
Siguiente capítulo: Sistemas — inline assembly, volatile, atomic →