《Go語言程序設計》讀書筆記(六) 基於共享變量的併發

競爭條件

  • 在一個線性(就是說只有一個goroutine的)的程序中,程序的執行順序只由程序的邏輯來決定。在有兩個或更多goroutine的程序中,每個goroutine內的語句也是按照既定的順序去執行的,可是通常狀況下咱們無法知道分別位於兩個goroutine的事件x和y的執行順序,x是在y以前?以後?仍是同時發生?是無法判斷的。當咱們沒有辦法確認一個事件是在另外一個事件的前面仍是後面發生的話,就說明x和y這兩個事件是併發的。
  • 一個函數在線性程序中能夠正確地工做。若是在併發的狀況下,這個函數依然能夠正確地工做的話,那麼咱們就說這個函數是併發安全的,併發安全的函數不須要額外的同步工做。咱們能夠把這個概念歸納爲一個特定類型的一些方法和操做函數,若是這個類型是併發安全的話,那麼全部它的訪問方法和操做就都是併發安全的。
  • 競爭條件指的是程序在多個goroutine交叉執行操做時,沒有給出正確的結果。競爭條件是很惡劣的一種場景,由於這種問題會一直潛伏在你的程序裏,而後在很是少見的時候蹦出來,或許只是會在很大的負載時纔會發生。
  • 不管任什麼時候候,只要有兩個goroutine併發訪問同一變量,且至少其中的一個是寫操做的時候就會發生數據競爭。
  • 避免數據競爭的方法是容許不少goroutine去訪問變量,可是在同一個時刻最多隻有一個goroutine在訪問。這種方式被稱爲「互斥」。

sync.Mutex互斥鎖

  • 咱們能夠用一個容量只有1的channel來保證最多隻有一個goroutine在同一時刻訪問一個共享變量。一個只能爲1和0的信號量叫作二元信號量(binary semaphore)。
  • 下面用容量爲 1 的 bufferred channel 實現互斥鎖
var (
    sema    = make(chan struct{}, 1) // a binary semaphore guarding balance
    balance int
)

func Deposit(amount int) {
    sema <- struct{}{} // acquire token
    balance = balance + amount
    <-sema // release token
}

func Balance() int {
    sema <- struct{}{} // acquire token
    b := balance
    <-sema // release token
    return b
}
  • sync包裏的Mutex類型直接支持了互斥。它的Lock方法可以獲取到token(這裏叫鎖),Unlock方法會釋放這個token:
import "sync"

var (
    mu      sync.Mutex // guards balance
    balance int
)

func Deposit(amount int) {
    mu.Lock()
    balance = balance + amount
    mu.Unlock()
}

func Balance() int {
    mu.Lock()
    b := balance
    mu.Unlock()
    return b
}
  • 在Lock和Unlock之間的代碼段在goroutine能夠隨便讀取或者修改共享變量,這個代碼段叫作臨界區。goroutine在結束後釋放鎖是必要的,不管以哪條路徑經過函數都須要釋放,即便是在錯誤路徑中,也要記得釋放。
  • 因爲上面存款和查詢餘額函數中的臨界區代碼這麼短--只有一行,沒有分支調用--在代碼最後去調用Unlock就顯得更爲直截了當。在更復雜的臨界區的應用中,尤爲是必需要儘早處理錯誤並返回的狀況下,就很難去(靠人)判斷對Lock和Unlock的調用是在全部路徑中都可以嚴格配對的了。Go語言裏的defer簡直就是這種狀況下的救星:咱們用defer來調用Unlock,臨界區會隱式地延伸到函數做用域的最後,這樣咱們就從「總要記得在函數返回以後或者發生錯誤返回時要記得調用一次Unlock」這種狀態中得到了解放。Go會自動幫咱們完成這些事情。
func Balance() int {
    mu.Lock()
    defer mu.Unlock()
    return balance
}

上面的例子裏Unlock會在return語句讀取完balance的值以後執行,因此Balance函數是併發安全的。程序員

  • 一個deferred Unlock即便在臨界區發生panic時依然會執行,這對於用recover 來恢復的程序來講是很重要的。defer調用只會比顯式地調用Unlock成本高那麼一點點,不過卻在很大程度上保證了代碼的整潔性。大多數狀況下對於併發程序來講,代碼的整潔性比過分的優化更重要。儘可能使用defer來將臨界區擴展到函數的結束。

sync.RWMutex讀寫鎖

  • 因爲Balance函數只須要讀取變量的狀態,因此咱們同時讓多個Balance調用併發運行事實上是安全的,只要在運行的時候沒有存款或者取款操做就行。在這種場景下咱們須要一種特殊類型的鎖,其容許多個只讀操做並行執行,但寫操做會徹底互斥。這種鎖叫作「多讀單寫」鎖(multiple readers, single writer lock),Go語言提供的這樣的鎖是sync.RWMutex:
