go并发利器sync.Once如何使用

其他教程   发布日期:2023年08月24日   浏览次数:471

这篇文章主要介绍了go并发利器sync.Once如何使用的相关知识,内容详细易懂,操作简单快捷,具有一定借鉴价值,相信大家阅读完这篇go并发利器sync.Once如何使用文章都会有所收获,下面我们一起来看看吧。

1. 简介

本文主要介绍 Go 语言中的 Once 并发原语,包括 Once 的基本使用方法、原理和注意事项,从而对 Once 的使用有基本的了解。

2. 基本使用

2.1 基本定义

  1. sync.Once
是Go语言中的一个并发原语,用于保证某个函数只被执行一次。
  1. Once
类型有一个
  1. Do
方法,该方法接收一个函数作为参数,并在第一次调用时执行该函数。如果
  1. Do
方法被多次调用,只有第一次调用会执行传入的函数。

2.2 使用方式

使用

  1. sync.Once
非常简单,只需要创建一个
  1. Once
类型的变量,然后在需要保证函数只被执行一次的地方调用其
  1. Do
方法即可。下面是一个简单的例子:
  1. var once sync.Once
  2. func initOperation() {
  3. // 这里执行一些初始化操作,只会被执行一次
  4. }
  5. func main() {
  6. // 在程序启动时执行initOperation函数,保证初始化只被执行一次
  7. once.Do(initOperation)
  8. // 后续代码
  9. }

2.3 使用例子

下面是一个简单使用

  1. sync.Once
的例子,其中我们使用
  1. sync.Once
来保证全局变量config只会被初始化一次:
  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. )
  6. var (
  7. config map[string]string
  8. once sync.Once
  9. )
  10. func loadConfig() {
  11. // 模拟从配置文件中加载配置信息
  12. fmt.Println("load config...")
  13. config = make(map[string]string)
  14. config["host"] = "127.0.0.1"
  15. config["port"] = "8080"
  16. }
  17. func GetConfig() map[string]string {
  18. once.Do(loadConfig)
  19. return config
  20. }
  21. func main() {
  22. // 第一次调用GetConfig会执行loadConfig函数,初始化config变量
  23. fmt.Println(GetConfig())
  24. // 第二次调用GetConfig不会执行loadConfig函数,直接返回已初始化的config变量
  25. fmt.Println(GetConfig())
  26. }

在这个例子中,我们定义了一个全局变量

  1. config
和一个
  1. sync.Once
类型的变量
  1. once
。在
  1. GetConfig
函数中,我们通过调用
  1. once.Do
方法来保证
  1. loadConfig
函数只会被执行一次,从而保证
  1. config
变量只会被初始化一次。 运行上面的程序,输出如下:
  1. load config...
  2. map[host:127.0.0.1 port:8080]
  3. map[host:127.0.0.1 port:8080]

可以看到,

  1. GetConfig
函数在第一次调用时执行了
  1. loadConfig
函数,初始化了
  1. config
变量。在第二次调用时,
  1. loadConfig
函数不会被执行,直接返回已经初始化的
  1. config
变量。

3. 原理

下面是

  1. sync.Once
的具体实现如下:
  1. type Once struct {
  2. done uint32
  3. m Mutex
  4. }
  5. func (o *Once) Do(f func()) {
  6. // 判断done标记位是否为0
  7. if atomic.LoadUint32(&o.done) == 0 {
  8. // Outlined slow-path to allow inlining of the fast-path.
  9. o.doSlow(f)
  10. }
  11. }
  12. func (o *Once) doSlow(f func()) {
  13. // 加锁
  14. o.m.Lock()
  15. defer o.m.Unlock()
  16. // 执行双重检查,再次判断函数是否已经执行
  17. if o.done == 0 {
  18. defer atomic.StoreUint32(&o.done, 1)
  19. f()
  20. }
  21. }

  1. sync.Once
的实现原理比较简单,主要依赖于一个
  1. done
标志位和一个互斥锁。当
  1. Do
方法被第一次调用时,会先原子地读取
  1. done
