Go併發程式設計基礎

發表於2015-06-04

本文是一篇併發程式設計方面的入門文章,以Go語言編寫示例程式碼,內容涵蓋:

  • 執行期併發執行緒(goroutines)
  • 基本的同步技術(管道和鎖)
  • Go語言中基本的併發模式
  • 死鎖和資料競爭
  • 平行計算

在開始閱讀本文之前,你應該知道如何編寫簡單的Go程式。如果你熟悉的是C/C++、Java或Python之類的語言,那麼 Go語言之旅 能提供所有必要的背景知識。也許你還有興趣讀一讀 為C++程式設計師準備的Go語言教程 或 為Java程式設計師準備的Go語言教程

Go允許使用go語句開啟一個新的執行期執行緒,即 goroutine,以一個不同的、新建立的goroutine來執行一個函式。同一個程式中的所有goroutine共享同一個地址空間。

Goroutine非常輕量,除了為之分配的棧空間,其所佔用的記憶體空間微乎其微。並且其棧空間在開始時非常小,之後隨著堆儲存空間的按需分配或釋放而變化。內部實現上,goroutine會在多個作業系統執行緒上多路複用。如果一個goroutine阻塞了一個作業系統執行緒,例如:等待輸入,這個執行緒上的其他goroutine就會遷移到其他執行緒,這樣能繼續執行。開發者並不需要關心/擔心這些細節。

下面所示程式會輸出“Hello from main goroutine”。也可能會輸出“Hello from another goroutine”,具體依賴於兩個goroutine哪個先結束。

goroutine1.go

接下來的這個程式,多數情況下,會輸出“Hello from main goroutine”和“Hello from another goroutine”,輸出的順序不確定。但還有另一個可能性是:第二個goroutine執行得極其慢,在程式結束之前都沒來得及輸出相應的訊息。

goroutine2.go

下面則是一個相對更加實際的示例,其中定義了一個函式使用併發來推遲觸發一個事件。

publish1.go

你可能會這樣使用Publish函式:

publish1.go

這個程式,絕大多數情況下,會輸出以下三行,順序固定,每行輸出之間相隔5秒。

一般來說,通過睡眠的方式來編排執行緒之間相互等待是不太可能的。下一章節會介紹Go語言中的一種同步機制 – 管道,並演示如何使用管道讓一個goroutine等待另一個goroutine。

2. 管道(channel)

Sushi conveyor belt

管道是Go語言的一個構件,提供一種機制用於兩個goroutine之間通過傳遞一個指定型別的值來同步執行和通訊。操作符<-用於指定管道的方向,傳送或接收。如果未指定方向,則為雙向管道。

管道是引用型別,基於make函式來分配。

如果通過管道傳送一個值,則將<-作為二元操作符使用。通過管道接收一個值,則將其作為一元操作符使用:

如果管道不帶緩衝,傳送方會阻塞直到接收方從管道中接收了值。如果管道帶緩衝,傳送方則會阻塞直到傳送的值被拷貝到緩衝區內;如果緩衝區已滿,則意味著需要等待直到某個接收方獲取到一個值。接收方在有值可以接收之前會一直阻塞。

關閉管道(Close)

close 函式標誌著不會再往某個管道傳送值。在呼叫close之後,並且在之前傳送的值都被接收後,接收操作會返回一個零值,不會阻塞。一個多返回值的接收操作會額外返回一個布林值用來指示返回的值是否傳送操作傳遞的。

一個帶有range子句的for語句會依次讀取發往管道的值,直到該管道關閉:

sushi.go

3. 同步

下一個示例中,我們讓Publish函式返回一個管道 – 用於在釋出text變數值時廣播一條訊息:

publish2.go

注意:我們使用了一個空結構體的管道:struct{}。這明確地指明該管道僅用於發訊號,而不是傳遞資料。

我們可能會這樣使用這個函式:

publish2.go

這個程式會按指定的順序輸出以下三行內容。最後一行在新聞(news)一出就會立即輸出。

4. 死鎖

traffic jam

