// 03 · la joya de la corona

Concurrencia: goroutines y canales

La razón por la que muchos equipos migran a Go. Si conoces los virtual threads de Java 21: Go los tiene desde 2009, integrados en el corazón del lenguaje.

// dos letras: go

Goroutines

Una es un hilo ultraligero gestionado por el runtime: arranca con ~2 KB de stack (un Thread de Java: ~1 MB) y puedes tener millones corriendo a la vez.

goroutines.go
func main() {
	var wg sync.WaitGroup // como un CountDownLatch que cuenta solo

	for _, url := range []string{"a.com", "b.com", "c.com"} {
		wg.Add(1)
		go func() {              // "go" lanza la función en una goroutine. Ya está.
			defer wg.Done()
			descargar(url)       // las 3 descargas corren EN PARALELO
		}()
	}

	wg.Wait() // bloquea hasta que todas llamen Done()
	fmt.Println("todas listas")
}
ModeloCosto por unidadCómo se escribe
Thread de Java clásico~1 MB stack, cambio de contexto por SOExecutorService, pools que dimensionar
Virtual threads (Java 21)ligeros, montados sobre carrier threadsAPI nueva sobre 25 años de APIs bloqueantes
async/await (JS, Dart, Python)barato, pero "colorea" las funcionesToda la cadena debe ser async
Goroutine~2 KB, scheduler del runtimego f() — el código sigue siendo síncrono a la vista

// tuberías tipadas entre goroutines

Canales y select

El lema de Go: "no compartas memoria para comunicarte; comunícate para compartir memoria". Un canal (chan T) es una cola tipada y thread-safe por diseño — piensa en una BlockingQueue elevada a construcción del lenguaje.

canales.go
func main() {
	// Canal sin buffer: enviar BLOQUEA hasta que alguien reciba (cita a ciegas)
	resultados := make(chan string)

	go func() { resultados <- consultarAPI("clima") }()
	go func() { resultados <- consultarAPI("noticias") }()

	// Recibir también bloquea hasta que llegue algo:
	fmt.Println(<-resultados) // el primero que termine
	fmt.Println(<-resultados) // el segundo

	// Canal CON buffer: enviar no bloquea hasta llenarlo (cola de tamaño N)
	cola := make(chan int, 5)
	cola <- 1
	cola <- 2
	close(cola)              // el emisor cierra cuando no enviará más

	for v := range cola {    // range consume hasta que el canal se cierre
		fmt.Println(v)
	}
}

select: esperar varios canales a la vez

select.go
// select es un switch para operaciones de canal: ejecuta el caso que
// esté listo primero. Es la base de timeouts, cancelación y multiplexado.
select {
case datos := <-canalDatos:
	procesar(datos)
case err := <-canalErrores:
	log.Println("falló:", err)
case <-time.After(2 * time.Second):
	fmt.Println("timeout: nadie respondió en 2s")
case <-ctx.Done():
	fmt.Println("cancelado desde arriba:", ctx.Err())
}
Las reglas de los canales (apréndelas y evita el 90% de deadlocks)
  • Cierra el canal solo desde el emisor, nunca desde el receptor. Enviar a un canal cerrado = panic.
  • Recibir de un canal cerrado devuelve el zero value al instante; el patrón v, ok := <-ch distingue "valor real" de "canal cerrado".
  • Un canal nil bloquea para siempre — útil a propósito en select (desactivar un caso), letal por accidente.
  • No necesitas cerrar todos los canales: el GC los recoge; cerrar es una señal de "no habrá más datos", no una liberación de recursos.
  • Las flechas en tipos documentan dirección: chan<- T solo envía, <-chan T solo recibe. Úsalas en firmas: el compilador vigila.

// cancelación en cadena

context: timeouts y cancelación

El es el idioma universal para propagar cancelación, timeouts y deadlines por toda la pila de llamadas. Toda función que hace I/O en Go serio recibe un ctx context.Context como primer parámetro — te lo encontrarás en todas partes.

context.go
// Si la operación completa no termina en 3s, TODO lo que cuelgue de ctx
// (queries SQL, llamadas HTTP, goroutines) recibe la señal de cancelación.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() // SIEMPRE, aunque termine antes: libera el timer

fila := db.QueryRowContext(ctx, "SELECT nombre FROM usuarios WHERE id=$1", id)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)

// En un handler HTTP, el contexto ya viene creado y se cancela solo
// si el cliente corta la conexión:
func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	// pásalo hacia abajo y toda la cadena se vuelve cancelable
}
✓

El paralelo SpringEs lo que intentan ser @Transactional(timeout=...) + RequestContextHolder + la interrupción de threads, unificados en un solo objeto explícito que viaja por parámetro en lugar de vivir en ThreadLocals mágicos.

// los tres patrones que cubren casi todo

Patrones: worker pool, pipeline y fan-in

Worker pool: N trabajadores, una cola

workerpool.go
func main() {
	trabajos := make(chan int, 100)
	resultados := make(chan string, 100)

	// 4 workers compitiendo por el mismo canal de trabajos
	for w := 1; w <= 4; w++ {
		go worker(w, trabajos, resultados)
	}

	for j := 1; j <= 12; j++ { trabajos <- j }
	close(trabajos) // no habrá más trabajo: los workers terminarán su for range

	for i := 0; i < 12; i++ {
		fmt.Println(<-resultados)
	}
}