标志位,如果该标志位为0,说明函数还没有被执行过,此时会加锁并执行传入的函数,并将
  1. done
标志位置为1,然后释放锁。如果标志位为1,说明函数已经被执行过了,直接返回。

4. 使用注意事项

4.1 不能将sync.Once作为函数局部变量

下面是一个简单的例子,说明将

  1. sync.Once
作为局部变量会导致的问题:
  1. var config map[string]string
  2. func initConfig() {
  3. fmt.Println("initConfig called")
  4. config["1"] = "hello world"
  5. }
  6. func getConfig() map[string]string{
  7. var once sync.Once
  8. once.Do(initCount)
  9. fmt.Println("getConfig called")
  10. }
  11. func main() {
  12. for i := 0; i < 10; i++ {
  13. go getConfig()
  14. }
  15. time.Sleep(time.Second)
  16. }

这里初始化函数会被多次调用,这与

  1. initConfig
方法只会执行一次的预期不符。这是因为将
  1. sync.Once
作为局部变量时,每次调用函数都会创建新的
  1. sync.Once
实例,每个
  1. sync.Once
实例都有自己的
  1. done
标志,多个实例之间无法共享状态。导致初始化函数会被多次调用。

如果将

  1. sync.Once
作为全局变量或包级别变量,就可以避免这个问题。所以基于此,不能定义
  1. sync.Once
作为函数局部变量来使用。

4.2 不能在once.Do中再次调用once.Do

下面举一个在

  1. once.Do
方法中再次调用
  1. once.Do
方法的例子:
  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. )
  6. func main() {
  7. var once sync.Once
  8. var onceBody func()
  9. onceBody = func() {
  10. fmt.Println("Only once")
  11. once.Do(onceBody) // 再次调用once.Do方法
  12. }
  13. // 执行once.Do方法
  14. once.Do(onceBody)
  15. fmt.Println("done")
  16. }

在上述代码中,当

  1. once.Do(onceBody)
第一次执行时,会输出"Only once",然后在执行
  1. once.Do(onceBody)
时会发生死锁,程序无法继续执行下去。

这是因为

  1. once.Do()
方法在执行过程中会获取互斥锁,在方法内再次调用
  1. once.Do()
方法,那么就会在获取互斥锁时出现死锁。

因此,我们不能在once.Do方法中再次调用once.Do方法。

4.3 需要对传入的函数进行错误处理

4.3.1 基本说明

一般情况下,如果传入的函数不会出现错误,可以不进行错误处理。但是,如果传入的函数可能出现错误,就必须对其进行错误处理,否则可能会导致程序崩溃或出现不可预料的错误。

因此,在编写传入Once的Do方法的函数时,需要考虑到错误处理问题,保证程序的健壮性和稳定性。

4.3.2 未错误处理导致的问题

下面举一个传入的函数可能出现错误,但是没有对其进行错误处理的例子:

  1. import (
  2. "fmt"
  3. "net"
  4. "sync"
  5. )
  6. var (
  7. initialized bool
  8. connection net.Conn
  9. initOnce sync.Once
  10. )
  11. func initConnection() {
  12. connection, _ = net.Dial("tcp", "err_address")
  13. }
  14. func getConnection() net.Conn {
  15. initOnce.Do(initConnection)
  16. return connection
  17. }
  18. func main() {
  19. conn := getConnection()
  20. fmt.Println(conn)
  21. conn.Close()
  22. }

在上面例子中,其中

  1. initConnection
为传入的函数,用于建立TCP网络连接,但是在
  1. sync.Once
中执行该函数时,是有可能返回错误的,而这里并没有进行错误处理,直接忽略掉错误。此时调用
  1. getConnection
方法,如果
  1. initConnection
报错的话,获取连接时会返回空连接,后续调用将会出现空指针异常。因此,如果传入
  1. sync.Once
当中的函数可能发生异常,此时应该需要对其进行处理。

4.3.3 处理方式

  • 4.3.3.1 panic退出执行

应用程序第一次启动时,此时调用

  1. sync.Once
