Go语言内存模型

2023-02-03 ⏳6.5分钟(2.6千字)

最近做了一个项目,需要将大量数据加载到内存,然后对外提供查询使用。同时,数据又需要按照一定的策略更新,但更新的频率很低。原则上并发读写需要加锁。但考虑到写的频率远低于读,为这点写加锁实在是浪费,所以就想研究一下有没有不加锁的方案。这就需要用到本文要讲的内存模型(Memory Medel)了。

注意,本文所讲内容面向有经验的开发者。对于新人,本文强烈建议以加锁等经典方式来处理并发读写的问题。正如 Go 语言官方文档1所言:

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.

本文要讲的内容是针对特定条件的优化,必须使用这些黑科技。大家在使用的时候一定要谨慎。

基本概念

在 C/C++ 中,只要没有加锁,所有的并发读写操作都是未定义行为。也就是说具体的执行结果完全由编译器决定。但 Go 语言与之不同,它通过内存模型对并发读写作了较为详细的规定,程序在运行的时候基本不会出现未定义行为。程序在读取内存数据时,如果数据的长度为一个字长2或者半个字长,那么读取的结果一定是原子的。换句话说就是要么读到更新前的值、要么读到更新后的值,而绝不会读到更新了「一半」的值。这简化了调试和排错过程。

所谓内存模型,其实是一种技术规范,它规定了在不显式同步的情况下,不同协程并发读写相同区域内存的行为。具体来说包含两部分。一部分是明确了特定并发场景下各协程的执行顺序。另一部分是规定了多协程并发更新同一内存区域后,更新结果能否被其他协程读取到的具体规则。而我们想实现的不加锁更新数据正是在内存模型的基础上实现的。

接下来我们介绍主要的同步场景和非同步场景。为了行文方便,我们把某协程的改动结果可以被其他协程读取到的处理过程称为「数据同步」。

同步场景

初始化

程序的init()函数由单个协程运行,但程序可能在执行的过程中创建新的协程,它会会并发执行。

如果一个包p导入了包q,那么q中的init()函数一定会在p中的init()函数都执行完成之后才会开始。

程序的主函数main.main()会在所有init()都完成后才执行。

协程创建

程序创建新协程,在协程启动之前会做一次数据同步。

比如在下面的示例中:

var a string

func f() {
  print(a)
}

func hello() {
  a = "hello, world"
  go f()
}

调用函数hello()会在某个时候(此时可能hello()已经结束了)输出”hello, world”。

这里的go f()会让运行时同步数据更新。所以新的协程可以读到变量a最新的值。

协程退出

协程退出并不保证一定会执行数据同步。比如在下面的示例中:

var a string

func hello() {
  go func() { a = "hello" }()
  print(a)
}

调用hello()并不一定能输出”hello”。甚至有些激进的编译器会直接将go func()一行「优化」掉。

如果程序一定想读取协程里的改动,则需要使用加锁等显式同步机制。

通道通信

Go 协程主要依靠通道(Channel)实现通信。对于一个特定通道来说,一次发送必然对应一次接收。通常发送和接收在不同的协程中执行。

在通道发送消息之前的改动会在接接操作完成之前做一次数据同步。

下面的程序:

var c = make(chan int, 10)
var a string

func f() {
  a = "hello, world"
  c <- 0
}

func main() {
  go f()
  <-c
  print(a)
}

始终会输出”hello, world”。因为f()在更新变量a之后向通道c发送数据,而主函数收到后一定能读到a最新的改动。

关闭通道也有同样的效果。

把上述程序中的c <- 0改为close(c)也是输出”hello, world”。

对于无缓冲通道,接收方接收之前的改动也会在发送方发送结束前完成同步。

比如这个程序(就是把上面的代码中交换了发送和接收的顺序并改用无缓冲通道):

var c = make(chan int)
var a string

func f() {
  a = "hello, world"
  <-c
}

func main() {
  go f()
  c <- 0
  print(a)
}

也能保证输出”hello, world”。因为没有缓冲,所以f()没有完成之前c <- 0不会返回。进而主函数在c <- 0完成后一定能看到发送方对a的改动。

如果通道有缓冲,那么程序就不一定输出”hello, world”了。

假设通道缓冲长度为C,那么第k次接收完成之前一定会同步第k+C次发送之前的数据变动。

这是无缓冲通道的推广形式。这种行为其实是实现了「信号量」功能,典型的使用场景是控制最大并发数量。

