sync.Once es un tipo de dato de la librería estándar diseñado para asegurar que una función se ejecute exactamente una vez, sin importar cuántas veces se llame o cuántas goroutines intenten hacerlo simultáneamente. Funciona mediante un mecanismo interno que combina una operación atómica sobre un flag de estado y un sync.Mutex. Cuando una goroutine llama a Do, primero realiza una comprobación atómica extremadamente rápida; si el flag indica que la función ya se ejecutó, la llamada retorna de inmediato. Si no, entra en un bloqueo mediante un mutex para asegurar que solo una goroutine ejecute la lógica, mientras las demás se quedan esperando (bloqueadas) hasta que dicha ejecución termine.
Este diseño es preferible a implementar manualmente un if !inicializado { mu.Lock(); ... } porque la implementación de Go optimiza el “camino feliz” (cuando la variable ya está inicializada) usando instrucciones atómicas que evitan el overhead de adquirir un mutex cada vez que se consulta el recurso. Debes usarlo cuando necesites lazy initialization (inicialización perezosa), como cargar una configuración pesada desde un disco o establecer una conexión a una base de datos solo en el momento en que sea estrictamente necesaria. Si intentas usarlo mal, por ejemplo, si la función pasada a Do lanza un panic, sync.Once considerará que la ejecución fue exitosa y marcará el estado como “completado”, lo que impedirá cualquier reintento posterior y dejará a tu sistema en un estado inconsistente.
package main
import (
"fmt"
"sync"
"time"
)
// Config representa un recurso pesado que requiere inicialización.
type Config struct {
once sync.Once
data map[string]string
}
// Load carga los datos de la configuración de forma perezosa.
func (c *Config) Load() {
// Do garantiza que el bloque interno se ejecute solo una vez.
// Si varias goroutines llaman a Load() a la vez, todas esperarán
// hasta que esta función termine.
c.once.Do(func() {
fmt.Println("[LOG] Iniciando carga pesada de configuración...")
// Simulamos latencia de red o I/O
time.Sleep(2 * time.Second)
c.data = map[string]string{
"api_key": "secret-token-123",
"env": "production",
}
fmt.Println("[LOG] Configuración cargada con éxito.")
})
}
// Get retorna un valor de la configuración de forma segura.
func (c *Config) Get(key string) (string, bool) {
// Nos aseguramos de que esté cargada antes de acceder.
c.Load()
val, ok := c.data[key]
return val, ok
}
func main() {
config := &Config{}
var wg sync.WaitGroup
// Simulamos 5 goroutines intentando acceder a la configuración
// casi al mismo tiempo.
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("Goroutine %d: Intentando obtener api_key...\n", id)
// El primer llamado disparará la carga; los otros 4 esperarán.
if val, ok := config.Get("api_key"); ok {
fmt.Printf("Goroutine %d: Obtuvo llave: %s\n", id, val)
} else {
fmt.Printf("Goroutine %d: No se encontró la llave\n", id)
}
}(i)
}
wg.Wait()
fmt.Println("Proceso finalizado.")
}
Desglose del ejemplo
En el código anterior, la estructura Config encapsula un sync.Once llamado once. Esto es fundamental para evitar que el estado de inicialización sea accesible globalmente de forma desordenada.
Cuando las 5 goroutines ejecutan config.Get("api_key"), todas llaman internamente a c.Load(). La primera goroutine que logre entrar en c.once.Do tomará el control del mutex interno de Once. Mientras esa primera goroutine está durmiendo en el time.Sleep, las otras 4 goroutines se quedan bloqueadas en la línea del c.once.Do.
Una vez que la primera goroutine termina la función anónima, el estado interno de once se actualiza atómicamente. A partir de ese microsegundo, cualquier llamada futura a c.once.Do (incluso si llega una sexta goroutine un segundo después) saltará directamente el bloque de código mediante una comparación atómica rápida, sin necesidad de bloquear el hilo con un mutex. Esto hace que el acceso a config.data sea extremadamente eficiente una vez que el recurso ya está listo.
El error frecuente
Un error crítico ocurre cuando la función pasada a Do falla catastróficamente. Si el código dentro de Do lanza un panic, el mecanismo de sync.Once no lo detecta como un error de inicialización, sino como una ejecución completada.
var once sync.Once
// Error: La función lanza un panic
once.Do(func() {
panic("fallo crítico de conexión")
})
// Este segundo bloque NUNCA se ejecutará,
// aunque la configuración sea vital para el programa.
once.Do(func() {
fmt.Println("Esto nunca se imprimirá")
})
Si tu inicialización es crítica para la integridad del sistema, asegúrate de que la función dentro de Do maneje sus propios errores internamente o que el panic sea realmente un error terminal que deba detener toda la aplicación mediante un log.Fatalf, de lo contrario, el resto de tu sistema intentará usar un objeto no inicializado.
La ventaja competitiva de sync.Once sobre el uso de la función init() es que permite el manejo de errores en tiempo de ejecución y evita el aumento del tiempo de arranque de la aplicación si el recurso no se llega a utilizar.
N° 145