Concurrencia en Go: goroutines, canales y sincronización

Capítulo 6

Tiempo estimado de lectura: 10 minutos

+ Ejercicio

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.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

Descargar la aplicación

ch := make(chan int)      // no bufferizado
buf := make(chan int, 10) // bufferizado

Patró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 llamar Done).
  • 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 ctx como primer parámetro en funciones que hacen trabajo cancelable: func Do(ctx context.Context, ...).
  • Deriva contextos con context.WithCancel, context.WithTimeout o context.WithDeadline.
  • Llama al cancel() para liberar recursos cuanto antes.
  • En loops, chequea <-ctx.Done() con select.

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.Mutex o estructuras seguras (p. ej. sync.Map cuando 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 WaitGroup cuyo contador nunca llega a cero (faltó Done o Add mal 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/pprof para perfiles (CPU, heap, goroutines, mutex).
  • runtime/trace para 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/goroutine

Mensajes 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.Context y respetar ctx.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)

PreguntaQué 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.

Ahora responde el ejercicio sobre el contenido:

En un worker pool con canales jobs y results, ¿qué estrategia evita bloqueos y garantiza que el consumidor pueda terminar su range sobre results?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

El productor debe cerrar jobs para finalizar a los workers. Como varios workers envían a results, se cierra una sola vez cuando el WaitGroup indica que todos terminaron, permitiendo que el consumidor salga del range sin deadlocks.

Siguiente capítulo

Entrada/Salida y sistema de archivos con Go: datos, formatos y streams

Arrow Right Icon
Portada de libro electrónico gratuitaGo desde Cero: Programación Moderna, Rápida y Escalable
50%

Go desde Cero: Programación Moderna, Rápida y Escalable

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.