配置加载、数据库连接、单例对象创建,这类逻辑看起来普通,但一旦放进并发环境,就很容易出现重复初始化、资源浪费,甚至竞态问题。sync.Once正是 Go 为“一次性执行”提供的标准解法:它的接口很小,却覆盖了不少高频需求。下面从基本用法、实现思路、适用场景到常见误区,系统梳理这个并发工具该怎么用、什么时候用。
核心用法:`Do` 方法到底保证了什么
sync.Once位于 Go 标准库 sync 包中,核心能力只有一个方法:

func (o *Once) Do(f func())
它接收一个无参数、无返回值的函数,并保证这个函数在整个程序运行期间只会被执行一次。这里的“只执行一次”不是“某个 goroutine 看起来只调用一次”,而是无论多少个 goroutine 同时触发,真正进入执行的也只有第一次。
先看一个最直接的示例:
package main
import (
"fmt"
"sync"
)
var once sync.Once
func initialize() {
fmt.Println("Initializing...")
}
func main() {
// 多个goroutine并发调用
for i := 0; i < 5; i++ {
go once.Do(initialize)
}
// 等待所有goroutine执行
fmt.Scanln()
}
这段代码里,虽然启动了 5 个 goroutine 并发调用 once.Do(initialize),但最终只会输出一次 "Initializing..."。这就是 sync.Once 最核心的承诺:同一个 Once 实例,只放行一次。
理解这一点后,也就能看出它最适合的任务类型:初始化动作只允许成功进入一次,而且之后所有调用方都共享这次结果。
工作原理:为什么它既安全又不算重
sync.Once 常被拿来做高频初始化保护,如果每次调用都加锁,开销会比较明显。它之所以实用,关键在于内部把原子操作和互斥锁配合到了一起。

原文中的结构定义如下:
// sync/once.go
type Once struct {
_ noCopy
done atomic.Uint32 // 标记是否已执行
m Mutex // 互斥锁,用于并发控制
}
它的执行大致可以分成两条路径:
- 快速路径:先通过原子读检查
done是否已经置为 1;如果已经执行过,就直接返回。 - 慢速路径:如果发现还没执行,再进入加锁阶段,在临界区里进行一次双重检查,确认确实还没执行后才调用目标函数。
这种设计的价值在于,真正需要抢锁的只有第一次附近的竞争调用。等初始化完成后,后续绝大多数调用只需要做一次原子读,就能非常快地返回,不必反复进入锁竞争。
换句话说,sync.Once 并不是用“每次都严格加锁”的方式换安全,而是在保证正确性的前提下,把常态路径尽量压缩到了更轻的成本。这也是它适合放在全局初始化入口、单例访问器中的原因。
哪些场景适合用 `sync.Once`
sync.Once 的能力非常聚焦,所以它适合的场景也很集中,基本都围绕“某段初始化逻辑只该发生一次”展开。
单例模式
这是最常见的用法。全局实例只创建一次,所有调用方共享同一个对象:

