Concurrencia en Go: goroutines explicadas para backend developers

Goroutines, concurrencia vs paralelismo, patrones reales para backend y errores frecuentes. Concurrencia simple y efectiva en Go.

Cover for Concurrencia en Go: goroutines explicadas para backend developers

En Python tienes asyncio. En Java, threads y executors. En Go, escribes go delante de una llamada a función. Y esa simplicidad es, creo, al mismo tiempo una de las mejores decisiones de diseño del lenguaje y una de las trampas más peligrosas para quienes lo adoptan sin entender qué hay debajo.

Llevo años construyendo backends en Kotlin y Python. He trabajado con coroutines de Kotlin, con CompletableFuture de Java, con asyncio de Python. Y puedo decir, al menos desde mi experiencia, que la concurrencia en Go no elimina la complejidad. Lo que hace es que puedas trabajar con ella sin sentir que estás peleando con el lenguaje. La sintaxis se aparta, las abstracciones son pocas, y el modelo mental es sorprendentemente directo. Pero directo no significa trivial, y ahí es donde mucha gente se pierde.

Este artículo cubre goroutines, la diferencia real entre concurrencia y paralelismo, los mecanismos de sincronización del paquete sync, patrones reales para backend y los errores que vas a cometer. Si ya sabes programar y quieres entender la concurrencia en Go desde la perspectiva de alguien que construye servicios reales, esto es para ti. Si estás empezando con Go, puede que quieras primero leer aprender Go.


Concurrencia vs paralelismo: la distinción que importa

Antes de escribir una sola goroutine, creo que necesitas tener claro un concepto que la mayoría de desarrolladores mezclan (yo lo mezclé durante años): concurrencia y paralelismo no son lo mismo.

Concurrencia es la capacidad de gestionar múltiples tareas a la vez. No significa que se ejecuten al mismo tiempo. Significa que tu programa está estructurado para poder progresar en varias tareas sin que una bloquee a las demás.

Paralelismo es ejecución simultánea real. Dos cosas corriendo literalmente al mismo tiempo en dos CPUs distintas.

Rob Pike, uno de los creadores de Go, lo resume en una frase que repito cada vez que alguien confunde ambos conceptos:

“Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.”

En la práctica, para backend, la concurrencia importa mucho más que el paralelismo. Y siendo honestos, la mayoría de nuestros servicios pasan el 90% del tiempo esperando: esperando respuestas de bases de datos, esperando llamadas HTTP a otros servicios, esperando I/O. Si tu programa puede hacer algo útil mientras espera, ganas rendimiento. No porque ejecutes cosas en paralelo, sino porque no desperdicias tiempo bloqueado.

Go te da ambas cosas. Las goroutines permiten concurrencia estructural. El runtime de Go, si tiene múltiples CPUs disponibles, las puede ejecutar en paralelo. Pero el valor fundamental está en la concurrencia: en que tu código puede estar haciendo diez peticiones HTTP a la vez sin necesitar diez threads del sistema operativo.

Un ejemplo concreto. Tu servicio recibe una petición y necesita:

  1. Consultar la base de datos para obtener un usuario
  2. Llamar a un servicio externo para obtener sus permisos
  3. Consultar una caché para obtener sus preferencias

De forma secuencial, si cada operación tarda 100ms, necesitas 300ms. De forma concurrente, puedes lanzar las tres a la vez y esperar a que terminen: 100ms. No porque ejecutes en paralelo (puede que sí, pero no es lo relevante), sino porque no esperas a que una termine para empezar la siguiente.


Qué es una goroutine

Una goroutine es una función que se ejecuta de forma concurrente con el resto del programa. Técnicamente es una lightweight thread gestionada por el runtime de Go, no por el sistema operativo.

Esto es importante, y ahí es donde Go marca la diferencia. En Java, cada thread es un thread del OS. Crearlos es caro (típicamente 1-2 MB de stack por thread), hacer context switch entre ellos es caro, y tener miles a la vez es problemático. En Go, una goroutine empieza con un stack de unos 8 KB que crece dinámicamente. Puedes tener cientos de miles corriendo sin problemas. El runtime de Go las multiplexa sobre un número mucho menor de threads del OS usando su propio scheduler.

