Go語言 Go中的內(nèi)存順序保證

2023-02-16 17:39 更新

關(guān)于內(nèi)存順序

很多編譯器優(yōu)化(在編譯時(shí)刻)和CPU處理器優(yōu)化(在運(yùn)行時(shí)刻)會(huì)常常調(diào)整指令執(zhí)行順序,從而使得指令執(zhí)行順序和代碼中指定的順序不太一致。 指令順序也稱為內(nèi)存順序。

當(dāng)然,指令執(zhí)行順序的調(diào)整規(guī)則不是任意的。 最基本的要求是發(fā)生在一個(gè)不與其它協(xié)程共享數(shù)據(jù)的協(xié)程內(nèi)部的指令執(zhí)行順序調(diào)整在此協(xié)程內(nèi)部必須不能被覺察到。 換句話說,從這樣的一個(gè)協(xié)程的角度看,其中的指令執(zhí)行順序和代碼中指定的順序總是一致的,即使確實(shí)有一些指令的執(zhí)行順序發(fā)生了調(diào)整。

然而,如果一些協(xié)程之間共享數(shù)據(jù),那么在其中一個(gè)協(xié)程中發(fā)生的指令執(zhí)行順序調(diào)整將有可能被剩余的其它協(xié)程覺察到,從而影響到所有這些協(xié)程的行為。 在并發(fā)編程中,多個(gè)協(xié)程之間共享數(shù)據(jù)是家常便飯。 如果我們忽視了指令執(zhí)行順序調(diào)整帶來的影響,我們編寫的并發(fā)程序的行為將依賴于特定編譯器和CPU。這樣的程序常常表現(xiàn)出異常行為。

下面是一個(gè)編寫得非常不職業(yè)的Go程序。此程序的編寫沒有考慮指令執(zhí)行順序調(diào)整帶來的影響。 此程序擴(kuò)展于官方文檔Go 1 內(nèi)存模型一文中的一個(gè)例子.

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) // 期待的打印結(jié)果:hello, world
}

此程序的行為很可能正如我們所料,hello, world將被打印輸出。 然而,此程序的行為并非跨編譯器和跨平臺(tái)架構(gòu)兼容的。 如果此程序使用一個(gè)不同的(但符合Go規(guī)范的)編譯器或者不同的編譯器版本編譯,它的運(yùn)行結(jié)果可能是不同的。 即使此程序使用同一個(gè)編譯器編譯,在不同的平臺(tái)架構(gòu)上的運(yùn)行結(jié)果也可能是不同的。

編譯器和CPU可能調(diào)整setup函數(shù)中的前兩條語句的執(zhí)行順序,使得setup協(xié)程中的指令的執(zhí)行順序和下面的代碼指定的順序一致。

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

setup協(xié)程并不會(huì)覺察到此執(zhí)行順序調(diào)整,所以此協(xié)程中的log.Println(len(a))語句將總是打印出12(如果此打印語句在程序退出之前得到了執(zhí)行的話)。 但是,此執(zhí)行順序調(diào)整將被主協(xié)程覺察到,所以最終的打印結(jié)果有可能是空,而不是hello, world。

除了沒有考慮指令執(zhí)行順序調(diào)整帶來的影響,此程序還存在數(shù)據(jù)競爭的問題。 變量adone的使用沒有進(jìn)行同步。 所以此程序是一個(gè)充滿了各種并發(fā)編程錯(cuò)誤的不良例子。 一個(gè)職業(yè)的Go程序員不應(yīng)該寫出這樣的使用于生產(chǎn)環(huán)境中的代碼。

我們可以使用Go官方工具鏈中的go build -race命令來編譯并運(yùn)行一個(gè)程序,以檢查此程序中是否存在著數(shù)據(jù)競爭。

Go內(nèi)存模型(Memory Model)

有時(shí),為了程序的邏輯正確性,我們需要確保一個(gè)協(xié)程中的一些語句一定要在另一個(gè)協(xié)程的某些語句之后(或者之前)執(zhí)行(從這兩個(gè)協(xié)程的角度觀察都是如此)。 指令執(zhí)行順序調(diào)整可能會(huì)給此需求帶來一些麻煩。 我們應(yīng)如何防止某些可能發(fā)生的指令執(zhí)行順序調(diào)整呢?

不同的CPU架構(gòu)提供了不同的柵欄(fence)指令來防止各種指令執(zhí)行順序調(diào)整。 一些編程語言提供對應(yīng)的函數(shù)來在代碼中的合適位置插入各種柵欄指令。 但是,理解和正確地使用這些柵欄指令極大地提高了并發(fā)編程的門檻。

