Skip to main content

Command Palette

Search for a command to run...

Goroutines en Go: Concurrencia Ligera y Poderosa

Updated
9 min read

En los posts anteriores exploramos los fundamentos de Go: structs, interfaces, métodos y colecciones. Hoy vamos a adentrarnos en una de las características más distintivas y poderosas de Go: las goroutines. Si vienes de Java, esto cambiará completamente tu forma de pensar sobre la concurrencia.

¿Qué son las Goroutines?

Las goroutines son funciones que se ejecutan concurrentemente en Go. Son el equivalente conceptual a los threads en Java, pero con una diferencia crucial: son extremadamente ligeras.

Comparación: Threads vs Goroutines

AspectoThreads (Java)Goroutines (Go)
Tamaño inicial~1-2 MB de stack~2 KB de stack
EscalabilidadMiles de threadsMillones de goroutines
GestiónSistema operativoRuntime de Go
OverheadAltoMínimo
SincronizaciónCompleja (locks, semáforos)Simple (channels)
// Crear una goroutine es tan simple como agregar "go" antes de la función
go sayHello("Goroutine 1")
go sayHello("Goroutine 2")

Goroutines Básicas

La sintaxis es increíblemente simple. Solo necesitas la palabra clave go antes de una llamada a función:

func sayHello(name string) {
    for i := 0; i < 3; i++ {
        fmt.Printf("Hello from %s (iteration %d)\n", name, i)
        time.Sleep(100 * time.Millisecond)
    }
}

func main() {
    // Ejecutar función como goroutine (no bloquea)
    go sayHello("Goroutine 1")
    go sayHello("Goroutine 2")

    // Esperar un poco para que las goroutines terminen
    // En producción, usarías channels o sync.WaitGroup
    time.Sleep(1 * time.Second)
}

Salida (puede variar):

Hello from Goroutine 1 (iteration 0)
Hello from Goroutine 2 (iteration 0)
Hello from Goroutine 1 (iteration 1)
Hello from Goroutine 2 (iteration 1)
Hello from Goroutine 1 (iteration 2)
Hello from Goroutine 2 (iteration 2)

Las goroutines se ejecutan de forma concurrente, no secuencial. El orden de ejecución no está garantizado.

Goroutines Anónimas

Puedes crear goroutines con funciones anónimas, similar a las lambdas en Java:

func main() {
    // Función anónima como goroutine
    go func(msg string) {
        fmt.Println(msg)
    }("Hello from anonymous goroutine")

    time.Sleep(100 * time.Millisecond)
}

Esto es útil cuando necesitas ejecutar código simple sin definir una función separada.

⚠️ Cuidado: Closures y Variables de Loop

Este es uno de los errores más comunes cuando trabajas con goroutines. Las goroutines capturan variables por referencia, no por valor:

❌ Problema Común

func main() {
    // PROBLEMA: Todas las goroutines ven el mismo valor de i
    for i := 0; i < 3; i++ {
        go func() {
            fmt.Printf("Incorrect: i = %d\n", i) // i puede ser cualquier valor (0, 1, 2, o incluso 3)
        }()
    }
    time.Sleep(100 * time.Millisecond)
}

Salida posible:

Incorrect: i = 3
Incorrect: i = 3
Incorrect: i = 3

Todas las goroutines pueden ver el valor final de i porque comparten la misma variable.

✅ Solución 1: Pasar como Parámetro

func main() {
    // SOLUCIÓN: Pasar el valor como parámetro
    for i := 0; i < 3; i++ {
        go func(val int) {
            fmt.Printf("Correct: val = %d\n", val)
        }(i) // ← Pasar i como argumento
    }
    time.Sleep(100 * time.Millisecond)
}

Salida:

Correct: val = 0
Correct: val = 1
Correct: val = 2

✅ Solución 2: Crear Copia Local

func main() {
    // SOLUCIÓN: Crear una nueva variable en cada iteración
    for i := 0; i < 3; i++ {
        i := i // ← Crear nueva variable en cada iteración
        go func() {
            fmt.Printf("Correct: i = %d\n", i)
        }()
    }
    time.Sleep(100 * time.Millisecond)
}

Ambas soluciones funcionan. La primera es más explícita y generalmente preferida.

Sincronización con sync.WaitGroup

En el ejemplo anterior usamos time.Sleep() para esperar que las goroutines terminen. Esto es un anti-patrón en producción. La forma idiomática de esperar múltiples goroutines es usando sync.WaitGroup.

WaitGroup es similar a CountDownLatch en Java:

import "sync"

