Justin-刘清政的博客

Go内存模型

2019-03-10

内存模型介绍

Go语言的内存模型规定了一种规则。这种规则可以保证,在一个goroutine读取某个变量的值,是其他goroutine对同一个变量写入的值。

建议

程序中多个goroutine同时修改相同数据,必须使之能够按序访问。为了按序访问以及保护数据,可以使用channel操作或者其他同步原语例如sync及sync/atomic包。

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.

Happens-Before原则

在单个goroutine中,读取和写入的操作一定与程序的执行顺序一致。 也就是说,仅当重新排序语句不会改变语言规范所定义的该goroutine中的行为时,编译器和处理器才可以对单个goroutine中执行的读取和写入进行重新排序。 由于此重新排序,一个goroutine观察到的执行顺序可能不同于另一个goroutine所察觉的执行顺序。 例如,如果一个goroutine执行a = 1; b = 2;,另一个goroutine可能会a的值更新前,就已经观察到b的更新后的值。

在Go程序中,为了限定读取和写入的要求,我们定义happens-before原则,此原则限定了程序在读写内存时的一种偏序关系。 如果事件e1发生在事件e2之前,那么我们说e2发生在e1之后。 同样,如果e1不在e2之前发生并且在e2之后也没有发生,那么我们说e1和e2同时发生。

Happen-Before是一个术语,不仅仅是go才有的。

原始的定义是由Leslie Lamport在1978年的文章 Time, Clocks and the Ordering of Events in a Distributed System中提出来的

另见维基百科 https://en.wikipedia.org/wiki/Happened-before

在单个goroutine中,happens-before发生顺序即是程序中的顺序。

如果同时满足下述两个规则,那么对变量v的读操作r能够观察到对变量v的写操作w:

  1. 读操作r没有发生在写操作w之前
  2. 没有另外一个针对变量v的写操作w`发生在,写操作w之后且在读操作r之前

为了保证对变量v的读操作r能够观察到对变量v的特定的写操作w的值,即确保读操作r可以观察到写操作w是唯一的对变量v的写操作。 那么,如果满足下述两个原则,读操作r将能够观察到写操作w

  1. w发生在r之前
  2. 任何其他的对共享变量v的写操作w`发生在写操作w之前或者读操作r之后

