Golang面向併發的記憶體模型

Gundy發表於2019-04-02

在早期,CPU都是以單核的形式順序執行機器指令。Go語言的祖先C語言正是這種順序程式語言的代表。順序程式語言中的順序是指:所有的指令都是以序列的方式執行,在相同的時刻有且僅有一個CPU在順序執行程式的指令。

隨著處理器技術的發展,單核時代以提升處理器頻率來提高執行效率的方式遇到了瓶頸,目前各種主流的CPU頻率基本被鎖定在了3GHZ附近。單核CPU的發展的停滯,給多核CPU的發展帶來了機遇。相應地,程式語言也開始逐步向並行化的方向發展。Go語言正是在多核和網路化的時代背景下誕生的原生支援併發的程式語言。

常見的並行程式設計有多種模型,主要有多執行緒、訊息傳遞等。從理論上來看,多執行緒和基於訊息的併發程式設計是等價的。由於多執行緒併發模型可以自然對應到多核的處理器,主流的作業系統因此也都提供了系統級的多執行緒支援,同時從概念上講多執行緒似乎也更直觀,因此多執行緒程式設計模型逐步被吸納到主流的程式語言特性或語言擴充套件庫中。而主流程式語言對基於訊息的併發程式設計模型支援則相比較少,Erlang語言是支援基於訊息傳遞併發程式設計模型的代表者,它的併發體之間不共享記憶體。Go語言是基於訊息併發模型的集大成者,它將基於CSP模型的併發程式設計內建到了語言中,通過一個go關鍵字就可以輕易地啟動一個Goroutine,與Erlang不同的是Go語言的Goroutine之間是共享記憶體的。

Goroutine和系統執行緒

Goroutine是Go語言特有的併發體,是一種輕量級的執行緒,由go關鍵字啟動。在真實的Go語言的實現中,goroutine和系統執行緒也不是等價的。儘管兩者的區別實際上只是一個量的區別,但正是這個量變引發了Go語言併發程式設計質的飛躍。

首先,每個系統級執行緒都會有一個固定大小的棧(一般預設可能是2MB),這個棧主要用來儲存函式遞迴呼叫時引數和區域性變數。固定了棧的大小導致了兩個問題:一是對於很多隻需要很小的棧空間的執行緒來說是一個巨大的浪費,二是對於少數需要巨大棧空間的執行緒來說又面臨棧溢位的風險。針對這兩個問題的解決方案是:要麼降低固定的棧大小,提升空間的利用率;要麼增大棧的大小以允許更深的函式遞迴呼叫,但這兩者是沒法同時兼得的。相反,一個Goroutine會以一個很小的棧啟動(可能是2KB或4KB),當遇到深度遞迴導致當前棧空間不足時,Goroutine會根據需要動態地伸縮棧的大小(主流實現中棧的最大值可達到1GB)。因為啟動的代價很小,所以我們可以輕易地啟動成千上萬個Goroutine。

Go的執行時還包含了其自己的排程器,這個排程器使用了一些技術手段,可以在n個作業系統執行緒上多工排程m個Goroutine。Go排程器的工作和核心的排程是相似的,但是這個排程器只關注單獨的Go程式中的Goroutine。Goroutine採用的是半搶佔式的協作排程,只有在當前Goroutine發生阻塞時才會導致排程;同時發生在使用者態,排程器會根據具體函式只儲存必要的暫存器,切換的代價要比系統執行緒低得多。執行時有一個runtime.GOMAXPROCS變數,用於控制當前執行正常非阻塞Goroutine的系統執行緒數目。

在Go語言中啟動一個Goroutine不僅和呼叫函式一樣簡單,而且Goroutine之間排程代價也很低,這些因素極大地促進了併發程式設計的流行和發展。

原子操作

所謂的原子操作就是併發程式設計中“最小的且不可並行化”的操作。通常,如果多個併發體對同一個共享資源進行的操作是原子的話,那麼同一時刻最多隻能有一個併發體對該資源進行操作。從執行緒角度看,在當前執行緒修改共享資源期間,其它的執行緒是不能訪問該資源的。原子操作對於多執行緒併發程式設計模型來說,不會發生有別於單執行緒的意外情況,共享資源的完整性可以得到保證。

一般情況下,原子操作都是通過“互斥”訪問來保證的,通常由特殊的CPU指令提供保護。當然,如果僅僅是想模擬下粗粒度的原子操作,我們可以藉助於sync.Mutex來實現:

import (
	"sync"
)

var total struct {
	sync.Mutex
	value int
}

func worker(wg *sync.WaitGroup) {
	defer wg.Done()

	for i := 0; i <= 100; i++ {
		total.Lock()
		total.value += i
		total.Unlock()
	}
}