var mu sync.RWMutex
var balance int
func Balance() int {
    mu.RLock() // readers lock
    defer mu.RUnlock()
    return balance
}
  • Balance函數如今調用了RLock和RUnlock方法來獲取和釋放一個讀共享鎖。Deposit函數沒有變化,會調用mu.Lock和mu.Unlock方法來獲取和釋放一個寫互斥鎖。
  • RWMutex只有當得到鎖的大部分goroutine都是讀操做,並且鎖是在競爭條件下,也就是說,goroutine們必須等待才能獲取到鎖的時候,RWMutex纔是最能帶來好處的。RWMutex須要更復雜的內部記錄,因此會讓它的性能比通常的mutex鎖慢一些。

內存同步

你可能比較糾結爲何Balance方法只由一個簡單的操做組成也須要用到互斥條件?這裏使用mutex有兩方面考慮。第一Balance不會在其它操做好比Withdraw「中間」執行。第二(更重要)的是"同步"不只僅是一堆goroutine執行順序的問題;一樣也會涉及到內存的問題。編程

在現代計算機中可能會有一堆處理器,每個都會有其本地緩存(local cache)。爲了效率,對內存的寫入通常會在每個處理器中緩衝,並在必要時一塊兒flush到主存。這種狀況下這些數據可能會以與當初goroutine寫入順序不一樣的順序被提交到主存。像channel通訊或者互斥量操做這樣的原語會使處理器將其彙集的寫入flush並commit,這樣goroutine在某個時間點上的執行結果才能被其它處理器上運行的goroutine獲得。小程序

考慮一下下面代碼片斷的可能輸出:緩存

var x, y int
go func() {
    x = 1 // A1
    fmt.Print("y:", y, " ") // A2
}()
go func() {
    y = 1                   // B1
    fmt.Print("x:", x, " ") // B2
}()

由於兩個goroutine是併發執行,而且訪問共享變量時也沒有互斥,會有數據競爭,因此程序的運行結果無法預測的話也請不要驚訝。咱們可能但願它可以打印出下面這四種結果中的一種,至關於幾種不一樣的交錯執行時的狀況:安全

y:0 x:1
x:0 y:1
x:1 y:1
y:1 x:1

然而實際的運行時仍是有些狀況讓咱們有點驚訝:數據結構

x:0 y:0
y:0 x:0

那麼這兩種狀況要怎麼解釋呢?多線程

在一個獨立的goroutine中,每個語句的執行順序是能夠被保證的;也就是說goroutine是順序連貫的。可是在不使用channel且不使用mutex這樣的顯式同步操做時,咱們就無法保證事件在不一樣的goroutine中看到的執行順序是一致的了。儘管goroutine A中必定須要觀察到x=1執行成功以後纔會去讀取y,但它無法確保本身觀察獲得goroutine B中對y的寫入,因此A還可能會打印出y的一箇舊版的值。併發

儘管去理解併發的一種嘗試是去將其運行理解爲不一樣goroutine語句的交錯執行,但看看上面的例子,這已經不是現代的編譯器和cpu的工做方式了。由於賦值和打印指向不一樣的變量,編譯器可能會判定兩條語句的順序不會影響執行結果,而且會交換兩個語句的執行順序。若是兩個goroutine在不一樣的CPU上執行,每個核心有本身的緩存,這樣一個goroutine的寫入對於其它goroutine的Print,在主存同步以前就是不可見的了。函數

全部併發的問題均可以用一致的、簡單的既定的模式來規避。因此可能的話,將變量限定在goroutine內部;若是是多個goroutine都須要訪問的變量,使用互斥條件來訪問。工具

競爭條件檢測

只要在go build,go run或者go test命令後面加上-race的flag,就會使編譯器建立一個你的應用的「修改」版或者一個附帶了可以記錄全部運行期對共享變量訪問工具的test,而且會記錄下每個讀或者寫共享變量的goroutine的身份信息。另外,修改版的程序會記錄下全部的同步事件,好比go語句,channel操做,以及對(sync.Mutex).Lock,(sync.WaitGroup).Wait等等的調用。

競爭檢查器會報告全部的已經發生的數據競爭。然而,它只能檢測到運行時的競爭條件;並不能證實以後不會發生數據競爭。因此爲了使結果儘可能正確,請保證你的測試併發地覆蓋到了你到包。

因爲須要額外的記錄,所以構建時加了競爭檢測的程序跑起來會慢一些,且須要更大的內存,即便是這樣,這些代價對於不少生產環境的工做來講仍是能夠接受的。對於一些偶發的競爭條件來講,讓競爭檢查器來幹活能夠節省無很多天夜的debugging。

goroutine和線程的區別

動態棧

每個OS線程都有一個固定大小的內存塊(通常會是2MB)來作棧,這個棧會用來存儲當前正在被調用或掛起(指在調用其它函數時)的函數的內部變量。這個固定大小的棧同時很大又很小。由於2MB的棧對於一個小小的goroutine來講是很大的內存浪費,好比對於咱們用到的,一個只是用來WaitGroup以後關閉channel的goroutine來講。而對於go程序來講,同時建立成百上千個gorutine是很是廣泛的,若是每個goroutine都須要這麼大的棧的話,那這麼多的goroutine就不太可能了。除去大小的問題以外,固定大小的棧對於更復雜或者更深層次的遞歸函數調用來講顯然是不夠的。修改固定的大小能夠提高空間的利用率容許建立更多的線程,而且能夠容許更深的遞歸調用,不過這二者是無法同時兼備的。

