一次CodeReview引发的GolangMemo

一、背景

1.1 一个 CodeReview 引发的思考

背景是需要在 Golang 项目里面用 Double Check 实现了一个单例,伪代码如下:

var (
    lock     sync.Mutex
    instance *UserInfo
)

func getInstance() (*UserInfo, error) {
   if instance == nil {
      lock.Lock()
      defer lock.Unlock()
      if instance == nil {
         // 上面做各种初始化逻辑,如果有错误则return err
         instance = &UserInfo{
            Name: "test",
         }
      }
   }
   return instance, nil
}
复制代码

go run -race 检查,提示这段代码有 Data Race 的警告:

==================
WARNING: DATA RACE
Read at 0x0000012452a0 by goroutine 8:
  main.getInstance()
      /Users/fanlv/go/src/git/fanlv/GolangDemo/GoTest/go_race_demo.go:32 +0x52
  main.main.func1()
      /Users/fanlv/go/src/git/fanlv/GolangDemo/GoTest/go_race_demo.go:22 +0x33
Previous write at 0x0000012452a0 by goroutine 7:
  main.getInstance()
      /Users/fanlv/go/src/git/fanlv/GolangDemo/GoTest/go_race_demo.go:36 +0x19d
  main.main.func1()
      /Users/fanlv/go/src/git/fanlv/GolangDemo/GoTest/go_race_demo.go:22 +0x33
==================
复制代码

