// 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.
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")
}| Modelo | Costo por unidad | Cómo se escribe |
|---|---|---|
| Thread de Java clásico | ~1 MB stack, cambio de contexto por SO | ExecutorService, pools que dimensionar |
| Virtual threads (Java 21) | ligeros, montados sobre carrier threads | API nueva sobre 25 años de APIs bloqueantes |
| async/await (JS, Dart, Python) | barato, pero "colorea" las funciones | Toda la cadena debe ser async |
| Goroutine | ~2 KB, scheduler del runtime | go 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.
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 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 := <-chdistingue "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<- Tsolo envía,<-chan Tsolo 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.
// 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
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
// 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
// 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)
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.
// 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
| Error | Síntoma | Cura |
|---|---|---|
| Goroutine leak | Memoria crece; goroutines esperando en un canal que nadie leerá jamás | Todo go func() necesita una salida garantizada: ctx.Done(), close del canal o timeout |
| Olvidar el buffer | Deadlock: "all goroutines are asleep" | Un envío sin receptor listo bloquea; dale buffer o asegura el receptor primero |
| Compartir un map sin lock | Panic: "concurrent map writes" (¡en producción!) | Mutex alrededor, o sync.Map para casos de caché |
Ignorar el resultado de wg.Wait/errores | Fallos silenciosos | Usa errgroup cuando las goroutines puedan fallar |
| time.After en bucles | Fuga 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.