Concurrencia en Go: Goroutines y Canales - Guía Completa

Domina la concurrencia en Go con goroutines y canales. Patrones avanzados, sincronización, sentencias select y mejores prácticas con ejemplos de código detallados.

Concurrencia en Go - Goroutines y canales en acción

La concurrencia es una de las mayores fortalezas de Go. A diferencia de otros lenguajes donde el multithreading sigue siendo complejo, Go ofrece un modelo elegante basado en goroutines y canales que simplifica considerablemente el desarrollo de aplicaciones concurrentes.

Filosofía de Go

"No te comuniques compartiendo memoria; comparte memoria comunicándote." Este principio fundamental guía todo el diseño de concurrencia en Go.

Comprender las Goroutines

Las goroutines son hilos ligeros gestionados por el runtime de Go. Consumen aproximadamente 2 KB de pila (frente a varios MB para los hilos del sistema operativo) y permiten ejecutar miles de tareas concurrentes sin sobrecargar el sistema.

Lanzar una goroutine requiere simplemente colocar la palabra clave go antes de una llamada a función. El runtime se encarga de la planificación y distribución entre los hilos disponibles.

goroutines_basic.gogo
package main

import (
    "fmt"
    "time"
)

// fetchData simulates a network request
func fetchData(id int) {
    // Simulates network delay
    time.Sleep(100 * time.Millisecond)
    fmt.Printf("Data %d fetched\n", id)
}

func main() {
    // Sequential execution - 500ms total
    start := time.Now()
    for i := 1; i <= 5; i++ {
        fetchData(i)
    }
    fmt.Printf("Sequential: %v\n", time.Since(start))

    // Concurrent execution - ~100ms total
    start = time.Now()
    for i := 1; i <= 5; i++ {
        go fetchData(i) // Execute as goroutine
    }
    time.Sleep(150 * time.Millisecond) // Wait for completion
    fmt.Printf("Concurrent: %v\n", time.Since(start))
}

La ejecución concurrente reduce el tiempo total de 500ms a aproximadamente 100ms. Sin embargo, usar time.Sleep para sincronizar goroutines no es una buena práctica. Los canales ofrecen una solución elegante.

Canales: Comunicación Entre Goroutines

Un canal es un conducto tipado para enviar y recibir valores entre goroutines. Los canales garantizan la sincronización: una goroutine que envía espera hasta que otra reciba, y viceversa.

La creación de un canal utiliza la función make. El operador <- envía y recibe datos según su posición respecto al canal.

channels_basic.gogo
package main

import "fmt"

// worker performs computation and returns result via channel
func worker(id int, jobs <-chan int, results chan<- int) {
    // Receives jobs until channel closes
    for job := range jobs {
        result := job * 2 // Processing
        results <- result // Send result
    }
}

func main() {
    // Create channels
    jobs := make(chan int, 10)    // Buffered channel
    results := make(chan int, 10)

    // Start 3 workers
    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    // Send 5 jobs
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs) // Signal end of jobs

    // Collect results
    for r := 1; r <= 5; r++ {
        result := <-results
        fmt.Printf("Result: %d\n", result)
    }
}

Los canales direccionales (<-chan para recepción, chan<- para envío) refuerzan la seguridad del código limitando las operaciones posibles.

Canales con Buffer vs Sin Buffer

La distinción entre estos tipos de canales afecta directamente al comportamiento de sincronización entre goroutines.

Los canales sin buffer bloquean al emisor hasta que un receptor esté listo. Los canales con buffer permiten enviar hasta N valores sin bloquear, donde N representa la capacidad del buffer.

buffered_channels.gogo
package main

import "fmt"

func main() {
    // Unbuffered channel - strict synchronization
    unbuffered := make(chan string)

    go func() {
        unbuffered <- "message" // Blocks until received
    }()

    msg := <-unbuffered // Unblocks the send
    fmt.Println(msg)

    // Buffered channel - capacity of 2
    buffered := make(chan int, 2)

    // These sends don't block
    buffered <- 1
    buffered <- 2
    // buffered <- 3 // Would block because buffer is full

    fmt.Println(<-buffered) // 1
    fmt.Println(<-buffered) // 2

    // Check capacity
    fmt.Printf("Length: %d, Capacity: %d\n",
        len(buffered), cap(buffered))
}

Los canales con buffer desacoplan productores y consumidores, mientras que los sin buffer garantizan una sincronización punto a punto.

Cuidado con los Deadlocks