警告中指明在多线程执行 getInstance 这个方法的时候,在 if instance == nil { 这一行会发生 data race。为什么在这一行会有 data race,我们这里先不说,先介绍下 Memory Model 的概念。

1.2 什么是 Memory Model

我们能先看下 Wikipedia 的解释

In computing, a memory model describes the interactions of threads through memory and their shared use of the data.

翻译过来就是:在计算中,内存模型描述了多线程如何通过内存的交互来共享数据。

再看下 Golang 官方文档的解释:The Go Memory Model

The Go memory model specifies the conditions under which reads of a variable in one goroutine can be guaranteed to observe values produced by writes to the same variable in a different goroutine.

翻译过来就是:Go 内存模型指定了某些条件,在这种条件下,可以保证一个共享变量,在一个 goroutine(线程)中写入,可以在另外一个线程被观察到。

我们总结下: Memory Model 其实是一个概念,表示在多线程场景下,如何保证数据同步的正确性(主要包括可见性和有序性)。

二、CPU 的缓存一致性

2.1 线程可见性问题

我们先看一下这个线程可见性的测试 DEMO

func main() {
   var flag int32 = 1
   go func() {
      count := 1
      for flag == 1 {
         count++
      }
      println("thread1 end") //这个循环永远也不会结束,为什么?
   }()

   go func() {
      for {
         flag = 0
      }
   }()
   time.Sleep(time.Hour)
   return
}
复制代码

为什么在一个线程修改了共享变量,另外一个线程感知不到呢?这里我们需要先了解下 CPU 的 Cache 结构。

image.png

现代多核 CPU 的 Cache 模型基本都跟上图所示一样,L1 L2 cache 是每个核独占的,只有 L3 是共享的,当多个 CPU 读、写同一个变量时,就需要在多个 CPU 的 Cache 之间同步数据,跟分布式系统一样,必然涉及到一致性的问题。为了解决这个问题,所以有了 CPU 的缓存一致性协议

2.2 CPU 缓存一致性协议-MESI

在 MESI(还有 MSI、MESIF 等等)协议中,每个 Cache line 有 4 个状态,可用 2 个 bit 表示,它们分别是:

  • 失效(Invalid)缓存段,要么已经不在缓存中,要么它的内容已经过时。为了达到缓存的目的,这种状态的段将会被忽略。一旦缓存段被标记为失效,那效果就等同于它从来没被加载到缓存中。
  • 共享(Shared)缓存段,它是和主内存内容保持一致的一份拷贝,在这种状态下的缓存段只能被读取,不能被写入。多组缓存可以同时拥有针对同一内存地址的共享缓存段,这就是名称的由来。
  • 独占(Exclusive)缓存段,和 S 状态一样,也是和主内存内容保持一致的一份拷贝。区别在于,如果一个处理器持有了某个 E 状态的缓存段,那其他处理器就不能同时持有它,所以叫“独占”。这意味着,如果其他处理器原本也持有同一缓存段,那么它会马上变成“失效”状态。
  • 已修改(Modified)缓存段,属于脏段,它们已经被所属的处理器修改了。如果一个段处于已修改状态,那么它在其他处理器缓存中的拷贝马上会变成失效状态,这个规律和 E 状态一样。此外,已修改缓存段如果被丢弃或标记为失效,那么先要把它的内容回写到内存中——这和回写模式下常规的脏段处理方式一样。

举例说明

假设一个四核的 CPU,只有 Core 0 访问变量 x,它的 Cache line 状态为 E(Exclusive):

image.png

当有两个 Core 都访问了变量 x,它们对应的 Cache line 为 S(Shared)状态:

image.png

Core 0 修改了 x 的值之后,这个 Cache line 变成了 M(Modified)状态,其他 Core 对应的 Cache line 变成了 I(Invalid)状态:

image.png

在 MESI 协议中,每个 Cache 的 Cache 控制器不仅知道自己的读写操作,而且也监听(snoop)其它 Cache 的读写操作。每个 Cache line 所处的状态根据本核和其它核的读写操作在 4 个状态间进行迁移。

image.png

2.3 为什么有 MESI 协议还会有缓存一致性问题

由上面的 MESI 协议,我们可以知道如果满足下面两个条件,你就可以得到完全的顺序一致性:

  1. 缓存一收到总线事件,就可以在当前指令周期中迅速做出响应.
  2. 处理器如实地按程序的顺序,把内存操作指令送到缓存,并且等前一条执行完后才能发送下一条。

当然,实际上现代处理器一般都无法满足以上条件,主要原因有:

  • 缓存不会及时响应总线事件。如果总线上发来一条消息,要使某个缓存段失效,但是如果此时缓存正在处理其他事情(比如和 CPU 传输数据),那这个消息可能无法在当前的指令周期中得到处理,而会进入所谓的“失效队列(invalidation queue)”,这个消息等在队列中直到缓存有空为止。
  • 处理器一般不会严格按照程序的顺序向缓存发送内存操作指令。当然,有乱序执行(Out-of-Order execution)功能的处理器肯定是这样的。顺序执行(in-order execution)的处理器有时候也无法完全保证内存操作的顺序(比如想要的内存不在缓存中时,CPU 就不能为了载入缓存而停止工作)。
  • 写操作尤其特殊,因为它分为两阶段操作:在写之前我们先要得到缓存段的独占权。如果我们当前没有独占权,我们先要和其他处理器协商,这也需要一些时间。同理,在这种场景下让处理器闲着无所事事是一种资源浪费。实际上,写操作首先发起获得独占权的请求,然后就进入所谓的由“写缓冲(store buffer)”组成的队列(有些地方使用“写缓冲”指代整个队列,我这里使用它指代队列的一条入口)。写操作在队列中等待,直到缓存准备好处理它,此时写缓冲就被“清空(drained)”了,缓冲区被回收用于处理新的写操作。
  • 这些特性意味着,默认情况下,读操作有可能会读到过时的数据(如果对应失效请求还等在队列中没执行),写操作真正完成的时间有可能比它们在代码中的位置晚,一旦牵涉到乱序执行,一切都变得模棱两可。

三、Golang 一致性原语

3.1 什么是 Happens Before

Happens Before 是 Memory Model 中一个通用的概念。主要是用来保证内存操作的可见性。如果要保证 E1 的内存写操作能够被 E2 读到,那么需要满足:

  • E1 Happens Before E2;
  • 其他所有针对此内存的写操作,要么 Happens Before E1,要么 Happens After E2。也就是说不能存在其他的一个写操作 E3,这个 E3 Happens Concurrently E1/E2。

让我们再回头来看下官方文档 The Go Memory Model,里面讲到 golang 中有数个地方实现了 Happens Before 语义,分别是 init 函数、goruntine 的创建、goruntine 的销毁、channel 通讯、锁、sync、sync/atomic、sync/waitgroup.

Init

  • 如果包 P1 中导入了包 P2,则 P2 中的 init 函数 Happens Before 所有 P1 中的操作
  • main 函数 Happens After 所有的 init 函数

Goroutine

  • Goroutine 的创建 Happens Before 所有此 Goroutine 中的操作
  • Goroutine 的销毁 Happens After 所有此 Goroutine 中的操作

Channel

  • channel 底层实现主要是由 ringbuf、sendqueue、recequeue、mutex 组成。
  • 内部实现主要是使用锁来保证一致性,但这把锁并不是标准库里的锁,而是在 runtime 中自己实现的一把更加简单、高效的锁。

Lock

Go 里面有 Mutex 和 RWMutex 两种锁,RWMutex 是在 Mutex 基础上实现的。所以这里主要说下 Mutex。

Mutex 是一个公平锁,有正常模式和饥饿模式两种状态。看下 mutex 结构体

type Mutex struct {
    // 第 0 位:表示是否加锁,第 1 位:表示有 goroutine 被唤醒,尝试获取锁;第 2 位:是否为饥饿状态。
    state int32
    // semaphore,锁的信号量。
    // 一般通过 runtime_SemacquireMutex 来获取、runtime_Semrelease 来释放
    sema  uint32 
}
复制代码

在看下Mutex加锁是怎么实现的

func (m *Mutex) Lock() {
    // 先 CAS 判断是否加锁成功,成就返回
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        return
    }
    // lockSlow 里面主要是尝试自旋、正常模式、饥饿模式切换
    m.lockSlow()

}
复制代码

