sync.Once 是延迟加载唯一安全简洁的初始化方案,其原子操作+状态机确保最多执行一次;手写双检锁易出错且开销大,Proxy 应存真实类型指针而非嵌入,接口定义宜窄,临界区须避免IO,init不可defer且不支持失败重试。

延迟加载必须用 sync.Once,别手写双检锁
并发调用时,sync.Once 是唯一安全、简洁、无坑的初始化方案。自己写“if p.real == nil { p.real = newReal() }”加 sync.RWMutex 不仅容易漏掉第二次检查,还会因读写锁切换引入额外开销和死锁风险。
常见错误现象:nil pointer dereference panic、资源重复打开(比如文件句柄泄漏)、初始化逻辑执行多次。
-
sync.Once底层用原子操作 + 状态机,保证最多执行一次,且无自旋浪费 CPU - 不要在
Once.Do()回调里做耗时操作(如http.Get),否则所有等待 goroutine 会卡住 - 如果初始化失败需重试,
sync.Once不支持——此时应改用带错误返回的懒加载函数 + 外部缓存结果
Proxy 结构体里别嵌入真实类型,只存指针
嵌入(embedding)真实结构体会暴露所有未受控方法,破坏访问控制意图,也导致延迟加载失效——因为 Go 在结构体初始化时就会调用嵌入字段的构造逻辑。
使用场景:你想对 Load() 做权限校验或日志埋点,但不想让调用方直接访问 RealLoader.Read() 或 RealLoader.Close()。
立即学习“go语言免费学习笔记(深入)”;
- 正确做法:
type ProxyLoader struct { real *RealLoader; once sync.Once } - 错误写法:
type ProxyLoader struct { RealLoader; once sync.Once }—— 这会让RealLoader在ProxyLoader{}构造时就被实例化 - 接口定义要窄:只暴露需要拦截的方法,避免把
io.Closer等宽接口直接塞进 proxy
临界区越小越好,别在锁里做网络/IO
即使用了 sync.Once,如果初始化函数里包含 os.Open、http.Post 或 time.Sleep,其他 goroutine 仍会长时间阻塞在 once.Do() 的内部锁上。
性能影响:pprof 中 runtime.semacquire 占比飙升,火焰图顶部出现大量 sync.(*Once).Do 调用栈。
- 推荐拆分:先异步拉取数据(如启动 goroutine 预热),再用
atomic.Value原子替换;或用sync.Pool缓存已初始化对象 - 若必须同步初始化,把 IO 操作移出
Once.Do(),只在锁内做内存赋值:p.real = loadedValue - 注意:
sync.Once内部锁不是用户可调的,无法换成RWMutex或自旋锁来优化
别在 defer 里注册 Once.Do,它不适用 defer
sync.Once.Do() 本身不是锁操作,不能 defer;有人误写 defer once.Do(init),结果 init 根本不执行——因为 Do 只在第一次调用时生效,defer 时早已过期。
容易踩的坑:把初始化逻辑当成“资源清理”来管理,混淆了 lazy-init 和 cleanup 的语义边界。
- 正确姿势:在首次访问方法里直接调用
p.once.Do(p.init),然后立刻使用p.real - init 方法必须是无参函数(或闭包捕获必要变量),且不能依赖外部锁状态
- 如果 proxy 需要上下文感知(如
context.Context),init 函数得接收参数,那就不能用sync.Once,得换为带锁的懒加载逻辑
sync.Once 不提供重试或清除机制,这时候你得自己维护状态机,而不是幻想一把锁能解决所有问题。


