El modelo es M:N. M goroutines mapeadas sobre N threads del OS. El runtime de Go decide cuándo y cómo distribuirlas. Tú no tienes que gestionar thread pools, ni configurar el número de threads, ni pensar en context switches. Lanzas goroutines y el runtime se encarga.

func procesar(id int) {
    fmt.Printf("procesando tarea %d\n", id)
    time.Sleep(100 * time.Millisecond) // simula trabajo
    fmt.Printf("tarea %d completada\n", id)
}

Esta función no tiene nada especial. Es una función normal. La magia está en cómo la llamas.


Lanzar goroutines: la palabra clave go

Lanzar una goroutine es la operación más simple del modelo de concurrencia de Go:

go procesar(1)

Eso es todo. La función procesar se ejecuta en una nueva goroutine. La ejecución del código que hizo la llamada continúa inmediatamente sin esperar a que procesar termine.

func main() {
    fmt.Println("inicio")

    go procesar(1)
    go procesar(2)
    go procesar(3)

    fmt.Println("goroutines lanzadas")
    time.Sleep(1 * time.Second) // espera poco elegante
}

Este ejemplo lanza tres goroutines. Las tres se ejecutan concurrentemente. El time.Sleep al final es necesario porque si main termina, el programa termina, y las goroutines mueren con él. Evidentemente, time.Sleep no es la forma correcta de sincronizar goroutines. Es un hack que verás en tutoriales y que no deberías usar en producción. Pero sirve para ilustrar el punto: lanzar goroutines es trivial.

También puedes lanzar funciones anónimas como goroutines:

go func() {
    fmt.Println("ejecutándome en una goroutine")
}()

go func(msg string) {
    fmt.Println(msg)
}("hola desde goroutine")

El patrón de pasar argumentos a la función anónima es importante. Si capturas variables del scope exterior directamente en lugar de pasarlas como argumento, puedes acabar con race conditions. Más sobre esto en la sección de errores comunes.


El problema: goroutines sin sincronización

Ahí es donde la simplicidad de go se convierte en trampa. Es tan fácil lanzar goroutines que muchos desarrolladores —y me incluyo al principio— las lanzan sin pensar en cómo se sincronizan. Y entonces empiezan los problemas.

func main() {
    contador := 0

    for i := 0; i < 1000; i++ {
        go func() {
            contador++
        }()
    }

    time.Sleep(1 * time.Second)
    fmt.Println(contador) // ¿1000? No necesariamente.
}

Este código tiene una race condition. Mil goroutines intentan incrementar la misma variable al mismo tiempo. contador++ no es una operación atómica: lee el valor, lo incrementa, y lo escribe. Si dos goroutines leen el mismo valor antes de que ninguna escriba, una de las escrituras se pierde.

El resultado puede ser 1000, o 987, o 953. Depende del scheduling del runtime, de la carga del sistema, del número de CPUs. Es no-determinístico, y siendo honestos, esa es la peor clase de bug que existe: el que funciona en tu máquina y falla en producción.

Sin sincronización, las goroutines son un generador de bugs. La regla fundamental de la concurrencia en Go es simple: si dos goroutines acceden a la misma variable y al menos una la modifica, necesitas sincronización.


sync.WaitGroup: esperar a que las goroutines terminen

El primer mecanismo de sincronización que necesitas es sync.WaitGroup. Resuelve el problema más básico: saber cuándo un grupo de goroutines ha terminado.

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            fmt.Printf("goroutine %d trabajando\n", id)
            time.Sleep(100 * time.Millisecond)
        }(i)
    }

    wg.Wait() // bloquea hasta que todas las goroutines llamen a Done()
    fmt.Println("todas las goroutines terminaron")
}

WaitGroup tiene tres métodos:

  • Add(n): incrementa el contador en n. Lo llamas antes de lanzar la goroutine.
  • Done(): decrementa el contador en 1. Lo llamas cuando la goroutine termina. Usar defer es la convención.
  • Wait(): bloquea hasta que el contador llegue a 0.