func main() {
    var wg sync.WaitGroup

    // Lanzar múltiples goroutines
    for i := 0; i < 5; i++ {
        wg.Add(1) // Incrementar contador
        go func(id int) {
            defer wg.Done() // Decrementar contador cuando termine
            fmt.Printf("Goroutine %d: Starting\n", id)
            time.Sleep(time.Duration(id) * 100 * time.Millisecond)
            fmt.Printf("Goroutine %d: Finished\n", id)
        }(i)
    }

    // Esperar a que todas terminen
    fmt.Println("Waiting for all goroutines to finish...")
    wg.Wait() // Bloquea hasta que el contador llegue a 0
    fmt.Println("All goroutines finished!")
}

Cómo funciona:

  1. wg.Add(1) - Incrementa el contador interno

  2. defer wg.Done() - Decrementa el contador cuando la goroutine termina

  3. wg.Wait() - Bloquea hasta que el contador llegue a 0

Patrón recomendado:

  • Siempre usa defer wg.Done() para asegurar que se llame incluso si hay un panic

  • Llama wg.Add() antes de lanzar la goroutine, no dentro de ella

Ejemplo Práctico: Procesar Tareas en Paralelo

Uno de los casos de uso más comunes de las goroutines es procesar múltiples tareas en paralelo:

type Task struct {
    ID   int
    Data string
}

func processTask(task Task) {
    fmt.Printf("Processing task %d: %s\n", task.ID, task.Data)
    time.Sleep(500 * time.Millisecond) // Simular trabajo
    fmt.Printf("Task %d completed\n", task.ID)
}

func main() {
    tasks := []Task{
        {ID: 1, Data: "Task 1"},
        {ID: 2, Data: "Task 2"},
        {ID: 3, Data: "Task 3"},
        {ID: 4, Data: "Task 4"},
        {ID: 5, Data: "Task 5"},
    }

    var wg sync.WaitGroup
    start := time.Now()

    // Procesar todas las tareas en paralelo
    for _, task := range tasks {
        wg.Add(1)
        go func(t Task) {
            defer wg.Done()
            processTask(t)
        }(task)
    }

    wg.Wait()
    elapsed := time.Since(start)
    fmt.Printf("All tasks completed in %v\n", elapsed)
}

Salida:

Processing task 1: Task 1
Processing task 2: Task 2
Processing task 3: Task 3
Processing task 4: Task 4
Processing task 5: Task 5
Task 1 completed
Task 2 completed
Task 3 completed
Task 4 completed
Task 5 completed
All tasks completed in ~500ms

Si procesáramos las tareas secuencialmente, tomaría ~2.5 segundos (5 tareas × 500ms). Con goroutines, todas se ejecutan en paralelo y toma solo ~500ms.

Ejemplo: Simulación de Servidor Web

Las goroutines son perfectas para manejar múltiples requests concurrentes en un servidor web:

type Request struct {
    ID   int
    Path string
}

func handleRequest(req Request) {
    fmt.Printf("[Request %d] Handling %s\n", req.ID, req.Path)
    time.Sleep(200 * time.Millisecond) // Simular procesamiento
    fmt.Printf("[Request %d] Completed\n", req.ID)
}

func main() {
    requests := []Request{
        {ID: 1, Path: "/api/users"},
        {ID: 2, Path: "/api/products"},
        {ID: 3, Path: "/api/orders"},
        {ID: 4, Path: "/api/payments"},
        {ID: 5, Path: "/api/reports"},
    }

    var wg sync.WaitGroup

    // Simular servidor que maneja requests concurrentemente
    for _, req := range requests {
        wg.Add(1)
        go func(r Request) {
            defer wg.Done()
            handleRequest(r)
        }(req)
    }

    wg.Wait()
    fmt.Println("All requests handled")
}

Este patrón es exactamente lo que hace el servidor HTTP de Go (net/http) internamente. Cada request se maneja en su propia goroutine.

Comparación: Java vs Go

Java: Threads Tradicionales