来初始化一些资源,此时发生错误,同时初始化的资源是必须初始化的,可以考虑在出现错误的情况下,使用panic将程序退出,避免程序继续执行导致更大的问题。具体代码示例如下:
  1. import (
  2. "fmt"
  3. "net"
  4. "sync"
  5. )
  6. var (
  7. connection net.Conn
  8. initOnce sync.Once
  9. )
  10. func initConnection() {
  11. // 尝试建立连接
  12. connection, err = net.Dial("tcp", "err_address")
  13. if err != nil {
  14. panic("net.Dial error")
  15. }
  16. }
  17. func getConnection() net.Conn {
  18. initOnce.Do(initConnection)
  19. return connection
  20. }

如上,当initConnection方法报错后,此时我们直接panic,退出整个程序的执行。

  • 4.3.3.2 修改

    1. sync.Once
    实现,Do函数的语意修改为只成功执行一次

在程序运行过程中,可以选择记录下日志或者返回错误码,而不需要中断程序的执行。然后下次调用时再执行初始化的逻辑。这里需要对

  1. sync.Once
进行改造,原本
  1. sync.Once
中Do函数的实现为执行一次,这里将其修改为只成功执行一次。具体使用方式需要根据具体业务场景来决定。下面是其中一个实现:
  1. type MyOnce struct {
  2. done int32
  3. m sync.Mutex
  4. }
  5. func (o *MyOnce) Do(f func() error) {
  6. if atomic.LoadInt32(&o.done) == 0 {
  7. o.doSlow(f)
  8. }
  9. }
  10. func (o *MyOnce) doSlow(f func() error) {
  11. o.m.Lock()
  12. defer o.m.Unlock()
  13. if o.done == 0 {
  14. // 只有在函数调用不返回err时,才会设置done
  15. if err := f(); err == nil {
  16. atomic.StoreInt32(&o.done, 1)
  17. }
  18. }
  19. }

上述代码中,增加了一个错误处理逻辑。当

  1. f()
函数返回错误时,不会将
  1. done
标记位置为 1,以便下次调用时可以重新执行初始化逻辑。

需要注意的是,这种方式虽然可以解决初始化失败后的问题,但可能会导致初始化函数被多次调用。因此,在编写

  1. f()
函数时,需要考虑到这个问题,以避免出现不可预期的结果。

下面是一个简单的例子,使用我们重新实现的Once,展示第一次初始化失败时,第二次调用会重新执行初始化逻辑,并成功初始化:

  1. var (
  2. hasCall bool
  3. conn net.Conn
  4. m MyOnce
  5. )
  6. func initConn() (net.Conn, error) {
  7. fmt.Println("initConn...")
  8. // 第一次执行,直接返回错误
  9. if !hasCall {
  10. return nil, errors.New("init error")
  11. }
  12. // 第二次执行,初始化成功,这里默认其成功
  13. conn, _ = net.Dial("tcp", "baidu.com:80")
  14. return conn, nil
  15. }
  16. func GetConn() (net.Conn, error) {
  17. m.Do(func() error {
  18. var err error
  19. conn, err = initConn()
  20. if err != nil {
  21. return err
  22. }
  23. return nil
  24. })
  25. // 第一次执行之后,将hasCall设置为true,让其执行初始化逻辑
  26. hasCall = true
  27. return conn, nil
  28. }
  29. func main() {
  30. // 第一次执行初始化逻辑,失败
  31. GetConn()
  32. // 第二次执行初始化逻辑,还是会执行,此次执行成功
  33. GetConn()
  34. // 第二次执行成功,第三次调用,将不会执行初始化逻辑
  35. GetConn()
  36. }

在这个例子中,第一次调用

  1. Do
方法初始化失败了,
  1. done
标记位被设置为0。在第二次调用
  1. Do
方法时,由于
  1. done
标记位为0,会重新执行初始化逻辑,这次初始化成功了,
  1. done
标记位被设置为1。第三次调用,由于之前
  1. Do
方法已经执行成功了,不会再执行初始化逻辑。

以上就是go并发利器sync.Once如何使用的详细内容,更多关于go并发利器sync.Once如何使用的资料请关注九品源码其它相关文章!