Errores habituales con WaitGroup:

  1. Llamar a Add dentro de la goroutine en vez de antes de lanzarla. Si la goroutine no se ha schedulado todavía cuando llamas a Wait, el contador puede estar a 0 y Wait retorna prematuramente.
// MAL
go func() {
    wg.Add(1) // puede ejecutarse después de wg.Wait()
    defer wg.Done()
    // trabajo
}()

// BIEN
wg.Add(1)
go func() {
    defer wg.Done()
    // trabajo
}()
  1. Olvidar Done(). Si una goroutine no llama a Done, Wait se bloquea para siempre. Usa defer wg.Done() siempre como primera línea de la goroutine.

  2. Pasar WaitGroup por valor. WaitGroup no debe copiarse. Si lo pasas a una función, pásalo como puntero.

// MAL: wg se copia, Done() no afecta al original
func worker(wg sync.WaitGroup) {
    defer wg.Done()
    // trabajo
}

// BIEN: pasa el puntero
func worker(wg *sync.WaitGroup) {
    defer wg.Done()
    // trabajo
}

Un patrón real que uso constantemente: lanzar N llamadas HTTP concurrentes y esperar a que todas terminen.

func fetchAll(urls []string) []Response {
    var wg sync.WaitGroup
    results := make([]Response, len(urls))

    for i, url := range urls {
        wg.Add(1)
        go func(idx int, u string) {
            defer wg.Done()
            results[idx] = fetch(u)
        }(i, url)
    }

    wg.Wait()
    return results
}

Observa que cada goroutine escribe en una posición distinta del slice results. No hay race condition porque no comparten posiciones. Si todas escribieran en la misma variable, necesitarías un mutex.


sync.Mutex: proteger estado compartido

Cuando dos o más goroutines necesitan leer y escribir la misma variable, necesitas un sync.Mutex. Un mutex (mutual exclusion) garantiza que solo una goroutine accede a la sección crítica a la vez.

type SafeCounter struct {
    mu    sync.Mutex
    count int
}

func (c *SafeCounter) Increment() {
    c.mu.Lock()
    c.count++
    c.mu.Unlock()
}

func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

Ahora el ejemplo del contador funciona correctamente:

func main() {
    counter := &SafeCounter{}
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Increment()
        }()
    }

    wg.Wait()
    fmt.Println(counter.Value()) // siempre 1000
}

sync.RWMutex: lecturas concurrentes

Si tu caso de uso tiene muchas lecturas y pocas escrituras, sync.RWMutex es más eficiente. Permite múltiples lectores concurrentes pero solo un escritor exclusivo.

type Cache struct {
    mu    sync.RWMutex
    items map[string]string
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    val, ok := c.items[key]
    return val, ok
}

func (c *Cache) Set(key, value string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = value
}

Múltiples goroutines pueden llamar a Get simultáneamente. Pero cuando una llama a Set, bloquea a todas las demás hasta que termine.

Reglas prácticas para mutex

  1. Mantén la sección crítica lo más pequeña posible. No metas I/O dentro de un Lock. Haz el Lock, modifica la variable, haz el Unlock. Si necesitas hacer una llamada HTTP, copia los datos que necesitas dentro del Lock y haz la llamada fuera.

  2. Usa defer para Unlock. Es la forma idiomática y te protege de olvidar el Unlock si hay un early return o un panic.

  3. Nunca copies un Mutex. Al igual que con WaitGroup, pasa siempre punteros.

  4. No hagas Lock dentro de un Lock del mismo mutex. Es un deadlock inmediato. Go no tiene mutexes reentrantes a propósito.

// DEADLOCK: Lock dentro de Lock
func (c *SafeCounter) Bad() {
    c.mu.Lock()
    // ...
    c.mu.Lock() // se bloquea aquí para siempre
}

Cuándo usar mutex vs channels

Go tiene dos mecanismos principales de sincronización: mutexes y channels. La pregunta de cuándo usar cada uno genera debates interminables, y no creo que haya una respuesta única. Pero mi regla es pragmática:

Usa mutex cuando proteges un estado compartido. Si tienes una variable que varias goroutines necesitan leer y escribir, un mutex es la solución más directa. Un cache en memoria, un contador, un mapa de sesiones activas.

