[译] Go内存模型

The Go Memory Model

The Go Memory Model

Introduction

Go的内存模型指定了一个数据共享、可见条件,这个条件保证在一个goroutine中写入一个数据,另外的goroutine对相同的数据读取时可见。

Advice

程序对多个goroutines同时访问的数据的修改,必须序列化对该数据的访问(包括读和写)。要对数据进行序列化访问,可以使用channel或syncsync/atomic包中提供的同步原语。

Happens Before

在一个goroutine里面,读和写操作必须按照程序指定的顺序执行。也就是说,编译器和处理器只有在不会改变程序定义的goroutine的行为的情况下才会乱序执行一个goroutine的读和写操作。因为乱序的原因,一个goroutine看到的执行顺序可能跟其他goroutine感受到的不一样。比如一个goroutine执行a = 1; b = 2,其他的goroutines可能先获取到b的更新,然后才是a的更新。

我们定义了happen before来指定读取和写入, happen before是Go程序中执行内存操作部分的顺序。如果事件e1在事件e2之前发生,我们则说e2e1之后发生。同样,如果e1不是发生在e2之前,又不是发生在e2之后,我们则说e1e2并行发生。

在一个goroutine里面,happen before顺序是程序表达的顺序。

在以下两个条件都成立时写操作w写变量v,对读操作r对变量v的读取可见。

  1. r不在w前发生;
  2. w之后,r之前没有其他的w'v.

为了保证rv看到的就是特定的w写的,需要确保w是r被允许观察到的唯一一个写操作。也就是说下面两个条件都满足时,保证r能够看到w写的值:

  1. wr之前发生;
  2. 任何其他对共享变量v的写操作都发生在w之前或r之后。

这两个条件比前两个条件更严格。它要求没有其他的写操作与wr并发。

在一个goroutine里面,因为没有并发,所以前面两种定义是相等的:一个对v的读总是可见最近的一个对w的写操作。当有多个goroutines访问一个共享的v时,就需要引入同步机制来建立happen-before条件来确保读操作见到的是它期望的写操作。

将变量v初始化为它的类型零值在内存模型中被当成一次写操作。

对一个占用空间大于一个机器字的数据的读写表现为对多个机器字的读写,读写顺序不能指定(未知,可能被编译器和处理器乱序)。

Synchronization

Initialization

程序初始化运行在一个goroutine里面,但是这个goroutine可能会创建和并发运行其他goroutines。

如果一个包p import了另一个包qqinit函数将在p的任何代码之前执行。main.main函数在所有import包的init函数执行完后开始执行。

Goroutine Creation

go语句启动一个新的goroutine比这个goroutine开始执行先发生。

例如,下面代码中调用hello会在程序的某个点打印"hello, world",这个点可能是在hello返回之后。

var a string

func f() {
	print(a)
}

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

Goroutine destruction

Goroutine的退出并不能被保证是发生在程序的任何事件之前。例如下面的代码中,对a的复制没有跟任何的同步事件,所以这个复制操作并不保证对任何其他Goroutine可见。事实上比较激进的编译器可能还会移除掉这一整行代码。

var a string

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

如果一个goroutine的影响必须对其他goroutines可见,使用像lockchannel通信这样的机制来建立一个相对的顺序。

Channel Communication

通过channel通信是goroutines之间同步的主要方法。每一个对特定channel的写都对应一个对应的对该channel的读操作,通常这发生在不同的goroutines里面。

对一个channel的写发生在对对应的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",因为对a的赋值发生在写channel前,写channel又发生在读channel完成前,所有这些都发生在打印前。

在从一个channel读取数据时channel已经被关闭,将返回一个零值

上面的示例代码中,将c <- 0替换为close(c)将产生一个行为相同的程序。

从一个不带缓冲的channel读取数据发生在向这个channel发送数据完成之前

下面这段代码跟前面的一样,只是互换到了channl发送和接收的位置,且使用的是不带缓冲的channel。

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的赋值发生在接收channelc之前,接收channel发生在写channel完成前,所有这些都发生在打印前。

如果把channel改成带Buffer的(例如:c := make(chan int, 1)),然后这段程序就不能保证还能打印"hello, world"了。(它可能会打印空字符串,崩溃甚至做任何其他事情。)

从一个容量为C的带缓冲channel读取第k次发生在第k+C次写入完成之前(例如第2次读取发生在第2+C次写之前,这就不会造成2+C次写阻塞)

这条规则将之前的规则概括到了带缓冲的channel上。它允许计数信号量(counting Semaphore)通过带缓冲channel的方式建模:channel中实际数据的数量对应实际使用的数量,channel的容量对应可同时使用的最大数量。发送一个数据获得一个信号量,读取一个数据释放一个信号量。这是限制并发数量的一个常用做法。

下面的代码为work列表中的每个入口都起了个goroutine,但是所有这些goroutines之间使用limitchannel协调,确保同时只有3个goroutines执行。

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.Mutexsync.RWMutex

对于任何的sync.Mutexsync.RWMutex变量l,并且假设n<m。第n次调用l.Unlock()发生在第m次调用l.Lock()前。

如下面这段程序保证能打印“hello,world",第一次调用l.Unlock()(在f()中)发生在第二次调用l.Lock()(在main()中)之前,这些都发生在打印数据之前。

var l sync.Mutex
var a string

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

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

对于sync.RWMutex变量l上的任何l.RLock()调用,存在一个n,就是l.RLock()发生在n次调用l.Unlock()之后,然后与之匹配的l.RUnlock发生在n+1l.Lock调用之前。

Once

sync包通过Once类型提供了在多个goroutines存在的情况下进行初始化的安全机制。对于一个特定的f多个线程都可以执行once.Do(f),但是最终实际只有一个线程会运行f(),并且其他的线程都将被阻塞直到f()返回。

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将打印两次”hello,world“,但是setup只会被执行一次。

Incorrect synchronization

注意一个读取r可能能观察到并发的写w对一个数据的写操作。即使这个可能发生了,这也并不意味着在r之后的读操作就能看到在w之前的写操作写入的数据。(**注:所以这里所的之前,之后都是代码语义上的前和后,并不是实际执行的前和后。正式因为不是实际的执行顺序,才造成了前面说的情况的存在。)

var a, b int

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

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

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

上面的代码中,g()可能会先打印2然后再打印0。这个事实使一些常见的惯用语法无效。(编译器或处理器乱序执行造成的。)

双重检查锁是一种降低同步开销的尝试。例如,twopoint程序可能被错误的写成下面的样子:

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的写操作就意味着doprint能够看到对a的赋值操作。所以这个版本可能会错误的打印一个空字符串,而不是打印"hello, world"。

另一种错误的语法是一直等(busy waiting)一个值,例如:

var a string
var done bool

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

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

跟前面的示例一样,这里同样不能保证在main中能够看到对done的写操作,并不意味着就能看到对a的操作。所以这段代码也可能会打印一段空字符串。更坏的情况是,因为没有一个同步机制,这里setup中对done的写操作可能并不一定能被main看到(比如有其他的goroutine在main看到前又将done设成了false),main函数的循环可能永远不会结束。

这种写法的程序还有一个变体版本,比如:

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

就算main中看到了对g的赋值g=t使得g != nil,这也并不能保证g就已经获得了初始化后新赋的msg值.

结论

前面所有的示例都指向了一个相同的解决方案:使用显式的同步

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值