Menu

WaitGroup en Golang: Add, Done, Wait y un worker pool

Cómo espera sync.WaitGroup a que termine un conjunto de goroutines: las reglas de Add, Done y Wait, por qué hay que pasarlo por puntero, recoger resultados y errores, y un worker pool construido sobre él.

Esta página incluye editores ejecutables: edita, ejecuta y ve el resultado al instante.

El patrón básico

sync.WaitGroup cuenta las goroutines en marcha. Add sube la cuenta, Done la baja y Wait se bloquea hasta que vale cero.

Las tres descargas se ejecutan de forma concurrente, así que el programa tarda unos 10 ms en lugar de 30. Los resultados se imprimen en el orden de entrada porque cada goroutine solo escribe su propio índice de sizes, y main solo los lee después de Wait.

El valor cero de un WaitGroup está listo para usar. Sin constructor.

Las tres reglas

Llama a Add antes de go, no dentro de la goroutine. Si la goroutine llama ella misma a Add, main puede llegar a Wait antes de que haya arrancado ninguna goroutine, ver la cuenta a cero y retornar sin que el trabajo haya empezado. Cuando conoces la cuenta de antemano, un solo wg.Add(len(files)) antes del bucle es equivalente.

Llama a Done con defer en la primera línea de la goroutine. Una goroutine que retorna antes por un error, o que provoca un panic, sigue decrementando el contador. Un Done que falta deja Wait bloqueado para siempre. Si es la única goroutine que queda, el runtime informa de fatal error: all goroutines are asleep con sync.WaitGroup.Wait en la traza.

Nunca copies un WaitGroup después de empezar a usarlo. Pasa *sync.WaitGroup a las funciones, o captura la variable en una closure como arriba.

Pasar un WaitGroup a una función

Cuando el cuerpo de la goroutine es una función con nombre, pasa un puntero:

Con wg sync.WaitGroup como parámetro por valor, cada worker llamaría a Done sobre su propia copia y main se quedaría bloqueado en Wait para siempre. go vet lo detecta antes de que ejecutes nada:

./main.go:8:24: worker passes lock by value: sync.WaitGroup contains sync.noCopy

Un diseño más limpio deja la concurrencia fuera de worker por completo: que sea una función normal y que la contabilidad de Add/Done se haga en la closure de quien llama. Así worker es fácil de testear y de llamar de forma síncrona.

Contador negativo

Done equivale a Add(-1). Si la cuenta baja de cero, el programa provoca un panic:

La salida es recovered: sync: negative WaitGroup counter. La causa habitual es una goroutine con defer wg.Done() que además llama a wg.Done() de forma explícita en alguna ruta.

Recoger errores

Un WaitGroup solo cuenta. Para los errores, da a cada goroutine su propia posición e inspecciónalas después de Wait:

errors.Join (Go 1.20) se salta los valores nil y devuelve nil si todos son nil, así que combina "un error por goroutine" sin contabilidad adicional.

Si quieres detener el resto del trabajo en cuanto falle una goroutine, usa golang.org/x/sync/errgroup. Es un WaitGroup más el primer error más un context que se cancela ante un fallo, y g.SetLimit(n) limita la concurrencia. Está fuera de la librería estándar, así que no se puede ejecutar en el editor de esta página:

g, ctx := errgroup.WithContext(ctx)
for _, h := range hosts {
	g.Go(func() error { return checkCtx(ctx, h) })
}
if err := g.Wait(); err != nil {
	return err // the first error; ctx was cancelled for the others
}

Un worker pool

Un número fijo de goroutines que leen trabajos de un channel mantiene la concurrencia acotada, haya los trabajos que haya. El WaitGroup te dice cuándo han terminado todos los workers, que es cuando se puede cerrar el channel de resultados.

El orden de las tres piezas importa:

  • main tiene que estar recibiendo resultados mientras los workers se ejecutan. Si main llamara a wg.Wait() directamente antes de leer, los workers se bloquearían al enviar a results, nunca llegarían a Done y todo acabaría en deadlock. Por eso Wait se ejecuta en su propia goroutine.
  • close(results) ocurre solo después de Wait, así que ningún worker puede enviar a un channel cerrado.
  • Quien alimenta los trabajos también se ejecuta en una goroutine, así que alimentar y recoger se solapan.

Qué worker atendió qué trabajo cambia de una ejecución a otra, así que el programa ordena por trabajo antes de imprimir. Todo lo que imprime es determinista.

WaitGroup, channel o errgroup

NecesidadUsa
Esperar a N goroutines, resultados en posiciones indexadassync.WaitGroup
Esperar a una goroutineun channel done o el propio channel de resultados
Resultados enviados a medida que terminanun channel, cerrado después de wg.Wait()
Detenerlo todo ante el primer errorerrgroup.WithContext
Detenerlo todo ante un timeout o una cancelación de quien llamacontext.Context más un WaitGroup o un errgroup

Go 1.25 añade wg.Go(func() { ... }), que hace por ti el Add(1) y el Done diferido. El código para Go 1.24 y anteriores, incluido el editor de esta página, usa la forma explícita mostrada arriba.

Errores comunes

  • wg.Add(1) dentro de la goroutine. Wait puede retornar antes de que se ejecute.
  • Olvidar Done en un retorno temprano. Usa siempre defer wg.Done().
  • Pasar el WaitGroup por valor. Usa un puntero; go vet marca la copia.
  • Esperar en la misma goroutine que tiene que vaciar un channel. Mueve wg.Wait() y el close a una goroutine aparte.
  • Reutilizar un WaitGroup antes de que haya retornado el Wait anterior. Empieza un nuevo ciclo de llamadas a Add solo cuando Wait haya terminado.

Preguntas frecuentes

¿Cómo funciona sync.WaitGroup en Go?

Un WaitGroup es un contador. wg.Add(n) lo aumenta, wg.Done() lo reduce en uno y wg.Wait() se bloquea hasta que llega a cero. Llama a Add antes de lanzar cada goroutine, pon defer wg.Done() dentro de ella y Wait donde necesites que todo haya terminado.

¿Debo pasar un WaitGroup por valor o por puntero?

Por puntero (*sync.WaitGroup), o deja que las goroutines lo capturen en una closure. Una copia tiene su propio contador, así que Done sobre la copia nunca llega al original y Wait se bloquea para siempre. go vet informa del error como "passes lock by value".

¿Qué provoca "sync: negative WaitGroup counter"?

Más llamadas a Done que a Add. Normalmente una goroutine llama a Done dos veces (una con defer y otra de forma explícita), o en alguna ruta se salta el Add(1). El programa provoca un panic, porque el contador ya no puede decirte nada cierto.

¿Cómo obtengo los errores de goroutines lanzadas con un WaitGroup?

Un WaitGroup no transporta resultados ni errores. Da a cada goroutine su propia posición en un slice de errores y combínalos después de Wait (por ejemplo con errors.Join), o usa golang.org/x/sync/errgroup, cuyo Wait devuelve el primer error y puede cancelar las demás a través de un context.

Coddy programming languages illustration

Aprende a programar con Coddy

COMENZAR