Un deadlock ocurre cuando todas las goroutines están bloqueadas esperando. El runtime de Go lo detecta y termina el programa con un mensaje de error explícito.

Select: Multiplexación de Canales

La sentencia select espera operaciones simultáneas en varios canales. Se asemeja a un switch para comunicaciones concurrentes.

Esta construcción es esencial para gestionar timeouts, cancelaciones y múltiples comunicaciones sin bloquearse indefinidamente en un solo canal.

select_example.gogo
package main

import (
    "fmt"
    "time"
)

// fetchAPI simulates an API call with variable delay
func fetchAPI(name string, delay time.Duration, ch chan<- string) {
    time.Sleep(delay)
    ch <- fmt.Sprintf("%s: data received", name)
}

func main() {
    api1 := make(chan string)
    api2 := make(chan string)

    // Launch two API calls in parallel
    go fetchAPI("API-1", 100*time.Millisecond, api1)
    go fetchAPI("API-2", 200*time.Millisecond, api2)

    // Global timeout of 150ms
    timeout := time.After(150 * time.Millisecond)

    // Collect results with timeout
    for i := 0; i < 2; i++ {
        select {
        case result := <-api1:
            fmt.Println(result)
        case result := <-api2:
            fmt.Println(result)
        case <-timeout:
            fmt.Println("Timeout - operation cancelled")
            return
        }
    }
}

El select elige el primer canal disponible. Si varios están listos, la elección es pseudoaleatoria para evitar la inanición.

¿Listo para aprobar tus entrevistas de Go?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

Patrón Worker Pool

El patrón worker pool distribuye tareas entre varios workers, limitando la concurrencia y optimizando el uso de recursos. Este patrón resulta indispensable para procesar grandes cantidades de datos.

La implementación se basa en un canal de tareas compartido entre los workers y un canal de resultados para la recolección.

worker_pool.gogo
package main

import (
    "fmt"
    "sync"
    "time"
)

// Task represents a unit of work
type Task struct {
    ID   int
    Data string
}

// Result contains the processing result
type Result struct {
    TaskID int
    Output string
}

// worker processes received tasks
func worker(id int, tasks <-chan Task, results chan<- Result, wg *sync.WaitGroup) {
    defer wg.Done()

    for task := range tasks {
        // Simulate processing
        time.Sleep(50 * time.Millisecond)

        results <- Result{
            TaskID: task.ID,
            Output: fmt.Sprintf("Worker %d processed: %s", id, task.Data),
        }
    }
}

func main() {
    const numWorkers = 3
    const numTasks = 10

    tasks := make(chan Task, numTasks)
    results := make(chan Result, numTasks)

    var wg sync.WaitGroup

    // Start workers
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, tasks, results, &wg)
    }

    // Send tasks
    for i := 1; i <= numTasks; i++ {
        tasks <- Task{ID: i, Data: fmt.Sprintf("task-%d", i)}
    }
    close(tasks)

    // Close results channel after workers finish
    go func() {
        wg.Wait()
        close(results)
    }()

    // Collect results
    for result := range results {
        fmt.Printf("Task %d: %s\n", result.TaskID, result.Output)
    }
}

El sync.WaitGroup coordina la espera de todos los workers antes de cerrar el canal de resultados.

Patrón Fan-Out/Fan-In

Este patrón distribuye el trabajo entre varias goroutines (fan-out) y luego agrega los resultados (fan-in). Maximiza el paralelismo simplificando la recolección de resultados.

fan_out_fan_in.gogo
package main

import (
    "fmt"
    "sync"
)

// generate produces numbers on a channel
func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

// square computes the square of received numbers
func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

// merge combines multiple channels into one (fan-in)
func merge(channels ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup

    // Output function for each channel
    output := func(c <-chan int) {
        defer wg.Done()
        for n := range c {
            out <- n
        }
    }

    // Launch a goroutine per channel
    wg.Add(len(channels))
    for _, c := range channels {
        go output(c)
    }

    // Close after all goroutines finish
    go func() {
        wg.Wait()
        close(out)
    }()

    return out
}

func main() {
    // Generate data
    numbers := generate(1, 2, 3, 4, 5, 6, 7, 8)

    // Fan-out: distribute to 3 workers
    sq1 := square(numbers)
    sq2 := square(numbers)
    sq3 := square(numbers)

    // Fan-in: aggregate results
    for result := range merge(sq1, sq2, sq3) {
        fmt.Println(result)
    }
}

Este patrón destaca para operaciones CPU-bound distribuibles y pipelines de procesamiento de datos.

Context para Cancelación y Deadlines

