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 才会用得稳。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。