現在我們在Publish函式中引入一個bug:

主程式還是像之前一樣開始執行:輸出第一行,然後等待5秒,這時Publish函式開啟的goroutine會輸出突發新聞(breaking news),然後退出,留下主goroutine獨自等待。

此刻之後,程式無法再繼續往下執行。眾所周知,這種情形即為死鎖。

死鎖是執行緒之間相互等待,其中任何一個都無法向前執行的情形。

Go語言對於執行時的死鎖檢測具備良好的支援。當沒有任何goroutine能夠往前執行的情形發生時,Go程式通常會提供詳細的錯誤資訊。以下就是我們的問題程式的輸出:

大多數情況下找出Go程式中造成死鎖的原因都比較容易,那麼剩下的就是如何解決這個bug了。

5. 資料競爭(data race)

死鎖也許聽起來令人挺憂傷的,但伴隨併發程式設計真正災難性的錯誤其實是資料競爭,相當常見,也可能非常難於除錯。

當兩個執行緒併發地訪問同一個變數,並且其中至少一個訪問是寫操作時,資料競爭就發生了。

下面的這個函式就有資料競爭問題,其行為是未定義的。例如,可能輸出數值1。程式碼之後是一個可能性解釋,試圖搞清楚這一切是如何發生得。

datarace.go

程式碼中的兩個goroutine(假設命名為g1g2)參與了一次競爭,我們無法獲知操作會以何種順序發生。以下是諸多可能中的一種:

  • g1 從 n 中獲取值0
  • g2 從 n 中獲取值0
  • g1 將值從0增大到1
  • g1 將1寫到 n
  • g2 將值從0增大到1
  • g2 將1寫到 n
  • 程式輸出 n 的值,當前為1

“資料競爭(data race)”這名字有點誤導的嫌疑。不僅操作的順序是未定義的,其實根本沒有任何保證(no guarantees whatsoever)。編譯器和硬體為了得到更好的效能,經常都會對程式碼進行上下內外的順序變換。如果你看到一個執行緒處於中間行為狀態時,那麼當時的場景可能就像下圖所示的一樣:

mid action

避免資料競爭的唯一方式是執行緒間同步訪問所有的共享可變資料。有幾種方式能夠實現這一目標。Go語言中,通常是使用管道或者鎖。(syncsync/atomic包中還有更低層次的機制可供使用,但本文中不做討論)。

Go語言中,處理併發資料訪問的推薦方式是使用管道從一個goroutine中往下一個goroutine傳遞實際的資料。有格言說得好:“不要通過共享記憶體來通訊,而是通過通訊來共享記憶體”。

datarace.go

以上程式碼中的管道肩負雙重責任 – 從一個goroutine將資料傳遞到另一個goroutine,並且起到同步的作用:傳送方goroutine會等待另一個goroutine接收資料,接收方goroutine也會等待另一個goroutine傳送資料。

Go語言記憶體模型 – 要保證一個goroutine中對一個變數的讀操作得到的值正好是另一個goroutine中對同一個變數寫操作產生的值,條件相當複雜,但goroutine之間只要通過管道來共享所有可變資料,那麼就能遠離資料競爭了。

6. 互斥鎖

lock

有時,通過顯式加鎖,而不是使用管道,來同步資料訪問,可能更加便捷。Go語言標準庫為這一目的提供了一個互斥鎖 – sync.Mutex

要想這類加鎖起效的話,關鍵之處在於:所有對共享資料的訪問,不管讀寫,僅當goroutine持有鎖才能操作。一個goroutine出錯就足以破壞掉一個程式,引入資料競爭。

因此,應該設計一個自定義資料結構,具備明確的API,確保所有的同步都在資料結構內部完成。下例中,我們構建了一個安全、易於使用的併發資料結構,AtomicInt,用於儲存一個整型值。任意數量的goroutine都能通過AddValue方法安全地訪問這個數值。

datarace.go

7. 檢測資料競爭

