Pon go delante de una llamada a función y esa función se ejecuta en concurrencia con el resto del programa, gestionada por el runtime de Go en lugar del sistema operativo. Lanzarla es la parte fácil. La difícil es conseguir que dos goroutines se pasen un resultado sin pisarse el estado mutuamente, y de eso se encargan los channels.
¿Qué es una goroutine?
Una goroutine es una función que corre de forma independiente de la que la lanzó, gestionada por el runtime de Go y no por el sistema operativo. Un thread del sistema operativo reserva un megabyte o más de stack y le cuesta al kernel tiempo real planificarlo. Una goroutine arranca con un stack de unos 2KB que crece según lo necesita, y el runtime multiplexa muchas goroutines sobre un puñado de threads del sistema operativo. Lanzar miles de ellas es normal.
| |
Ejecuta esto varias veces y el orden de “hi, alice” y “hi, bob” cambia. Decide el scheduler. El time.Sleep solo mantiene vivo a main el tiempo suficiente para que las goroutines impriman: quítalo y el programa puede terminar antes de que se ejecute cualquiera de las dos, porque cuando main retorna el proceso acaba sin importar qué otra cosa siga en marcha. El sleep es un truco, no una sincronización. Un channel es lo que lo sustituye.
¿Cómo conectan los channels a las goroutines?
Un channel es una tubería tipada: una goroutine envía un valor y otra lo recibe, y el runtime gestiona el traspaso. Imagina una cinta transportadora de un solo carril entre dos operarios. El que envía pone un objeto en la cinta y no puede poner un segundo hasta que el primero se haya retirado. Eso es un channel sin buffer, y ese traspaso bloqueante es todo el mecanismo de sincronización.
| |
make(chan string) crea un channel de strings sin buffer. El envío (ch <- "payload ready") se bloquea hasta que algo lo recibe, y la recepción (<-ch) se bloquea hasta que algo lo envía. Ninguno de los dos lados avanza hasta que ambos están listos, que es justo el traspaso que el time.Sleep anterior solo simulaba.
Dale al channel una capacidad y la cinta soporta más de un objeto antes de atascarse:
| |
El envío de un channel bufferizado se bloquea solo cuando el buffer está lleno; la recepción se bloquea solo cuando está vacío. Eso relaja el acoplamiento entre quien envía y quien recibe, lo que ayuda a absorber picos de trabajo. No elimina la necesidad de coordinarse. Solo mueve el punto donde empieza el bloqueo.
¿Cómo se cierra un channel y se hace range sobre él?
Quien envía cierra un channel para decir “no va a llegar nada más”:
| |
for v := range ch sigue recibiendo hasta que el channel se cierra y se vacía, y entonces sale por sí solo. Sin valor centinela, sin contador manual. Para distinguir un channel cerrado y vacío de uno que entregó un valor cero, usa la forma de dos valores: v, ok := <-ch pone ok en false solo cuando el channel está cerrado y no queda nada por recibir.
close tiene tres formas de entrar en panic, y el compilador no detecta ninguna:
- Enviar a un channel cerrado entra en panic.
- Cerrar un channel ya cerrado entra en panic.
- Cerrar un channel
nilentra en panic.
Así que quien cierra es siempre el que envía, nunca el que recibe. Quien recibe no tiene forma de saber si otra goroutine todavía va a enviar algo.
¿Cómo se espera a que terminen varias goroutines?
range funciona cuando un channel señala el final del trabajo. Cuando has lanzado varias goroutines y solo necesitas saber que todas terminaron, usa sync.WaitGroup:
| |
Add(1) incrementa el contador antes de que arranque cada goroutine, Done() lo decrementa (con defer, así que se ejecuta aunque la función entre en panic), y Wait() se bloquea hasta que el contador llega a cero. Mueve Add dentro de la goroutine y has escrito una race: main puede llegar a Wait(), ver el contador en cero y retornar antes de que ningún worker lo haya incrementado.
¿Cómo funciona select con varios channels?
select espera sobre varias operaciones de channel a la vez y ejecuta la que esté lista primero. Los timeouts y la cancelación en Go se construyen sobre esto.
| |
Si hay más de un case listo, select elige uno al azar, no en el orden en que está escrito, así que el orden de los case no da ninguna prioridad. Sin default y sin ningún case listo, select se bloquea; un select{} vacío se bloquea para siempre. Añade un case default y toda la instrucción se vuelve no bloqueante: si en ese momento nada está listo, se ejecuta default.
Un worker pool combina casi todo esto. Un número fijo de goroutines toma jobs de un channel y escribe resultados en otro, de modo que el trabajo concurrente queda limitado sin importar cuántos jobs lleguen.
| |
Tres workers, nueve jobs. close(jobs) después del último envío permite que el loop range de cada worker salga en cuanto la cola se vacía. Las firmas usan jobs <-chan int (solo recepción) y results chan<- int (solo envío). La flecha indica la dirección y el compilador la hace cumplir, así que un worker que por error envía a jobs falla al compilar en lugar de comportarse mal en tiempo de ejecución.
¿Por qué las goroutines hacen leak o deadlock?
Leaks de goroutines. Una goroutine bloqueada para siempre en un envío o una recepción que nunca va a ocurrir no revienta. Se queda ahí, reteniendo su stack y todo lo que capturó por closure, durante toda la vida del programa. La causa habitual es una goroutine que envía un resultado a un channel que ya nadie lee, porque quien debía recibirlo ya se rindió tras un timeout. Dale a la goroutine una salida, normalmente un context.Context sobre el que también hace select, para que pueda abandonar el envío.
Deadlock. fatal error: all goroutines are asleep - deadlock! significa que todas las goroutines, main incluido, están bloqueadas en un channel o un lock sin nada que las pueda desbloquear. El ejemplo más pequeño es un envío sin buffer en main sin ninguna otra goroutine corriendo para recibirlo.
Races que los channels no evitan. Un channel sincroniza el traspaso de un valor. No protege una variable que sigas tocando fuera del channel. Dos goroutines que incrementan el mismo contador capturado siguen haciendo race, con o sin channel, igual que en el caso de los threads explicado en qué es una race condition y cómo solucionarla. Go incluye un detector para esto: compila o testea con -race y el runtime reporta los accesos concurrentes a la misma memoria donde al menos uno es una escritura.
| |
Solo detecta las races que ocurren realmente durante la ejecución, así que encuentra más bajo una carga concurrente realista que en un test unitario tranquilo. Esa carga es también donde los tests cargados de goroutines se vuelven flaky, el mismo tipo de inestabilidad que se cubre en pruebas unitarias vs de integración vs end-to-end. Espera varias veces más memoria y CPU con -race activo. Ejecútalo en CI, no en el binario que distribuyes.
¿Channel o mutex: cuál usar?
Los dos hacen seguro el acceso concurrente, pero desde extremos opuestos. Un sync.Mutex protege el estado compartido en su sitio: bloquea, lee o escribe, desbloquea, y el dato nunca se mueve. Un channel traslada la propiedad de un valor de una goroutine a otra, de modo que solo una goroutine lo toca a la vez por construcción. La propia guía de Go: usa channels para coordinar o pasar trabajo entre goroutines, y un mutex para proteger un único dato compartido que varias goroutines actualizan en su sitio, como un cache o un contador. Si una función necesita las dos cosas, seguramente está haciendo dos trabajos que se leerían mejor separados.
Si vienes de JavaScript, el modelo es distinto. Un solo thread ejecuta tu código y await solo cede el control en los puntos que ves en el código fuente; async/await y el event loop de JavaScript explicados muestra dónde están esos puntos de suspensión. Go te da paralelismo real, así que la elección del primitivo depende de ti.
Para tu próximo batch job (redimensionar una carpeta de imágenes, o llamar a una API una vez por fila de un CSV), empieza por el worker pool de arriba. Elige el número de workers, cierra el channel de jobs cuando termines de enviar, y añade un context.Context al select la primera vez que necesites cancelar.