深入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() 创建的对象可能会导致程序异常。

相关推荐
侠客行03176 小时前
Mybatis连接池实现及池化模式
java·mybatis·源码阅读
Victor3566 小时前
https://editor.csdn.net/md/?articleId=139321571&spm=1011.2415.3001.9698
后端
Victor3566 小时前
Hibernate(89)如何在压力测试中使用Hibernate?
后端
灰子学技术7 小时前
go response.Body.close()导致连接异常处理
开发语言·后端·golang
Gogo8168 小时前
BigInt 与 Number 的爱恨情仇,为何大佬都劝你“能用 Number 就别用 BigInt”?
后端
fuquxiaoguang8 小时前
深入浅出:使用MDC构建SpringBoot全链路请求追踪系统
java·spring boot·后端·调用链分析
毕设源码_廖学姐9 小时前
计算机毕业设计springboot招聘系统网站 基于SpringBoot的在线人才对接平台 SpringBoot驱动的智能求职与招聘服务网
spring boot·后端·课程设计
mtngt1110 小时前
AI DDD重构实践
go
野犬寒鸦10 小时前
从零起步学习并发编程 || 第六章:ReentrantLock与synchronized 的辨析及运用
java·服务器·数据库·后端·学习·算法
逍遥德11 小时前
如何学编程之01.理论篇.如何通过阅读代码来提高自己的编程能力?
前端·后端·程序人生·重构·软件构建·代码规范