Go语言 Go中的内存顺序保证

2023-02-16 17:39 更新

关于内存顺序

很多编译器优化(在编译时刻)和CPU处理器优化(在运行时刻)会常常调整指令执行顺序,从而使得指令执行顺序和代码中指定的顺序不太一致。 指令顺序也称为内存顺序

当然,指令执行顺序的调整规则不是任意的。 最基本的要求是发生在一个不与其它协程共享数据的协程内部的指令执行顺序调整在此协程内部必须不能被觉察到。 换句话说,从这样的一个协程的角度看,其中的指令执行顺序和代码中指定的顺序总是一致的,即使确实有一些指令的执行顺序发生了调整。

然而,如果一些协程之间共享数据,那么在其中一个协程中发生的指令执行顺序调整将有可能被剩余的其它协程觉察到,从而影响到所有这些协程的行为。 在并发编程中,多个协程之间共享数据是家常便饭。 如果我们忽视了指令执行顺序调整带来的影响,我们编写的并发程序的行为将依赖于特定编译器和CPU。这样的程序常常表现出异常行为。

下面是一个编写得非常不职业的Go程序。此程序的编写没有考虑指令执行顺序调整带来的影响。 此程序扩展于官方文档Go 1 内存模型一文中的一个例子.

package main

import "log"
import "runtime"

var a string
var done bool

func setup() {
	a = "hello, world"
	done = true
	if done {
		log.Println(len(a)) // 如果被打印出来,它总是12
	}
}

func main() {
	go setup()

	for !done {
		runtime.Gosched()
	}
	log.Println(a) // 期待的打印结果:hello, world
}

此程序的行为很可能正如我们所料,hello, world将被打印输出。 然而,此程序的行为并非跨编译器和跨平台架构兼容的。 如果此程序使用一个不同的(但符合Go规范的)编译器或者不同的编译器版本编译,它的运行结果可能是不同的。 即使此程序使用同一个编译器编译,在不同的平台架构上的运行结果也可能是不同的。

编译器和CPU可能调整setup函数中的前两条语句的执行顺序,使得setup协程中的指令的执行顺序和下面的代码指定的顺序一致。

func setup() {
	done = true
	a = "hello, world"
	if done {
		log.Println(len(a))
	}
}

setup协程并不会觉察到此执行顺序调整,所以此协程中的log.Println(len(a))语句将总是打印出12(如果此打印语句在程序退出之前得到了执行的话)。 但是,此执行顺序调整将被主协程觉察到,所以最终的打印结果有可能是空,而不是hello, world

除了没有考虑指令执行顺序调整带来的影响,此程序还存在数据竞争的问题。 变量adone的使用没有进行同步。 所以此程序是一个充满了各种并发编程错误的不良例子。 一个职业的Go程序员不应该写出这样的使用于生产环境中的代码。

我们可以使用Go官方工具链中的go build -race命令来编译并运行一个程序,以检查此程序中是否存在着数据竞争。

Go内存模型(Memory Model)

有时,为了程序的逻辑正确性,我们需要确保一个协程中的一些语句一定要在另一个协程的某些语句之后(或者之前)执行(从这两个协程的角度观察都是如此)。 指令执行顺序调整可能会给此需求带来一些麻烦。 我们应如何防止某些可能发生的指令执行顺序调整呢?

不同的CPU架构提供了不同的栅栏(fence)指令来防止各种指令执行顺序调整。 一些编程语言提供对应的函数来在代码中的合适位置插入各种栅栏指令。 但是,理解和正确地使用这些栅栏指令极大地提高了并发编程的门槛。

Go语言的设计哲学是用尽量少的易于理解的特性来支持尽量多的使用场景,同时还要尽量保证代码的高执行效率。 所以Go内置和标准库并没有提供直接插入各种栅栏指令的途径。 事实上,这些栅栏指令被使用在Go中提供的各种高级数据同步技术的实现中。 所以,我们应该使用Go中提供的高级数据同步技术来保证我们所期待的代码执行顺序。

本文的余下部分将列出Go中保证的各种代码执行顺序。 这些顺序保证可能在Go 1 内存模型一文提到了,也可能没有提到。

在下面的叙述中,如果我们说事件A保证发生在事件B之前,这意味着这两个事件涉及到的任何协程都将观察到在事件A之前的语句肯定将在事件B之后的语句先执行。 对于不相关的协程,它们所观察到的顺序可能并非如此所述。

一个协程的创建发生在此协程中的任何代码执行之前

在下面这个函数中,对xy的赋值保证发生在对它们的打印之前,并且对x的打印肯定发生在对y的打印之前。