// Las flechas en el tipo documentan la dirección: solo-lectura / solo-escritura
func worker(id int, trabajos <-chan int, resultados chan<- string) {
	for j := range trabajos {
		time.Sleep(100 * time.Millisecond) // simular trabajo
		resultados <- fmt.Sprintf("worker %d terminó el trabajo %d", id, j)
	}
}

Pipeline: etapas conectadas por canales

pipeline.go
// Cada etapa recibe un canal y devuelve otro: se componen como tuberías Unix.
// generar(1..n) → cuadrado → filtrarPares → consumir

func generar(n int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for i := 1; i <= n; i++ { out <- i }
	}()
	return out
}

func cuadrado(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for v := range in { out <- v * v }
	}()
	return out
}

func main() {
	for v := range cuadrado(generar(5)) {
		fmt.Println(v) // 1 4 9 16 25
	}
}

Fan-out / fan-in: repartir y juntar

fanin.go
// Fan-out: varias goroutines leen del MISMO canal (reparto automático).
// Fan-in: juntar varios canales en uno solo:
func fanIn[T any](canales ...<-chan T) <-chan T {
	out := make(chan T)
	var wg sync.WaitGroup

	for _, c := range canales {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for v := range c { out <- v }
		}()
	}

	go func() { wg.Wait(); close(out) }()
	return out
}
errgroup: WaitGroup + errores + cancelación (lo que usarás en el trabajo)
errgroup.go
import "golang.org/x/sync/errgroup"

func cargarDashboard(ctx context.Context) error {
	g, ctx := errgroup.WithContext(ctx)

	g.Go(func() error { return cargarVentas(ctx) })
	g.Go(func() error { return cargarUsuarios(ctx) })
	g.Go(func() error { return cargarMetricas(ctx) })

	// Espera a las tres; si UNA falla, el ctx se cancela y las demás abortan.
	// Compáralo con el malabarismo de CompletableFuture.allOf + exceptionally.
	return g.Wait()
}

// cuando el estado compartido es inevitable

Mutex, Once y atomic

Canales para transferir datos y coordinar; para proteger estado compartido. Si solo proteges una variable, un mutex es más simple y rápido que un canal.

sync.go
// El patrón estándar: el mutex vive DENTRO del struct que protege,
// y ningún método sale sin soltar el lock (defer).
type Cache struct {
	mu    sync.RWMutex
	datos map[string]string
}

func (c *Cache) Get(k string) (string, bool) {
	c.mu.RLock()         // muchos lectores a la vez
	defer c.mu.RUnlock()
	v, ok := c.datos[k]
	return v, ok
}

func (c *Cache) Set(k, v string) {
	c.mu.Lock()          // escritor exclusivo
	defer c.mu.Unlock()
	c.datos[k] = v
}

// sync.Once: inicialización perezosa thread-safe (tu singleton bien hecho)
var (
	instancia *Config
	una       sync.Once
)

func ObtenerConfig() *Config {
	una.Do(func() { instancia = cargarConfig() }) // corre EXACTAMENTE una vez
	return instancia
}

// sync/atomic para contadores calientes, sin lock:
var peticiones atomic.Int64
peticiones.Add(1)
fmt.Println(peticiones.Load())
!

Siempre, siempre: go test -raceEl detector de integrado instrumenta tu binario y delata cualquier acceso concurrente sin sincronizar, con stack traces exactos. En Java necesitarías herramientas comerciales; aquí es un flag. Ponlo en tu CI y duerme tranquilo.

// nuevo · los que todos cometemos el primer mes

Errores comunes de concurrencia

ErrorSíntomaCura
Goroutine leakMemoria crece; goroutines esperando en un canal que nadie leerá jamásTodo go func() necesita una salida garantizada: ctx.Done(), close del canal o timeout
Olvidar el bufferDeadlock: "all goroutines are asleep"Un envío sin receptor listo bloquea; dale buffer o asegura el receptor primero
Compartir un map sin lockPanic: "concurrent map writes" (¡en producción!)Mutex alrededor, o sync.Map para casos de caché
Ignorar el resultado de wg.Wait/erroresFallos silenciososUsa errgroup cuando las goroutines puedan fallar
time.After en buclesFuga de timers (cada llamada crea uno nuevo)time.NewTimer reutilizado, o ctx.WithTimeout
Cómo funciona el scheduler (G-M-P) — para tu lado curioso

El runtime multiplexa Goroutines sobre M (hilos del SO) usando P (procesadores lógicos, por defecto = núcleos de tu CPU, configurable con GOMAXPROCS). Cuando una goroutine bloquea en I/O, el scheduler la aparta y monta otra en el mismo hilo — por eso 100.000 goroutines durmiendo no cuestan 100.000 hilos.

Es el mismo concepto que el scheduler de virtual threads del JDK 21 o el event loop de Node, pero con dos ventajas: el código es 100% síncrono a la vista (sin async/await, sin colores de funciones) y el runtime lleva 15 años en producción a escala Google.