Usa channels cuando coordinas flujos de trabajo. Si necesitas pasar datos de una goroutine a otra, señalizar que algo ha terminado, o implementar un patrón productor-consumidor, los channels son la abstracción correcta.

La frase famosa es “Don’t communicate by sharing memory; share memory by communicating.” Es un buen principio, pero llevado al extremo produce código artificialmente complejo. He visto —y confieso que he escrito— implementaciones de un simple contador con channels en vez de un mutex, y el resultado era ilegible.

// Mutex: simple, directo, correcto
var mu sync.Mutex
var count int

func increment() {
    mu.Lock()
    count++
    mu.Unlock()
}

// Channels para un contador: sobreingeniería
func counterManager(inc <-chan struct{}, get <-chan chan int) {
    count := 0
    for {
        select {
        case <-inc:
            count++
        case reply := <-get:
            reply <- count
        }
    }
}

El segundo ejemplo es técnicamente correcto, y en algunos contextos puede tener sentido. Pero para la gran mayoría de casos, creo que el mutex es más legible, más rápido y más fácil de depurar.

Una guía rápida:

SituaciónUsa
Proteger lectura/escritura de una variablesync.Mutex o sync.RWMutex
Pasar resultados entre goroutinesChannels
Señalizar que algo ha terminadosync.WaitGroup o un channel
Fan-out / fan-inChannels
Worker poolChannels
Cache en memoriasync.RWMutex
Limitar concurrenciaBuffered channel como semáforo

Si estás empezando con Go, domina primero WaitGroup y Mutex. Después pasa a channels en Go y worker pools en Go. No intentes aprender todo a la vez.


Patrones reales de backend: llamadas concurrentes y queries paralelas

Vamos a lo que importa: patrones que vas a usar en servicios reales.

Llamadas HTTP concurrentes

Tu servicio necesita llamar a tres APIs externas para componer una respuesta. Hacerlo secuencialmente es desperdiciar tiempo.

type UserProfile struct {
    User        User
    Permissions []Permission
    Preferences Preferences
}

func GetUserProfile(ctx context.Context, userID string) (*UserProfile, error) {
    var (
        wg          sync.WaitGroup
        user        User
        permissions []Permission
        preferences Preferences
        userErr     error
        permErr     error
        prefErr     error
    )

    wg.Add(3)

    go func() {
        defer wg.Done()
        user, userErr = fetchUser(ctx, userID)
    }()

    go func() {
        defer wg.Done()
        permissions, permErr = fetchPermissions(ctx, userID)
    }()

    go func() {
        defer wg.Done()
        preferences, prefErr = fetchPreferences(ctx, userID)
    }()

    wg.Wait()

    if userErr != nil {
        return nil, fmt.Errorf("fetching user: %w", userErr)
    }
    if permErr != nil {
        return nil, fmt.Errorf("fetching permissions: %w", permErr)
    }
    if prefErr != nil {
        return nil, fmt.Errorf("fetching preferences: %w", prefErr)
    }

    return &UserProfile{
        User:        user,
        Permissions: permissions,
        Preferences: preferences,
    }, nil
}

Este patrón es el pan de cada día del backend con Go. Tres llamadas que antes tardaban 300ms ahora tardan 100ms. Las variables de error son separadas porque cada goroutine escribe en la suya. No hay race condition.

Para un patrón más robusto con cancelación, puedes usar errgroup del paquete golang.org/x/sync:

func GetUserProfile(ctx context.Context, userID string) (*UserProfile, error) {
    g, ctx := errgroup.WithContext(ctx)

    var user User
    var permissions []Permission
    var preferences Preferences

    g.Go(func() error {
        var err error
        user, err = fetchUser(ctx, userID)
        return err
    })

    g.Go(func() error {
        var err error
        permissions, err = fetchPermissions(ctx, userID)
        return err
    })

    g.Go(func() error {
        var err error
        preferences, err = fetchPreferences(ctx, userID)
        return err
    })

    if err := g.Wait(); err != nil {
        return nil, err
    }

    return &UserProfile{
        User:        user,
        Permissions: permissions,
        Preferences: preferences,
    }, nil
}