sync.Mutex 底层都是使用 Atomic 来读写锁的状态。所以我们可以理解为,Mutex 都是基于 Atomic 来实现Happens Before 语义。我们下面来看下 Atomic 是如何实现的。

Atomic

Golang 中的 Atomic 主要保证了三件事,原子性、可见性、有序性

我们先看下 Go 的源码里面 Atomic 的 API,主要包括 Swap、CAS、Add、Load、Store、Pointer 几类,在 IA64 CPU 上对应的汇编指令如下:

关于 LOCK prefix 和 XCHG 指令英特尔开发人员手册 section 8.2.5 中,我们找到了如下的解释:

For the Intel486 and Pentium processors, the LOCK# signal is always asserted on the bus during a LOCK operation, even if the area of memory being locked is cached in the processor.

For the P6 and more recent processor families, if the area of memory being locked during a LOCK operation is cached in the processor that is performing the LOCK operation as write-back memory and is completely contained in a cache line, the processor may not assert the LOCK# signal on the bus. Instead, it will modify the memory location internally and allow it’s cache coherency mechanism to ensure that the operation is carried out atomically. This operation is called “cache locking.” The cache coherency mechanism automatically prevents two or more processors that have cached the same area of memory from simultaneously modifying data in that area.

The I/O instructions, locking instructions, the LOCK prefix, and serializing instructions force stronger orderingon the processor.

Synchronization mechanisms in multiple-processor systems may depend upon a strong memory-ordering model. Here, a program can use a locking instruction such as the XCHG instruction or the LOCK prefix to ensure that a read-modify-write operation on memory is carried out atomically. Locking operations typically operate like I/O operations in that they wait for all previous instructions to complete and for all buffered writes to drain to memory (see Section 8.1.2, “Bus Locking”).

从描述中,我们了解到:LOCK prefix 和 XCHG 指令前缀提供了强一致性的内(缓)存读写保证,可以保证 LOCK 之后的指令在带 LOCK 前缀的指令执行之后才会执行。同时,我们在手册中还了解到,现代的 CPU 中的 LOCK 操作并不是简单锁 CPU 和主存之间的通讯总线, Intel 在 cache 层实现了这个 LOCK 操作,此因此我们也无需为 LOCK 的执行效率担忧。

PS:Java 中的 volatile 关键字也是基于 Lock prefix 实现的。

从上面可以看到 Swap、CAS、Add、Store 都是基于 LOCK prefix 和 XCHG 指令实现的,他能保证缓存读写的强一致性。

我们单独来看下 Load 指令,在 IA32IA64Arm 的 CPU 架构下就是对应 MOV 指令。我们写个简单 demo 验证下。测试代码如下:

var numB uint32

func main() {
   numB = 8
   fmt.Println(normalLoad())
   fmt.Println(atomicLoad())
}

func normalLoad() uint32 {
   a := numB
   return a
}

func atomicLoad() uint32 {
   a := atomic.LoadUint32(&numB)
   return a
}
复制代码