var x, y int
func f1() {
	x, y = 123, 789
	go func() {
		fmt.Println(x)
		go func() {
			fmt.Println(y)
		}()
	}()
}

然而这些顺序在下面这个函数中是得不到任何保证的。此函数存在着数据竞争。

var x, y int
func f2() {
	go func() {
		fmt.Println(x) // 可能打印出0、123,或其它值
	}()
	go func() {
		fmt.Println(y) // 可能打印出0、789,或其它值
	}()
	x, y = 123, 789
}

通道操作相关的顺序保证

下面列出的是通道操作做出的基本顺序保证:

  1. 一个通道上的第n次成功发送操作的开始发生在此通道上的第n次成功接收操作完成之前,无论此通道是缓冲的还是非缓冲的。
  2. 一个容量为m通道上的第n次成功接收操作的开始发生在此通道上的第n+m次发送操作完成之前。 特别地,如果此通道是非缓冲的(m == 0),则此通道上的第n次成功接收操作的开始发生在此通道上的第n次发送操作完成之前。
  3. 一个通道的关闭操作发生在任何因为此通道被关闭而从此通道接收到了零值的操作完成之前。

事实上, 对一个非缓冲通道来说,其上的第n次成功发送的完成的发送和其上的第n次成功接收的完成应被视为同一事件。

下面这段代码展示了一个非缓冲通道上的发送和接收操作是如何保证特定的代码执行顺序的。

func f3() {
	var a, b int
	var c = make(chan bool)

	go func() {
		a = 1
		c <- true
		if b != 1 {
			panic("b != 1") // 绝不可能发生
		}
	}()

	go func() {
		b = 1
		<-c
		if a != 1  {
			panic("a != 1") // 绝不可能发生
		}
	}()
}

对于函数f3中创建的两个协程,下列顺序将得到保证:

  • 赋值语句b = 1肯定在条件b != 1被估值之前执行完毕。
  • 赋值语句a = 1肯定在条件a != 1被估值之前执行完毕。

所以,上例代码中两个协程中的panic调用将永不可能得到执行。 做为对比,下面这段代码中的panic调用有可能会得到执行,因为上述通道操作相关的顺序保证对于不相关的协程是无效的。

func f4() {
	var a, b, x, y int
	c := make(chan bool)

	go func() {
		a = 1
		c <- true
		x = 1
	}()

	go func() {
		b = 1
		<-c
		y = 1
	}()

	// 一个和上面的通道操作不相关的协程。
	// 这是一个不良代码的例子,它造成了很多数据竞争。
	go func() {
		if x == 1 {
			if a != 1 {
				panic("a != 1") // 有可能发生
			}
			if b != 1 {
				panic("b != 1") // 有可能发生
			}
		}

		if y == 1 {
			if a != 1 {
				panic("a != 1") // 有可能发生
			}
			if b != 1 {
				panic("b != 1") // 有可能发生
			}
		}
	}()
}

这里的新创建的第三个协程是一个和通道c上的发送和接收操作不相关的一个协程。 它所观察到的执行顺序和其它两个新创建的协程可能是不同的。 条件a != 1b != 1的估值有可能为true,所以四个panic调用有可能会得到执行。

事实上,大多数编译器的实现确实很可能能够保证上面这个不良的例子中的四个panic调用永远不可能被执行,但是,没有任何Go官方文档做出了这样的保证。 此不良例子的执行结果是依赖于不同的编译器和不同的编译器版本的。 我们编写的Go代码应该以Go官方文档中明确记录下来的规则为依据。

下面是一个缓冲通道的例子。

func f5() {
	var k, l, m, n, x, y int
	c := make(chan bool, 2)

	go func() {
		k = 1
		c <- true
		l = 1
		c <- true
		m = 1
		c <- true
		n = 1
	}()

	go func() {
		x = 1
		<-c
		y = 1
	}()
}

在此例子中,下面的顺序得以保证:

  • 赋值语句k = 1的执行保证在赋值语句y = 1的执行之前结束。
  • 赋值语句x = 1的执行保证在赋值语句n = 1的执行之前结束。

然而,赋值语句x = 1的执行并不能保证在赋值语句l = 1m = 1的执行之前结束, 赋值语句l = 1m = 1的执行也不能保证在赋值语句y = 1的执行之前结束。

下面是一个通道关闭的例子。在这个例子中,赋值语句k = 1的执行保证在赋值语句y = 1执行之前结束,但不能保证在赋值语句x = 1执行之前结束。

