Patrones de Concurrencia en Go: Goroutines y Channels
Introducción
La concurrencia es una de las características más distintivas de Go. El lenguaje fue diseñado intencionalmente para que escribir código concurrente sea práctico, sencillo y eficiente, empleando goroutines y channels como ejes centrales. En lugar de adoptar hilos pesados del sistema operativo, Go proporciona un modelo accesible que permite escalar programas a miles de operaciones concurrentes con una mínima sobrecarga.
En este post, te mostraré goroutines, channels y varios patrones de concurrencia empleando una mezcla de ejemplos sencillos y buenas prácticas. En el camino, destacaré errores comunes, compromisos y algunas lecciones prácticas de su uso en el mundo real. Una vez comprendas estos conceptos, estarás preparado para diseñar sistemas concurrentes robustos y eficientes en Go, ya sean servidores web, pipelines de datos o servicios en segundo plano.
Entendiendo las Goroutines
Las goroutines son tareas ligeras orquestadas por el runtime de Go en lugar del sistema operativo. Comienzan con una pila pequeña (alrededor de 2KB) y crecen dinámicamente, lo que hace razonable crear miles en un solo proceso sin agotar la memoria.
Creando goroutines
Iniciar una goroutine solo requiere la palabra clave go
:
package main
import (
"fmt"
"sync"
"time"
)
func sayHelloInsistently(name string, wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < 3; i++ {
fmt.Printf("¡Hola, %s! (%d)\n", name, i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
var wg sync.WaitGroup
wg.Add(2)
go sayHelloInsistently("Marc", &wg)
go sayHelloInsistently("Alex", &wg)
// La goroutine principal también participa
sayHelloInsistently("Main", &wg)
wg.Wait()
}
Nota
Muchos ejemplos usan time.Sleep
para mantener la goroutine principal viva el tiempo suficiente para que otras se completen.
Esto está bien para demos sencillas, pero no se recomienda en código de producción.
Este ejemplo usa un sync.WaitGroup
en su lugar.
Este espera a que todas las goroutines terminen antes de permitir que la función main salga, lo cual es un enfoque más robusto.
En este ejemplo, creamos dos goroutines que se ejecutan concurrentemente con la goroutine principal.
Cada goroutine imprime un saludo múltiples veces, demostrando cómo entrelazan su salida.
El sync.WaitGroup
asegura que la función main espere a que todas las goroutines se completen antes de salir.
Funciones anónimas como goroutines
Las funciones anónimas pueden ser lanzadas como goroutines. Ten cuidado al capturar variables de bucle:
package main
import (
"fmt"
"time"
)
func main() {
message := "Hola desde closure"
go func() {
fmt.Println(message)
}()
for i := 0; i < 3; i++ {
// Seguro: pasa explícitamente la variable del bucle
go func(val int) {
fmt.Printf("Valor: %d\n", val)
}(i)
}
time.Sleep(100 * time.Millisecond)
}
Advertencia
Olvidar pasar las variables de bucle correctamente a menudo lleva a errores sutiles donde cada goroutine imprime el mismo valor final.
Este ejemplo declara una función goroutine anónima que captura la variable message
de su scope circundante.
Adicionalmente, declara múltiples goroutines dentro de un bucle, pasando la variable del bucle i
como argumento para evitar capturar su valor final.
Introducción a los Channels
Los channels son conductos tipados para la comunicación entre goroutines. Permiten que los valores se pasen de forma segura sin bloqueos explícitos.
En Go, el mantra es:
"No comuniques compartiendo memoria; comparte memoria comunicándote."
Creando y usando channels
En el siguiente fragmento de código, creo un channel para enviar y recibir mensajes string entre goroutines:
package main
import (
"fmt"
"time"
)
func main() {
messages := make(chan string)
go func() {
time.Sleep(1 * time.Second)
messages <- "¡Hola desde la goroutine!"
}()
msg := <-messages
fmt.Println("Recibido:", msg)
}
Nota
Nota cómo la goroutine principal se bloquea en <-messages
hasta que se envía un valor al channel.
La función main
demuestra el uso básico de channels:
- Comenzamos creando un channel con
make(chan string)
. - Una goroutine envía un mensaje al channel después de una breve demora.
- La goroutine principal espera a recibir el mensaje, demostrando la sincronización entre las dos.
Channels con buffer
Los channels con buffer te permiten encolar valores sin bloquear, hasta su capacidad:
package main
import (
"fmt"
)
func main() {
buffer := make(chan string, 2)
buffer <- "primero"
buffer <- "segundo"
fmt.Println(<-buffer)
fmt.Println(<-buffer)
}
Aquí, creamos un channel con buffer con una capacidad de 2. Podemos enviar dos mensajes al channel sin bloquear. Cuando leemos del channel, recupera los mensajes en el orden en que fueron enviados.
El siguiente fragmento muestra cómo se ve la salida al ejecutar el código anterior:
$ go run buffered_channels.go
primero
segundo
Direcciones de Channels
Los channels pueden ser restringidos a solo envío o solo recepción. Restringir channels a solo envío o solo recepción hace que las APIs sean más claras y mejora la seguridad de tipos.
El siguiente ejemplo demuestra ambos tipos:
package main
import (
"fmt"
)
func sendData(ch chan<- string) {
ch <- "datos"
}
func receiveData(ch <-chan string) string {
return <-ch
}
func main() {
ch := make(chan string)
go sendData(ch)
data := receiveData(ch)
fmt.Println(data)
}
En este ejemplo, sendData
solo puede enviar al channel, mientras que receiveData
solo puede recibir de él.
Esta restricción ayuda a prevenir el uso accidental incorrecto de channels.
Operaciones y Patrones de Channels
Ahora que entendemos los fundamentos de los channels, exploremos algunas operaciones y patrones comunes.
Cerrando channels
Cerrar un channel señala que no se enviarán más valores:
package main
import (
"fmt"
)
func producer(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
}
func consumer(ch <-chan int) {
for value := range ch {
fmt.Printf("Recibido: %d\n", value)
}
}
func main() {
ch := make(chan int)
go producer(ch)
consumer(ch)
}
En este ejemplo, la función producer
envía integers al channel y luego lo cierra.
La función consumer
lee del channel hasta que se cierra, usando un bucle for range
.
Al cerrar el channel, evitamos deadlocks y señalamos al consumidor que no llegarán más datos.
Declaración select
La declaración select
te permite reaccionar a cualquier operación de channel que esté lista primero:
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "desde ch1"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "desde ch2"
}()
for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println("Recibido", msg1)
case msg2 := <-ch2:
fmt.Println("Recibido", msg2)
case <-time.After(3 * time.Second):
fmt.Println("Timeout")
}
}
}
En este ejemplo, tenemos dos channels, ch1
y ch2
, cada uno recibiendo mensajes después de diferentes demoras.
La declaración select
espera a que cualquier channel reciba un mensaje o a que ocurra un timeout.
La salida mostrará qué mensaje se recibió primero, demostrando cómo select
puede usarse para manejar múltiples operaciones de channel concurrentemente.
La salida se verá así al ejecutar el código anterior:
$ go run select_statement.go
Recibido desde ch1
Recibido desde ch2
Primitivos de Sincronización
Aunque los channels cubren muchos casos, a veces necesitas herramientas de nivel más bajo como Mutex
o WaitGroup
del paquete sync
.
Mutex
Los mutex protegen datos compartidos de race conditions. Los bloqueos mutex aseguran que solo una goroutine puede acceder a una sección crítica de código a la vez.
Veamos un ejemplo:
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
func main() {
counter := &Counter{}
var wg sync.WaitGroup
// Inicia 100 goroutines que incrementan el contador
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
counter.Increment()
}
}()
}
wg.Wait()
fmt.Printf("Valor final del contador: %d\n", counter.Value())
}
En este ejemplo, definimos una estructura Counter
con un sync.Mutex
para proteger el acceso a su value
.
Múltiples goroutines incrementan el contador concurrentemente, pero el mutex asegura que solo una goroutine pueda modificar el value
cada vez.
La salida final mostrará el valor correcto del contador, demostrando acceso concurrente seguro.
WaitGroup
Los WaitGroups ayudan a coordinar goroutines esperando a que terminen.
Ya hemos visto sync.WaitGroup
en acción en el ejemplo de goroutines anterior, pero aquí hay un ejemplo más específico:
package main
import (
"fmt"
"sync"
"time"
)
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 iniciando\n", id)
time.Sleep(time.Duration(id) * 100 * time.Millisecond)
fmt.Printf("goroutine %d terminada\n", id)
}(i)
}
wg.Wait()
fmt.Println("Todas las goroutines completadas")
}
En este ejemplo, iniciamos cinco goroutines, cada una simulando trabajo durmiendo durante una duración basada en su ID.
El WaitGroup
asegura que la función main espere a que todas las (5) goroutines terminen antes de imprimir el mensaje final.
Aunque no podemos predecir el orden exacto de los mensajes goroutine %d terminada
debido a los tiempos de sleep variables, podemos estar seguros de que Todas las goroutines completadas
solo se imprimirá después de que todas las goroutines hayan terminado.
Manejo de Errores en Código Concurrente
Los sistemas concurrentes a menudo necesitan manejo de errores estructurado. Un patrón común es devolver tanto resultados como errores a través de un channel.
Veamos un ejemplo:
package main
import (
"fmt"
)
type Result struct {
Value int
Error error
}
func worker(id int, jobs <-chan int, results chan<- Result) {
for job := range jobs {
// Simula una operación que podría fallar
if job%2 == 0 {
results <- Result{Value: job * job, Error: nil}
} else {
results <- Result{Value: 0, Error: fmt.Errorf("worker %d: número impar %d", id, job)}
}
}
}
func main() {
jobs := make(chan int, 10)
results := make(chan Result, 10)
// Inicia workers
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// Envía trabajos
for j := 1; j <= 10; j++ {
jobs <- j
}
close(jobs)
// Recolecta resultados y maneja errores
for r := 1; r <= 10; r++ {
result := <-results
if result.Error != nil {
fmt.Printf("Error: %v\n", result.Error)
} else {
fmt.Printf("Éxito: %d\n", result.Value)
}
}
}
En este ejemplo, definimos una estructura Result
para encapsular tanto el valor como cualquier error que ocurra durante el procesamiento.
La función worker
procesa trabajos y envía resultados de vuelta a través del channel results
.
La función main inicia múltiples workers, envía trabajos y recolecta resultados, manejando errores apropiadamente.
Este patrón permite una separación clara de resultados exitosos y errores, haciendo más fácil manejar flujos de trabajo concurrentes complejos.
Context para Cancelación
El paquete context
es la forma idiomática de propagar cancelación y timeouts.
Te permite señalar a las goroutines que detengan el trabajo cuando el contexto se cancela.
El siguiente fragmento de código demuestra el uso de context
para cancelación con timeouts:
package main
import (
"context"
"fmt"
"time"
)
func longRunningTask(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("Tarea %d cancelada: %v\n", id, ctx.Err())
return
default:
fmt.Printf("Tarea %d trabajando...\n", id)
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
for i := 1; i <= 3; i++ {
go longRunningTask(ctx, i)
}
time.Sleep(3 * time.Second)
fmt.Println("Función main terminando")
}
En este ejemplo, creamos un contexto con un timeout de 2 segundos.
Cada goroutine longRunningTask
verifica el contexto en su bucle y sale elegantemente cuando el contexto es cancelado.
La función main duerme durante 3 segundos para permitir que las tareas se ejecuten antes de que termine.
Cuando ejecutes este código, verás que cada tarea imprime su mensaje de trabajo hasta que el contexto expira, momento en el cual todas imprimen un mensaje de cancelación y salen.
Mejores Prácticas y Errores Comunes
Mejores prácticas
- Usa channels para comunicación: Prefiere usar channels sobre memoria compartida cuando sea posible.
- Cierra channels deliberadamente: Siempre cierra channels cuando no se enviarán más datos.
- Elige channels con buffer cuidadosamente: Entiende su semántica de bloqueo.
- Gestiona ciclos de vida de goroutines: Usa
context
o señales para detenerlas elegantemente. - Verifica leaks: Monitorea conteos de goroutines en producción y usa herramientas como
go test -race
ogo-leak
.
Errores comunes
- Captura de variable de bucle: olvidar pasar valores explícitamente lleva a valores finales repetidos.
- Deadlocks por channels no cerrados: siempre cierra channels productores cuando terminen.
- Race conditions con datos compartidos: protege el estado con
sync.Mutex
o delega propiedad a una goroutine.
Conclusión
En este post, he cubierto los fundamentos del modelo de concurrencia de Go, incluyendo goroutines, channels y primitivas de sincronización. El modelo de concurrencia de Go proporciona bloques de construcción elegantes para programas escalables.
Al dominar estos patrones podrás construir sistemas listos para producción que pueden manejar miles de operaciones en paralelo sin ningún problema.