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) |
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() {
go sayHello("Goroutine 1")
go sayHello("Goroutine 2")
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() {
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() {
for i := 0; i < 3; i++ {
go func() {
fmt.Printf("Incorrect: i = %d\n", i)
}()
}
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() {
for i := 0; i < 3; i++ {
go func(val int) {
fmt.Printf("Correct: val = %d\n", val)
}(i)
}
time.Sleep(100 * time.Millisecond)
}
Salida:
Correct: val = 0
Correct: val = 1
Correct: val = 2
✅ Solución 2: Crear Copia Local
func main() {
for i := 0; i < 3; i++ {
i := i
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
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("Goroutine %d: Starting\n", id)
time.Sleep(time.Duration(id) * 100 * time.Millisecond)
fmt.Printf("Goroutine %d: Finished\n", id)
}(i)
}
fmt.Println("Waiting for all goroutines to finish...")
wg.Wait()
fmt.Println("All goroutines finished!")
}
Cómo funciona:
wg.Add(1) - Incrementa el contador interno
defer wg.Done() - Decrementa el contador cuando la goroutine termina
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)
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()
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)
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
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
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();
}
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
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
go doSomething()
time.Sleep(1 * time.Second)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
doSomething()
}()
wg.Wait()
2. Maneja Errores Correctamente
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)
}()
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:
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{}{}
defer func() { <-semaphore }()
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
default:
processTask(t)
}
}(task)
}
Errores Comunes
❌ Error 1: No Esperar que las Goroutines Terminen
func main() {
go doSomething()
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
doSomething()
}()
wg.Wait()
}
❌ Error 2: Capturar Variables de Loop Incorrectamente
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
for i := 0; i < 10; i++ {
go func(val int) {
fmt.Println(val)
}(i)
}
❌ Error 3: Olvidar Defer en WaitGroup.Done()
go func() {
wg.Done()
riskyOperation()
}()
go func() {
defer wg.Done()
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.