กระรอกดินต้นฉบับหมุนกุญแจอำพันเพียงครั้งเดียวเพื่อเปิดลิ้นชักค่าคอนฟิกให้รางงานหลายราง

Go sync.OnceValue: โหลดค่าครั้งเดียวโดยไม่เปิดช่องให้ cache แข่งกัน

พี่โชว์Engineering

ค่าคอนฟิกที่ parse แพงหรือ object ที่สร้างเมื่อถูกใช้ครั้งแรกมักเริ่มจากตัวแปร package-level กับเงื่อนไข if cached == nil แต่เมื่อหลาย goroutine เข้ามาพร้อมกัน ทั้งคู่สามารถเห็น cache ว่างและสร้างซ้ำได้ sync.OnceValue รวม “ทำครั้งเดียว” กับ “จำค่าที่คืนมา” เป็นฟังก์ชันเดียว จึงลด state ที่เราต้องประสานเองครับ

ให้นึกถึงตู้เอกสารที่มีผู้ถือกุญแจคนเดียว คนแรกที่มาถึงเปิดตู้และวางสำเนาไว้ หลังจากนั้นทุกคนรับสำเนาเดียวกันโดยไม่ต้องแย่งกุญแจ แม้จะเรียกพร้อมกันก็ตาม

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

type Config struct{ Region string }

func main() {
	var loads atomic.Int32
	config := sync.OnceValue(func() Config {
		loads.Add(1)
		return Config{Region: "ap-southeast-1"}
	})

	var wg sync.WaitGroup
	results := make(chan string, 8)
	for range 8 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			results <- config().Region
		}()
	}
	wg.Wait()
	close(results)

	for region := range results {
		if region != "ap-southeast-1" {
			panic("unexpected region")
		}
	}
	if loads.Load() != 1 {
		panic("loader ran more than once")
	}
	fmt.Println("loaded once")
}

จุดที่ต้องรู้คือผลลัพธ์ถูกตรึงตลอดอายุของ closure ถ้าค่าต้อง refresh ตามเวลา ให้ใช้ cache ที่มี expiry หรือ object ที่สลับ snapshot ได้แทน และถ้าฟังก์ชันแรก panic การเรียกครั้งต่อไปจะ panic ด้วยค่าเดิม ไม่ได้ลองโหลดใหม่

ถ้าต้องคืน (value, error) ใช้ sync.OnceValues ได้โดยตรง แต่ก็จำ error ครั้งแรกเช่นกัน จึงเหมาะกับการอ่านข้อมูล immutable หรือการสร้าง dependency ที่ความล้มเหลวควรหยุดโปรแกรม ไม่เหมาะกับ network call ที่อาจสำเร็จเมื่อ retry

Recap

  • ใช้ OnceValue กับค่าที่ควรสร้างครั้งเดียวจริง ๆ
  • อย่าใช้แทน cache ที่ต้อง refresh
  • จำไว้ว่า panic และ error ครั้งแรกถูกจำด้วย
  • ทดสอบเรียกพร้อมกันและยืนยันว่า loader ทำงานครั้งเดียว