// Java
public class TaskProcessor {
    public void processTasks(List<Task> tasks) {
        List<Thread> threads = new ArrayList<>();

        for (Task task : tasks) {
            Thread thread = new Thread(() -> {
                processTask(task);
            });
            threads.add(thread);
            thread.start();
        }

        // Esperar a que todos terminen
        for (Thread thread : threads) {
            try {
                thread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

Problemas:

  • Overhead alto por thread

  • Limitado a miles de threads

  • Gestión manual de threads

  • Manejo de excepciones complejo

Go: Goroutines

// Go
func processTasks(tasks []Task) {
    var wg sync.WaitGroup

    for _, task := range tasks {
        wg.Add(1)
        go func(t Task) {
            defer wg.Done()
            processTask(t)
        }(task)
    }

    wg.Wait()
}

Ventajas:

  • Overhead mínimo

  • Puedes tener millones de goroutines

  • Gestión automática por el runtime

  • Sincronización simple con WaitGroup

¿Cuándo Usar Goroutines?

Usa goroutines cuando:

  • Necesitas procesar múltiples tareas en paralelo

  • Tienes operaciones I/O bloqueantes (archivos, red, bases de datos)

  • Quieres mejorar el rendimiento de operaciones independientes

  • Necesitas manejar múltiples conexiones simultáneas

No uses goroutines cuando:

  • Las tareas son muy pequeñas (el overhead puede ser mayor que el beneficio)

  • Las tareas dependen fuertemente entre sí (puede ser más complejo)

  • Ya tienes suficiente concurrencia (más goroutines no siempre es mejor)

Mejores Prácticas

1. Siempre Usa WaitGroup para Sincronización

// ❌ MAL
go doSomething()
time.Sleep(1 * time.Second) // ¿Qué pasa si toma más tiempo?

// ✅ BIEN
var wg sync.WaitGroup
wg.Add(1)
go func() {
    defer wg.Done()
    doSomething()
}()
wg.Wait()

2. Maneja Errores Correctamente

// ✅ BIEN: Usar channels para errores
errChan := make(chan error, len(tasks))
var wg sync.WaitGroup

for _, task := range tasks {
    wg.Add(1)
    go func(t Task) {
        defer wg.Done()
        if err := processTask(t); err != nil {
            errChan <- err
        }
    }(task)
}

go func() {
    wg.Wait()
    close(errChan)
}()

// Procesar errores
for err := range errChan {
    log.Printf("Error: %v", err)
}

3. Limita el Número de Goroutines Concurrentes

Lanzar millones de goroutines puede ser contraproducente. Usa un worker pool o semáforo:

// Limitar a 10 goroutines concurrentes
semaphore := make(chan struct{}, 10)
var wg sync.WaitGroup

for _, task := range tasks {
    wg.Add(1)
    go func(t Task) {
        defer wg.Done()
        semaphore <- struct{}{}        // Adquirir
        defer func() { <-semaphore }() // Liberar

        processTask(t)
    }(task)
}

wg.Wait()

4. Usa Context para Cancelación

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

var wg sync.WaitGroup
for _, task := range tasks {
    wg.Add(1)
    go func(t Task) {
        defer wg.Done()

        select {
        case <-ctx.Done():
            return // Cancelar si el contexto expira
        default:
            processTask(t)
        }
    }(task)
}

Errores Comunes

❌ Error 1: No Esperar que las Goroutines Terminen

// ❌ MAL: El programa puede terminar antes que las goroutines
func main() {
    go doSomething()
    // Programa termina aquí
}

// ✅ BIEN: Esperar con WaitGroup
func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        doSomething()
    }()
    wg.Wait()
}

❌ Error 2: Capturar Variables de Loop Incorrectamente

// ❌ MAL: Todas ven el mismo valor
for i := 0; i < 10; i++ {
    go func() {
        fmt.Println(i) // Puede imprimir 10 diez veces
    }()
}

// ✅ BIEN: Pasar como parámetro
for i := 0; i < 10; i++ {
    go func(val int) {
        fmt.Println(val)
    }(i)
}

❌ Error 3: Olvidar Defer en WaitGroup.Done()

// ❌ MAL: Si hay un panic, Done() nunca se llama
go func() {
    wg.Done() // Puede no ejecutarse si hay panic antes
    riskyOperation()
}()

// ✅ BIEN: Usar defer
go func() {
    defer wg.Done() // Siempre se ejecuta
    riskyOperation()
}()

Conclusiones

Las goroutines son una de las características más poderosas de Go:

Simplicidad: Crear una goroutine es tan simple como agregar go antes de una función

Eficiencia: Extremadamente ligeras, puedes tener millones ejecutándose simultáneamente

Sincronización: sync.WaitGroup hace que esperar múltiples goroutines sea trivial

Idiomático: Las goroutines son la forma natural de hacer concurrencia en Go

Escalabilidad: Perfectas para servidores web, procesamiento de datos, y operaciones I/O

Si vienes de Java, las goroutines pueden parecer mágicas al principio, pero una vez que entiendas cómo funcionan y cómo sincronizarlas correctamente, verás cómo simplifican enormemente la programación concurrente.

Próximos Pasos

En el siguiente post exploraremos los channels, el mecanismo de comunicación entre goroutines. Los channels son la forma idiomática de Go para compartir datos de forma segura entre goroutines y evitar race conditions.


¿Has usado goroutines en tus proyectos de Go? Comparte tus experiencias y casos de uso en los comentarios. Y si quieres ver el código completo de estos ejemplos, puedes encontrarlo en mi repositorio go-mastery-lab.

More from this blog

JoeDayz

53 posts

Community Guy | Java Champion | AWS Architect | Software Architect