func main() {
	var wg sync.WaitGroup
	wg.Add(2)
	go worker(&wg)
	go worker(&wg)
	wg.Wait()

	fmt.Println(total.value)
}
複製程式碼

worker的迴圈中,為了保證total.value += i的原子性,我們通過sync.Mutex加鎖和解鎖來保證該語句在同一時刻只被一個執行緒訪問。對於多執行緒模型的程式而言,進出臨界區前後進行加鎖和解鎖都是必須的。如果沒有鎖的保護,total的最終值將由於多執行緒之間的競爭而可能會不正確。

用互斥鎖來保護一個數值型的共享資源,麻煩且效率低下。標準庫的sync/atomic包對原子操作提供了豐富的支援。我們可以重新實現上面的例子:

import (
	"sync"
	"sync/atomic"
)

var total uint64

func worker(wg *sync.WaitGroup) {
	defer wg.Done()

	var i uint64
	for i = 0; i <= 100; i++ {
		atomic.AddUint64(&total, i)
	}
}

func main() {
	var wg sync.WaitGroup
	wg.Add(2)

	go worker(&wg)
	go worker(&wg)
	wg.Wait()
}
複製程式碼

atomic.AddUint64函式呼叫保證了total的讀取、更新和儲存是一個原子操作,因此在多執行緒中訪問也是安全的。

原子操作配合互斥鎖可以實現非常高效的單件模式。互斥鎖的代價比普通整數的原子讀寫高很多,在效能敏感的地方可以增加一個數字型的標誌位,通過原子檢測標誌位狀態降低互斥鎖的使用次數來提高效能。

type singleton struct {}

var (
	instance    *singleton
	initialized uint32
	mu          sync.Mutex
)

func Instance() *singleton {
	if atomic.LoadUint32(&initialized) == 1 {
		return instance
	}

	mu.Lock()
	defer mu.Unlock()

	if instance == nil {
		defer atomic.StoreUint32(&initialized, 1)
		instance = &singleton{}
	}
	return instance
}
複製程式碼

我們可以將通用的程式碼提取出來,就成了標準庫中sync.Once的實現:

type Once struct {
	m    Mutex
	done uint32
}

func (o *Once) Do(f func()) {
	if atomic.LoadUint32(&o.done) == 1 {
		return
	}

	o.m.Lock()
	defer o.m.Unlock()

	if o.done == 0 {
		defer atomic.StoreUint32(&o.done, 1)
		f()
	}
}
複製程式碼

基於sync.Once重新實現單件模式:

var (
	instance *singleton
	once     sync.Once
)

func Instance() *singleton {
	once.Do(func() {
		instance = &singleton{}
	})
	return instance
}
複製程式碼

sync/atomic包對基本的數值型別及複雜物件的讀寫都提供了原子操作的支援。atomic.Value原子物件提供了LoadStore兩個原子方法,分別用於載入和儲存資料,返回值和引數都是interface{}型別,因此可以用於任意的自定義複雜型別。

var config atomic.Value // 儲存當前配置資訊

// 初始化配置資訊
config.Store(loadConfig())

// 啟動一個後臺執行緒, 載入更新後的配置資訊
go func() {
	for {
		time.Sleep(time.Second)
		config.Store(loadConfig())
	}
}()

// 用於處理請求的工作者執行緒始終採用最新的配置資訊
for i := 0; i < 10; i++ {
	go func() {
		for r := range requests() {
			c := config.Load()
			// ...
		}
	}()
}
複製程式碼

這是一個簡化的生產者消費者模型:後臺執行緒生成最新的配置資訊;前臺多個工作者執行緒獲取最新的配置資訊。所有執行緒共享配置資訊資源。

順序一致性記憶體模型

如果只是想簡單地線上程之間進行資料同步的話,原子操作已經為程式設計人員提供了一些同步保障。不過這種保障有一個前提:順序一致性的記憶體模型。要了解順序一致性,我們先看看一個簡單的例子:

var a string
var done bool

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

func main() {
	go setup()
	for !done {}
	print(a)
}
複製程式碼

我們建立了setup執行緒,用於對字串a的初始化工作,初始化完成之後設定done標誌為truemain函式所在的主執行緒中,通過for !done {}檢測done變為true時,認為字串初始化工作完成,然後進行字串的列印工作。

但是Go語言並不保證在main函式中觀測到的對done的寫入操作發生在對字串a的寫入的操作之後,因此程式很可能列印一個空字串。更糟糕的是,因為兩個執行緒之間沒有同步事件,setup執行緒對done的寫入操作甚至無法被main執行緒看到,main函式有可能陷入死迴圈中。

