sincronizacion-goroutines-waitgroups

sync.WaitGroup en Go para esperar tareas concurrentes

  • 4 min

sync.WaitGroup es un contador de sincronización para esperar a que varias goroutines terminen. Es la forma limpia de decir: no sigas hasta que todas hayan acabado.

En el artículo anterior usamos time.Sleep de forma provisional para esperar a que terminaran las goroutines.

Eso en un entorno real es inaceptable. Si pones un Sleep(5 segundos) y la tarea tarda 10ms, estás desperdiciando 4.99 segundos. Si la tarea tarda 6 segundos, el programa se cerrará antes de acabar y perderás datos.

Necesitamos una forma de decirle al programa principal: “No te cierres hasta que estas X goroutines hayan terminado”.

Para eso, la biblioteca estándar sync ofrece WaitGroup.

Qué es un WaitGroup

Un WaitGroup es, en esencia, un contador seguro para la concurrencia (Thread-safe counter).

Su funcionamiento es muy simple:

  1. Incrementas el contador cuando lanzas una tarea.
  2. Decrementas el contador cuando una tarea termina.
  3. Bloqueas la goroutine que llama a Wait hasta que el contador llegue a cero.

Los métodos clave

Para usarlo, necesitamos importar el paquete sync y conocer estos métodos:

  • Add(n int): Suma n al contador. Generalmente hacemos Add(1) justo antes de lanzar la goroutine.
  • Done(): Resta 1 al contador. Se llama dentro de la goroutine cuando ha terminado.
  • Wait(): Bloquea la ejecución hasta que el contador interno sea 0.
  • Go(f func()): Lanza una función en una goroutine y la añade al grupo automáticamente (Go 1.25+).

Ejemplo básico

Vamos a arreglar el ejemplo del artículo anterior.

package main

import (
    "fmt"
    "sync" // 1. Importamos sync
    "time"
)

func trabajador(id int, wg *sync.WaitGroup) {
    // 3. Al terminar la función, avisamos al WaitGroup
    // Usamos defer para asegurar que se ejecuta al retornar
    defer wg.Done()

    fmt.Printf("Trabajador %d iniciando...\n", id)
    time.Sleep(time.Second) // Simulamos trabajo
    fmt.Printf("Trabajador %d completado\n", id)
}

func main() {
    // Definimos el WaitGroup (su zero value ya es utilizable)
    var wg sync.WaitGroup

    for i := 1; i <= 3; i++ {
        // 2. Incrementamos el contador ANTES de lanzar la goroutine
        wg.Add(1)

        // Lanzamos la goroutine pasando el puntero del WaitGroup
        go trabajador(i, &wg)
    }

    fmt.Println("El Main está esperando...")

    // 4. Bloqueamos hasta que todas terminen (contador == 0)
    wg.Wait()

    fmt.Println("Todo el trabajo ha finalizado. Adiós.")
}
Copied!

En el patrón clásico, llama a wg.Add(1) antes de la sentencia go. Si lo haces dentro, wg.Wait() podría observar el contador a cero antes de que la goroutine haya arrancado.

El atajo moderno: WaitGroup.Go

Desde Go 1.25, sync.WaitGroup tiene el método Go, que agrupa el patrón Add(1), go func() y Done() en una sola llamada.

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 3; i++ {
        id := i

        wg.Go(func() {
            fmt.Printf("Trabajador %d iniciando...\n", id)
            time.Sleep(time.Second)
            fmt.Printf("Trabajador %d completado\n", id)
        })
    }

    wg.Wait()
}
Copied!

Es más compacto y evita desajustes entre Add y Done. Su contrato exige que la función no provoque un panic; si una operación puede fallar de forma esperable, debemos gestionar o comunicar su error dentro de la tarea.

No copiar un WaitGroup

Fíjate bien en la firma de la función trabajador del ejemplo anterior: func trabajador(id int, wg *sync.WaitGroup)

Estamos pasando un Puntero. ¿Por qué?

Un sync.WaitGroup es un Struct. En Go, todo se pasa por valor (copia). Si pasas el WaitGroup sin puntero, la goroutine recibe una fotocopia del WaitGroup.

  1. La goroutine hace Done() sobre su fotocopia.
  2. El main espera sobre el WaitGroup original.
  3. El original nunca llega a cero.
  4. Resultado: el original no llega a cero y Wait puede quedar bloqueado indefinidamente.

Nunca copies un WaitGroup. Si tienes que pasarlo a funciones, usa siempre un puntero *sync.WaitGroup.

Uso con funciones anónimas

Lo más habitual en Go es no crear una función externa como trabajador, sino lanzar funciones anónimas dentro del bucle. Aquí los Closures brillan.

func main() {
    var wg sync.WaitGroup
    urls := []string{"http://google.com", "http://spotify.com", "http://luisllamas.es"}

    for _, url := range urls {
        wg.Add(1)

        // Lanzamos función anónima
        go func(u string) {
            defer wg.Done()
            // Simulamos petición
            fmt.Printf("Visitando %s\n", u)
        }(url) // Pasamos 'url' como argumento
    }

    wg.Wait()
}
Copied!

El closure captura la variable wg, así que trabaja con la misma instancia del ámbito de main. No necesitamos pasarla como parámetro.

WaitGroup frente a canales

Es importante entender cuándo usar qué.

  • Usa WaitGroups cuando solo te importa esperar a que terminen un conjunto de tareas, pero no necesitas que te devuelvan resultados.
  • Usa Canales (que veremos a continuación) cuando necesitas que las goroutines se comuniquen o te devuelvan datos.