errgroup es mejor que WaitGroup para este caso porque cancela el contexto si cualquiera de las goroutines falla. Si la llamada a permisos falla, las otras goroutines reciben la señal de cancelación a través del contexto y pueden detenerse en lugar de seguir trabajando inútilmente.

Queries concurrentes a base de datos

Mismo patrón, pero para consultas a PostgreSQL:

func GetDashboardData(ctx context.Context, db *sql.DB, userID int64) (*Dashboard, error) {
    g, ctx := errgroup.WithContext(ctx)

    var orders []Order
    var stats Stats
    var notifications []Notification

    g.Go(func() error {
        var err error
        orders, err = getRecentOrders(ctx, db, userID)
        return err
    })

    g.Go(func() error {
        var err error
        stats, err = getUserStats(ctx, db, userID)
        return err
    })

    g.Go(func() error {
        var err error
        notifications, err = getUnreadNotifications(ctx, db, userID)
        return err
    })

    if err := g.Wait(); err != nil {
        return nil, fmt.Errorf("loading dashboard: %w", err)
    }

    return &Dashboard{
        Orders:        orders,
        Stats:         stats,
        Notifications: notifications,
    }, nil
}

Tres queries que antes se ejecutaban secuencialmente, ahora se ejecutan concurrentemente. Si tu connection pool de PostgreSQL tiene suficientes conexiones, esto reduce la latencia drásticamente.

Procesar un batch con concurrencia limitada

A veces necesitas procesar miles de items pero no puedes lanzar miles de goroutines a la vez (por ejemplo, porque la base de datos o la API externa tiene un límite de conexiones). Un buffered channel actúa como semáforo:

func processBatch(ctx context.Context, items []Item) error {
    const maxConcurrency = 10
    sem := make(chan struct{}, maxConcurrency)

    g, ctx := errgroup.WithContext(ctx)

    for _, item := range items {
        item := item // captura la variable del loop
        g.Go(func() error {
            sem <- struct{}{}        // adquiere slot
            defer func() { <-sem }() // libera slot

            return processItem(ctx, item)
        })
    }

    return g.Wait()
}

El channel sem tiene un buffer de 10. Cuando 10 goroutines han escrito en él, la siguiente se bloquea hasta que una de las anteriores lea del channel (liberando un slot). Es un patrón de semáforo simple y efectivo. Para patrones más avanzados de este tipo, puedes leer worker pools en Go.


Errores comunes: goroutine leaks, race conditions y olvidos

Después de trabajar con Go en producción, puedo decir que el 80% de los bugs de concurrencia caen en unas pocas categorías. Las enumero aquí porque conocerlas de antemano te puede ahorrar semanas de depuración.

Goroutine leaks

Una goroutine leak ocurre cuando lanzas una goroutine que nunca termina. Se queda ahí, consumiendo memoria, esperando algo que nunca va a llegar.

// LEAK: si nadie lee del channel, la goroutine se bloquea para siempre
func leakyFunction() {
    ch := make(chan int)
    go func() {
        result := expensiveComputation()
        ch <- result // se bloquea si nadie lee
    }()

    // la función retorna sin leer de ch
    // la goroutine queda bloqueada para siempre
}

El fix depende del caso. A veces necesitas un buffered channel. A veces necesitas un select con un ctx.Done(). A veces necesitas asegurarte de que alguien lee del channel.

// FIX: buffered channel de tamaño 1
func fixedFunction() {
    ch := make(chan int, 1) // buffer de 1: la goroutine puede escribir y terminar
    go func() {
        result := expensiveComputation()
        ch <- result
    }()
    // incluso si nadie lee, la goroutine no se bloquea
}

Otra causa común de leaks: goroutines que escuchan un channel que nadie cierra.

// LEAK: si nadie cierra ch, la goroutine nunca termina
go func() {
    for item := range ch {
        process(item)
    }
}()

Regla: si lanzas una goroutine, ten claro cuál es su condición de terminación. Si no puedes explicar cuándo y por qué va a terminar, tienes un leak potencial.

