Golang面向併發的內存模型

在早期,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的最終值將因爲多線程之間的競爭而可能會不正確。ui

用互斥鎖來保護一個數值型的共享資源,麻煩且效率低下。標準庫的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語言高級編程

相關文章
相關標籤/搜索