在Go語言中,同一個Goroutine執行緒內部,順序一致性記憶體模型是得到保證的。但是不同的Goroutine之間,並不滿足順序一致性記憶體模型,需要通過明確定義的同步事件來作為同步的參考。如果兩個事件不可排序,那麼就說這兩個事件是併發的。為了最大化並行,Go語言的編譯器和處理器在不影響上述規定的前提下可能會對執行語句重新排序(CPU也會對一些指令進行亂序執行)。

因此,如果在一個Goroutine中順序執行a = 1; b = 2;兩個語句,雖然在當前的Goroutine中可以認為a = 1;語句先於b = 2;語句執行,但是在另一個Goroutine中b = 2;語句可能會先於a = 1;語句執行,甚至在另一個Goroutine中無法看到它們的變化(可能始終在暫存器中)。也就是說在另一個Goroutine看來, a = 1; b = 2;兩個語句的執行順序是不確定的。如果一個併發程式無法確定事件的順序關係,那麼程式的執行結果往往會有不確定的結果。比如下面這個程式:

func main() {
	go println("你好, 世界")
}
複製程式碼

根據Go語言規範,main函式退出時程式結束,不會等待任何後臺執行緒。因為Goroutine的執行和main函式的返回事件是併發的,誰都有可能先發生,所以什麼時候列印,能否列印都是未知的。

用前面的原子操作並不能解決問題,因為我們無法確定兩個原子操作之間的順序。解決問題的辦法就是通過同步原語來給兩個事件明確排序:

func main() {
	done := make(chan int)

	go func(){
		println("你好, 世界")
		done <- 1
	}()

	<-done
}
複製程式碼

<-done執行時,必然要求done <- 1也已經執行。根據同一個Gorouine依然滿足順序一致性規則,我們可以判斷當done <- 1執行時,println("你好, 世界")語句必然已經執行完成了。因此,現在的程式確保可以正常列印結果。

當然,通過sync.Mutex互斥量也是可以實現同步的:

func main() {
	var mu sync.Mutex

	mu.Lock()
	go func(){
		println("你好, 世界")
		mu.Unlock()
	}()

	mu.Lock()
}
複製程式碼

可以確定後臺執行緒的mu.Unlock()必然在println("你好, 世界")完成後發生(同一個執行緒滿足順序一致性),main函式的第二個mu.Lock()必然在後臺執行緒的mu.Unlock()之後發生(sync.Mutex保證),此時後臺執行緒的列印工作已經順利完成了。

初始化順序

前面函式章節中我們已經簡單介紹過程式的初始化順序,這是屬於Go語言面向併發的記憶體模型的基礎規範。

Go程式的初始化和執行總是從main.main函式開始的。但是如果main包裡匯入了其它的包,則會按照順序將它們包含進main包裡(這裡的匯入順序依賴具體實現,一般可能是以檔名或包路徑名的字串順序匯入)。如果某個包被多次匯入的話,在執行的時候只會匯入一次。當一個包被匯入時,如果它還匯入了其它的包,則先將其它的包包含進來,然後建立和初始化這個包的常量和變數。然後就是呼叫包裡的init函式,如果一個包有多個init函式的話,實現可能是以檔名的順序呼叫,同一個檔案內的多個init則是以出現的順序依次呼叫(init不是普通函式,可以定義有多個,所以不能被其它函式呼叫)。最終,在main包的所有包常量、包變數被建立和初始化,並且init函式被執行後,才會進入main.main函式,程式開始正常執行

要注意的是,在main.main函式執行之前所有程式碼都執行在同一個Goroutine中,也是執行在程式的主系統執行緒中。如果某個init函式內部用go關鍵字啟動了新的Goroutine的話,新的Goroutine和main.main函式是併發執行的。

因為所有的init函式和main函式都是在主執行緒完成,它們也是滿足順序一致性模型的。

Goroutine的建立

go語句會在當前Goroutine對應函式返回前建立新的Goroutine. 例如:

var a string

func f() {
	print(a)
}

func hello() {
	a = "hello, world"
	go f()
}
複製程式碼

執行go f()語句建立Goroutine和hello函式是在同一個Goroutine中執行, 根據語句的書寫順序可以確定Goroutine的建立發生在hello函式返回之前, 但是新建立Goroutine對應的f()的執行事件和hello函式返回的事件則是不可排序的,也就是併發的。呼叫hello可能會在將來的某一時刻列印"hello, world",也很可能是在hello函式執行完成後才列印。

基於Channel的通訊