Captura de variables del loop

Este es un clásico que ha mordido a todo desarrollador de Go alguna vez. Desde Go 1.22, las variables del loop se capturan correctamente en la mayoría de los casos, pero es importante entender el problema porque todavía hay código legacy que lo tiene.

// PROBLEMA (Go < 1.22): todas las goroutines imprimen el mismo valor
for _, url := range urls {
    go func() {
        fetch(url) // url es la variable del loop, no una copia
    }()
}

// SOLUCIÓN: pasar como argumento
for _, url := range urls {
    go func(u string) {
        fetch(u)
    }(url)
}

Antes de Go 1.22, la variable url del loop era la misma en cada iteración. Las goroutines capturaban una referencia a esa variable, y para cuando se ejecutaban, url ya tenía el último valor. Desde Go 1.22, cada iteración crea una nueva variable, pero pasar como argumento sigue siendo el patrón más explícito y seguro.

Acceso no sincronizado a maps

Los maps en Go no son thread-safe. Y esto puede sorprender: dos goroutines escribiendo en el mismo map simultáneamente provocan un panic en runtime, no un resultado incorrecto silencioso. Un crash directo.

// PANIC: concurrent map writes
m := make(map[string]int)
for i := 0; i < 100; i++ {
    go func(n int) {
        m[fmt.Sprintf("key-%d", n)] = n // panic
    }(i)
}

Soluciones:

  1. sync.Mutex para proteger el acceso al map.
  2. sync.Map si tienes un caso de uso con muchas lecturas y pocas escrituras y las keys son relativamente estables.
  3. Rediseñar para que cada goroutine tenga su propio map y los combines al final.
// Opción 1: Mutex
type SafeMap struct {
    mu sync.Mutex
    m  map[string]int
}

func (sm *SafeMap) Set(key string, val int) {
    sm.mu.Lock()
    sm.m[key] = val
    sm.mu.Unlock()
}

Olvidar pasar el contexto

En servicios backend, el contexto (context.Context) es tu mecanismo de cancelación. Si lanzas una goroutine que hace una llamada HTTP o una query a la base de datos y no le pasas el contexto, esa goroutine no se enterará de que la request original fue cancelada.

// MAL: la goroutine sigue trabajando aunque el cliente haya cancelado
go func() {
    result, err := http.Get(url) // sin contexto
    // ...
}()

// BIEN: usa el contexto del request
go func() {
    req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
    result, err := http.DefaultClient.Do(req)
    // ...
}()

Más sobre esto en context en Go.


El race detector: go test -race

Go tiene una herramienta integrada que detecta race conditions en runtime. Es una de las mejores cosas del tooling de Go y deberías usarla siempre.

go test -race ./...
go run -race main.go
go build -race -o myapp

El race detector instrumenta tu código para detectar accesos concurrentes no sincronizados a la misma variable. Cuando detecta una race condition, imprime un informe detallado con los stacks de las goroutines involucradas:

WARNING: DATA RACE
Read at 0x00c0000a4000 by goroutine 7:
  main.main.func1()
      /home/roger/app/main.go:15 +0x3c

Previous write at 0x00c0000a4000 by goroutine 6:
  main.main.func1()
      /home/roger/app/main.go:15 +0x52

Goroutine 7 (running) created at:
  main.main()
      /home/roger/app/main.go:13 +0x84

Goroutine 6 (finished) created at:
  main.main()
      /home/roger/app/main.go:13 +0x84

Te dice exactamente qué variable, qué goroutines, y en qué línea de código. Es, creo, una de las mejores herramientas de todo el ecosistema de Go.

Cuándo y cómo usarlo

En tests, siempre. Tu CI debería ejecutar go test -race ./... en cada commit. No hay excusa para no hacerlo. La penalización de rendimiento existe (el código corre 2-10x más lento y usa más memoria), pero en tests eso no importa.

En desarrollo, frecuentemente. Compila con -race cuando estés trabajando en código concurrente. El race detector solo detecta races que realmente ocurren durante la ejecución, no las posibles, así que necesitas que el código conflictivo se ejecute.

