sync.WaitGroup, Mutex y errgroup: coordinación de goroutines en Go
1. Descripción general
Ejecutar una goroutine en Go es fácil, solo necesitas agregar la palabra clave go. La parte difícil es saber cuándo terminan las goroutines, proteger los datos compartidos y manejar errores cuando una goroutine falla.
Este artículo cubre tres herramientas para esas tres tareas: sync.WaitGroup, sync.Mutex y errgroup.Group. Al terminarlo, sabrás cuál elegir en cada situación, cómo escribir pruebas con la bandera -race y qué errores evitar.
Los ejemplos usan Go 1.22 o superior. A partir de esta versión, cada iteración del bucle for crea una nueva variable, por lo que ya no necesitas escribir id := id.
2. Problema de ejemplo
Supongamos que tienes una lista de códigos de pedidos (Order). Cada pedido necesita llamar a un servicio lento que tarda aproximadamente 100ms. Si llamas 10 pedidos secuencialmente, tardas unos 1 segundo. Si los llamas en paralelo, solo tardas unos 100ms.
Esta es la función que simula la llamada al servicio:
package main
import (
"fmt"
"time"
)
func fetchOrder(id int) string {
time.Sleep(100 * time.Millisecond)
return fmt.Sprintf("order-%d", id)
}
Si solo escribes go fetchOrder(id) en el bucle, la función main terminará antes de que las goroutines terminen. El programa se cierra y los resultados se pierden. La siguiente sección soluciona este problema.
3. sync.WaitGroup: esperar un grupo de goroutines
3.1. Uso básico
WaitGroup es un contador. Incrementas el contador con Add, lo decrementas con Done, y Wait bloquea hasta que el contador llega a 0.
func main() {
var wg sync.WaitGroup
ids := []int{1, 2, 3}
for _, id := range ids {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("fetched", fetchOrder(id))
}()
}
wg.Wait()
fmt.Println("all done")
}
El código anterior funciona así:
wg.Add(1)se llama antes de iniciar la goroutine, en la goroutine padre.defer wg.Done()garantiza que el contador siempre se decrementa, incluso si la goroutine termina prematuramente o entra en pánico.wg.Wait()bloqueamainhasta que las tres goroutines llamen aDone.
El resultado podría verse así. El orden de las líneas fetched cambia entre ejecuciones:
fetched order-2
fetched order-1
fetched order-3
all done
3.2. WaitGroup.Go en Go 1.25
A partir de Go 1.25, WaitGroup tiene un nuevo método Go. Este método combina Add, go y Done en una única llamada:
var wg sync.WaitGroup
for _, id := range ids {
wg.Go(func() {
fmt.Println("fetched", fetchOrder(id))
})
}
wg.Wait()
Escribirlo así es más conciso y evita dos errores comunes: olvidar llamar a Done, o llamar a Add dentro de la goroutine.
3.3. Recopilar resultados sin lock
Frecuentemente necesitas retener los resultados, no solo imprimirlos en pantalla. Una forma segura es crear un slice con tamaño predefinido y dejar que cada goroutine escriba en su propio índice:
results := make([]string, len(ids))
var wg sync.WaitGroup
for i, id := range ids {
wg.Go(func() {
results[i] = fetchOrder(id)
})
}
wg.Wait()
fmt.Println(results)
[order-1 order-2 order-3]
Como ninguna goroutine escribe en el mismo elemento, aquí no hay data race. wg.Wait() también establece una relación happens-before. Por lo tanto, después de Wait, la goroutine padre puede leer todos los datos que escribieron las goroutines hijo.
Nota: No llames a
wg.Add(1)dentro de una goroutine. Si lo haces,Wait()podría ejecutarse antes deAddy retornar mientras el trabajo aún no está completo.
4. sync.Mutex: proteger datos compartidos
4.1. Cómo se ve un data race
Ahora supongamos que necesitas acumular ingresos por cliente (Customer), y varias goroutines actualizan el mismo map:
revenue := map[string]float64{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() {
revenue["alice"] += 10
})
}
wg.Wait()
Este programa podría fallar con el siguiente error:
fatal error: concurrent map writes
El caso de usar solo una variable int es aún más peligroso. El programa no se bloquea pero silenciosamente produce un resultado incorrecto, por ejemplo 9730 en lugar de 10000. Para detectar este tipo de errores, ejecuta con el race detector:
go run -race main.go
WARNING: DATA RACE
Write at 0x00c000012345 by goroutine 8:
...
4.2. Arreglado con Mutex
sync.Mutex garantiza que en cualquier momento solo una goroutine ejecute el código entre Lock y Unlock. La práctica común es agrupar el mutex con los datos que protege, en el mismo struct:
package shop
import "sync"
type RevenueTracker struct {
mu sync.Mutex
total map[string]float64
}
func NewRevenueTracker() *RevenueTracker {
return &RevenueTracker{total: make(map[string]float64)}
}
func (r *RevenueTracker) Add(customer string, amount float64) {
r.mu.Lock()
defer r.mu.Unlock()
r.total[customer] += amount
}
func (r *RevenueTracker) Total(customer string) float64 {
r.mu.Lock()
defer r.mu.Unlock()
return r.total[customer]
}
Algunos puntos a tener en cuenta:
- Todo acceso a
total, incluyendo lecturas, debe pasar por el lock. defer r.mu.Unlock()ayuda a que el lock siempre se libere, incluso si la función tiene múltiples caminosreturn.- Los métodos usan receivers de puntero (
*RevenueTracker). Si usas receivers de valor, cada llamada copiará el mutex, y ese lock no protegerá nada.
Cuando hay muchas más lecturas que escrituras, puedes usar sync.RWMutex. Los métodos de lectura llaman a RLock/RUnlock, los métodos de escritura llaman a Lock/Unlock. Muchas goroutines pueden leer simultáneamente, pero solo una goroutine puede tener el lock al escribir.
Nota: No copies un struct que contiene
Mutexdespués de usarlo. El comandogo vetadvertirá sobre esto con el mensajecopylocks. Además, Go no tiene locks reentrantes: si una goroutine llama aLock()dos veces en el mismo mutex, se producirá un deadlock.
4.3. Cuándo no necesitas Mutex
Para un contador simple, sync/atomic es más conciso y rápido:
var processed atomic.Int64
processed.Add(1)
fmt.Println(processed.Load())
Cuando los datos necesitan fluir de una goroutine a otra en forma de stream, los channels suelen ser más apropiados. Mutex es más adecuado cuando tienes un estado compartido, como un map o struct, y múltiples goroutines necesitan leer o escribir en él.
5. errgroup: WaitGroup con manejo de errores
WaitGroup no sabe nada sobre errores. Si fetchOrder puede retornar un error, tienes que recopilar errores manualmente usando mutex o channels, e intentar cancelar las goroutines restantes. El paquete errgroup hace esto automáticamente.
5.1. Agregar la dependencia
errgroup se encuentra en el módulo golang.org/x/sync, no en la biblioteca estándar:
go get golang.org/x/sync
5.2. Agrupar errores y cancelación con context
El primer paso es reescribir fetchOrder para que la función acepte context.Context y pueda retornar un error:
package shop
import (
"context"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
type Order struct {
ID int
Customer string
}
func fetchOrder(ctx context.Context, id int) (Order, error) {
if id < 0 {
return Order{}, fmt.Errorf("ID de pedido inválido %d", id)
}
select {
case <-time.After(100 * time.Millisecond):
return Order{ID: id, Customer: "alice"}, nil
case <-ctx.Done():
return Order{}, ctx.Err()
}
}
func LoadOrders(ctx context.Context, ids []int) ([]Order, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(3)
orders := make([]Order, len(ids))
for i, id := range ids {
g.Go(func() error {
o, err := fetchOrder(ctx, id)
if err != nil {
return err
}
orders[i] = o
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return orders, nil
}
El código anterior funciona así:
errgroup.WithContext(ctx)crea un grupo y un context hijo. Tan pronto como una goroutine retorna el primer error, el context hijo se cancela.g.SetLimit(3)permite un máximo de 3 goroutines ejecutándose simultáneamente. La siguiente llamada ag.Gose bloqueará hasta que haya espacio. De esta forma, no sobrecargas el servicio cuando la lista tiene miles de pedidos.g.Goacepta una función de tipofunc() error, y no necesitas llamar aAdd/Done.g.Wait()espera a que todas las goroutines terminen y retorna el primer error. Los errores posteriores se ignoran.fetchOrderescuchactx.Done(), por lo que las goroutines restantes se detienen prematuramente en lugar de ejecutarse los 100ms completos.
Llamar a la función con un código de pedido inválido:
_, err := shop.LoadOrders(context.Background(), []int{1, 2, -5, 4})
fmt.Println(err)
ID de pedido inválido -5
Si necesitas intentar ejecutar sin bloquear cuando se alcanza el límite, usa g.TryGo(f). Esta función retorna false en lugar de esperar.
Nota: La cancelación del context solo funciona si el código dentro de la goroutine activamente comprueba
ctx. SifetchOrderusatime.Sleepsin escuchar el context, la goroutine seguirá ejecutándose los 100ms completos aunque el grupo falle.
6. Escribir pruebas con -race
Go no tiene JUnit o AssertJ. Usas el paquete testing de la biblioteca estándar. Lo más importante es ejecutar siempre las pruebas con la bandera -race, ya que muchos errores de concurrencia solo se revelan con el race detector.
package shop
import (
"context"
"sync"
"testing"
)
func TestRevenueTracker_ConcurrentAdd(t *testing.T) {
tracker := NewRevenueTracker()
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() {
tracker.Add("alice", 10)
})
}
wg.Wait()
if got := tracker.Total("alice"); got != 10000 {
t.Fatalf("Total(alice) = %v, want 10000", got)
}
}
func TestLoadOrders_ReturnsAllOrders(t *testing.T) {
orders, err := LoadOrders(context.Background(), []int{1, 2, 3})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for i, o := range orders {
if o.ID != i+1 {
t.Errorf("orders[%d].ID = %d, want %d", i, o.ID, i+1)
}
}
}
func TestLoadOrders_StopsOnFirstError(t *testing.T) {
orders, err := LoadOrders(context.Background(), []int{1, -1, 3})
if err == nil {
t.Fatal("expected error, got nil")
}
if orders != nil {
t.Errorf("expected nil orders, got %v", orders)
}
}
Ejecuta las pruebas:
go test -race ./...
ok example.com/shop 0.412s
Intenta eliminar las dos líneas Lock/Unlock en Add y luego ejecuta de nuevo. La prueba fallará con WARNING: DATA RACE o un crash por concurrent map writes. Esta es una forma rápida de verificar que el mutex realmente es necesario.
La segunda prueba también confirma que se mantiene el orden correcto de los resultados: orders[i] coincide con ids[i], aunque las goroutines terminen en cualquier orden.
7. Errores comunes y cómo elegir herramientas
Los errores más comunes son:
- Llamar a
Adddentro de una goroutine.Waitpuede retornar demasiado pronto. Llama aAddantes del comandogo, o usawg.Go. - Pasar
WaitGroupoMutexpor valor. La función recibe una copia, y el contador o lock original no cambia. Pasa un puntero*sync.WaitGroup, o usa una closure. - Mantener el lock mientras realizas I/O. Por ejemplo, llamar a HTTP dentro de
Lock. Todas las demás goroutines deben esperar en cola, y el programa pierde los beneficios de la concurrencia. Mantén el lock solo en la sección de código más crítica posible. - Usar
errgroup.Groupvacío (var g errgroup.Group) pero esperar que el context se cancele. SoloWithContextcrea un context que se puede cancelar. - Ignorar el valor de retorno de
g.Wait(). Cuando lo haces, los errores desaparecen sin que nadie lo sepa. - Lanzar goroutines sin límite para decenas de miles de solicitudes. Usa
SetLimito un pool de workers.
Cómo elegir la herramienta:
| Situación | Herramienta |
|---|---|
| Esperar múltiples tareas que no retornan errores | sync.WaitGroup |
| Múltiples goroutines modificando un map o struct compartido | sync.Mutex / sync.RWMutex |
| Contador simple o flag | sync/atomic |
| Tareas que pueden fallar, necesitas cancelación temprana, limitar goroutines | errgroup |
Nota:
errgroupsolo retiene el primer error. Si necesitas saber todos los errores, por ejemplo para reportar qué pedidos fallaron, deja que cada goroutine escriba su error enerrs[i], luego combínalos conerrors.Join(errs...)después de llamar aWait.
8. Conclusión
sync.WaitGroup responde la pregunta “¿cuándo termina?”. sync.Mutex responde “¿quién puede modificar los datos ahora?”. errgroup combina la espera con el manejo de errores, cancelación con context y limitación de goroutines concurrentes.
En código de producción, cuando llamas múltiples servicios en paralelo, errgroup.WithContext con SetLimit suele ser la opción predeterminada razonable. Independientemente de qué herramienta uses, siempre ejecuta go test -race. Esta es la forma más económica de detectar data races antes de que lleguen a producción.