相反,一個goroutine會以一個很小的棧開始其生命週期,通常只須要2KB。一個goroutine的棧,和操做系統線程同樣,會保存其活躍或掛起的函數調用的本地變量,可是和OS線程不太同樣的是一個goroutine的棧大小並非固定的;棧的大小會根據須要動態地伸縮。而goroutine的棧的最大值有1GB,比傳統的固定大小的線程棧要大得多,儘管通常狀況下,大多goroutine都不須要這麼大的棧。

goroutine 調度

OS線程會被操做系統內核調度。每幾毫秒,一個硬件計時器會中斷處理器,這會調用一個叫作scheduler的內核函數。這個函數會掛起當前執行的線程並保存內存中它的寄存器內容,檢查線程列表並決定下一次哪一個線程能夠被運行,並從內存中恢復該線程的寄存器信息,而後恢復執行該線程的現場並開始執行線程。由於操做系統線程是被內核所調度,因此從一個線程向另外一個「移動」須要完整的上下文切換,也就是說,保存一個用戶線程的狀態到內存,恢復另外一個線程的到寄存器,而後更新調度器的數據結構。這幾步操做很慢,由於其局部性不好須要幾回內存訪問,而且會增長運行的cpu週期。

Go的運行時包含了其本身的調度器,這個調度器使用了一些技術手段,好比m:n調度,由於其會在n個操做系統線程上多工(調度)m個goroutine。Go調度器的工做和內核的調度是類似的,可是這個調度器只關注單獨的Go程序中的goroutine。

和操做系統的線程調度不一樣的是,Go調度器並非用一個硬件定時器而是被Go語言"建築"自己進行調度的。例如當一個goroutine調用了time.Sleep或者被channel調用或者mutex操做阻塞時,調度器會使其進入休眠並開始執行另外一個goroutine直到時機到了再去喚醒第一個goroutine。由於由於這種調度方式不須要進入內核的上下文,因此從新調度一個goroutine比調度一個線程代價要低得多。

GOMAXPROCS

Go的調度器使用了一個叫作GOMAXPROCS的變量來決定會有多少個操做系統的線程同時執行Go的代碼。其默認的值是運行機器上的CPU的核心數,因此在一個有8個核心的機器上時,調度器一次會在8個OS線程上去調度GO代碼。(GOMAXPROCS是前面說的m:n調度中的n)。在休眠中的或者在通訊中被阻塞的goroutine是不須要一個對應的線程來作調度的。在I/O中或系統調用中或調用非Go語言函數時,是須要一個對應的操做系統線程的,可是GOMAXPROCS並不須要將這幾種狀況計數在內。

你能夠用GOMAXPROCS的環境變量顯式地控制這個參數,或者也能夠在運行時用runtime.GOMAXPROCS函數來修改它。咱們在下面的小程序中會看到GOMAXPROCS的效果,這個程序會無限打印0和1。

for {
    go fmt.Print(0)
    fmt.Print(1)
}

$ GOMAXPROCS=1 go run hacker-cliché.go
111111111111111111110000000000000000000011111...

$ GOMAXPROCS=2 go run hacker-cliché.go
010101010101010101011001100101011010010100110...

在第一次執行時,最多同時只能有一個goroutine被執行。初始狀況下只有main goroutine被執行,因此會打印不少1。過了一段時間後,GO調度器會將其置爲休眠,並喚醒另外一個goroutine,這時候就開始打印不少0了,在打印的時候,goroutine是被調度到操做系統線程上的。在第二次執行時,咱們使用了兩個操做系統線程,因此兩個goroutine能夠一塊兒被執行,以一樣的頻率交替打印0和1。咱們必須強調的是goroutine的調度是受不少因子影響的,而runtime也是在不斷地發展演進的,因此這裏的你實際獲得的結果可能會由於版本的不一樣而與咱們運行的結果有所不一樣。

Goroutine沒有ID號

在大多數支持多線程的操做系統和程序語言中,當前的線程都有一個獨特的身份(id),而且這個身份信息能夠以一個普通值的形式被被很容易地獲取到,典型的能夠是一個integer或者指針值。這種狀況下咱們作一個抽象化的thread-local storage(線程本地存儲,多線程編程中不但願其它線程訪問的內容)就很容易,只須要以線程的id做爲key的一個map就能夠解決問題,每個線程以其id就能從中獲取到值,且和其它線程互不衝突。

goroutine沒有能夠被程序員獲取到的身份(id)的概念。這一點是設計上故意而爲之,因爲thread-local storage老是會被濫用。Go鼓勵更爲簡單的模式,這種模式下參數對函數的影響都是顯式的。這樣不只使程序變得更易讀,並且會讓咱們自由地向一些給定的函數分配子任務時不用擔憂其身份信息影響行爲。

相關文章
相關標籤/搜索