En producción, no. La penalización de rendimiento y memoria es demasiado alta para producción. Pero si tienes un entorno de staging, considera correr con -race ahí.

Un detalle importante: el race detector encuentra races que ocurren durante la ejecución. Si una race condition solo se manifiesta bajo carga alta y tus tests no generan esa carga, el detector no la encontrará. Por eso es importante tener tests que ejerciten los paths concurrentes de tu código.

func TestConcurrentAccess(t *testing.T) {
    counter := &SafeCounter{}
    var wg sync.WaitGroup

    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := 0; j < 100; j++ {
                counter.Increment()
                _ = counter.Value()
            }
        }()
    }

    wg.Wait()

    if counter.Value() != 10000 {
        t.Errorf("expected 10000, got %d", counter.Value())
    }
}

Este test no solo verifica el resultado: al correr con -race, también verifica que no hay accesos no sincronizados.


Goroutines y servidores HTTP: una goroutine por request

Si usas net/http (o frameworks como Gin, Chi, Echo), cada request HTTP se maneja en su propia goroutine. No tienes que hacer nada para que esto ocurra. El servidor estándar de Go lanza una goroutine por cada conexión entrante.

func main() {
    http.HandleFunc("/api/users", handleUsers)
    http.ListenAndServe(":8080", nil)
}

func handleUsers(w http.ResponseWriter, r *http.Request) {
    // esta función ya está corriendo en su propia goroutine
    // no necesitas lanzar una goroutine adicional para manejar la request
}

Esto tiene implicaciones prácticas:

  1. Tu handler ya es concurrente. Mil requests simultáneas significan mil goroutines ejecutando tu handler. Si tu handler accede a estado global mutable (una variable de paquete, un map compartido), necesitas sincronización.

  2. El contexto del request se cancela cuando el cliente se desconecta. r.Context() te da un contexto que se cancela si el cliente cierra la conexión. Pásalo a todas tus operaciones downstream (queries, llamadas HTTP, etc.).

  3. Puedes lanzar goroutines dentro del handler, pero ten cuidado. Si lanzas una goroutine que sobrevive al handler, necesitas asegurarte de que no use el http.ResponseWriter ni el *http.Request después de que el handler retorne, porque serán reciclados.

func handleUsers(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    // BIEN: goroutines que terminan antes de que el handler retorne
    g, ctx := errgroup.WithContext(ctx)
    var users []User
    var count int

    g.Go(func() error {
        var err error
        users, err = getUsers(ctx)
        return err
    })

    g.Go(func() error {
        var err error
        count, err = getUserCount(ctx)
        return err
    })

    if err := g.Wait(); err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(map[string]any{
        "users": users,
        "count": count,
    })
}

Un error que veo a menudo en código de principiantes:

func handleUsers(w http.ResponseWriter, r *http.Request) {
    // MAL: goroutine que escribe en w después de que el handler retorna
    go func() {
        users, _ := getUsers(r.Context())
        json.NewEncoder(w).Encode(users) // w puede haber sido reciclado
    }()
    // handler retorna inmediatamente, el ResponseWriter ya no es válido
}

Si necesitas hacer trabajo en background que sobreviva al request (enviar un email, actualizar un cache), no uses el ResponseWriter ni el Request. Copia los datos que necesites y usa un contexto independiente.

func handleOrder(w http.ResponseWriter, r *http.Request) {
    order := processOrder(r)

    // Responde al cliente inmediatamente
    json.NewEncoder(w).Encode(order)

    // Trabajo en background: usa context.Background(), no r.Context()
    go func(orderID string) {
        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancel()
        sendConfirmationEmail(ctx, orderID)
    }(order.ID)
}

Performance: cuántas goroutines son demasiadas

La respuesta corta, y probablemente no la que esperas: más de las que crees que puedes tener.

He visto benchmarks con millones de goroutines corriendo simultáneamente. Cada goroutine empieza con un stack de unos 8 KB, así que un millón de goroutines consume unos 8 GB de memoria solo en stacks. En la práctica, para un servicio backend típico, tener decenas de miles de goroutines activas es completamente normal y no debería preocuparte.

