Go sync.Once 入门:懒加载资源时只初始化一次

用模板解析和配置加载示例讲 sync.Once 的基本用法、错误缓存、并发安全和不适合使用 Once 的场景。

Go 服务里经常有一些资源只需要初始化一次:解析模板、加载规则、创建昂贵对象、读取本地配置。你可以在程序启动时全部准备好,也可以第一次使用时再懒加载。懒加载时最怕并发:多个请求同时进来,发现资源还没初始化,于是重复执行。sync.Once 就是用来保证某段初始化逻辑只执行一次的。

sync.Once 的 API 很小,只有一个核心方法 Do。但它的语义非常明确:无论多少 goroutine 同时调用,传入的函数最多执行一次。本文用几个贴近日常的例子讲它的用法和边界。

最小示例

var once sync.Once
var templates *template.Template

func Templates() *template.Template {
	once.Do(func() {
		templates = template.Must(template.ParseGlob("templates/*.html"))
	})
	return templates
}

第一次调用 Templates() 时会解析模板,后续调用直接返回已经解析好的结果。即使多个 goroutine 同时第一次调用,也只有一个会执行解析函数,其他会等待它完成。

这个写法适合“失败就让程序崩”的初始化,比如模板语法错误意味着程序本身不可用。但很多时候我们不想 panic,而是返回错误。

缓存初始化错误

type RuleLoader struct {
	once  sync.Once
	rules []Rule
	err   error
	path  string
}

func (l *RuleLoader) Rules() ([]Rule, error) {
	l.once.Do(func() {
		l.rules, l.err = LoadRules(l.path)
	})
	if l.err != nil {
		return nil, l.err
	}
	return l.rules, nil
}

注意:如果 LoadRules 第一次失败,后续调用不会重试,而是一直返回同一个错误。这是 sync.Once 的重要特性。它适合“初始化只应该尝试一次”的场景,不适合需要失败重试的场景。

如果你希望失败后下次再试,不能直接用 sync.Once,需要自己用 mutex 管理状态,或者设计显式的 Reload。

用构造函数包起来

比全局变量更清楚的写法是放进结构体:

type Renderer struct {
	once      sync.Once
	templates *template.Template
	err       error
	pattern   string
}

func NewRenderer(pattern string) *Renderer {
	return &Renderer{pattern: pattern}
}

func (r *Renderer) Render(w io.Writer, name string, data any) error {
	r.once.Do(func() {
		r.templates, r.err = template.ParseGlob(r.pattern)
	})
	if r.err != nil {
		return r.err
	}
	return r.templates.ExecuteTemplate(w, name, data)
}

这样依赖更容易注入和测试。全局 once 很方便,但项目大了以后,生命周期会变得模糊。结构体把“这个 renderer 的模板只解析一次”表达得更明确。

不要复制 sync.Once

sync.Once 使用后不应该被复制。比如:

type Cache struct {
	once sync.Once
}

如果你复制 Cache 值,里面的 once 状态也会被复制,行为可能变得混乱。因此含有 mutex、once、waitgroup 这类同步字段的结构体,通常用指针传递:

func NewCache() *Cache {
	return &Cache{}
}

代码审查时看到包含同步字段的结构体被值传递,要多看一眼。

Once 和启动初始化的取舍

懒加载的好处是启动快,只有真正用到资源时才初始化。坏处是第一次请求可能变慢,而且错误会发生在请求路径上。启动初始化的好处是失败早暴露,服务没准备好就不启动。坏处是所有资源都要启动时准备。

对于核心资源,比如数据库连接、路由模板、关键配置,通常更推荐启动时初始化。对于很少用的报表模板、可选规则、调试资源,可以考虑 sync.Once 懒加载。

不要为了“看起来高级”到处懒加载。初始化策略是产品和运维体验的一部分。

测试只执行一次

func TestOnceRunsOnlyOnce(t *testing.T) {
	var once sync.Once
	var calls atomic.Int64

	var wg sync.WaitGroup
	for i := 0; i < 20; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			once.Do(func() {
				calls.Add(1)
			})
		}()
	}
	wg.Wait()

	if calls.Load() != 1 {
		t.Fatalf("calls = %d, want 1", calls.Load())
	}
}

这个测试验证的是 sync.Once 的基本语义。实际项目里更常测的是你的封装:并发调用 Rules() 后加载函数只执行一次,返回结果一致。

需要重载时不要用 Once 硬撑

有些资源看起来适合懒加载,但后来产品要求“配置改了马上生效”。这时不要试图重置 sync.Once。Once 的语义就是一次性执行,强行替换会让代码很难理解。更合适的方式是把“加载一次”和“可重载”分成两套结构。

比如规则配置需要重载,可以用 mutex 保护:

type ReloadableRules struct {
	mu    sync.RWMutex
	rules []Rule
	path  string
}

func (r *ReloadableRules) Load() error {
	rules, err := LoadRules(r.path)
	if err != nil {
		return err
	}
	r.mu.Lock()
	r.rules = rules
	r.mu.Unlock()
	return nil
}

func (r *ReloadableRules) Get() []Rule {
	r.mu.RLock()
	defer r.mu.RUnlock()
	out := make([]Rule, len(r.rules))
	copy(out, r.rules)
	return out
}

这段代码比 sync.Once 多一些样板,但语义更准确:它允许多次加载。工具要跟需求匹配,不要因为 Once 简单就把所有初始化都塞进去。

Once 的 helper

较新的 Go 版本里,标准库还提供了基于 Once 的辅助函数,可以把函数包装成只执行一次的形式。即使使用这些 helper,核心语义也一样:只执行一次,结果会被复用。入门阶段先理解 sync.Once 本身,再看这些便利 API 会更轻松。

如果团队里有人用 helper,有人用传统 Once,不必急着统一。重要的是代码能清楚表达资源生命周期。对于核心服务,我更愿意看到显式结构体字段,因为它能放下错误、配置路径和测试替身。

不要在 Once 里做可变全局配置

还有一个常见误用:把环境变量读取、默认值合并、远程配置拉取都藏在 Once 里,业务代码随时调用 Config()。这会让配置来源变得不透明。更稳的做法是启动阶段加载配置,作为参数传给需要的组件。Once 适合保护昂贵初始化,不适合替代清晰的启动流程。

小结

sync.Once 适合并发安全地执行一次初始化。它简单可靠,但要记住:函数只会执行一次,失败也会被缓存;包含 Once 的结构体不要复制;核心资源未必适合懒加载。

初学者使用 Once 时,不要只想着“只执行一次”,还要问:失败后要不要重试?第一次调用变慢能不能接受?这个资源生命周期属于谁?这些问题回答清楚,Once 才会用得稳。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页