🐉大家好,我是gopher_looklook,现任某独角兽企业Go语言工程师,喜欢钻研Go源码,发掘各项技术在大型Go微服务项目中的最佳实践,期待与各位小伙伴多多交流,共同进步!
概念
sync.Once
是Go语言标准库中的一个同步原语,用于确保某个操作只执行一次。它在多线程环境中非常有用,尤其是在需要初始化共享资源或执行某些一次性任务时。
简单示例
- 当我们在web服务访问某个路由时,如果需要事先获取某些配置,往往会写一个
loadConfig
函数,获取一个cfg
配置项。多次路由访问所需要获取的配置项通常是相同的,如果对于每次路由访问,都加载一次loadConfig
函数,会导致产生一些不必要的开销。如果loadConfig
涉及到读取文件、解析配置、网络请求时,有可能会额外增加的请求响应时间,降低服务的吞吐量。使用sync.Once
包提供的Do
函数,就可以只在第一次请求时调用loadConfig
函数加载配置,之后的请求都复用第一次请求的配置,缩短响应时间。
package main
import (
"log"
"net/http"
"sync"
)
type Config struct {
APIKey string
LogLevel string
}
var (
config *Config
once sync.Once
)
func loadConfig() {
// 模拟从文件或环境变量加载配置
config = &Config{
APIKey: "secret-key",
LogLevel: "debug",
}
log.Println("Configuration loaded")
}
func GetConfig() *Config {
once.Do(loadConfig) // 仅第一次访问时会执行loadConfig函数
return config
}
func handler(w http.ResponseWriter, r *http.Request) {
cfg := GetConfig()
log.Printf("Request handled with API key: %s", cfg.APIKey)
w.Write([]byte("OK"))
}
func main() {
http.HandleFunc("/", handler)
log.Fatal(http.ListenAndServe(":8888", nil))
}
源码解读
- 源码文件:src/sync/once.go (go 1.23 版本)
package sync
import (
"sync/atomic"
)
type Once struct {
done atomic.Uint32 // 是否已执行标识位,0-未执行 1-已执行
m Mutex // 互斥锁,确保并发安全
}
func (o *Once) Do(f func()) {
// 第一次执行Do函数时,原子操作检查o.done==0,执行doSlow函数后,o.done==1
// 第二次及之后执行Do函数,原子操作检查o.done标识位为1,Do函数不执行任何功能,确保了f函数只在第一次被执行
if o.done.Load() == 0 {
o.doSlow(f) // 调用doSlow函数执行f方法。第一次执行时,同一时间可能有多个goroutine尝试同时执行doSlow函数
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock() // 加锁保护,避免多个goroutine同时绕过之前的原子操作检查,并发修改o.done的值
defer o.m.Unlock()
// 二次检查o.done的值,同一时间并发执行doSlow函数的goroutine,在第一个goroutine将o.done置为1并解除互斥锁后,
// 剩下的goroutine识别到自身的o.done已经被设为1,无法绕过二次检查
if o.done.Load() == 0 {
defer o.done.Store(1) // 需要在f()函数执行完成之后,原子性地将o.done设为1
f() // 执行f方法,一定只有一个goroutine会调用这个方法
}
}
- 可以看到,
once.go
文件的代码非常精炼。仅定义了一个含2个非导出字段done
和m
的结构体Once
,并提供了一个doSlow
方法用于执行f
函数。当我们调用Do
方法时,程序经历了几个关键步骤:
- 判断
done
标志位是否等于0,如果是,说明f
函数还没有被执行,执行doSlow
方法 mu
互斥锁加锁,防止多个goroutine
并发操作- double-check
done
标志位是否等于0,如果是,说明f
函数还没有被执行,执行f
函数 f
函数执行完成之后,再将done
标志位原子性设为1。使用原子操作是从内存可见性的角度出发,如果done
使用uint32
而不是atomic.Uint32
,done
修改可能不会立即被其它goroutine
感知,解锁后仍有可能存在goroutine
的done
等于0,重复执行f
函数mu
互斥锁解锁。此时进入到doSlow
函数的其它goroutine
也感知到了o.done
等于1,不会重复执行f
函数了
总结
- 以上就是我针对Go源码
sync.Once
原理和使用方式的讲解。在实际开发中,sync.Once
的使用还是非常普遍的。掌握sync.Once的底层原理,有助于我们在今后的开发中更有把握地利用它永远只执行一次函数的特性,完成复杂的技术需求或者业务需求。