«

Golang并发利器sync.Once怎么使用

时间:2024-4-30 09:01     作者:韩俊     分类: Go语言


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

sync.Once 基本概念

什么是 sync.Once

sync.Once
Go
语言中的一种同步原语,用于确保某个操作或函数在并发环境下只被执行一次。它只有一个导出的方法,即
Do
,该方法接收一个函数参数。在
Do
方法被调用后,该函数将被执行,而且只会执行一次,即使在多个协程同时调用的情况下也是如此。

sync.Once 的应用场景

sync.Once 主要用于以下场景:

    单例模式:确保全局只有一个实例对象,避免重复创建资源。

    延迟初始化:在程序运行过程中需要用到某个资源时,通过

    sync.Once
    动态地初始化该资源。

    只执行一次的操作:例如只需要执行一次的配置加载、数据清理等操作。

sync.Once 应用实例

单例模式

在单例模式中,我们需要确保一个结构体只被初始化一次。使用

sync.Once
可以轻松实现这一目标。

package main

import (
   "fmt"
   "sync"
)

type Singleton struct{}

var (
   instance *Singleton
   once     sync.Once
)

func GetInstance() *Singleton {
   once.Do(func() {
      instance = &Singleton{}
   })
   return instance
}

func main() {
   var wg sync.WaitGroup

   for i := 0; i < 5; i++ {
      wg.Add(1)
      go func() {
         defer wg.Done()
         s := GetInstance()
         fmt.Printf("Singleton instance address: %p
", s)
      }()
   }

   wg.Wait()
}

上述代码中,

GetInstance
函数通过
once.Do()
确保
instance
只会被初始化一次。在并发环境下,多个协程同时调用
GetInstance
时,只有一个协程会执行
instance = &Singleton{}
,所有协程得到的实例
s
都是同一个。

延迟初始化

有时候希望在需要时才初始化某些资源。使用

sync.Once
可以实现这一目标。

package main

import (
   "fmt"
   "sync"
)

type Config struct {
   config map[string]string
}

var (
   config *Config
   once   sync.Once
)

func GetConfig() *Config {
   once.Do(func() {
      fmt.Println("init config...")
      config = &Config{
         config: map[string]string{
            "c1": "v1",
            "c2": "v2",
         },
      }
   })
   return config
}

func main() {
   // 第一次需要获取配置信息,初始化 config
   cfg := GetConfig()
   fmt.Println("c1: ", cfg.config["c1"])

   // 第二次需要,此时 config 已经被初始化过,无需再次初始化
   cfg2 := GetConfig()
   fmt.Println("c2: ", cfg2.config["c2"])
}

在这个示例中,定义了一个

Config
结构体,它包含一些设置信息。使用
sync.Once
来实现
GetConfig
函数,该函数在第一次调用时初始化
Config
。这样,我们可以在真正需要时才初始化
Config
,从而避免不必要的开销。

sync.Once 实现原理

type Once struct {
   // 表示是否执行了操作
   done uint32
   // 互斥锁,确保多个协程访问时,只能一个协程执行操作
   m    Mutex
}

func (o *Once) Do(f func()) {
   // 判断 done 的值,如果是 0,说明 f 还没有被执行过
   if atomic.LoadUint32(&o.done) == 0 {
      // 构建慢路径(slow-path),以允许对 Do 方法的快路径(fast-path)进行内联
      o.doSlow(f)
   }
}

func (o *Once) doSlow(f func()) {
   // 加锁
   o.m.Lock()
   defer o.m.Unlock()
   // 双重检查,避免 f 已被执行过
   if o.done == 0 {
      // 修改 done 的值
      defer atomic.StoreUint32(&o.done, 1)
      // 执行函数
      f()
   }
}

sync.Once
结构体包含两个字段:
done
mu
done
是一个
uint32
类型的变量,用于表示操作是否已经执行过;
m
是一个互斥锁,用于确保在多个协程访问时,只有一个协程能执行操作。

sync.Once
结构体包含两个方法:
Do
doSlow
Do
方法是其核心方法,它接收一个函数参数
f
。首先它会通过原子操作
atomic.LoadUint32
(保证并发安全) 检查
done
的值,如果为 0,表示
f
函数没有被执行过,然后执行
doSlow
方法。

doSlow
方法里,首先对互斥锁
m
进行加锁,确保在多个协程访问时,只有一个协程能执行
f
函数。接着再次检查
done
变量的值,如果
done
的值仍为 0,说明
f
函数没有被执行过,此时执行
f
函数,最后通过原子操作
atomic.StoreUint32
done
变量的值设置为 1。

为什么会封装一个 doSlow 方法

doSlow
方法的存在主要是为了性能优化。将慢路径(
slow-path
)代码从
Do
方法中分离出来,使得
Do
方法的快路径(
fast-path
)能够被内联(
inlined
),从而提高性能。

为什么会有双重检查(double check)的写法

从源码可知,存在两次对

done
的值的判断。

    第一次检查:在获取锁之前,先使用原子加载操作

    atomic.LoadUint32
    检查
    done
    变量的值,如果
    done
    的值为 1,表示操作已执行,此时直接返回,不再执行
    doSlow
    方法。这一检查可以避免不必要的锁竞争。

    第二次检查:获取锁之后,再次检查

    done
    变量的值,这一检查是为了确保在当前协程获取锁期间,其他协程没有执行过
    f
    函数。如果
    done
    的值仍为 0,表示
    f
    函数没有被执行过。

通过双重检查,可以在大多数情况下避免锁竞争,提高性能。

加强的 sync.Once

sync.Once
提供的
Do
方法并没有返回值,意味着如果我们传入的函数如果发生
error
导致初始化失败,后续调用
Do
方法也不会再初始化。为了避免这个问题,我们可以实现一个 类似
sync.Once
的并发原语。

package main

import (
   "sync"
   "sync/atomic"
)

type Once struct {
   done uint32
   m    sync.Mutex
}

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

func (o *Once) doSlow(f func() error) error {
   o.m.Lock()
   defer o.m.Unlock()
   var err error
   if o.done == 0 {
      err = f()
      // 只有没有 error 的时候,才修改 done 的值
      if err == nil {
         atomic.StoreUint32(&o.done, 1)
      }
   }
   return err
}

上述代码实现了一个加强的

Once
结构体。与标准的
sync.Once
不同,这个实现允许
Do
方法的函数参数返回一个
error
。如果执行函数没有返回
error
,则修改
done
的值以表示函数已执行。这样,在后续的调用中,只有在没有发生
error
的情况下,才会跳过函数执行,避免初始化失败。

sync.Once 的注意事项

死锁

通过分析

sync.Once
的源码,可以看到它包含一个名为
m
的互斥锁字段。当我们在
Do
方法内部重复调用
Do
方法时,将会多次尝试获取相同的锁。但是
mutex
互斥锁并不支持可重入操作,因此这将导致死锁现象。

func main() {
   once := sync.Once{}
   once.Do(func() {
      once.Do(func() {
         fmt.Println("init...")
      })
   })
}

初始化失败

这里的初始化失败指的是在调用

Do
方法之后,执行
f
函数的过程中发生
error
,导致执行失败,现有的
sync.Once
设计我们是无法感知到初始化的失败的,为了解决这个问题,我们可以实现一个类似
sync.Once
的加强
once
,前面的内容已经提供了具体实现。

标签: golang

热门推荐