Basado en los ejemplos de gobyexample.com. Este documento explica cada tema con sus propias palabras, agrega el porqué detrás de cada mecanismo y muestra cómo se relacionan entre sí, para que sirva como referencia de consulta y no solo como transcripción de ejemplos.
Todos los bloques de código de este documento son programas completos: incluyen package main y sus import, así que se pueden copiar directo a un archivo .go y correr con go run archivo.go.
1. Goroutines
Una goroutine es una función que se ejecuta de forma concurrente con el resto del programa. No es un hilo del sistema operativo: es una unidad de ejecución manejada por el runtime de Go, mucho más liviana (unos pocos KB de stack inicial, que crece según se necesita). El runtime multiplexa miles de goroutines sobre un número reducido de hilos reales del SO.
Se lanza anteponiendo la palabra go a una llamada a función:
package main
import "fmt"
func decir(s string) {
for i := 0; i < 3; i++ {
fmt.Println(s)
}
}
func main() {
go decir("mundo") // se ejecuta en una goroutine nueva
decir("hola") // se ejecuta en la goroutine principal
}
Si corrés este programa, lo más probable es que veas "hola" impreso tres veces y "mundo" ni una sola vez. Esto no es un error del código ni del documento: es el comportamiento esperado, y vale la pena entenderlo bien porque es la primera trampa con la que se topa cualquiera que empieza con goroutines.
Lo que pasa es esto:
go decir("mundo")no ejecuta la función ahí mismo. Solo le dice al runtime “en algún momento, corré esto en otra goroutine”. La goroutine principal sigue de largo inmediatamente, sin esperar a que la nueva goroutine siquiera empiece.- La goroutine principal llama a
decir("hola")de forma síncrona, y esa llamada es tan rápida (tresPrintlnsin ningúnSleepni operación bloqueante) que termina antes de que el scheduler de Go le dé tiempo de CPU a la goroutine de"mundo". - En cuanto
main()termina su última instrucción, el programa entero termina — incluida cualquier goroutine que todavía no haya corrido. No hay una espera implícita a que otras goroutines terminen.
En otras palabras: perdiste la carrera antes de que empezara. La goroutine de "mundo" nunca llegó a ejecutarse porque main() cerró el programa primero.
Para comprobarlo, este segundo programa agrega una pequeña pausa antes de que main() termine, dándole tiempo al scheduler para correr la goroutine:
package main
import (
"fmt"
"time"
)
func decir(s string) {
for i := 0; i < 3; i++ {
fmt.Println(s)
}
}
func main() {
go decir("mundo")
decir("hola")
time.Sleep(10 * time.Millisecond) // le da tiempo a la goroutine de "mundo"
}
Con el Sleep al final, ahora sí vas a ver "mundo" en la salida (probablemente intercalado con "hola" de forma distinta en cada corrida). Pero ojo: time.Sleep acá es solo para demostrar el problema, no es una solución real. Es frágil — ¿cuánto hay que dormir? ¿y si la goroutine tarda más que eso? — y en código de producción nunca deberías depender de dormir un tiempo arbitrario para esperar a que otra goroutine termine. La forma correcta de esperar de verdad a que una goroutine termine es con un canal (punto 4) o con un WaitGroup (punto 14); ambos eliminan la incertidumbre por completo.
Puntos clave:
- El
main()es en sí mismo una goroutine. Simain()termina, el programa termina, sin importar si otras goroutines siguen corriendo. - El orden de ejecución entre goroutines no está garantizado. Por eso, en el ejemplo anterior, la salida de
"mundo"y"hola"puede intercalarse de cualquier forma — o no aparecer en absoluto. - Lanzar una goroutine es “disparar y olvidar” salvo que uses algún mecanismo de sincronización (canales,
WaitGroup, etc.) para esperar su resultado o su finalización. - Las goroutines comparten el mismo espacio de memoria del proceso, por lo que el acceso concurrente a variables compartidas requiere cuidado (ver Mutexes y Atomic Counters más adelante).
2. Channels
Un canal (channel) es el mecanismo idiomático de Go para que las goroutines se comuniquen y se sincronicen entre sí, en lugar de compartir memoria directamente (“no comuniques compartiendo memoria; comparte memoria comunicando”).
package main
import "fmt"
func main() {
mensajes := make(chan string)
go func() {
mensajes <- "ping" // enviar al canal
}()
msg := <-mensajes // recibir del canal
fmt.Println(msg)
}
Comportamiento fundamental:
- Enviar (
canal <- valor) y recibir (<-canal) son operaciones bloqueantes por defecto en un canal sin buffer: la goroutine que envía se detiene hasta que otra reciba, y viceversa. - Ese bloqueo automático es justamente lo que permite sincronizar sin usar locks explícitos ni variables de bandera manuales.
- Los canales están tipados:
chan string,chan int,chan MiStruct, etc. Solo se puede enviar/recibir valores de ese tipo.
3. Channel Buffering
Por defecto los canales no tienen buffer (capacidad 0), lo que fuerza sincronización estricta emisor-receptor. Se les puede dar capacidad con un segundo argumento en make:
package main
import "fmt"
func main() {
mensajes := make(chan string, 2)
mensajes <- "buffered"
mensajes <- "channel"
fmt.Println(<-mensajes)
fmt.Println(<-mensajes)
}
- Un canal con buffer permite enviar valores sin bloquear hasta que el buffer se llene. Solo entonces el emisor se bloquea esperando que alguien reciba.
- Un receptor se bloquea únicamente si el buffer está vacío.
- El buffering es útil para desacoplar productor y consumidor cuando no necesitan avanzar exactamente al mismo ritmo, pero no reemplaza la necesidad de pensar en sincronización: solo cambia el punto en el que el bloqueo ocurre.
4. Channel Synchronization
Además de pasar datos, un canal se puede usar únicamente como señal de “terminé”, sin que el valor en sí importe. Es el patrón clásico para esperar a que una goroutine finalice:
package main
import (
"fmt"
"time"
)
func trabajador(listo chan bool) {
fmt.Println("trabajando...")
time.Sleep(time.Second)
fmt.Println("listo")
listo <- true // señal de finalización
}
func main() {
listo := make(chan bool, 1)
go trabajador(listo)
<-listo // bloquea main() hasta recibir la señal
}
Si main() no esperara con <-listo, el programa podría terminar antes de que trabajador imprima “listo”, ya que main() no espera automáticamente a las demás goroutines. Este es el patrón base que luego se generaliza con WaitGroup cuando hay múltiples goroutines que esperar.
5. Channel Directions
Cuando un canal se pasa como parámetro a una función, se puede restringir su dirección: solo envío o solo recepción. Esto documenta la intención y el compilador impide el uso incorrecto.
package main
import "fmt"
func enviar(canal chan<- string, msg string) {
canal <- msg // solo puede enviar
}
func recibir(canal <-chan string, listo chan<- bool) {
msg := <-canal // solo puede recibir
fmt.Println(msg)
listo <- true
}
func main() {
canal := make(chan string, 1)
listo := make(chan bool)
go recibir(canal, listo)
enviar(canal, "directo")
<-listo // espera a que recibir() termine
}
chan<- string: canal de solo envío.<-chan string: canal de solo recepción.- Un canal bidireccional (
chan string) se puede convertir implícitamente a uno direccional al pasarlo a una función, pero no al revés. Esto hace que la API interna de tu programa sea más segura y explícita.
6. Select
select permite esperar sobre múltiples operaciones de canal a la vez, y ejecuta el primer case que esté listo. Si varios están listos simultáneamente, elige uno al azar.
package main
import (
"fmt"
"time"
)
func main() {
c1 := make(chan string)
c2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
c1 <- "uno"
}()
go func() {
time.Sleep(2 * time.Second)
c2 <- "dos"
}()
for i := 0; i < 2; i++ {
select {
case msg1 := <-c1:
fmt.Println("recibido", msg1)
case msg2 := <-c2:
fmt.Println("recibido", msg2)
}
}
}
select es la pieza central para construir lógica concurrente más compleja: combinarlo con un case default da operaciones no bloqueantes (ver punto 8), y combinarlo con time.After da timeouts (punto 7).
7. Timeouts
Go no tiene una palabra clave de “timeout” para canales; se construye combinando select con time.After, que devuelve un canal que recibe un valor después de que transcurre la duración indicada.
package main
import (
"fmt"
"time"
)
func main() {
c1 := make(chan string, 1)
go func() {
time.Sleep(2 * time.Second)
c1 <- "resultado"
}()
select {
case res := <-c1:
fmt.Println(res)
case <-time.After(1 * time.Second):
fmt.Println("tiempo agotado")
}
}
Esto evita que una goroutine se quede esperando indefinidamente una respuesta que puede no llegar nunca (por ejemplo, una llamada de red colgada). Es un patrón muy usado en producción para llamadas externas.
8. Non-Blocking Channel Operations
Agregando un case default a un select, las operaciones de canal dejan de bloquear: si ningún otro case está listo de inmediato, se ejecuta default.
package main
import "fmt"
func main() {
mensajes := make(chan string)
select {
case msg := <-mensajes:
fmt.Println("mensaje recibido", msg)
default:
fmt.Println("no hay mensajes")
}
}
Esto sirve para “espiar” un canal sin detener la ejecución, útil en bucles de sondeo (polling) o para combinar trabajo concurrente con otra lógica que no debe detenerse.
9. Closing Channels
Cerrar un canal (close(canal)) indica que no se enviarán más valores. Es una señal de “no hay más trabajo”, distinta de enviar un valor concreto.
package main
import "fmt"
func main() {
trabajos := make(chan int, 5)
listo := make(chan bool)
go func() {
for {
j, mas := <-trabajos
if mas {
fmt.Println("recibido trabajo", j)
} else {
fmt.Println("no quedan trabajos")
listo <- true
return
}
}
}()
for j := 1; j <= 3; j++ {
trabajos <- j
}
close(trabajos)
<-listo
}
Reglas importantes:
- Al recibir de un canal, la forma
v, ok := <-canalte dice enoksi el valor vino de un envío real (true) o si el canal ya está cerrado y vacío (false). - Cerrar un canal es responsabilidad del emisor, nunca del receptor.
- Enviar a un canal cerrado provoca panic. Cerrar un canal ya cerrado también provoca panic.
- Cerrar un canal es opcional: solo es necesario cuando el receptor necesita saber que no llegará más nada (por ejemplo, para terminar un
range, ver el siguiente punto).
10. Range over Channels
range sobre un canal recibe valores repetidamente hasta que el canal se cierra, momento en el cual el bucle termina automáticamente.
package main
import "fmt"
func main() {
trabajos := make(chan string, 3)
trabajos <- "uno"
trabajos <- "dos"
trabajos <- "tres"
close(trabajos)
for elem := range trabajos {
fmt.Println(elem)
}
}
Si el canal nunca se cerrara, este range bloquearía indefinidamente esperando el siguiente valor tras vaciar el buffer. Por eso close y range suelen ir de la mano cuando se conoce el punto final del flujo de datos.
11. Timers
Un Timer representa un evento único en el futuro: pasado cierto tiempo, envía el momento actual a un canal.
package main
import (
"fmt"
"time"
)
func main() {
timer1 := time.NewTimer(2 * time.Second)
<-timer1.C
fmt.Println("timer 1 disparado")
}
También se puede cancelar antes de que se dispare con timer.Stop(), lo cual es útil si la condición que motivó el timer deja de existir (por ejemplo, cancelar un timeout porque la operación ya terminó por otro lado):
package main
import (
"fmt"
"time"
)
func main() {
timer2 := time.NewTimer(time.Second)
go func() {
<-timer2.C
fmt.Println("timer 2 disparado")
}()
stop := timer2.Stop()
if stop {
fmt.Println("timer 2 detenido")
}
}
time.After, usado en el punto de timeouts, es en el fondo un Timer de un solo uso simplificado.
12. Tickers
Un Ticker es como un Timer pero repetido: dispara eventos a intervalos regulares hasta que se detiene explícitamente con Stop().
package main
import (
"fmt"
"time"
)
func main() {
ticker := time.NewTicker(500 * time.Millisecond)
done := make(chan bool)
go func() {
for {
select {
case <-done:
return
case t := <-ticker.C:
fmt.Println("tick en", t)
}
}
}()
time.Sleep(1600 * time.Millisecond)
ticker.Stop()
done <- true
fmt.Println("ticker detenido")
}
Se usa para tareas periódicas: polling de un estado, heartbeats, refrescar métricas, etc. Siempre conviene llamar Stop() cuando el ticker ya no se necesita, para no dejarlo corriendo en segundo plano y liberar recursos.
13. Worker Pools
Patrón que combina varios de los conceptos anteriores: un conjunto fijo de goroutines (“workers”) que consumen tareas de un canal compartido y devuelven resultados por otro canal. Permite paralelizar trabajo controlando cuántas goroutines corren simultáneamente.
package main
import (
"fmt"
"time"
)
func trabajador(id int, trabajos <-chan int, resultados chan<- int) {
for j := range trabajos {
fmt.Println("trabajador", id, "procesando trabajo", j)
time.Sleep(time.Second)
resultados <- j * 2
}
}
func main() {
const numTrabajos = 5
trabajos := make(chan int, numTrabajos)
resultados := make(chan int, numTrabajos)
for w := 1; w <= 3; w++ {
go trabajador(w, trabajos, resultados)
}
for j := 1; j <= numTrabajos; j++ {
trabajos <- j
}
close(trabajos)
for a := 1; a <= numTrabajos; a++ {
<-resultados
}
}
Ideas clave:
- El número de workers (aquí 3) es independiente del número de trabajos (aquí 5): controla el grado de paralelismo real.
- Cerrar el canal
trabajosdespués de encolar todo permite que cada worker termine surangecuando ya no queda trabajo. - Este patrón es la base de casi cualquier pipeline de scraping o procesamiento por lotes con límite de concurrencia.
14. WaitGroups
Un sync.WaitGroup sirve para esperar a que un número arbitrario de goroutines termine, sin necesidad de un canal dedicado para cada una.
package main
import (
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
func trabajador(id int) {
defer wg.Done()
fmt.Println("trabajador", id, "comenzando")
time.Sleep(time.Second)
fmt.Println("trabajador", id, "terminado")
}
func main() {
for i := 1; i <= 5; i++ {
wg.Add(1)
go trabajador(i)
}
wg.Wait()
}
Add(n)incrementa el contador de goroutines pendientes.Done()(normalmente condefer) lo decrementa; debe llamarse una vez por cada goroutine lanzada.Wait()bloquea hasta que el contador llega a cero.- A diferencia del canal de señal manual del punto 4,
WaitGroupescala naturalmente a “N” goroutines sin tener que coordinar N canales o contar recepciones a mano. - Un
WaitGroupno transporta datos ni resultados; solo indica finalización. Si necesitás resultados, se combina con un canal (como en el worker pool) o con estructuras protegidas por mutex.
15. Rate Limiting
Controla la frecuencia con la que se procesan eventos, típicamente para no saturar un recurso (una API externa, una base de datos). Se implementa con un time.Ticker que actúa como “permiso” periódico.
package main
import (
"fmt"
"time"
)
func main() {
solicitudes := make(chan int, 5)
for i := 1; i <= 5; i++ {
solicitudes <- i
}
close(solicitudes)
limitador := time.Tick(200 * time.Millisecond)
for req := range solicitudes {
<-limitador
fmt.Println("solicitud", req, "atendida en", time.Now())
}
}
Para permitir ráfagas controladas (procesar varias de golpe pero limitando el promedio), se usa un canal con buffer que se va rellenando con un ticker:
package main
import (
"fmt"
"time"
)
func main() {
solicitudes := make(chan int, 5)
for i := 1; i <= 5; i++ {
solicitudes <- i
}
close(solicitudes)
ráfaga := make(chan time.Time, 3)
for i := 0; i < 3; i++ {
ráfaga <- time.Now()
}
go func() {
for t := range time.Tick(200 * time.Millisecond) {
ráfaga <- t
}
}()
for req := range solicitudes {
<-ráfaga
fmt.Println("solicitud", req, "atendida con ráfaga en", time.Now())
}
}
Esto es directamente aplicable por ejemplo a un scraper, limitar cuántas peticiones por segundo se hacen contra un sitio o contra el navegador headless vía CDP, evitando bloqueos por parte del servidor de destino.
16. Atomic Counters
Cuando varias goroutines modifican el mismo valor numérico concurrentemente (por ejemplo, un contador compartido), incrementarlo con contador++ no es seguro: esa operación no es atómica, puede perder incrementos por condiciones de carrera. El paquete sync/atomic ofrece operaciones que se ejecutan de forma indivisible a nivel de hardware.
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var ops atomic.Uint64
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for c := 0; c < 1000; c++ {
ops.Add(1)
}
}()
}
wg.Wait()
fmt.Println("ops:", ops.Load())
}
Add,Load,Store,CompareAndSwapson operaciones atómicas típicas.- Es la opción más liviana cuando lo único que se comparte es un contador o una bandera simple; para estructuras de datos más complejas, un mutex suele ser más claro (punto siguiente).
17. Mutexes
Un sync.Mutex protege una sección de código para que solo una goroutine a la vez pueda ejecutarla, evitando condiciones de carrera sobre datos compartidos más complejos que un simple contador atómico.
package main
import (
"fmt"
"sync"
)
type contenedor struct {
mu sync.Mutex
contadores map[string]int
}
func (c *contenedor) inc(nombre string) {
c.mu.Lock()
defer c.mu.Unlock()
c.contadores[nombre]++
}
func main() {
c := contenedor{contadores: map[string]int{"a": 0, "b": 0}}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.inc("a")
}()
}
wg.Wait()
fmt.Println("a:", c.contadores["a"])
}
Lock()bloquea el acceso para las demás goroutines hasta que se llameUnlock().- Usar
defer c.mu.Unlock()inmediatamente después deLock()es la práctica recomendada, para garantizar que el mutex se libere incluso si hay un panic en medio de la función. - Un mutex no distingue “quién” está esperando ni transporta datos, solo serializa el acceso.
- Regla práctica: si el estado compartido es más que un número simple, preferí un mutex sobre operaciones atómicas individuales, porque agrupar varias operaciones atómicas no te da atomicidad sobre el conjunto.
18. Stateful Goroutines
Alternativa a mutexes: en vez de proteger memoria compartida con locks, una sola goroutine es la única dueña del estado, y las demás goroutines le piden lecturas/escrituras enviando mensajes por canales. Es la aplicación directa del principio “comparte memoria comunicando” a un caso con estado mutable.
package main
import "fmt"
type lectura struct {
clave int
respuesta chan int
}
type escritura struct {
clave int
valor int
respuesta chan bool
}
func main() {
lecturas := make(chan lectura)
escrituras := make(chan escritura)
go func() {
estado := make(map[int]int)
for {
select {
case l := <-lecturas:
l.respuesta <- estado[l.clave]
case e := <-escrituras:
estado[e.clave] = e.valor
e.respuesta <- true
}
}
}()
// Ejemplo de uso: otras goroutines (o el propio main, como acá)
// envían lectura{...} o escritura{...} por los canales, en vez
// de tocar el mapa directamente.
escrituraOK := make(chan bool)
escrituras <- escritura{clave: 1, valor: 42, respuesta: escrituraOK}
<-escrituraOK
valorLeido := make(chan int)
lecturas <- lectura{clave: 1, respuesta: valorLeido}
fmt.Println("valor para la clave 1:", <-valorLeido)
}
- Nadie más que la goroutine dueña del estado toca el mapa directamente, así que no hace falta mutex.
- Este patrón tiene sentido cuando la lógica de acceso al estado es más compleja que un simple get/set, o cuando querés centralizar reglas de negocio junto con los datos.
- Es un trade-off frente a mutexes: gana en claridad de “quién es dueño de qué” pero agrega la sobrecarga de pasar mensajes por canal para cada operación.
19. Context
context.Context no forma parte del bloque de concurrencia original de gobyexample.com (aparece más adelante, junto a HTTP Client/Server), pero es la evolución natural de varios patrones ya vistos aquí: en vez de armar a mano un canal + select + time.After cada vez que necesitás cancelar una goroutine o propagar un timeout, context estandariza esa señal y permite encadenarla a través de múltiples funciones y llamadas.
Un Context lleva tres cosas: una señal de cancelación, un deadline/timeout opcional, y valores de request-scope opcionales (por ejemplo, un ID de traza).
package main
import (
"context"
"fmt"
"time"
)
func tarea(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err() // context.Canceled o context.DeadlineExceeded
default:
// hacer trabajo
time.Sleep(100 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
if err := tarea(ctx); err != nil {
fmt.Println("terminó por:", err)
}
}
Formas más comunes de crear un Context:
context.Background(): el contexto raíz, sin cancelación ni deadline. Punto de partida habitual enmain()o al inicio de una request.context.WithCancel(padre): devuelve un contexto y una funcióncancel()para cancelarlo manualmente en cualquier momento.context.WithTimeout(padre, duración)/context.WithDeadline(padre, momento): se cancelan automáticamente al cumplirse el tiempo, exactamente como el patrón de timeouts del punto 7 pero reutilizable y propagable.context.WithValue(padre, clave, valor): adjunta datos de alcance de request; se usa con moderación, no como reemplazo de parámetros normales.
Ideas clave:
- Un
Contextes inmutable: cadaWith...devuelve un contexto hijo nuevo; cancelar el padre cancela automáticamente a todos los hijos, pero no al revés. - La convención en Go es pasar el
Contextcomo primer parámetro de la función, nombradoctx, y nunca guardarlo dentro de un struct. <-ctx.Done()es un canal que se cierra (no que recibe un valor) cuando el contexto se cancela o vence su deadline — por eso funciona directamente dentro de unselect, igual que cualquier otro canal de señal del punto 4.ctx.Err()te dice la causa exacta:context.Canceled(cancelación manual) ocontext.DeadlineExceeded(venció el tiempo).- Siempre hay que llamar
cancel()(normalmente condefer) aunque el contexto termine por timeout, para liberar los recursos internos asociados; olvidarlo es una fuga de goroutines silenciosa bastante común. - Es el mecanismo estándar en librerías HTTP, gRPC y bases de datos en Go para propagar cancelación a través de toda una cadena de llamadas: si un cliente HTTP cierra la conexión, ese
ctxse cancela y todas las goroutines que dependen de él pueden detenerse limpiamente.
Cómo se relacionan estos temas entre sí
Para verlo como un mapa mental en vez de una lista suelta:
- Base: goroutines + channels son los dos bloques fundamentales; todo lo demás se construye encima.
- Variaciones sobre channels: buffering, direcciones, cierre y
rangeson propiedades/usos distintos de un mismo canal. - Control de flujo con channels:
selectes la herramienta central; timeouts y operaciones no bloqueantes son dos usos concretos deselect. - Tiempo: timers y tickers son la base de timeouts (uso único) y rate limiting (uso repetido).
- Coordinación de múltiples goroutines: WaitGroups (esperar N goroutines) y worker pools (limitar cuántas corren a la vez) combinan channels + goroutines para resolver problemas de concurrencia real.
- Estado compartido: atomic counters, mutexes y stateful goroutines son tres formas distintas de resolver el mismo problema —proteger datos compartidos entre goroutines—, de menor a mayor complejidad estructural (contador simple → sección crítica → dueño exclusivo del estado).
- Cancelación estandarizada: Context generaliza el patrón manual de timeouts (punto 7) y el canal de señal (punto 4) en una herramienta única, propagable a través de cadenas de llamadas; en código de producción moderno reemplaza a ambos en la mayoría de los casos.
Un consejo práctico para reforzar esto: en vez de solo releer, tomá el worker pool (punto 13) y modificalo para que use WaitGroup en lugar de contar resultados manualmente, y después agregale rate limiting. Combinar los patrones entre sí fija mucho mejor los conceptos que estudiarlos aislados.