深入GO之sync.Once你需要了解的快慢路径编程范式

这是我写 go 源码系列第 4 篇,感兴趣可以阅读前 3 篇

深入 GO Context「源码分析+详细案例」-CSDN博客

GO singleflight 你真的会用吗?「源码分析+详细案例」-CSDN博客

GO unsafe.Pointer & uintptr-CSDN博客

写作背景

sync.Once 代码虽然非常少(20 行左右),但是用了非常典型的编程范式(快慢路径)值得大家学习和借鉴。另外在日常开发中如果遇到对象初始化一次、某个逻辑执行一次等,你会有更优雅的方案。

名词解释

sync.Once 是 Go 语言标准库中提供的一种机制,用于执行一次性操作,通常用于初始化只需执行一次的任务。它的作用是确保某个操作只会执行一次,无论是在单线程环境还是多线程环境下都可以保证。

慢路径(slow-path)

慢路径(Slow Path)指一种更加保守、安全但性能较低的解决方案。代码会使用互斥锁等同步原语来确保并发安全性。慢路径会导致性能开销增加,因为它需要在多个线程之间进行显式的同步和互斥操作,以确保数据的一致性和正确性。

快路径(fast-path)

快路径(Fast Path)指一种更加高效但风险较高的解决方案。代码会使用原子操作等非阻塞的同步机制来尽量减少同步开销。快路径会更高效,因为它避免了显式的同步和互斥操作,但在某些情况下会导致竞态条件或数据不一致的问题。

源码剖析(代码非常精简)

type Once struct {
	done uint32
	m    Mutex
}

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

func (o *Once) doSlow(f func()) {
	o.m.Lock()
	defer o.m.Unlock()
	if o.done == 0 {
		defer atomic.StoreUint32(&o.done, 1)
		f()
	}
}

sync.Once 结构体仅有两个字段:

m :是一个 sync.Mutex 类型,确保并发安全。

done:是一个 uint32 类型标志,用于表示初始化是否已完成,0 表示未完成,1 表示已完成。那有人会有疑问了,如果只有 0 和 1 为啥还要用 uint32?因为需要原子操作。

Do 方法接受一个函数作为参数,如果初始化函数还没有执行过,则执行初始化函数,否则直接返回。

Do 方法的第一步操作是快路径通过 atomic.LoadUint32(&o.done) 执行原子操作,判断是否执行过,如果快路径执行过直接返回,如果没有执行过调用满路径方法 doSlow() 。

慢路径用了互斥锁,单凭原子操作判断的保证是不够的。如果有两个 goroutine 都调用了同一个新的 Once 值的 Do 方法,并且同时执行到了第一个条件判断代码,它们都会因判断结果为 false,而继续执行剩余的代码。加互斥锁后还会执行 done == 0 判断,主要是为了更严谨,被称为双重检查。

参数函数 f() 执行完毕后,执行原子操作把 done 设置为 1。

有 2 个问题需要大家思考下:

1、 为啥 f() 函数要先于 atomic.StoreUint32(&o.done, 1) 函数执行?

2、 可以把源码代码改成下面这种写法吗?为什么?

if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
    f()
}

经典案例

场景一:RPC client、kafka 消费者.... 确保一个类只有一个实例对象的场景。

var (
	once     sync.Once
	consumer *KafkaConsumer
)

type KafkaConsumer struct {
}

func Init() {
	once.Do(func() {
		consumer = &KafkaConsumer{}
	})
}

/*
	消费逻辑忽略
*/

场景二:初始化配置场景

这个案例我就不讲了,从 json、yml 文档加载配置等

有人会问了,go 不是提供 init() 函数吗?为啥不用 init 初始化?原因很简单针对配置这种需要显示初始化,后面所有的操作可能会依赖配置,这里有时序的问题。

总结

1、 Once 代码虽然少,但是用了“快慢路径”这种非常经典的编程范式,通常用于在需要在性能优先和正确性保证之间进行权衡。

2、 Do 函数执行完后才会对 done 字段执行原子操作置为 1 ,如果你需要执行的逻辑耗时很长,会阻塞 goroutine。

3、 Do 执行完毕都会执行 defer 对 done 字段执行原子操作置为 1,不论你的参数函数执行成功与否,参数函数都无法执行了。

问题解析

为什么不用 atomic.CompareAndSwapUint32(),其实官方已经给出答案了。

atomic.CompareAndSwapUint32() 不能保证 Do 返回时,f() 已经执行完毕。如果有两个 goroutine 同时调用,如果第一个 goroutine 执行 f(),第二个 goroutine 则会立即返回,不会等待第一个 goroutine 调用 f() 完成的。如果第二个返回后直接使用 f() 创建的对象可能会导致程序异常。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值