Cuando utilizas la palabra clave go, le estás indicando al runtime de Go que ejecute esa función en una nueva goroutine, una unidad de ejecución ligera y extremadamente eficiente gestionada por el scheduler de Go, no por el sistema operativo directamente. Esta operación es no bloqueante: la función que lanza la goroutine continúa su ejecución inmediatamente, sin esperar a que la nueva tarea termine. Esto es ideal cuando necesitas realizar tareas en paralelo, como procesar múltiples archivos o realizar varias peticiones HTTP simultáneamente, para no detener el flujo principal.
Sin embargo, esto introduce un riesgo crítico de diseño. Como las goroutines comparten el mismo espacio de memoria (el heap), todas las partes de tu programa pueden ver y modificar las mismas variables, lo que requiere una gestión cuidadosa para evitar condiciones de carrera. El problema más común ocurre cuando lanzas goroutines y no implementas un mecanismo de espera. Si la función main llega a su fin, el runtime de Go asume que el trabajo ha terminado y mata todas las goroutines activas de forma abrupta, sin importar si estaban a mitad de una operación crítica. Si no sincronizas la terminación, tu programa puede cerrarse antes de que cualquier resultado sea procesado o guardado.
package main
import (
"fmt"
"sync"
"time"
)
// worker simula una tarea pesada que tarda un tiempo variable.
func worker(id int, wg *sync.WaitGroup, results chan<- string) {
// Nos aseguramos de notificar que la goroutine terminó,
// incluso si ocurre un error o un pánico en la función.
defer wg.Done()
// Simulamos trabajo con un delay.
sleepDuration := time.Duration(id) * 500 * time.Millisecond
time.Sleep(sleepDuration)
// Enviamos el resultado al canal.
results <- fmt.Sprintf("Tarea %d completada tras %v", id, sleepDuration)
}
func main() {
// sync.WaitGroup es un contador que nos permite esperar a un grupo de tareas.
var wg sync.WaitGroup
// Creamos un canal con buffer para recibir los resultados de las goroutines.
results := make(chan string, 3)
for i := 1; i <= 3; i++ {
// Incrementamos el contador antes de lanzar la goroutine.
// Es vital hacerlo fuera de la goroutine para evitar una condición de carrera.
wg.Add(1)
go worker(i, &wg, results)
}
// Lanzamos una goroutine de supervisión que espera a que todas las tareas
// terminen para cerrar el canal. Si no hiciéramos esto, el loop del range
// de abajo se quedaría bloqueado esperando más datos infinitamente.
go func() {
wg.Wait()
close(results)
}()
// Recorremos el canal hasta que se cierre. Esto bloquea el main
// hasta que no haya más elementos por procesar.
for res := range results {
fmt.Println("Resultado:", res)
}
fmt.Println("Todas las tareas han finalizado correctamente.")
}
En el código anterior, hemos utilizado sync.WaitGroup para resolver el problema de la terminación prematura. Fíjate en wg.Add(1): este comando incrementa el contador interno de la estructura. Es fundamental llamarlo en el hilo principal antes de go worker(...) para garantizar que el contador sea mayor a cero antes de que cualquier otra parte del código intente ejecutar wg.Wait().
Dentro de la función worker, usamos defer wg.Done(). Esto decrementa el contador de forma segura cuando la función termina su ejecución. Si olvidáramos esto, wg.Wait() en el main se quedaría esperando eternamente, provocando un deadlock.
Para manejar los resultados sin colisiones de memoria, hemos pasado un canal chan string a la función. En lugar de intentar escribir todos los resultados en una variable común (lo que causaría un error de race condition si no usáramos un Mutex), cada goroutine envía su mensaje al canal. Finalmente, la goroutine anónima que ejecuta wg.Wait() actúa como un orquestador: espera a que el contador de WaitGroup llegue a cero y, solo entonces, cierra el canal results, permitiendo que el for range en main termine su ejecución de forma limpia.
El error frecuente
Un error clásico es el patrón “fire and forget” (disparar y olvidar) sin control de ciclo de vida. Fíjate en este código que parece correcto pero es un error grave en producción:
func main() {
for i := 0; i < 5; i++ {
go fmt.Println("Procesando tarea", i)
}
// El programa llega aquí y termina inmediatamente.
// Lo más probable es que no veas NADA en la consola.
}
En este caso, el main lanza cinco goroutines y finaliza casi instantáneamente. El runtime de Go no espera a que las goroutines terminen; las mata en seco. En un entorno real, esto significa que tus procesos de escritura en base de datos, envío de logs o limpieza de archivos se cortarán a la mitad, dejando el sistema en un estado inconsistente.
N° 129