Ленивая инициализация Go: что именно запоминает sync.OnceValue
HTTP-сервис использует большой неизменяемый справочник. Он нужен не каждому запросу, поэтому загрузку решили отложить до первого обращения. Условие if dictionary == nil выглядит достаточно, пока одновременно не приходят два запроса: оба видят пустое значение и начинают загрузку. Заодно чтение и запись общей переменной могут образовать гонку.
Одна обёртка на общий ресурс
sync.OnceValue возвращает функцию, которая вычисляет значение один раз и затем возвращает сохранённый результат. Её можно вызывать одновременно из нескольких goroutine: публикация результата входит в контракт механизма. Это удобнее самостоятельной пары sync.Once и внешней переменной, когда у инициализации ровно одно возвращаемое значение.
Важна область создания обёртки. Её нужно построить один раз для того ресурса, который разделяют запросы. Если вызвать OnceValue внутри каждого обработчика, каждый запрос получит собственный независимый «один раз». В примере getName создаётся до запуска работников, затем двенадцать goroutine обращаются к одной функции.
Счётчик загрузки использует atomic, чтобы сама диагностическая проверка не добавляла гонку. Результаты собираются в буферизованный канал, а WaitGroup определяет завершение всех вызовов. Мы проверяем и число запусков, и одинаковое значение у каждого потребителя: одно из этих свойств без другого ещё не описывает корректную инициализацию.
Проверить конкурентный первый доступ
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var loads atomic.Int32
getName := sync.OnceValue(func() string {
loads.Add(1)
return "catalog-v1"
})
results := make(chan string, 12)
var workers sync.WaitGroup
for i := 0; i < 12; i++ {
workers.Add(1)
go func() {
defer workers.Done()
results <- getName()
}()
}
workers.Wait()
close(results)
count := 0
for value := range results {
if value != "catalog-v1" {
panic("inconsistent initialization")
}
count++
}
if loads.Load() != 1 || count != 12 {
panic("initialization count mismatch")
}
fmt.Println("loads:", loads.Load(), "readers:", count)
}
OnceValue доступен начиная с Go 1.21. Запуск main.go показывает одну загрузку и двенадцать читателей. Получившийся справочник должен оставаться неизменяемым, если его используют без дополнительной синхронизации. Механизм безопасно публикует значение, но не защищает последующие изменения вложенной карты или объекта по указателю.
Что делать с ошибкой и reload
Если инициализация возвращает значение и error, у sync есть OnceValues для двух результатов. При этом ошибка первой попытки тоже становится сохранённым результатом. Временный отказ сети не приведёт к автоматическому повтору при следующем запросе. Поэтому удалённое соединение с требованием восстановления обычно нуждается в отдельной машине состояний или явном старте сервиса.
Паника внутри вычисления тоже не означает новую попытку: следующие вызовы возвращённой функции паникуют тем же значением. Не используйте panic как способ предложить следующий retry. Определите политику загрузки до выбора инструмента: ресурс либо неизменяемо строится один раз, либо может обновляться и восстанавливаться.
Обновление конфигурации во время работы — другая задача. Статья про atomic snapshot показывает замену целой версии после проверки. OnceValue подходит к данным, которые должны появиться однажды; у него нет операции сброса версии для reload. Разделение этих двух требований помогает не превращать ленивую инициализацию в скрытый устаревший кэш.
Для собственного пакета проверьте конкурентный первый доступ и последовательные повторные вызовы. Затем добавьте сценарий ошибки первой загрузки и убедитесь, что поведение совпадает с обещанным контрактом. Именно отказ отличает подход «одна попытка» от «получить ресурс рано или поздно».
Продолжить практику
В курсе микросервисов на Go вы продолжите работу с состоянием и отказами зависимостей. Откройте программу и разберите, какие данные принадлежат одному процессу, что происходит в нескольких репликах и как проверять поведение при недоступности другой программы.
Продолжить на практике
- Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker ↗
От работающего Go API — к взаимодействию сервисов, которому можно доверять. Разберитесь, как проектировать контракты, сохранять согласованность данных и восстанавливать обработку после сбоев. HTTP, PostgreSQL, gRPC, Kafka и Docker — через код и наглядные разборы.