这对条件的约束强于前面一对条件的约束;它需要没有其他任何的写操作为w`,与写操作w 或 读操作r同时发生。

在单一的goroutine中,由于没有并发,因此两个定义也是等价的:读操作r可以观察到最近的写操作w写入到v的值。当多个goroutine访问一个共享变量v时,必须使用同步事件建立happens-before条件来确保读操作观察到期望的写操作的变化。

使用变量v的类型零值初始化变量v的行为与内存模型中写操作相同。

如果读取和写入的值大于一个机器字,那么多个机器字节的操作顺序不是固定的顺序。

同步

初始化

程序初始化操作是在单个goroutine中,但是该goroutine可能会创建的其他goroutine,这些goroutines是并发运行的。

如果包p导入了包q,则q包中的的init函数的完成发生在任何p包中的init函数开始之前。

函数main.main的启动发生在所有init函数完成之后。

Goroutine创建

使用go语句启动新的goroutine发生在此goroutine执行前。

例如下面的程序

1
2
3
4
5
6
7
8
9
10
var a string

func f() {
print(a)
}

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

调用hello()函数会在某个时刻打印hello, world字符串(也可能打印字符串的时刻发生在hello函数返回之后)

Goroutine销毁

不能保证goroutine的退出在程序中发生任何事件之前发生。 例如下面的程序中:

1
2
3
4
5
6
var a string

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

对变量a的赋值没有跟随任何同步事件之后, 所以不能保证会被其他goroutine观察到。 事实上,一个激进的编译器可能会删除整个go声明语句。

如果想让一个goroutine的执行操作影响必须对另外一个goroutine可见, 可以使用同步机制(例如lock或者channel通信)来确定相对的顺序。

Channel 通信

channel通信是goroutine之间同步数据的主要方法。 通常在不同的goroutine中,将特定channel上的每个发送与该channel上的相应接收进行匹配。

channel上的发送 happens-before 该channel上的相应接收操作完成。

下面程序

1
2
3
4
5
6
7
8
9
10
11
12
13
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”。 对a的写先于对c信息的发送,c的发送一定会先于c接受的完成,因此这些都先于print调用完成

happens before 具有传递性

关闭一个channel happens-before channel收到返回零值, 因为channel已经关闭了

在前面的例子中,如果使用close(c)代替c <- 0,能够保证相同的行为。

从一个无缓冲的channel接收数据 happens-before 向这个channel完成发送数据之前

下面这段程序(如前面的代码,但是使用无缓冲的channel)

1
2
3
4
5
6
7
8
9
10
11
12
var c = make(chan int)
var a string

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

这段程序一定能够打印出“hello,world”。对变量a的写操作发生在channel变量c的接收之前, c的接收发生在c的发送之前,c的发送发生在print打印之前。

如果是带缓冲的channel(例如c = make(chan int, 1)),那么程序则不一定能保证会打印“hello,world”。(可能打印空字符串或者crash、或者其他未知情况)

在缓冲大小为C的chanel上接收第k个数据 happens-before 在第 k + C发送完成之前。

该规则将前一个规则(无缓冲的channel)推广到缓冲channel。 它允许通过缓冲的channel对计数信号量进行建模:channel中的项目数量对应于活动使用的数量,channel的容量对应于同时使用的最大数量,发送一个项目获取信号量,以及 接收项目会释放信号量。 这是限制并发性的常见用法。

该程序为工作列表中的每个条目启动一个goroutine,但是goroutine使用限制channel进行协调,以确保一次最多运行三个work。

1
2
3
4
5
6
7
8
9
10
11
12
var limit = make(chan int, 3)

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

Locks 锁

sync包实现了两种类型的锁:互斥锁(sync.Mutex)及读写锁(sync.RWMutex)

对于任意 sync.Mutex 或 sync.RWMutex 类型的变量l。 如果 n < m ,那么第n次 l.Unlock() 调用在第 m次 l.Lock()调用返回前发生。

下面程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var l sync.Mutex
var a string

func f() {
a = "hello, world"
l.Unlock()
}

func main() {
l.Lock()
go f()
l.Lock()
print(a)
}

程序一定可以输出”hello, world”。第一次调用l.Unlock()(在函数f中)发生在第二次调用l.Lock()之前(在main函数中),第二次调用l.Lock()发生在print之前。

上面的例子相当于 n=1, m=2

在一个sync.RWMutex变量l上任何调用 l.RLock,都有一个n,使得l.RLock在调用n个l.Unlock之后发生(并返回),并且匹配的l.RUnlock发生在调用第n + 1个l.Lock之前。

Once

这个同步包为多个goroutines进行初始化只执行一次的操作,提供了一种安全的机制,多个线程可以执行once.Do(f),那么函数会且只会有一个执行f函数,而其他调用将阻塞直到f函数的返回。

使用once.Do(f)执行f()并返回,发生在任何调用once.Do(f)返回之前。

下面程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var a string
var once sync.Once

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

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

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

调用twoprint将只调用一次setup函数。 setup函数将在两次调用打印之前完成。 结果将是“ hello,world”将被打印两次。

错误的同步

注意,读操作r可能会观察到与r同时发生的写操作w写入的值。 即使发生这种情况,也并不意味着在r之后发生的读取将观察到在w之前发生的写入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var a, b int

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

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

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

有可能打印出2和0

即b已经赋值为2,但是a还未被赋值

这个事实使一些常见的习语无效。

双检锁是为了避免同步的开销,例如,twoprint程序可能会错误地写成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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()
}

这里不能保证在doprint函数里面,能够观察到变量done的值,就意味着能够观察到对a的赋值变化。 这里可能会输出空字符串,而不是希望的hello, world

下述为另外一个错误示例(一直忙等)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var a string
var done bool

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

func main() {
go setup()
for !done {
}
print(a)
}

上述示例所示,在主函数main中,观察到done值为true并不能保证一定能看到对a的赋值变化, 因此本示例程序也有可能打印空值。 更糟糕的是,setup函数对done=true的赋值,并不一定能被main函数观察到,因此这两个线程之间并没有使用任何的同步事件。 因此for循环在主函数中并不能保证一定会循环结束。

下述为一些更为难以发现的变体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 != nil且退出了循环,也不能保证main能够观察到g.msg的赋值内容。

对于上述的所有错误示例,都可以采用同样的解决办法:使用明确的同步方式。

Tags: Go
使用支付宝打赏
使用微信打赏

点击上方按钮,请我喝杯咖啡!

扫描二维码,分享此文章