Lo que sí debería preocuparte no es el número de goroutines sino lo que hacen:

  • Goroutines esperando I/O: son baratas. Una goroutine bloqueada en una lectura de red casi no consume CPU. Puedes tener miles.
  • Goroutines haciendo trabajo de CPU: son caras. Si tienes 8 cores y 10.000 goroutines haciendo cálculos, solo 8 pueden correr simultáneamente. El resto espera. El scheduling overhead empieza a importar.
  • Goroutines que crean más goroutines sin límite: peligrosas. Si cada request lanza N goroutines y recibes M requests, tienes M*N goroutines. Si N o M son grandes, puedes quedarte sin memoria.

Monitorizar goroutines

En producción, monitoriza el número de goroutines activas:

import "runtime"

func metricsHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "goroutines: %d\n", runtime.NumGoroutine())
}

Si ves que el número crece continuamente sin bajar, tienes un goroutine leak. Es una de las primeras métricas que configuro en cualquier servicio Go.

Con pprof, puedes inspeccionar exactamente qué están haciendo tus goroutines:

import _ "net/http/pprof"

func main() {
    go http.ListenAndServe(":6060", nil)
    // tu servidor principal en otro puerto
}

Después puedes acceder a http://localhost:6060/debug/pprof/goroutine?debug=1 para ver un dump de todas las goroutines activas, agrupadas por stack trace. Es invaluable para diagnosticar leaks.

GOMAXPROCS

GOMAXPROCS controla cuántos threads del OS utiliza el runtime de Go para ejecutar goroutines. Por defecto, es el número de CPUs disponibles. Rara vez necesitas cambiarlo, pero es bueno saber que existe.

import "runtime"

func main() {
    fmt.Println("CPUs:", runtime.NumCPU())
    fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0)) // 0 = solo consultar, no cambiar
}

En contenedores Docker, antes de Go 1.19, GOMAXPROCS podía tomar el número de CPUs del host en lugar del contenedor. Si tu contenedor tiene 2 CPUs pero el host tiene 64, Go creaba 64 threads. La librería automaxprocs de Uber era la solución estándar. Desde Go 1.19, el runtime respeta los límites de CPU del contenedor.


Siguientes pasos: channels, context, worker pools

La concurrencia en Go no termina con goroutines y mutexes. De hecho, acabo de cubrir la base. Pero la situación cambia bastante cuando incorporas los mecanismos que hacen que la concurrencia en Go sea realmente poderosa:

Channels en Go: el mecanismo de comunicación entre goroutines. Buffered vs unbuffered, directionalidad, el pattern select, y cómo cerrar channels de forma segura. Si sync.WaitGroup y sync.Mutex son el nivel 1 de la concurrencia en Go, los channels son el nivel 2.

Context en Go: cancelación, timeouts y propagación de valores. En un servicio backend, el context es lo que permite que cuando un cliente se desconecta, todas las operaciones downstream se detengan. Es el pegamento invisible que mantiene la concurrencia bajo control.

Worker pools en Go: cuando necesitas procesar miles de tareas con una concurrencia limitada. Workers que leen de un channel, resultados que se envían por otro channel, y cancelación limpia. Es el patrón más importante para procesamiento en batch.

Lo que recomiendo: practica primero con WaitGroup y Mutex hasta que te salgan sin pensar. Escribe tests con -race para todo. Cuando eso sea natural, pasa a channels. Y cuando domines channels, los worker pools y el context encajan solos.

La concurrencia en Go no es magia. Es un modelo simple con herramientas simples que, combinadas correctamente, te permiten escribir backend que aprovecha al máximo los recursos de tu hardware. La complejidad no desaparece —sería ingenuo pensar eso—, pero por primera vez en mucho tiempo, siento que el lenguaje está de mi lado en vez de en mi contra. Y no porque Go sea mejor que todo lo demás. Sino porque su modelo de concurrencia encaja de forma natural con lo que necesito construir la mayoría de los días.

OshyTech

Ingeniería backend y de datos orientada a sistemas escalables, automatización e IA.

Navegación

Copyright 2026 OshyTech. Todos los derechos reservados