sync.Once 在高并发环境下依然保持高效的原因

在 Go 的并发编程中,sync.Once 是一个非常经典的同步原语。它的作用很简单:保证某个函数在并发环境中只执行一次。无论有多少个 goroutine 同时调用,目标函数都只会被执行一次。

例如:

var once sync.Once

func initConfig() {
    fmt.Println("init config")
}

func main() {
    for i := 0; i < 100; i++ {
        go func() {
            once.Do(initConfig)
        }()
    }
}

即使有 100 个 goroutine 同时调用 once.Do()initConfig() 也只会执行一次。

但一个非常有意思的问题是:为什么 sync.Once 在高并发场景下依然非常高效?

答案在于它的内部设计:atomic + mutex + fast path 优化

其实sync.Oncesync.Map 都是精心设计的并发原语,它们的核心思想都是尽量减少加锁的开销。在 sync.Once 中,大多数时候都只做原子读取,没有锁,所以“读多写少”就让它非常快。同样的,sync.Map 也是通过“读多写少”的优化思路,把读操作拆到无锁的局部缓存,而只有写的时候才会加锁。所以这两者都是借助极致优化的读路径和偶尔加锁的写路径,让并发性能保持稳定高效。


一、sync.Once 的内部结构

sync.Once 的结构非常简单:

type Once struct {
    done uint32
    m    Mutex
}

其中:

  • done:表示函数是否已经执行过
  • m:互斥锁,用于保证并发安全

这两个字段配合使用,实现了“只执行一次”的语义。


二、fast path:绝大多数情况不需要加锁

sync.Once 最核心的优化在于 fast path(快速路径)

Do() 方法的简化实现如下:

func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 0 {
        o.doSlow(f)
    }
}

这里先通过 atomic.Load 读取 done

如果 done == 1,说明函数已经执行过:

直接返回

整个过程:

  • 不需要锁
  • 不需要阻塞
  • 只有一次原子读取

这使得 sync.Once 在函数已经执行过之后,性能几乎和普通函数调用一样。

在实际系统中,Once 通常用于:

  • 单例初始化
  • 配置加载
  • 连接池初始化

这些操作 只会发生一次,之后大量调用都会走 fast path。

因此性能非常高。


三、slow path:只在第一次执行时加锁

如果 done == 0,说明函数还没有执行,需要进入慢路径:

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()

    if o.done == 0 {
        defer atomic.StoreUint32(&o.done, 1)
        f()
    }
}

执行流程:

加锁
↓
再次检查 done
↓
执行函数 f()
↓
done = 1

这里有一个经典的并发设计:double-check(双重检查)

原因是可能存在这样的并发情况:

goroutine A 进入 Do
goroutine B 进入 Do

两者同时看到:

done == 0

于是都进入 doSlow()

但由于有互斥锁:

A 先获得锁
B 等待锁

A 执行完函数后:

done = 1

当 B 获得锁时再次检查:

done == 1

于是 B 不会再次执行函数。

这样就保证了函数只会执行一次。


四、atomic 的关键作用

如果 sync.Once 每次调用都加锁,那么在高并发环境下会出现严重的锁竞争。

Go 的设计是:

执行前:可能需要加锁
执行后:永远不加锁

实现方式就是:

atomic.Load + fast path

done == 1 时:

所有 goroutine 都只做一次原子读取

原子读取在 CPU 层面非常快,通常只需要几纳秒。

因此在高并发情况下,sync.Once 几乎没有性能损耗。


五、为什么 done 要在函数执行后才设置

源码中有一个细节:

defer atomic.StoreUint32(&o.done, 1)

也就是说:

先执行 f()
再设置 done

而不是:

先设置 done
再执行 f()

原因是 内存可见性(memory ordering)

如果先设置 done = 1

goroutine A
done = 1
f() 还没执行完

这时 goroutine B 可能看到:

done == 1

于是直接返回。

但实际上:

f() 还没有完成

这样就破坏了 Once 的语义。

因此必须保证:

f() 执行完成
↓
done = 1

这样其他 goroutine 才能安全地继续执行。


六、sync.Once 为什么适合高并发

总结 sync.Once 高性能的原因:

1. fast path 优化

大多数调用只需要:

atomic.Load

不需要锁。


2. 锁只发生一次

真正加锁的情况:

只有第一次执行

之后永远不会再加锁。


3. 原子操作代替锁

使用:

atomic.Load
atomic.Store

避免锁竞争。


4. 双重检查减少锁开销

即使在高并发同时初始化的情况下:

只有一个 goroutine 会执行函数

七、典型应用场景

sync.Once 常用于:

单例模式

var once sync.Once
var instance *Config

func GetConfig() *Config {
    once.Do(func() {
        instance = loadConfig()
    })
    return instance
}

懒加载

例如:

  • 数据库连接
  • Redis 客户端
  • 全局配置
  • 日志系统

总结

sync.Once 的设计非常经典,本质上是:

atomic + mutex + fast path

它的核心思想是:

第一次执行时保证正确性
之后的执行保证性能

正是这种设计,使得 sync.Once 在高并发环境下依然保持极高的效率。

这也是 Go 并发原语设计中非常值得学习的一个例子。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容