Goroutines y channels en Go: concurrencia sin dolores de cabeza

Una goroutine es una función que corre de forma concurrente con otras goroutines dentro del mismo proceso. Para lanzarla, antepones la palabra go a la llamada:

go f(x, y)

Eso es todo. Una sola palabra reservada y la función arranca en segundo plano. Pero lo que hace Go especial no es la sintaxis, sino lo que hay debajo.

Una goroutine no es un thread del sistema operativo. Un thread típico arranca con 1-8 MB de stack. Una goroutine empieza con 2-8 KB y crece según necesite. El runtime de Go se encarga de multiplexar miles o cientos de miles de goroutines sobre un número reducido de threads reales. Por eso es perfectamente normal ver programas Go con 100.000 goroutines corriendo a la vez sin que el sistema se resienta.

package main

import (
    "fmt"
    "time"
)

func saludar(nombre string) {
    fmt.Println("Hola,", nombre)
}

func main() {
    go saludar("Ana")
    go saludar("Luis")
    time.Sleep(100 * time.Millisecond) // esperar a que terminen
    fmt.Println("Listo")
}

El time.Sleep al final es un truco temporal. En producción usarás sync.WaitGroup o channels, que veremos más adelante.

Channels: la forma correcta de comunicar goroutines

Go tiene una filosofía clara respecto a la concurrencia: no compartas memoria para comunicarte, comunícate para compartir memoria. Los channels son la implementación de esa idea.

Un channel es un conducto tipado por el que una goroutine puede enviar datos y otra recibirlos:

ch := make(chan int)       // canal sin buffer (síncrono)
ch := make(chan int, 100)  // canal con buffer de 100 elementos

La diferencia importa. Un canal sin buffer bloquea al emisor hasta que hay alguien al otro lado leyendo. Un canal con buffer de 100 permite enviar hasta 100 valores antes de bloquearse. Enviar y recibir tienen su propia sintaxis:

ch <- 42        // enviar el valor 42 al canal
v := <-ch       // recibir un valor del canal

Cuando ya no necesitas enviar más datos, cierras el canal con close(ch). Los receptores que sigan leyendo recibirán el valor zero del tipo más un booleano false que indica que el canal está cerrado:

v, ok := <-ch
if !ok {
    fmt.Println("Canal cerrado")
}

También puedes iterar sobre un canal con range, que se detiene automáticamente al cerrarse:

for v := range ch {
    fmt.Println(v)
}

El patrón fan-out / fan-in

Uno de los usos más habituales de las goroutines es distribuir trabajo entre varios workers y recoger los resultados. Se llama fan-out (distribuir) y fan-in (agregar).

Imagina que tienes 1.000 URLs que comprobar. Hacerlo de forma secuencial puede tardar minutos. Con goroutines, repartes el trabajo:

package main

import (
    "fmt"
    "net/http"
)

func checkURL(url string, results chan<- string) {
    resp, err := http.Get(url)
    if err != nil {
        results <- fmt.Sprintf("ERROR %s: %v", url, err)
        return
    }
    defer resp.Body.Close()
    results <- fmt.Sprintf("%d %s", resp.StatusCode, url)
}

func main() {
    urls := []string{
        "https://example.com",
        "https://go.dev",
        "https://pkg.go.dev",
    }

    results := make(chan string, len(urls))

    for _, url := range urls {
        go checkURL(url, results)
    }

    for range urls {
        fmt.Println(<-results)
    }
}

El canal con buffer del tamaño de la lista evita que los workers se bloqueen al enviar resultados. Si necesitas limitar el paralelismo (por ejemplo, máximo 50 requests simultáneos), usa un canal semáforo:

sem := make(chan struct{}, 50) // máximo 50 goroutines a la vez

for _, url := range urls {
    sem <- struct{}{}
    go func(u string) {
        defer func() { <-sem }()
        checkURL(u, results)
    }(url)
}

Si quieres comparar cómo plantea Go la concurrencia frente a Rust, puedes comparar la concurrencia de Go con Rust en este artículo.

select: trabajar con varios channels a la vez

select es como un switch pero para operaciones de canal. Espera en varios canales a la vez y ejecuta el case que esté listo primero:

select {
case v := <-ch1:
    fmt.Println("Recibido de ch1:", v)
case v := <-ch2:
    fmt.Println("Recibido de ch2:", v)
default:
    fmt.Println("Ningún canal tiene datos aún")
}

Si varios cases están listos al mismo tiempo, Go elige uno al azar. El default es opcional: sin él, select bloquea hasta que algún canal tenga datos; con él, no bloquea nunca.