Go語言的設(shè)計(jì)哲學(xué)是用盡量少的易于理解的特性來支持盡量多的使用場景,同時(shí)還要盡量保證代碼的高執(zhí)行效率。 所以Go內(nèi)置和標(biāo)準(zhǔn)庫并沒有提供直接插入各種柵欄指令的途徑。 事實(shí)上,這些柵欄指令被使用在Go中提供的各種高級(jí)數(shù)據(jù)同步技術(shù)的實(shí)現(xiàn)中。 所以,我們應(yīng)該使用Go中提供的高級(jí)數(shù)據(jù)同步技術(shù)來保證我們所期待的代碼執(zhí)行順序。

本文的余下部分將列出Go中保證的各種代碼執(zhí)行順序。 這些順序保證可能在Go 1 內(nèi)存模型一文提到了,也可能沒有提到。

在下面的敘述中,如果我們說事件A保證發(fā)生在事件B之前,這意味著這兩個(gè)事件涉及到的任何協(xié)程都將觀察到在事件A之前的語句肯定將在事件B之后的語句先執(zhí)行。 對于不相關(guān)的協(xié)程,它們所觀察到的順序可能并非如此所述。

一個(gè)協(xié)程的創(chuàng)建發(fā)生在此協(xié)程中的任何代碼執(zhí)行之前

在下面這個(gè)函數(shù)中,對xy的賦值保證發(fā)生在對它們的打印之前,并且對x的打印肯定發(fā)生在對y的打印之前。

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

然而這些順序在下面這個(gè)函數(shù)中是得不到任何保證的。此函數(shù)存在著數(shù)據(jù)競爭。

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

通道操作相關(guān)的順序保證

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

  1. 一個(gè)通道上的第n次成功發(fā)送操作的開始發(fā)生在此通道上的第n次成功接收操作完成之前,無論此通道是緩沖的還是非緩沖的。
  2. 一個(gè)容量為m通道上的第n次成功接收操作的開始發(fā)生在此通道上的第n+m次發(fā)送操作完成之前。 特別地,如果此通道是非緩沖的(m == 0),則此通道上的第n次成功接收操作的開始發(fā)生在此通道上的第n次發(fā)送操作完成之前。
  3. 一個(gè)通道的關(guān)閉操作發(fā)生在任何因?yàn)榇送ǖ辣魂P(guān)閉而從此通道接收到了零值的操作完成之前。

事實(shí)上, 對一個(gè)非緩沖通道來說,其上的第n次成功發(fā)送的完成的發(fā)送和其上的第n次成功接收的完成應(yīng)被視為同一事件。

下面這段代碼展示了一個(gè)非緩沖通道上的發(fā)送和接收操作是如何保證特定的代碼執(zhí)行順序的。

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

	go func() {
		a = 1
		c <- true
		if b != 1 {
			panic("b != 1") // 絕不可能發(fā)生
		}
	}()

	go func() {
		b = 1
		<-c
		if a != 1  {
			panic("a != 1") // 絕不可能發(fā)生
		}
	}()
}

對于函數(shù)f3中創(chuàng)建的兩個(gè)協(xié)程,下列順序?qū)⒌玫奖WC:

  • 賦值語句b = 1肯定在條件b != 1被估值之前執(zhí)行完畢。
  • 賦值語句a = 1肯定在條件a != 1被估值之前執(zhí)行完畢。