El paquete context estandariza la gestión de cancelaciones, deadlines y valores entre goroutines. Cualquier goroutine de larga duración debería aceptar un context como primer parámetro.

context_example.gogo
package main

import (
    "context"
    "fmt"
    "time"
)

// fetchWithContext simulates a cancellable request
func fetchWithContext(ctx context.Context, url string) (string, error) {
    // Simulates a long operation
    select {
    case <-time.After(2 * time.Second):
        return fmt.Sprintf("Data from %s", url), nil
    case <-ctx.Done():
        return "", ctx.Err() // context.Canceled or context.DeadlineExceeded
    }
}

func main() {
    // Context with 500ms timeout
    ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
    defer cancel() // Release resources

    result, err := fetchWithContext(ctx, "https://api.example.com")
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }
    fmt.Println(result)

    // Context with manual cancellation
    ctx2, cancel2 := context.WithCancel(context.Background())

    go func() {
        time.Sleep(100 * time.Millisecond)
        cancel2() // Explicit cancellation
    }()

    result, err = fetchWithContext(ctx2, "https://api2.example.com")
    if err != nil {
        fmt.Printf("Request cancelled: %v\n", err)
    }
}
Buena Práctica

Llamar siempre a defer cancel() inmediatamente después de crear un context para evitar fugas de recursos.

Sincronización con sync.Mutex

Aunque los canales son preferibles para la comunicación, el paquete sync sigue siendo necesario para proteger el acceso concurrente a estructuras de datos compartidas.

mutex_example.gogo
package main

import (
    "fmt"
    "sync"
)

// SafeCounter is a thread-safe counter
type SafeCounter struct {
    mu    sync.Mutex
    value map[string]int
}

// Increment increments the value for a given key
func (c *SafeCounter) Increment(key string) {
    c.mu.Lock()         // Exclusive lock
    defer c.mu.Unlock() // Guaranteed unlock
    c.value[key]++
}

// Value returns the current value
func (c *SafeCounter) Value(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value[key]
}

func main() {
    counter := SafeCounter{value: make(map[string]int)}

    var wg sync.WaitGroup

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

    wg.Wait()
    fmt.Printf("Total: %d\n", counter.Value("visits")) // 1000
}

El sync.RWMutex optimiza las lecturas concurrentes con RLock()/RUnlock() para operaciones de solo lectura.

Errores Comunes y Soluciones

La concurrencia en Go presenta trampas clásicas. Estos son los errores más frecuentes y cómo evitarlos.

common_mistakes.gogo
package main

import (
    "fmt"
    "sync"
)

func main() {
    // ERROR: Loop variable capture
    // All goroutines would print the same value
    for i := 0; i < 3; i++ {
        go func() {
            fmt.Println(i) // Capture by reference - BUG
        }()
    }

    // SOLUTION: Pass value as parameter
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            fmt.Println(n) // Local copy - CORRECT
        }(i)
    }
    wg.Wait()

    // ERROR: Send on nil channel
    var ch chan int
    // ch <- 1 // Blocks forever

    // SOLUTION: Always initialize with make
    ch = make(chan int, 1)
    ch <- 1
    fmt.Println(<-ch)

    // ERROR: Send on closed channel
    done := make(chan bool)
    close(done)
    // done <- true // Panic!

    // SOLUTION: Check before send or use sync.Once
    select {
    case done <- true:
        fmt.Println("Sent")
    default:
        fmt.Println("Channel closed or full")
    }
}

La detección de race conditions usa el flag -race durante la compilación o las pruebas: go test -race ./....

¡Empieza a practicar!

Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.

Conclusión

Dominar la concurrencia en Go se basa en algunos conceptos clave que, una vez bien comprendidos, permiten construir aplicaciones de alto rendimiento.

Puntos clave:

✅ Las goroutines son ligeras y económicas: crear miles sigue siendo aceptable

✅ Los canales sincronizan y transfieren datos entre goroutines

✅ La sentencia select gestiona múltiples comunicaciones y timeouts

✅ El patrón worker pool limita la concurrencia y optimiza los recursos

✅ El paquete context estandariza la cancelación y los deadlines

✅ Los mutex protegen los datos compartidos cuando los canales no bastan

✅ El flag -race detecta race conditions durante las pruebas

La filosofía "Compartir memoria comunicándose" guía hacia diseños más seguros y mantenibles que el multithreading tradicional con bloqueos.

Etiquetas

#go
#golang
#concurrency
#goroutines
#channels

Compartir

Artículos relacionados