Goroutines en Go: Concurrencia Ligera y Poderosa
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
| Aspecto | Threads (Java) | Goroutines (Go) |
| Tamaño inicial | ~1-2 MB de stack | ~2 KB de stack |
| Escalabilidad | Miles de threads | Millones de goroutines |
| Gestión | Sistema operativo | Runtime de Go |
| Overhead | Alto | Mínimo |
| Sincronización | Compleja (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:
wg.Add(1)- Incrementa el contador internodefer wg.Done()- Decrementa el contador cuando la goroutine terminawg.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 panicLlama
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.