所以,上例代碼中兩個(gè)協(xié)程中的panic調(diào)用將永不可能得到執(zhí)行。 做為對比,下面這段代碼中的panic調(diào)用有可能會(huì)得到執(zhí)行,因?yàn)樯鲜鐾ǖ啦僮飨嚓P(guān)的順序保證對于不相關(guān)的協(xié)程是無效的。

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

	// 一個(gè)和上面的通道操作不相關(guān)的協(xié)程。
	// 這是一個(gè)不良代碼的例子,它造成了很多數(shù)據(jù)競爭。
	go func() {
		if x == 1 {
			if a != 1 {
				panic("a != 1") // 有可能發(fā)生
			}
			if b != 1 {
				panic("b != 1") // 有可能發(fā)生
			}
		}

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

這里的新創(chuàng)建的第三個(gè)協(xié)程是一個(gè)和通道c上的發(fā)送和接收操作不相關(guān)的一個(gè)協(xié)程。 它所觀察到的執(zhí)行順序和其它兩個(gè)新創(chuàng)建的協(xié)程可能是不同的。 條件a != 1b != 1的估值有可能為true,所以四個(gè)panic調(diào)用有可能會(huì)得到執(zhí)行。

事實(shí)上,大多數(shù)編譯器的實(shí)現(xiàn)確實(shí)很可能能夠保證上面這個(gè)不良的例子中的四個(gè)panic調(diào)用永遠(yuǎn)不可能被執(zhí)行,但是,沒有任何Go官方文檔做出了這樣的保證。 此不良例子的執(zhí)行結(jié)果是依賴于不同的編譯器和不同的編譯器版本的。 我們編寫的Go代碼應(yīng)該以Go官方文檔中明確記錄下來的規(guī)則為依據(jù)。

下面是一個(gè)緩沖通道的例子。

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的執(zhí)行保證在賦值語句y = 1的執(zhí)行之前結(jié)束。
  • 賦值語句x = 1的執(zhí)行保證在賦值語句n = 1的執(zhí)行之前結(jié)束。

然而,賦值語句x = 1的執(zhí)行并不能保證在賦值語句l = 1m = 1的執(zhí)行之前結(jié)束, 賦值語句l = 1m = 1的執(zhí)行也不能保證在賦值語句y = 1的執(zhí)行之前結(jié)束。

下面是一個(gè)通道關(guān)閉的例子。在這個(gè)例子中,賦值語句k = 1的執(zhí)行保證在賦值語句y = 1執(zhí)行之前結(jié)束,但不能保證在賦值語句x = 1執(zhí)行之前結(jié)束。

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

互斥鎖相關(guān)的順序保證

Go中和互斥鎖相關(guān)的順序保證:

  1. 對于一個(gè)可尋址的sync.Mutex類型或者sync.RWMutex類型的值m,第n次成功的m.Unlock()方法調(diào)用保證發(fā)生在第n+1m.Lock()方法調(diào)用返回之前。
  2. 對一個(gè)可尋址的RWMutex類型值rw,如果它的第nrw.Lock()方法調(diào)用已成功返回,并且有一個(gè)rw.RLock()方法調(diào)用保證發(fā)生在此第nrw.Lock()方法調(diào)用返回之后,則第n次成功的rw.Unlock()方法調(diào)用保證發(fā)生在此rw.RLock()方法調(diào)用返回之前。
  3. 對一個(gè)可尋址的RWMutex類型值rw,如果它的第nrw.RLock()方法調(diào)用已成功返回,并且有一個(gè)rw.Lock()方法調(diào)用保證發(fā)生在此第nrw.RLock()方法調(diào)用返回之后,則第m次成功的rw.RUnlock()方法調(diào)用(其中m <= n)保證發(fā)生在此rw.Lock()方法調(diào)用返回之前。

在下面這個(gè)例子中,下列順序肯定得到保證。

  • 賦值語句a = 1的執(zhí)行保證在賦值語句b = 1的執(zhí)行之前結(jié)束。
  • 賦值語句m = 1的執(zhí)行保證在賦值語句n = 1的執(zhí)行之前結(jié)束。
  • 賦值語句x = 1的執(zhí)行保證在賦值語句y = 1的執(zhí)行之前結(jié)束。
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()
	}()
}

注意,在下面這段代碼中,根據(jù)Go官方文檔,賦值語句p = 1的執(zhí)行并不能保證在賦值語句q = 1的執(zhí)行之前結(jié)束,盡管多數(shù)編譯器確實(shí)能夠做出這樣的保證。

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

sync.WaitGroup值做出的順序保證

假設(shè)在某個(gè)給定時(shí)刻,一個(gè)可尋址的sync.WaitGroupwg維護(hù)的計(jì)數(shù)不為0,并且有一個(gè)wg.Wait()方法調(diào)用在此給定時(shí)刻之后調(diào)用。 如果有一組wg.Add(n)方法調(diào)用在此給定時(shí)刻之后調(diào)用,并且我們可以保證這組調(diào)用中只有最后一個(gè)返回的調(diào)用會(huì)將wg維護(hù)的計(jì)數(shù)修改為0, 則這組調(diào)用中的每個(gè)調(diào)用保證都發(fā)生在此wg.Wait()方法調(diào)用返回之前。

注意:調(diào)用wg.Done()wg.Add(-1)是等價(jià)的。

請閱讀sync.WaitGroup類型的解釋來獲取如何使用sync.WaitGroup值。

sync.Once值做出的順序保證

請閱讀sync.Once類型的解釋來獲取sync.Once值做出的順序保證和如何使用sync.Once值。

sync.Cond值做出的順序保證

sync.Cond值出的順序保證有些難以表達(dá)清楚。所以這里就只可意會(huì)不可言傳了。 請閱讀sync.Cond類型的解釋來獲取如何使用sync.Cond值。

原子操作相關(guān)的順序保證

從Go 1.19開始,Go 1 內(nèi)存模型正式地說明Go程序中執(zhí)行的原子操作按照順序一致次序(sequentially consistent order)執(zhí)行。 如果一個(gè)原子(寫)操作A的效果被一個(gè)原子(讀)操作B觀察到,則A肯定被同步到B之前執(zhí)行。

按照這個(gè)說法,在下面這個(gè)程序中,對變量b的原子寫操作肯定發(fā)生在對其讀取結(jié)果為1的原子原子讀操作之前。 從而使得對變量a的普通寫操作也發(fā)生于對其的普通讀操作之前。 所以此程序保證會(huì)打印出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()
	}
}

請閱讀原子操作一文來獲取如何使用原子操作。

和終結(jié)器相關(guān)的順序保證

調(diào)用runtime.SetFinalizer(x, f)發(fā)生在終結(jié)調(diào)用f(x)被執(zhí)行之前。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)