我们 go build -gcflags "-N -l" atomic.go 编译以后再 objdump -d atomic 导出对应的汇编代码。我们看到 normalLoad()atomicLoad() 对应的汇编代码是一样的,也印证了,我们上面说的 atomic.Load 方法在 IA64 就是简单的 MOV 指令。

image.png

再回来看,我们知道 Golang 的 Atomic 方法保证了三件事,原子性、可见性、有序性。

可见性和有序性 Store 方法保证了,Load 方法使用 MOV 指令只要能保证原子性就行了。我们知道 golang 里面是内存对齐的,所以能保证 MOV 指令是原子的。

3.2 Golang Happen Before 语义继承图:

                +----------+ +-----------+   +---------+
                | sync.Map | | sync.Once |   | channel |
                ++---------+++---------+-+   +----+----+
                 |          |          |          |
                 |          |          |          |
+------------+   | +-----------------+ |          |
|            |   | |       +v--------+ |          |
|  WaitGroup +---+ | RwLock|  Mutex  | |   +------v-------+
+------------+   | +-------+---------+ |   | runtime lock |
                 |                     |   +------+-------+
                 |                     |          |
                 |                     |          |
                 |                     |          |
         +------+v---------------------v   +------v-------+
         | LOAD | other atomic action  |   |runtime atomic|
         +------+--------------+-------+   +------+-------+
                               |                  |
                               |                  |
                  +------------v------------------v+
                  |           LOCK prefix          |
                  +--------------------------------+
复制代码

3.3 回头再看 Golang Double Check 的问题

var (
    lock     sync.Mutex
    instance *UserInfo
)

func getInstance() (*UserInfo, error) {
    if instance == nil {
        lock.Lock()
        defer lock.Unlock()
        if instance == nil {
            instance = &UserInfo{
                Name: "test",
            }
        }
    }
    return instance, nil
}
复制代码

再看下这段有问题的代码,由上面的 Golang Happy Before 一致性原语我们知道,instance 修改在 lock 临界区里面,其他的线程是可见的。那为什么在 if instance == nil 还是会发生 Data Race 呢?

真正的原因是是在 instance = &UserInfo{Name: "test"} 这句代码,这句代码并不是原子操作,这个赋值可能是会有几步指令,比如

  1. 先 new 一个 UserInfo
  2. 然后设置 Name="test"
  3. 最后把了 new 的对象赋值给 instance

如果这个时候发生了乱序,可能会变成

  1. 先了 new 一个 UserInfo
  2. 然后再赋值给 instance
  3. 最后再设置 Name="test"

A 进程进来的时候拿到锁,然后对 instance 进行赋值,这个时候 instance 对象是一个半初始化状态的数据,线程 B 来的时候判断 if instance == nil 发现不为 nil 就直接把半初始化状态的数据返回了,所以会有问题。

知道了原因,改造代码如下:

var flag uint32

func getInstance() (*UserInfo, error) {
   if atomic.LoadUint32(&flag) != 1 {
      lock.Lock()
      defer lock.Unlock()
      if instance == nil {
         // 其他初始化错误,如果有错误可以直接返回
 	instance = &UserInfo{
            Age: 18,
         }
         atomic.StoreUint32(&flag, 1)
      }
   }
   return instance, nil
}
复制代码

再次用 go run -race 检查发现已经没有警告了。

这里,我们主要是通过 atomic.store 和 lock 来保证 flag 和 instance 的修改对其他线程可见。通过 atomic.LoadUint32(&flag) 和 double check 来保证 instance 只会初始化一次。

总结

本文涉及到很多 CPU 底层实现逻辑,如果看完还是一头雾水可以选择无视。因为需要用到这些知识点的机会本就少之又少。正如 The Go Memory Model 所言:

If you must read the rest of this document to understand the behavior of your program, you are being too clever.
Don't be clever.

参考资料


加入我们

飞书-字节跳动旗下企业协作平台,集视频会议、在线文档、移动办公、协同软件的一站式企业沟通协作平台。目前飞书业务正在飞速发展中,在北京、深圳等城市都有研发中心,前端、移动端、Rust、服务端、测试、产品等职位都有足够的 HC,期待你的加入,和我们一起做有挑战的事情(请戳链接:future.feishu.cn/recruit

我们也欢迎和飞书的同学一起进行技术问题的交流,有兴趣的同学请点击 飞书技术交流群 入群交流。