El caso más útil en la práctica es implementar timeouts:

select {
case result := <-trabajo:
    fmt.Println("Resultado:", result)
case <-time.After(5 * time.Second):
    fmt.Println("Timeout: tardó demasiado")
}

time.After devuelve un canal que recibe un valor pasado el tiempo indicado. Si el trabajo no termina en 5 segundos, el segundo case gana y el programa continúa.

Context: cancelación y timeouts en cadena

Cuando tienes varias goroutines que trabajan juntas, necesitas una forma de decirles a todas que paren. Para eso está el paquete context.

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

go worker(ctx, resultados)

Dentro del worker, escuchas ctx.Done(), que es un canal que se cierra cuando el contexto se cancela o expira:

func worker(ctx context.Context, results chan<- int) {
    for {
        select {
        case <-ctx.Done():
            return // el contexto se canceló, salimos
        case results <- hacerAlgo():
            // seguimos trabajando
        }
    }
}

La regla es pasar siempre el context como primer argumento a las funciones que lanzan goroutines. Cuando el padre cancela, todas las goroutinas hijas se enteran a través de ctx.Done() y pueden limpiar recursos antes de salir.

context.WithCancel te da control manual sobre cuándo cancelar. context.WithTimeout y context.WithDeadline lo hacen automáticamente pasado un tiempo o una fecha.

sync.WaitGroup: esperar a que terminen todas

Cuando lanzas varias goroutines y solo necesitas saber cuándo han terminado todas (sin pasar datos de vuelta), sync.WaitGroup es la herramienta adecuada:

var wg sync.WaitGroup

for i := 0; i < 10; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        fmt.Println("Worker", id, "terminado")
    }(i)
}

wg.Wait()
fmt.Println("Todos han terminado")

El patrón es siempre el mismo: wg.Add(1) antes de lanzar, defer wg.Done() al principio de la goroutine (para que se ejecute aunque haya un panic), y wg.Wait() para bloquear hasta que el contador llegue a cero.

sync.Mutex vs channels: cuándo usar cada uno

Con goroutines y channels llevas un rato, y en algún momento te preguntarás: ¿cuándo uso un mutex en vez de un canal?

La respuesta práctica es que los channels van bien para transferir datos entre goroutines y para coordinar el flujo de ejecución. Los mutex van bien para proteger el acceso a estructuras de datos compartidas, como caches, contadores o mapas.

// Con mutex: proteger un mapa compartido
var mu sync.Mutex
cache := make(map[string]string)

func guardar(k, v string) {
    mu.Lock()
    defer mu.Unlock()
    cache[k] = v
}

func leer(k string) string {
    mu.Lock()
    defer mu.Unlock()
    return cache[k]
}

Si el mapa va a tener muchas lecturas y pocas escrituras, sync.RWMutex es mejor: permite lecturas concurrentes con RLock() y bloquea solo en escrituras con Lock().

Para casos de muchas lecturas y pocas escrituras en un mapa, Go también ofrece sync.Map, que evita tener que gestionar el mutex manualmente. No es la opción más rápida en todos los escenarios, pero simplifica el código cuando el acceso es muy asimétrico.

Errores habituales que conviene evitar

Goroutine leak

Una goroutine que nunca termina porque el canal del que lee nunca se cierra es un goroutine leak. Se acumulan en memoria y el programa se degrada lentamente. La solución es siempre cerrar los canales cuando ya no vas a enviar más datos, o usar un contexto con cancelación para que las goroutines sepan cuándo parar.

Deadlock

Un deadlock ocurre cuando dos goroutines se esperan mutuamente y ninguna puede avanzar. Go detecta los deadlocks globales (cuando todas las goroutines están bloqueadas) y termina el programa con un mensaje claro. Los deadlocks parciales son más difíciles de detectar y suelen surgir de canales sin buffer usados en la misma goroutine que debería leerlos.

Race conditions

Una race condition es cuando dos goroutines acceden a la misma variable al mismo tiempo sin sincronización. El resultado es impredecible. Go incluye un detector de races que se activa con:

go test -race ./...
go run -race main.go

Úsalo siempre durante el desarrollo. El detector tiene algo de overhead, pero localiza bugs que de otra forma pueden tardarse días en reproducir.

Para más contexto sobre cómo ha evolucionado el lenguaje y dónde encaja hoy, puedes leer sobre el estado de Go en 2025.

Imagen: Pexels / Pixabay

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP