Go到底能不能实现安全的双检锁?1

简介: Go到底能不能实现安全的双检锁?1

不安全的双检锁

从其他语言转入Go语言的同学经常会陷入一个思考:如何创建一个单例?

有些同学可能会把其它语言中的双检锁模式移植过来,双检锁模式也称为懒汉模式,首次用到的时候才创建实例。大部分人首次用Golang写出来的实例大概是这样的:

type Conn struct {
  Addr  string
  State int
}
var c *Conn
var mu sync.Mutex
func GetInstance() *Conn {
  if c == nil {
    mu.Lock()
    defer mu.Unlock()
    if c == nil {
      c = &Conn{"127.0.0.1:8080", 1}
    }
  }
  return c
}

这里先解释下这段代码的执行逻辑(已经清楚的同学可以直接跳过):

GetInstance用于获取结构体Conn的一个实例,其中:先判断c是否为空,如果为空则加锁,加锁之后再判断一次c是否为空,如果还为空,则创建Conn的一个实例,并赋值给c。这里有两次判空,所以称为双检,需要第二次判空的原因是:加锁之前可能有多个线程/协程都判断为空,这些线程/协程都会在这里等着加锁,它们最终也都会执行加锁操作,不过加锁之后的代码在多个线程/协程之间是串行执行的,一个线程/协程判空之后创建了实例,其它线程/协程在判断c是否为空时必然得出false的结果,这样就能保证c仅创建一次。而且后续调用GetInstance时都会仅执行第一次判空,得出false的结果,然后直接返回c。这样每个线程/协程最多只执行一次加锁操作,后续都只是简单的判断下就能返回结果,其性能必然不错。

了解Java的同学可能知道Java中的双检锁是非线程安全的,这是因为赋值操作中的两个步骤可能会出现乱序执行问题。这两个步骤是:对象内存空间的初始化和将内存地址设置给变量。因为编译器或者CPU优化,它们的执行顺序可能不确定,先执行第2步的话,锁外边的线程很有可能访问到没有初始化完毕的变量,从而引发某些异常。针对这个问题,Java以及其它一些语言中可以使用volatile来修饰变量,实际执行时会通过插入内存栅栏阻止指令重排,强制按照编码的指令顺序执行。

那么Go语言中的双检锁是安全的吗?

答案是也不安全

先来看看指令重排问题:

在Go语言规范中,赋值操作分为两个阶段:第一阶段对赋值操作左右两侧的表达式进行求值,第二阶段赋值按照从左至右的顺序执行。(参考:golang.google.cn/ref/spec#As…

说的有点抽象,但没有提到赋值存在指令重排的问题,隐约感觉不会有这个问题。为了验证,让我们看一下上边那段代码中赋值操作的伪汇编代码:

1689148791623.png

红框圈出来的部分对应的代码是: c = &Conn{"127.0.0.1:8080", 1}

其中有一行:CMPL $0x0, runtime.writeBarrier(SB) ,这个指令就是插入一个内存栅栏。前边是要赋值数据的初始化,后边是赋值操作。如此看,赋值操作不存在指令重排的问题。

既然赋值操作没有指令重排的问题,那这个双检锁怎么还是不安全的呢?

在Golang中,对于大于单个机器字的值,读写它的时候是以一种不确定的顺序多次执行单机器字的操作来完成的。机器字大小就是我们通常说的32位、64位,即CPU完成一次无定点整数运算可以处理的二进制位数,也可以认为是CPU数据通道的大小。比如在32位的机器上读写一个int64类型的值就需要两次操作。(参考:golang.google.cn/ref/mem#tmp…

因为Golang中对变量的读和写都没有原子性的保证,所以很可能出现这种情况:锁里边变量赋值只处理了一半,锁外边的另一个goroutine就读到了未完全赋值的变量。所以这个双检锁的实现是不安全的。

Golang中将这种问题称为data race,说的是对某个数据产生了并发读写,读到的数据不可预测,可能产生问题,甚至导致程序崩溃。可以在构建或者运行时检查是否会发生这种情况:

$ go test -race mypkg    // to test the package
$ go run -race mysrc.go  // to run the source file
$ go build -race mycmd   // to build the command
$ go install -race mypkg // to install the package

另外上边说单条赋值操作没有重排序的问题,但是重排序问题在Golang中还是存在的,稍不注意就可能写出BUG来。比如下边这段代码:

a=1
b=1
c=a+b

在执行这段程序的goroutine中并不会出现问题,但是另一个goroutine读取到b==1时并不代表此时a==1,因为a=1和b=1的执行顺序可能会被改变。针对重排序问题,Golang并没有暴露类似volatile的关键字,因为理解和正确使用这类能力进行并发编程的门槛比较高,所以Golang只是在一些自己认为比较适合的地方插入了内存栅栏,尽量保持语言的简单。对于goroutine之间的数据同步,Go提供了更好的方式,那就是Channel,不过这不是本文的重点,这里就不介绍了。

sync.Once的启示

还是回到最开始的问题,如何在Golang中创建一个单例?

很多人应该会被推荐使用 sync.Once ,这里看下如何使用:

type Conn struct {
  Addr  string
  State int
}
var c *Conn
var once sync.Once
func setInstance() {
  fmt.Println("setup")
  c = &Conn{"127.0.0.1:8080", 1}
}
func doPrint() {
  once.Do(setInstance)
  fmt.Println(c)
}
func loopPrint() {
  for i := 0; i < 10; i++ {
    go doprint()
  }
}

这里重用上文的结构体Conn,设置Conn单例的方法是setInstance,这个方法在doPrint中被once.Do调用,这里的once就是sync.Once的一个实例,然后我们在loopPrint方法中创建10个goroutine来调用doPrint方法。

按照sync.Once的语义,setInstance应该近执行一次。可以实际执行下看看,我这里直接贴出结果:

setup
&{127.0.0.1:8080 1}
&{127.0.0.1:8080 1}
&{127.0.0.1:8080 1}
&{127.0.0.1:8080 1}
&{127.0.0.1:8080 1}
&{127.0.0.1:8080 1}
&{127.0.0.1:8080 1}
&{127.0.0.1:8080 1}
&{127.0.0.1:8080 1}
&{127.0.0.1:8080 1}

无论执行多少遍,都是这个结果。那么sync.Once是怎么做到的呢?源码很短很清楚:

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()
  }
}

Once是一个结构体,其中第一个字段标识是否执行过,第二个字段是一个互斥量。Once仅公开了一个Do方法,用于执行目标函数f。

这里重点看下目标函数f是怎么被执行的?

  1. Do方法中第一行是判断字段done是否为0,为0则代表没执行过,为1则代表执行过。这里用了原子读,写的时候也要原子写,这样可以保证读写不会同时发生,能够读到当前最新的值。
  2. 如果done为0,则调用doSLow方法,从名字我们就可以体会到这个方法比较慢。
  3. doSlow中首先会加锁,使用的是Once结构体的第二个字段。
  4. 然后再判断done是否为0,注意这里没有使用原子读,为什么呢?因为锁中的方法是串行执行的,不会发生并发读写。
  5. 如果done为0,则调用目标函数f,执行相关的业务逻辑。
  6. 在执行目标函数f前,这里还声明了一个defer:defer atomic.StoreUint32(&o.done, 1) ,使用原子写改变done的值为1,代表目标函数已经执行过。它会在目标函数f执行完毕,doSlow方法返回之前执行。这个设计很精妙,精确控制了改写done值的时机。

可以看出,这里用的也是双检锁的模式,只不过做了两个增强:一是使用原子读写,避免了并发读写的内存数据不一致问题;二是在defer中更改完成标识,保证了代码执行顺序,不会出现完成标识更改逻辑被编译器或者CPU优化提前执行。

需要注意,如果目标函数f中发生了panic,目标函数也仅执行一次,不会执行多次直到成功。

相关文章
|
6月前
|
Go
go的并发初体验、加锁、异步
go的并发初体验、加锁、异步
|
4月前
|
安全 Go API
go语言中的Atomic操作与sema锁
在并发编程中,确保数据一致性和程序正确性是关键挑战。Go语言通过协程和通道提供强大支持,但在需精细控制资源访问时,Atomic操作和sema锁变得至关重要。Atomic操作确保多协程环境下对共享资源的访问是不可分割的,如`sync/atomic`包中的`AddInt32`等函数,底层利用硬件锁机制实现。sema锁(信号量锁)控制并发协程数量,其核心是一个uint32值,当大于零时通过CAS操作实现锁的获取与释放;当为零时,sema锁管理协程休眠队列。这两种机制共同保障了Go语言并发环境下的数据完整性和程序稳定性。
|
4月前
|
安全 Go 调度
[go 面试] 深入理解并发控制:掌握锁的精髓
[go 面试] 深入理解并发控制:掌握锁的精髓
|
4月前
|
存储 安全 程序员
|
4月前
|
NoSQL Go API
[go 面试] 为并发加锁:保障数据一致性(分布式锁)
[go 面试] 为并发加锁:保障数据一致性(分布式锁)
|
4月前
|
存储 安全 编译器
|
5月前
|
安全 Go
Go语言map并发安全,互斥锁和读写锁谁更优?
Go并发编程中,`sync.Mutex`提供独占访问,适合读写操作均衡或写操作频繁的场景;`sync.RWMutex`允许多个读取者并行,适用于读多写少的情况。明智选择锁可提升程序性能和稳定性。示例展示了如何在操作map时使用这两种锁。
64 0
|
5月前
|
安全 Go 开发者
Go语言map并发安全使用的正确姿势
在Go并发编程中,由于普通map不是线程安全的,多goroutine访问可能导致数据竞态。为保证安全,可使用`sync.Mutex`封装map或使用从Go 1.9开始提供的`sync.Map`。前者通过加锁手动同步,后者内置并发控制,适用于多goroutine共享。选择哪种取决于具体场景和性能需求。
70 0
|
5月前
|
存储 安全 Java
Go语言中的map为什么默认不是并发安全的?
Go语言的map默认不保证并发安全,以优化性能和简洁性。官方建议在需要时使用`sync.Mutex`保证安全。从Go 1.6起,并发读写map会导致程序崩溃,鼓励开发者显式处理并发问题。这样做的哲学是让代码更清晰,并避免不必要的性能开销。
62 0