func f6() {
	var k, x, y int
	c := make(chan bool, 1)

	go func() {
		c <- true
		k = 1
		close(c)
	}()

	go func() {
		<-c
		x = 1
		<-c
		y = 1
	}()
}

互斥锁相关的顺序保证

Go中和互斥锁相关的顺序保证:

  1. 对于一个可寻址的sync.Mutex类型或者sync.RWMutex类型的值m,第n次成功的m.Unlock()方法调用保证发生在第n+1m.Lock()方法调用返回之前。
  2. 对一个可寻址的RWMutex类型值rw,如果它的第nrw.Lock()方法调用已成功返回,并且有一个rw.RLock()方法调用保证发生在此第nrw.Lock()方法调用返回之后,则第n次成功的rw.Unlock()方法调用保证发生在此rw.RLock()方法调用返回之前。
  3. 对一个可寻址的RWMutex类型值rw,如果它的第nrw.RLock()方法调用已成功返回,并且有一个rw.Lock()方法调用保证发生在此第nrw.RLock()方法调用返回之后,则第m次成功的rw.RUnlock()方法调用(其中m <= n)保证发生在此rw.Lock()方法调用返回之前。

在下面这个例子中,下列顺序肯定得到保证。

  • 赋值语句a = 1的执行保证在赋值语句b = 1的执行之前结束。
  • 赋值语句m = 1的执行保证在赋值语句n = 1的执行之前结束。
  • 赋值语句x = 1的执行保证在赋值语句y = 1的执行之前结束。
func fab() {
	var a, b int
	var l sync.Mutex // or sync.RWMutex

	l.Lock()
	go func() {
		l.Lock()
		b = 1
		l.Unlock()
	}()
	go func() {
		a = 1
		l.Unlock()
	}()
}

func fmn() {
	var m, n int
	var l sync.RWMutex

	l.RLock()
	go func() {
		l.Lock()
		n = 1
		l.Unlock()
	}()
	go func() {
		m = 1
		l.RUnlock()
	}()
}

func fxy() {
	var x, y int
	var l sync.RWMutex

	l.Lock()
	go func() {
		l.RLock()
		y = 1
		l.RUnlock()
	}()
	go func() {
		x = 1
		l.Unlock()
	}()
}

注意,在下面这段代码中,根据Go官方文档,赋值语句p = 1的执行并不能保证在赋值语句q = 1的执行之前结束,尽管多数编译器确实能够做出这样的保证。

var p, q int
func fpq() {
	var l sync.Mutex
	p = 1
	l.Lock()
	l.Unlock()
	q = 1
}

sync.WaitGroup值做出的顺序保证

假设在某个给定时刻,一个可寻址的sync.WaitGroupwg维护的计数不为0,并且有一个wg.Wait()方法调用在此给定时刻之后调用。 如果有一组wg.Add(n)方法调用在此给定时刻之后调用,并且我们可以保证这组调用中只有最后一个返回的调用会将wg维护的计数修改为0, 则这组调用中的每个调用保证都发生在此wg.Wait()方法调用返回之前。

注意:调用wg.Done()wg.Add(-1)是等价的。

请阅读sync.WaitGroup类型的解释来获取如何使用sync.WaitGroup值。

sync.Once值做出的顺序保证

请阅读sync.Once类型的解释来获取sync.Once值做出的顺序保证和如何使用sync.Once值。

sync.Cond值做出的顺序保证

sync.Cond值出的顺序保证有些难以表达清楚。所以这里就只可意会不可言传了。 请阅读sync.Cond类型的解释来获取如何使用sync.Cond值。

原子操作相关的顺序保证

从Go 1.19开始,Go 1 内存模型正式地说明Go程序中执行的原子操作按照顺序一致次序(sequentially consistent order)执行。 如果一个原子(写)操作A的效果被一个原子(读)操作B观察到,则A肯定被同步到B之前执行。

按照这个说法,在下面这个程序中,对变量b的原子写操作肯定发生在对其读取结果为1的原子原子读操作之前。 从而使得对变量a的普通写操作也发生于对其的普通读操作之前。 所以此程序保证会打印出1

package main

import (
	"fmt"
	"runtime"
	"sync/atomic"
)

func main() {
	var a, b int32 = 0, 0

	go func() {
		a = 2
		atomic.StoreInt32(&b, 1)
	}()

	for {
		if n := atomic.LoadInt32(&b); n == 1 {
			fmt.Println(a) // 2
			break
		}
		runtime.Gosched()
	}
}

请阅读原子操作一文来获取如何使用原子操作。

和终结器相关的顺序保证

调用runtime.SetFinalizer(x, f)发生在终结调用f(x)被执行之前。


以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号