Channel通訊是在Goroutine之間進行同步的主要方法。在無快取的Channel上的每一次傳送操作都有與其對應的接收操作相配對,傳送和接收操作通常發生在不同的Goroutine上(在同一個Goroutine上執行2個操作很容易導致死鎖)。無快取的Channel上的傳送操作總在對應的接收操作完成前發生.

var done = make(chan bool)
var msg string

func aGoroutine() {
	msg = "你好, 世界"
	done <- true
}

func main() {
	go aGoroutine()
	<-done
	println(msg)
}
複製程式碼

可保證列印出“hello, world”。該程式首先對msg進行寫入,然後在done管道上傳送同步訊號,隨後從done接收對應的同步訊號,最後執行println函式。

若在關閉Channel後繼續從中接收資料,接收者就會收到該Channel返回的零值。因此在這個例子中,用close(c)關閉管道代替done <- false依然能保證該程式產生相同的行為。

var done = make(chan bool)
var msg string

func aGoroutine() {
	msg = "你好, 世界"
	close(done)
}

func main() {
	go aGoroutine()
	<-done
	println(msg)
}
複製程式碼

對於從無緩衝Channel進行的接收,發生在對該Channel進行的傳送完成之前。

基於上面這個規則可知,交換兩個Goroutine中的接收和傳送操作也是可以的(但是很危險):

var done = make(chan bool)
var msg string

func aGoroutine() {
	msg = "hello, world"
	<-done
}
func main() {
	go aGoroutine()
	done <- true
	println(msg)
}
複製程式碼

也可保證列印出“hello, world”。因為main執行緒中done <- true傳送完成前,後臺執行緒<-done接收已經開始,這保證msg = "hello, world"被執行了,所以之後println(msg)的msg已經被賦值過了。簡而言之,後臺執行緒首先對msg進行寫入,然後從done中接收訊號,隨後main執行緒向done傳送對應的訊號,最後執行println函式完成。但是,若該Channel為帶緩衝的(例如,done = make(chan bool, 1)),main執行緒的done <- true接收操作將不會被後臺執行緒的<-done接收操作阻塞,該程式將無法保證列印出“hello, world”。

對於帶緩衝的Channel,對於Channel的第K個接收完成操作發生在第K+C個傳送操作完成之前,其中C是Channel的快取大小。 如果將C設定為0自然就對應無快取的Channel,也即使第K個接收完成在第K個傳送完成之前。因為無快取的Channel只能同步發1個,也就簡化為前面無快取Channel的規則:對於從無緩衝Channel進行的接收,發生在對該Channel進行的傳送完成之前。

我們可以根據控制Channel的快取大小來控制併發執行的Goroutine的最大數目, 例如:

var limit = make(chan int, 3)

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

最後一句select{}是一個空的管道選擇語句,該語句會導致main執行緒阻塞,從而避免程式過早退出。還有for{}<-make(chan int)等諸多方法可以達到類似的效果。因為main執行緒被阻塞了,如果需要程式正常退出的話可以通過呼叫os.Exit(0)實現。

不靠譜的同步

前面我們已經分析過,下面程式碼無法保證正常列印結果。實際的執行效果也是大概率不能正常輸出結果。

func main() {
	go println("你好, 世界")
}
複製程式碼

剛接觸Go語言的話,可能希望通過加入一個隨機的休眠時間來保證正常的輸出:

func main() {
	go println("hello, world")
	time.Sleep(time.Second)
}
複製程式碼

因為主執行緒休眠了1秒鐘,因此這個程式大概率是可以正常輸出結果的。因此,很多人會覺得這個程式已經沒有問題了。但是這個程式是不穩健的,依然有失敗的可能性。我們先假設程式是可以穩定輸出結果的。因為Go執行緒的啟動是非阻塞的,main執行緒顯式休眠了1秒鐘退出導致程式結束,我們可以近似地認為程式總共執行了1秒多時間。現在假設println函式內部實現休眠的時間大於main執行緒休眠的時間的話,就會導致矛盾:後臺執行緒既然先於main執行緒完成列印,那麼執行時間肯定是小於main執行緒執行時間的。當然這是不可能的。

嚴謹的併發程式的正確性不應該是依賴於CPU的執行速度和休眠時間等不靠譜的因素的。嚴謹的併發也應該是可以靜態推匯出結果的:根據執行緒內順序一致性,結合Channel或sync同步事件的可排序性來推導,最終完成各個執行緒各段程式碼的偏序關係排序。如果兩個事件無法根據此規則來排序,那麼它們就是併發的,也就是執行先後順序不可靠的。

解決同步問題的思路是相同的:使用顯式的同步。

轉自Go語言高階程式設計

相關文章