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

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

深入Golang的Context「源码分析+详细案例」 - 掘金

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

深入 GO unsafe.Pointer & uintptr - 掘金

写作背景

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

名词解释

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

慢路径(slow-path)

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

快路径(fast-path)

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

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

go 复制代码
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、 可以把源码代码改成下面这种写法吗?为什么?

scss 复制代码
if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
    f()
}

经典案例

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

go 复制代码
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() 创建的对象可能会导致程序异常。

相关推荐
Kiri霧2 小时前
Rust开发环境搭建
开发语言·后端·rust
间彧3 小时前
Spring事件监听与消息队列(如Kafka)在实现解耦上有何异同?
后端
间彧3 小时前
Java如何自定义事件监听器,有什么应用场景
后端
叶梅树3 小时前
从零构建A股量化交易工具:基于Qlib的全栈系统指南
前端·后端·算法
间彧3 小时前
CopyOnWriteArrayList详解与SpringBoot项目实战
后端
间彧4 小时前
SpringBoot @FunctionalInterface注解与项目实战
后端
程序员小凯4 小时前
Spring Boot性能优化详解
spring boot·后端·性能优化
Asthenia04124 小时前
问题复盘:飞书OAuth登录跨域Cookie方案探索与实践
后端
tuine4 小时前
SpringBoot使用LocalDate接收参数解析问题
java·spring boot·后端
W.Buffer4 小时前
Nacos配置中心:SpringCloud集成实践与源码深度解析
后端·spring·spring cloud