var (
instance *Singleton
once sync.Once
)
type Singleton struct {
value int
}
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{value: 42}
fmt.Println("Singleton instance created")
})
return instance
}
这里的关键不是“少写几行单例模板代码”,而是在并发访问 GetInstance() 时,不会因为竞态条件而创建出多个实例。
延迟初始化昂贵资源
像数据库连接、文件句柄这类资源,如果程序启动时就全部创建,往往会带来额外开销。用 sync.Once 可以把初始化推迟到第一次真正使用时:
var (
db *sql.DB
once sync.Once
)
func GetDB() (*sql.DB, error) {
once.Do(func() {
var err error
db, err = sql.Open("mysql", "user:pass@/dbname")
if err != nil {
panic(err) // 实际项目中应更优雅地处理错误
}
})
return db, nil
}
这种写法的优势是按需分配资源,但也要注意:如果初始化过程本身可能失败,那么“失败后如何处理”要提前设计,不能只把逻辑塞进 Do 就算完成。
一次性加载配置
配置读取也是典型的一次性任务。尤其当配置来自文件、环境变量或远端服务时,重复解析通常没有必要:
var (
appConfig *Config
once sync.Once
)
type Config struct {
APIKey string
Debug bool
}
func LoadConfig() *Config {
once.Do(func() {
// 从文件或环境变量加载配置
appConfig = &Config{
APIKey: os.Getenv("API_KEY"),
Debug: os.Getenv("DEBUG") == "true",
}
})
return appConfig
}
如果你的配置在运行期间不会变化,这种方式很直接;如果配置需要热更新,sync.Once 就不适合了,因为它的状态不能回退。
使用时要注意的几个关键细节
sync.Once 看起来简单,真正的问题通常出在边界条件和误用方式上。下面几个点,基本决定了它在项目里是“省心”还是“埋雷”。
1. 不能重置,天然不是重复初始化工具
一旦某个 Once 实例执行过 Do,它的状态就是不可逆的,不能清空后再来一次。因此它适合“整个程序生命周期中只做一次”的工作,不适合“按阶段重新初始化”的需求。
2. 初始化中的错误要自己兜住
如果 Do 包裹的函数里出现错误,尤其是直接 panic,后续行为要格外小心。原文特别提醒:如果函数执行时发生错误或 panic,sync.Once 会标记为已执行,后续调用不会再次执行这段逻辑。因此,错误处理策略不能依赖“下一次再重试”。
这也是上面数据库示例里那句注释的重点:
panic(err) // 实际项目中应更优雅地处理错误
在正式项目里,更稳妥的办法通常是把错误保存到外层变量,由调用方统一判断,而不是简单地在初始化闭包里直接中断流程。
3. 同一个 `Once` 里不要递归调用自己
如果在传给 Do 的函数内部,又去调用同一个 sync.Once 实例的 Do 方法,就会形成死锁。这类问题在代码拆分后尤其容易被忽略,表面上看只是“复用了一个初始化函数”,实际却可能把自己锁住。
4. 需要参数时,用闭包传入
因为 Do 只能接收 func(),所以带参数的初始化逻辑一般通过闭包处理:
configFile := "app.conf"
once.Do(func() {
initConfig(configFile)
})
这也是 Go 里最自然的写法。你不需要为了传参额外包装一个复杂结构,只要保证闭包里引用的值符合预期即可。
5. 首次有锁成本,后续接近读标记
性能上,第一次调用确实要承担加锁和执行初始化函数的成本;但完成之后,后续调用大多只剩一次原子读操作,开销非常小。也正因为如此,sync.Once 更适合保护“初始化入口”,而不是拿来包裹那些本就需要频繁变化的业务逻辑。
和 `Mutex`、`Channel`、`init()` 相比怎么选
很多人第一次接触 sync.Once 时,会把它和其他同步机制放在一起比较。实际上,它们解决的问题并不完全相同,选型关键在于任务目标。
| 同步机制 | 特点 | 适用场景 |
|---|---|---|
sync.Once |
确保代码只执行一次 | 一次性初始化任务 |
sync.Mutex |
手动控制加锁,灵活性高 | 需要多次读写共享资源的场景 |
Channel |
通过通信协调协程 | 需要复杂协作逻辑的场景 |
init()函数 |
包级别的静态初始化 | 模块初始化,缺乏运行时控制 |
可以把它们理解为四种不同层级的工具:
sync.Once适合“一次就够”的运行时初始化;sync.Mutex适合持续性的共享状态保护;Channel更适合 goroutine 之间存在过程协作、状态流转的情况;init()则偏向包加载阶段的静态准备,控制力最弱,但写起来最直接。
如果你的诉求只是“这个对象只初始化一次,而且可能会被多个 goroutine 同时访问”,那 sync.Once 往往就是最合适的那个。若需求已经扩展到失败重试、动态销毁重建、复杂状态机协同,就该考虑别的机制了。
结语:小接口背后,是很典型的 Go 并发取舍
sync.Once 的价值不在于功能多,而在于它把“一次性初始化”这个常见问题压缩成了一个足够稳定、足够轻量的标准工具。对单例创建、按需连接数据库、加载全局配置这类任务来说,它通常比手写锁逻辑更直接,也更不容易出错。
实际使用时,重点不是会不会写 once.Do(...),而是先判断你的任务是否真的满足“全生命周期只执行一次”这个前提。这个判断一旦做对,sync.Once 往往会是 Go 并发代码里非常省心的一把工具。













