En esta página

Goroutines

15 min lectura TextoCap. 4 — Concurrencia

Goroutines: concurrencia liviana integrada en el lenguaje

Las goroutines son la característica que hace que Go sea único para programación de sistemas concurrentes. Una goroutine es una función que se ejecuta de forma concurrente con el resto del programa, gestionada por el runtime de Go (no por el sistema operativo directamente).

La diferencia clave con los hilos del sistema operativo es el costo:

  • Hilo del SO: 1-8 MB de stack, costoso de crear y destruir
  • Goroutine: ~2-4 KB de stack inicial (crece dinámicamente), creación en microsegundos

Un servidor web típico en Go puede manejar cientos de miles de conexiones concurrentes con goroutines, algo imposible con hilos del SO tradicionales.

El scheduler de Go: M:N threading

El runtime de Go implementa un modelo de scheduling M:N: multiplexea M goroutines sobre N hilos del SO. Los parámetros clave son:

  • GOMAXPROCS: número de hilos del SO que pueden ejecutar código Go en paralelo (por defecto = número de CPUs)
  • El scheduler de Go es cooperativo con preempción: las goroutines ceden el control en puntos específicos (llamadas a funciones, operaciones de I/O, canales)
import "runtime"

fmt.Println("CPUs:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))  // 0 = leer el valor actual
fmt.Println("Goroutines activas:", runtime.NumGoroutine())

Lanzar goroutines

La palabra clave go convierte cualquier llamada de función en una goroutine:

// Función normal
func saludar(nombre string) {
    fmt.Printf("¡Hola, %s!\n", nombre)
}

func main() {
    // Ejecutar en goroutine — retorna inmediatamente
    go saludar("Ana")
    go saludar("Luis")

    // Función anónima en goroutine
    go func() {
        fmt.Println("Goroutine anónima")
    }()

    // ¡IMPORTANTE! main() no espera a las goroutines
    // Si main() termina, todas las goroutines mueren también
    time.Sleep(100 * time.Millisecond)  // espera simple (no usar en producción)
}

`sync.WaitGroup`: esperar a múltiples goroutines

WaitGroup es el mecanismo estándar para esperar a que un grupo de goroutines termine:

import "sync"

func procesarItems(items []string) {
    var wg sync.WaitGroup

    for _, item := range items {
        wg.Add(1)  // incrementar antes de lanzar la goroutine

        go func(i string) {
            defer wg.Done()  // decrementar al terminar
            // procesar item...
            fmt.Println("Procesado:", i)
        }(item)  // pasar item como parámetro — ¡crucial!
    }

    wg.Wait()  // bloquear hasta que el contador sea 0
    fmt.Println("Todos los items procesados")
}

Error clásico: captura de variable en closure

// INCORRECTO: todas las goroutines capturan la misma variable i
for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i)  // i puede ser cualquier valor, incluso 5
    }()
}

// CORRECTO: pasar i como parámetro
for i := 0; i < 5; i++ {
    go func(id int) {
        fmt.Println(id)  // cada goroutine tiene su propia copia de id
    }(i)
}

// También correcto en Go 1.22+: el loop crea nueva variable i en cada iteración
for i := range 5 {
    go func() {
        fmt.Println(i)  // Go 1.22: i es nueva variable en cada iteración
    }()
}

Carreras de datos: el peligro de la concurrencia sin sincronización

Una carrera de datos ocurre cuando dos goroutines acceden a la misma memoria concurrentemente y al menos una de ellas escribe, sin ninguna sincronización:

// INCORRECTO: carrera de datos
contador := 0
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        contador++  // lectura-modificación-escritura no es atómica
    }()
}

wg.Wait()
fmt.Println(contador)  // puede ser < 1000 — resultado incorrecto

Detectar con el race detector

go run -race main.go
# ==================
# WARNING: DATA RACE
# Write at 0x00c0000b4010 by goroutine 7:
#   main.main.func1()
# ...

`sync.Mutex`: exclusión mutua

Un Mutex (Mutual Exclusion) garantiza que solo una goroutine pueda ejecutar una sección crítica a la vez:

type Caché struct {
    mu    sync.Mutex
    datos map[string]string
}

func NuevaCaché() *Caché {
    return &Caché{datos: make(map[string]string)}
}

