Modelo de concurrencia en Go
En Go, la concurrencia se basa en dos ideas: goroutines (unidades ligeras de ejecución) y canales (mecanismo para comunicar y sincronizar). La regla práctica es: comparte memoria comunicando (canales) y, cuando sea necesario, comunica compartiendo memoria (mutexes).
Cuándo usar goroutines y canales
- I/O concurrente: llamadas a red, disco, APIs, colas.
- Paralelismo: trabajo CPU-bound que puede dividirse (teniendo en cuenta GOMAXPROCS).
- Orquestación: coordinar etapas (pipelines), balancear carga (worker pools), combinar resultados (fan-in).
Goroutines: ejecutar trabajo concurrente
Una goroutine se lanza con go. Su costo es bajo comparado con threads del sistema, pero no es “gratis”: demasiadas goroutines pueden aumentar memoria, presión del scheduler y latencia.
go func() { /* trabajo */ }()Guía práctica: lanzar tareas y esperar su finalización
Evita “dormir” para esperar. Usa sync.WaitGroup para sincronizar el final de un conjunto de goroutines.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
jobs := []int{1, 2, 3, 4, 5}
wg.Add(len(jobs))
for _, j := range jobs {
j := j // captura segura
go func() {
defer wg.Done()
fmt.Println("procesando", j)
}()
}
wg.Wait()
fmt.Println("todas las tareas terminaron")
}Detalle importante: en bucles, reasigna la variable (j := j) para evitar capturas inesperadas.
Canales: comunicación y sincronización
Un canal (chan T) transporta valores de tipo T. En canales no bufferizados, el envío y la recepción se sincronizan (handshake). En canales bufferizados, el envío puede avanzar hasta llenar el buffer.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
ch := make(chan int) // no bufferizado
buf := make(chan int, 10) // bufferizadoPatrón básico: productor/consumidor
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
defer close(ch)
for i := 1; i <= 5; i++ {
ch <- i
}
}()
for v := range ch {
fmt.Println("recibido", v)
}
}Regla: quien produce suele ser quien cierra el canal. Cerrar indica “no habrá más valores”. Recibir de un canal cerrado devuelve el valor cero del tipo y un flag ok=false si se usa la forma de dos valores.
select: multiplexación y control de flujo
select permite esperar en múltiples operaciones de canal. Es clave para timeouts, cancelación y para combinar flujos.
select {
case v := <-ch1:
_ = v
case ch2 <- x:
// enviado
default:
// no hay operaciones listas (no bloquea)
}Timeouts con time.After
select {
case v := <-ch:
_ = v
case <-time.After(200 * time.Millisecond):
// timeout
}Para timeouts repetidos en bucles, suele ser mejor time.NewTimer o time.Ticker para evitar asignaciones frecuentes.
Patrones clave de concurrencia
1) Worker pool (pool de trabajadores)
Útil cuando tienes muchos trabajos y quieres limitar concurrencia (por CPU, rate limits, conexiones, etc.).
Pasos: (1) canal de trabajos, (2) N workers leyendo del canal, (3) canal de resultados (opcional), (4) cierre coordinado.
package main
import (
"fmt"
"sync"
)
type Job struct {
ID int
}
type Result struct {
ID int
Out string
}
func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
results <- Result{ID: j.ID, Out: fmt.Sprintf("worker %d procesó job %d", id, j.ID)}
}
}
func main() {
jobs := make(chan Job)
results := make(chan Result)
const nWorkers = 3
var wg sync.WaitGroup
wg.Add(nWorkers)
for i := 1; i <= nWorkers; i++ {
go worker(i, jobs, results, &wg)
}
// Cerrar results cuando todos los workers terminen
go func() {
wg.Wait()
close(results)
}()
// Productor de jobs
go func() {
defer close(jobs)
for i := 1; i <= 10; i++ {
jobs <- Job{ID: i}
}
}()
for r := range results {
fmt.Println(r.Out)
}
}Notas de diseño: (a) el canal jobs se cierra para que los workers terminen, (b) results se cierra cuando el WaitGroup indica que no habrá más envíos.
2) Fan-out / Fan-in
Fan-out: distribuir trabajo a múltiples goroutines. Fan-in: combinar múltiples canales en uno.
Ejemplo: procesar entradas en paralelo y unificar resultados.
package main
import (
"fmt"
"sync"
)
func fanIn[T any](chs ...<-chan T) <-chan T {
out := make(chan T)
var wg sync.WaitGroup
wg.Add(len(chs))
for _, ch := range chs {
ch := ch
go func() {
defer wg.Done()
for v := range ch {
out <- v
}
}()
}
go func() {
wg.Wait()
close(out)
}()
return out
}
func main() {
c1 := make(chan int)
c2 := make(chan int)
go func() { defer close(c1); for i := 1; i <= 3; i++ { c1 <- i } }()
go func() { defer close(c2); for i := 10; i <= 12; i++ { c2 <- i } }()
for v := range fanIn(c1, c2) {
fmt.Println(v)
}
}Precaución: si el consumidor de out deja de leer, las goroutines internas pueden bloquearse intentando enviar. Para hacerlo robusto, integra context.Context (ver más abajo).
3) Pipelines (tuberías por etapas)
Un pipeline encadena etapas: cada etapa lee de un canal de entrada, transforma y escribe a un canal de salida. Esto separa responsabilidades y permite paralelismo por etapa.
package main
import "fmt"
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * n
}
}()
return out
}
func main() {
in := gen(1, 2, 3, 4)
out := square(in)
for v := range out {
fmt.Println(v)
}
}Mejora típica: paralelizar una etapa (fan-out) con un pool de workers que leen del mismo canal de entrada y escriben a un canal de salida coordinado.
Sincronización con sync.WaitGroup y sync.Mutex
WaitGroup: esperar a que termine un conjunto de goroutines
- Llama
wg.Add(n)antes de lanzar las goroutines (o al menos antes de que puedan llamarDone). - En cada goroutine:
defer wg.Done(). - En el coordinador:
wg.Wait().
Mutex: proteger estado compartido
Si varias goroutines acceden a una variable compartida y al menos una escribe, necesitas sincronización (mutex, canales u otras primitivas). Un sync.Mutex protege una sección crítica.
package main
import (
"fmt"
"sync"
)
func main() {
var (
mu sync.Mutex
m = map[string]int{}
wg sync.WaitGroup
)
keys := []string{"a", "b", "a", "c", "b", "a"}
wg.Add(len(keys))
for _, k := range keys {
k := k
go func() {
defer wg.Done()
mu.Lock()
m[k]++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println(m)
}Buenas prácticas: mantén la sección crítica pequeña; evita llamar a funciones lentas (I/O, red) mientras sostienes el lock; define claramente quién “posee” el estado.
Cancelación y deadlines con context.Context
context.Context permite propagar cancelación, deadlines y valores de request a través de llamadas y goroutines. Es esencial para evitar goroutines “huérfanas” cuando el consumidor ya no necesita resultados.
Reglas de uso
- Recibe
ctxcomo primer parámetro en funciones que hacen trabajo cancelable:func Do(ctx context.Context, ...). - Deriva contextos con
context.WithCancel,context.WithTimeoutocontext.WithDeadline. - Llama al
cancel()para liberar recursos cuanto antes. - En loops, chequea
<-ctx.Done()conselect.
Ejemplo: worker pool cancelable con timeout
package main
import (
"context"
"fmt"
"sync"
"time"
)
func worker(ctx context.Context, id int, jobs <-chan int, results chan<- string, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
// Simula trabajo
time.Sleep(80 * time.Millisecond)
select {
case <-ctx.Done():
return
case results <- fmt.Sprintf("worker %d terminó job %d", id, j):
}
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
jobs := make(chan int)
results := make(chan string)
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(ctx, i, jobs, results, &wg)
}
go func() {
defer close(jobs)
for j := 1; j <= 20; j++ {
select {
case <-ctx.Done():
return
case jobs <- j:
}
}
}()
go func() {
wg.Wait()
close(results)
}()
for r := range results {
fmt.Println(r)
}
fmt.Println("ctx err:", ctx.Err())
}Observa cómo cada punto de bloqueo potencial (leer jobs, enviar results) está protegido con select y ctx.Done().
Riesgos comunes: data races y deadlocks
Data races (condiciones de carrera)
Ocurren cuando dos o más goroutines acceden a la misma memoria concurrentemente y al menos una escribe sin sincronización. Los síntomas son intermitentes: fallos raros, datos corruptos, resultados inconsistentes.
Cómo evitarlas:
- Prefiere inmutabilidad: crea valores y no los modifiques tras compartirlos.
- Define “propietario” del estado: una goroutine dueña y el resto se comunica por canales.
- Si compartes, protege con
sync.Mutexo estructuras seguras (p. ej.sync.Mapcuando aplique). - No asumas que operaciones “simples” (como
m[k]++) son atómicas.
Deadlocks (interbloqueos)
Un deadlock ocurre cuando goroutines quedan esperando indefinidamente por eventos que nunca suceden (por ejemplo, un envío a un canal que nadie recibe, o locks adquiridos en orden inconsistente).
Patrones típicos que lo causan:
- Enviar a un canal sin receptor (o receptor que dejó de leer).
- Olvidar cerrar un canal cuando el consumidor espera
range. - Esperar un
WaitGroupcuyo contador nunca llega a cero (faltóDoneoAddmal ubicado). - Adquirir múltiples mutexes en distinto orden en diferentes goroutines.
Herramientas para detectar problemas de concurrencia
Race detector
El detector de carreras es una de las herramientas más valiosas para concurrencia en Go.
go test -race ./...
go run -race .Úsalo en CI cuando sea posible. Ten en cuenta que aumenta consumo de CPU/memoria, pero detecta accesos concurrentes no sincronizados con gran efectividad.
pprof y trazas (profiling y tracing)
Para diagnosticar bloqueos, contención y comportamiento del scheduler:
net/http/pprofpara perfiles (CPU, heap, goroutines, mutex).runtime/tracepara trazas detalladas del scheduler y eventos.
// En un servidor HTTP, habilitar pprof:
// import _ "net/http/pprof"
// go http.ListenAndServe("localhost:6060", nil)
// Luego: go tool pprof http://localhost:6060/debug/pprof/goroutineMensajes de deadlock del runtime
Si todas las goroutines quedan dormidas y no hay progreso, Go puede terminar con un error del tipo: fatal error: all goroutines are asleep - deadlock!. Aun así, muchos deadlocks son parciales (solo algunas goroutines), por lo que pprof de goroutines y trazas ayudan más.
Diseño de sistemas concurrentes predecibles
Principios prácticos
- Define límites: usa worker pools, semáforos (canal bufferizado como token bucket) o rate limiters para no crear goroutines sin control.
- Propaga cancelación: todo pipeline/fan-in debería aceptar
context.Contexty respetarctx.Done(). - Evita bloqueos invisibles: cada envío/recepción potencialmente bloqueante debe tener una estrategia (buffer, goroutine dedicada, select con ctx, o backpressure explícita).
- Backpressure: un canal bufferizado no “soluciona” el problema; solo lo desplaza. Decide qué hacer cuando el consumidor es más lento: bloquear, descartar, agrupar (batch), o escalar workers.
- Propiedad del cierre: cierra canales desde el productor; no cierres un canal desde múltiples lugares.
- Minimiza estado compartido: favorece pasar datos por canales o usar estructuras inmutables.
- Orden de locks: si necesitas varios mutexes, define un orden global y respétalo.
Checklist de revisión (antes de dar por “listo” un módulo concurrente)
| Pregunta | Qué buscar |
|---|---|
| ¿Puede quedar una goroutine viva sin necesidad? | Falta de cancelación, canales no drenados, fan-in sin ctx. |
| ¿Quién cierra cada canal? | Un único responsable; consumidores no cierran. |
| ¿Qué pasa si el consumidor se detiene? | Productores deben detectar ctx.Done o tener estrategia de parada. |
| ¿Hay escrituras concurrentes a mapas/slices? | Mutex, canal dueño, o copias inmutables. |
| ¿Hay operaciones bloqueantes dentro de locks? | Evitar I/O o esperas largas con el mutex tomado. |
| ¿Se probó con race detector? | go test -race en rutas críticas. |