競爭有時非常難於檢測。下例中的這個函式有一個資料競爭問題,執行這個程式時會輸出55555。嘗試一下,也許你會得到一個不同的結果。(sync.WaitGroup是Go語言標準庫的一部分;用於等待一組goroutine結束執行。)

raceClosure.go

對於輸出55555,一個貌似合理的解釋是:執行i++的goroutine在其他goroutine執行列印語句之前就完成了5次i++操作。實際上變數i更新後的值為其他goroutine所見純屬巧合。

一個簡單的解決方案是:使用一個區域性變數,然後當開啟新的goroutine時,將數值作為引數傳遞:

raceClosure.go

這次程式碼就對了,程式會輸出期望的結果,如:24031。注意:goroutine之間的執行順序是不確定的。

仍舊使用閉包,但能夠避免資料競爭也是可能的,必須小心翼翼地讓每個goroutine使用一個獨有的變數。

raceClosure.go

資料競爭自動檢測

一般來說,不太可能能夠自動檢測發現所有可能的資料競爭情況,但Go(從版本1.1開始)有一個強大的資料競爭檢測器

這個工具用起來也很簡單:只要在使用go命令時加上-race標記即可。開啟檢測器執行上面的程式會給出清晰且資訊量大的輸出:

該工具發現一處資料競爭,包含:一個goroutine在第20行對一個變數進行寫操作,跟著另一個goroutine在第22行對同一個變數進行了未同步的讀操作。

注意:競爭檢測器只能發現在執行期確實發生的資料競爭(譯註:我也不太理解這話,請指導)

8. Select語句

select語句是Go語言併發工具集中的終極工具。select用於從一組可能的通訊中選擇一個進一步處理。如果任意一個通訊都可以進一步處理,則從中隨機選擇一個,執行對應的語句。否則,如果又沒有預設分支(default case),select語句則會阻塞,直到其中一個通訊完成。

以下是一個玩具示例,演示select語句如何用於實現一個隨機數生成器:

randBits.go

下面是相對更加實際一點的例子:如何使用select語句為一個操作設定一個時間限制。程式碼會輸出變數news的值或者超時訊息,具體依賴於兩個接收語句哪個先執行:

函式 time.After 是Go語言標準庫的一部分;它會在等待指定時間後將當前的時間傳送到返回的管道中。

9. 綜合所有示例

couples

花點時間認真研究一下這個示例。如果你完全理解,也就對Go語言中併發的應用方式有了全面的掌握。

這個程式演示瞭如何將管道用於被任意數量的goroutine傳送和接收資料,也演示瞭如何將select語句用於從多個通訊中選擇一個。

matching.go

示例輸出:

10. 平行計算

CPUs

併發的一個應用是將一個大的計算切分成一些工作單元,排程到不同的CPU上同時地計算。

將計算分佈到多個CPU上更多是一門藝術,而不是一門科學。以下是一些經驗法則:

  • 每個工作單元應該花費大約100微秒到1毫秒的時間用於計算。如果單元粒度太小,切分問題以及排程子問題的管理開銷可能就會太大。如果單元粒度太大,整個計算也許不得不等待一個慢的工作項結束。這種緩慢可能因為多種原因而產生,比如:排程、其他程式的中斷或者糟糕的記憶體佈局。(注意:工作單元的數目是不依賴於CPU的數目的)
  • 儘可能減小共享的資料量。併發寫操作的代價非常大,特別是如果goroutine執行在不同的CPU上。讀操作之間的資料共享則通常不會是個問題。
  • 資料訪問儘量利用良好的區域性性。如果資料能保持在快取中,資料載入和儲存將會快得多得多,這對於寫操作也格外地重要。

下面的這個示例展示如何切分一個開銷很大的計算並將其分佈在所有可用的CPU上進行計算。先看一下有待優化的程式碼:

思路很簡單:確定合適大小的工作單元,然後在不同的goroutine中執行每個工作單元。以下是併發版本的 Convolve

convolution.go

工作單元定義之後,通常情況下最好將排程工作交給執行時和作業系統。然而,對於Go 1.* 你也許需要告訴執行時希望多少個goroutine來同時地執行程式碼。

相關文章