func (c *Caché) Set(clave, valor string) {
    c.mu.Lock()
    defer c.mu.Unlock()  // siempre usar defer para garantizar el Unlock
    c.datos[clave] = valor
}

func (c *Caché) Get(clave string) (string, bool) {
    c.mu.Lock()
    defer c.mu.Unlock()
    val, ok := c.datos[clave]
    return val, ok
}

`sync.RWMutex`: múltiples lectores, escritor único

Cuando las lecturas son frecuentes y las escrituras son raras, RWMutex es más eficiente:

type CachéRW struct {
    mu    sync.RWMutex
    datos map[string]string
}

func (c *CachéRW) Set(clave, valor string) {
    c.mu.Lock()          // bloquea lectores Y escritores
    defer c.mu.Unlock()
    c.datos[clave] = valor
}

func (c *CachéRW) Get(clave string) (string, bool) {
    c.mu.RLock()         // permite lectores concurrentes, bloquea solo escritores
    defer c.mu.RUnlock()
    val, ok := c.datos[clave]
    return val, ok
}

`sync.Once`: inicialización única garantizada

Once garantiza que una función se ejecuta exactamente una vez, incluso si se llama desde múltiples goroutines:

type Servicio struct {
    config *Config
}

var (
    servicio *Servicio
    once     sync.Once
)

func ObtenerServicio() *Servicio {
    once.Do(func() {
        // Esta función se ejecuta exactamente una vez
        servicio = &Servicio{
            config: cargarConfigDesdeArchivo(),
        }
    })
    return servicio
}

`sync.Map`: map concurrente

Para maps con acceso concurrente frecuente, Go ofrece sync.Map:

var sm sync.Map

// Store, Load, LoadOrStore, Delete, Range
sm.Store("clave", "valor")

if val, ok := sm.Load("clave"); ok {
    fmt.Println(val.(string))
}

sm.Range(func(k, v any) bool {
    fmt.Printf("%v: %v\n", k, v)
    return true  // continuar iteración
})

sync.Map está optimizado para dos casos: cuando el map es escrito una vez y leído muchas veces, o cuando múltiples goroutines leen y escriben claves disjuntas.

`sync/atomic`: operaciones atómicas de bajo nivel

Para contadores simples, atomic es más eficiente que un Mutex:

import "sync/atomic"

var contador int64  // debe ser int32, int64, uint32, uint64, o Pointer

// Operaciones atómicas garantizan atomicidad sin Mutex
atomic.AddInt64(&contador, 1)
atomic.AddInt64(&contador, -1)
val := atomic.LoadInt64(&contador)
atomic.StoreInt64(&contador, 0)
ok := atomic.CompareAndSwapInt64(&contador, 0, 1)  // CAS

Goroutines y funciones anónimas: el patrón completo

func procesarConConcurrencia(tareas []Tarea) []Resultado {
    resultados := make([]Resultado, len(tareas))
    var wg sync.WaitGroup
    var mu sync.Mutex

    for i, tarea := range tareas {
        wg.Add(1)
        go func(idx int, t Tarea) {
            defer wg.Done()

            resultado, err := procesarTarea(t)
            if err != nil {
                log.Printf("error en tarea %d: %v", idx, err)
                return
            }

            mu.Lock()
            resultados[idx] = resultado
            mu.Unlock()
        }(i, tarea)
    }

    wg.Wait()
    return resultados
}

Con las goroutines dominadas, en la siguiente lección aprenderemos channels — el mecanismo de comunicación entre goroutines que hace que la concurrencia en Go sea segura y elegante.

Una goroutine cuesta ~2-4 KB, un hilo del SO cuesta ~1-8 MB
Las goroutines son extremadamente ligeras comparadas con los hilos del sistema operativo. Go puede ejecutar cientos de miles o incluso millones de goroutines simultáneamente porque el runtime de Go gestiona su propio scheduler y multiplexea goroutines sobre un número fijo de hilos del SO (GOMAXPROCS).
Detecta carreras de datos con go run -race
El race detector de Go detecta accesos concurrentes no sincronizados a memoria. Úsalo siempre durante desarrollo y tests: go run -race main.go o go test -race ./... Las carreras de datos son bugs silenciosos que solo se manifiestan bajo carga específica y pueden ser extremadamente difíciles de reproducir.