比如下面的代码:

var limit = make(chan int, 3)

func main() {
  for _, w := range work {
    go func(w func()) {
      limit <- 1
      w()
      <-limit
    }(w)
  }
  select{}
}

每次从work取一个任务并开一个协程处理。但协程在处理之前会先向limit中发送1,并在结束后再接收一条数据。因为limit的长度最大为3,所以同时最多有三个协程可以工作,这就限制了并发的数量。

原子变量

sync/atomic3 中提供的原子操作都会自动同步,不同协程对原子变量的操作都会被其他协程读取。

比如下代码:

var i atomic.Int32

func f() {
  a.Add(1)
}

func main() {
  go f()
  // 暂停一下,好让 go f() 有机会运行
  time.Sleep(1 * time.Second)
  print(i.Load())
}

该程序几乎总是输出1。但有时候main()Sleep等待结束了,go f()还是可能没有执行完毕,这时可能会输出0,但概率已经很小了。这里要演示的是两个协程同时操作原子变量不需要显式加锁。

Finalizers

runtime包提供SetFinalizer函数,可以在特定对象注册「类析构」函数,运行时会在内存回收之前执行该函数。调用SetFinalizer(x, f)之前的改动会完成数据同步,f在执行的时候可以读取到。

其他同步场景

其他诸如sync包提供的所有显示同步工具,它们都有自己的数据同步行为,具体可以考对应的文档。

非同步场景

如果不显式同步,程序读到的内容跟更新的顺序可能会不一致。

举个例子:

var a, b int

func f() {
  a = 1
  b = 2
}

func g() {
  print(b)
  print(a)
}

func main() {
  go f()
  g()
}

上面的程序会出现g先输出2再输出0的情况吗?实际上是可以的。这里的关键是在f 中先更新a后更新b。但在g中读到了更新后的b,也就是2,却读不到更新后的a。这确实有点匪夷所思。但 Russ Cox 在它的文章 Hardware Memory Models4 中有详细讨论,有兴趣的可以前往阅读。

这种行为可能会导致以下代码出错:

var a string
var done bool

func setup() {
  a = "hello, world"
  done = true
}

func doprint() {
  if !done {
    once.Do(setup)
  }
  print(a)
}

func twoprint() {
  go doprint()
  go doprint()
}

我们使用done标记setup是否完成。但即使setup已经完成,doprint读取到的可能仍然是false,从而产生诡异的BUG。

最后给一个更加隐蔽的示例:

type T struct {
  msg string
}

var g *T

func setup() {
  t := new(T)
  t.msg = "hello, world"
  g = t
}

func main() {
  go setup()
  for g == nil {
  }
  print(g.msg)
}

这里是给指针g指定了一个新的结构体变量。即使main函数中已经确定g != nil,但该程序仍然可能输出空字符串!!!这同样跟底层的硬件行为相关。最保险的做法还是手工同步。

无锁更新方案

以上就是内存模型的主要内容。有了这个基础,我们就可以给出无锁的数据更新方案了。

比如我们的数据使用map[int]float64保存。那我们可以声明一个全局变量m

var m map[int]float64

然后我们可以使用直接使用不同的协程查询或者更新这个变量。对于更新来说,我们可以在协程中直接给m赋新值:

go func() {
  nm := load()
  m = nm
}()

这里是直接赋值。如果尝试修改原来的 map 则会直接 panic。但赋值没有问题。又因为 map 变量本身是一个指针,长度为一个字,所以赋值过程是原子的。

对于查询来说,因为每个请求都会起新的协程处理,所以自然可以取到最新的数据:

go func() {
  v := m[k]
}()

根据内存模型规定,数据更新之后新起的协程一定能读到最新的内容。唯一的问题是跟数据更新协程同时运行的协程可能读到的还是旧数据。这在我的使用场景中根本不是问题。

这样就完美实现了无锁更新效果。其实此类读多写少的场景广泛存在,比如配置加载、内存缓存等等。大家可以按需选用。从代码上看好像并不麻烦,但要确保整个程序不出问题,还必须仔细研究内存模型。这是我写作本文的初衷。但无论如何,凡是涉及到并发操作的地方一定要谨慎!


  1. https://go.dev/ref/mem↩︎

  2. 字长即 CPU 一次所能处理的数据长度,比如 64 位或者 32 位。↩︎

  3. https://pkg.go.dev/sync/atomic↩︎

  4. https://research.swtch.com/hwmm↩︎