作為一門 21 世紀的語言,Go 原生支持應用之間的通信(網絡,客戶端和服務端,分布式計算)和程序的並發。程序可以在不同的處理器和計算機上同時執行不同的代碼段。Go 語言為構建並發程序的基本代碼塊是 協程 (goroutine) 與通道 (channel)。他們需要語言,編譯器,和runtime的支持。Go 語言提供的垃圾回收器對並發編程至關重要。
不要通過共享內存來通信,而通過通信來共享內存。
1. 並發、並行和協程
1.1 什么是協程
一個應用程序是運行在機器上的一個進程;進程是一個運行在自己內存地址空間里的獨立執行體。一個進程由一個或多個操作系統線程組成,這些線程其實是共享同一個內存地址空間的一起工作的執行體。幾乎所有'正式'的程序都是多線程的,以便讓用戶或計算機不必等待,或者能夠同時服務多個請求(如 Web 服務器),或增加性能和吞吐量(例如,通過對不同的數據集並行執行代碼)。一個並發程序可以在一個處理器或者內核上使用多個線程來執行任務,但是只有同一個程序在某個時間點同時運行在多核或者多處理器上才是真正的並行。
並行是一種通過使用多處理器以提高速度的能力。所以並發程序可以是並行的,也可以不是。
公認的,使用多線程的應用難以做到准確,最主要的問題是內存中的數據共享,它們會被多線程以無法預知的方式進行操作,導致一些無法重現或者隨機的結果(稱作 競態
)。
不要使用全局變量或者共享內存,它們會給你的代碼在並發運算的時候帶來危險。
解決之道在於同步不同的線程,對數據加鎖,這樣同時就只有一個線程可以變更數據。在 Go 的標准庫 sync
中有一些工具用來在低級別的代碼中實現加鎖。不過過去的軟件開發經驗告訴我們這會帶來更高的復雜度,更容易使代碼出錯以及更低的性能,所以這個經典的方法明顯不再適合現代多核/多處理器編程:thread-per-connection
模型不夠有效。
Go 更傾向於其他的方式,在諸多比較合適的范式中,有個被稱作 Communicating Sequential Processes(順序通信處理)
(CSP, C. Hoare 發明的)還有一個叫做 message passing-model(消息傳遞)
(已經運用在了其他語言中,比如 Erlang)。
在 Go 中,應用程序並發處理的部分被稱作 goroutines(協程)
,它可以進行更有效的並發運算。在協程和操作系統線程之間並無一對一的關系:協程是根據一個或多個線程的可用性,映射(多路復用,執行於)在他們之上的;協程調度器在 Go 運行時很好的完成了這個工作。
協程工作在相同的地址空間中,所以共享內存的方式一定是同步的;這個可以使用 sync
包來實現,不過我們很不鼓勵這樣做:Go 使用 channels
來同步協程
當系統調用(比如等待 I/O)阻塞協程時,其他協程會繼續在其他線程上工作。協程的設計隱藏了許多線程創建和管理方面的復雜工作。
協程是輕量的,比線程更輕。它們痕跡非常不明顯(使用少量的內存和資源):使用 4 K 的棧內存就可以在堆中創建它們。因為創建非常廉價,必要的時候可以輕松創建並運行大量的協程(在同一個地址空間中 100,000 個連續的協程)。並且它們對棧進行了分割,從而動態的增加(或縮減)內存的使用;棧的管理是自動的,但不是由垃圾回收器管理的,而是在協程退出后自動釋放。
協程可以運行在多個操作系統線程之間,也可以運行在線程之內,讓你可以很小的內存占用就可以處理大量的任務。由於操作系統線程上的協程時間片,你可以使用少量的操作系統線程就能擁有任意多個提供服務的協程,而且 Go 運行時可以聰明的意識到哪些協程被阻塞了,暫時擱置它們並處理其他協程。
存在兩種並發方式:確定性的(明確定義排序)和非確定性的(加鎖/互斥從而未定義排序)。Go 的協程和通道理所當然的支持確定性的並發方式(例如通道具有一個 sender 和一個 receiver)。
協程是通過使用關鍵字 go
調用(執行)一個函數或者方法來實現的(也可以是匿名或者 lambda 函數)。這樣會在當前的計算過程中開始一個同時進行的函數,在相同的地址空間中並且分配了獨立的棧,比如:go sum(bigArray)
,在后台計算總和。
協程的棧會根據需要進行伸縮,不出現棧溢出;開發者不需要關心棧的大小。當協程結束的時候,它會靜默退出:用來啟動這個協程的函數不會得到任何的返回值。
任何 Go 程序都必須有的 main()
函數也可以看做是一個協程,盡管它並沒有通過 go
來啟動。協程可以在程序初始化的過程中運行(在 init()
函數中)。
在一個協程中,比如它需要進行非常密集的運算,你可以在運算循環中周期的使用 runtime.Gosched()
:這會讓出處理器,允許運行其他協程;它並不會使當前協程掛起,所以它會自動恢復執行。使用 Gosched()
可以使計算均勻分布,使通信不至於遲遲得不到響應。
1.2 並發和並行的差異
Go 的並發原語提供了良好的並發設計基礎:表達程序結構以便表示獨立地執行的動作;所以Go的重點不在於並行的首要位置:並發程序可能是並行的,也可能不是。並行是一種通過使用多處理器以提高速度的能力。但往往是,一個設計良好的並發程序在並行方面的表現也非常出色。
在當前的運行時實現中,Go 默認沒有並行指令,只有一個獨立的核心或處理器被專門用於 Go 程序,不論它啟動了多少個協程;所以這些協程是並發運行的,但他們不是並行運行的:同一時間只有一個協程會處在運行狀態。
這個情況在以后可能會發生改變,不過屆時,為了使你的程序可以使用多個核心運行,這時協程就真正的是並行運行了,你必須使用 GOMAXPROCS
變量。
這會告訴運行時有多少個協程同時執行。
並且只有 gc 編譯器真正實現了協程,適當的把協程映射到操作系統線程。使用 gccgo
編譯器,會為每一個協程創建操作系統線程。
1.3 使用 GOMAXPROCS
在 gc 編譯器下(6 g 或者 8 g)你必須設置 GOMAXPROCS
為一個大於默認值 1 的數值來允許運行時支持使用多於 1 個的操作系統線程,所有的協程都會共享同一個線程除非將 GOMAXPROCS
設置為一個大於 1 的數。當 GOMAXPROCS 大於 1 時,會有一個線程池管理許多的線程。通過 gccgo
編譯器 GOMAXPROCS
有效的與運行中的協程數量相等。假設 n
是機器上處理器或者核心的數量。如果你設置環境變量 GOMAXPROCS>=n
,或者執行 runtime.GOMAXPROCS(n)
,接下來協程會被分割(分散)到 n
個處理器上。更多的處理器並不意味着性能的線性提升。有這樣一個經驗法則,對於 n
個核心的情況設置 GOMAXPROCS
為 n-1
以獲得最佳性能,也同樣需要遵守這條規則:協程的數量 > 1 + GOMAXPROCS > 1
。
所以如果在某一時間只有一個協程在執行,不要設置
GOMAXPROCS
!
還有一些通過實驗觀察到的現象:在一台 1 顆 CPU 的筆記本電腦上,增加 GOMAXPROCS
到 9 會帶來性能提升。在一台 32 核的機器上,設置 GOMAXPROCS=8
會達到最好的性能,在測試環境中,更高的數值無法提升性能。如果設置一個很大的 GOMAXPROCS
只會帶來輕微的性能下降;設置 GOMAXPROCS=100
,使用 top
命令和 H
選項查看到只有 7 個活動的線程。
增加 GOMAXPROCS
的數值對程序進行並發計算是有好處的;
GOMAXPROCS
等同於(並發的)線程數量,在一台核心數多於1個的機器上,會盡可能有等同於核心數的線程在並行運行。
1.4 Go 協程(goroutines)和協程(coroutines)
- Go 協程意味着並行(或者可以以並行的方式部署),協程一般來說不是這樣的
- Go 協程通過通道來通信;協程通過讓出和恢復操作來通信
Go 協程比協程更強大,也很容易從協程的邏輯復用到 Go 協程。
2. 協程間的信道
協程必須通信才會變得更有用:彼此之間發送和接收信息並且協調/同步他們的工作。協程可以使用共享變量來通信,但是很不提倡這樣做,因為這種方式給所有的共享內存的多線程都帶來了困難。
Go 有一種特殊的類型,通道(channel),就像一個可以用於發送類型化數據的管道,由其負責協程之間的通信,從而避開所有由共享內存導致的陷阱;這種通過通道進行通信的方式保證了同步性。數據在通道中進行傳遞:在任何給定時間,一個數據被設計為只有一個協程可以對其訪問,所以不會發生數據競爭。 數據的所有權(可以讀寫數據的能力)也因此被傳遞。
工廠的傳送帶是個很有用的例子。一個機器(生產者協程)在傳送帶上放置物品,另外一個機器(消費者協程)拿到物品並打包。
通道服務於通信的兩個目的:值的交換,同步的,保證了兩個計算(協程)任何時候都是可知狀態。
通常使用這樣的格式來聲明通道:
var identifier chan datatype
未初始化的通道的值是
nil
所以通道只能傳輸一種類型的數據,比如 chan int
或者 chan string
,所有的類型都可以用於通道,空接口 interface{}
也可以。甚至可以(有時非常有用)創建通道的通道。
通道實際上是類型化消息的隊列:使數據得以傳輸。它是先進先出(FIFO)的結構所以可以保證發送給他們的元素的順序(有些人知道,通道可以比作 Unix shells 中的雙向管道(two-way pipe))。通道也是引用類型,所以我們使用 make()
函數來給它分配內存。這里先聲明了一個字符串通道 ch1,然后創建了它(實例化):
var ch1 chan string
ch1 = make(chan string)
當然可以更短: ch1 := make(chan string)
通道是第一類對象:可以存儲在變量中,作為函數的參數傳遞,從函數返回以及通過通道發送它們自身。另外它們是類型化的,允許類型檢查,比如嘗試使用整數通道發送一個指針。
2.1 通信操作符 <-
這個操作符直觀的標示了數據的傳輸:信息按照箭頭的方向流動。
- 流向通道(發送)
ch <- int1
表示:變量 int1 發送到 通道 ch (雙目運算符,中綴 = 發送)
- 從通道流出(接收),三種方式:
int2 = <- ch
表示:變量 int2 從通道 ch(一元運算的前綴操作符,前綴 = 接收)接收數據(獲取新值);假設 int2 已經聲明過了,如果沒有的話可以寫成:int2 := <- ch
。
<- ch
可以單獨調用獲取通道的(下一個)值,當前值會被丟棄,但是可以用來驗證,所以以下代碼是合法的:
if <- ch != 1000{
...
}
同一個操作符 <-
既用於發送也用於接收,但Go會根據操作對象弄明白該干什么 。雖非強制要求,但為了可讀性通道的命名通常以 ch
開頭或者包含 chan
。通道的發送和接收都是原子操作:它們總是互不干擾的完成的。下面的示例展示了通信操作符的使用。
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go sendData(ch)
go getData(ch)
time.Sleep(1e9)
}
func sendData(ch chan string) {
ch <- "Washington"
ch <- "Tripoli"
ch <- "London"
ch <- "Beijing"
ch <- "Tokyo"
}
func getData(ch chan string) {
var input string
// time.Sleep(2e9)
for {
input = <-ch
fmt.Printf("%s ", input)
}
}
輸出:
Washington Tripoli London Beijing tokyo
main()
函數中啟動了兩個協程:sendData()
通過通道 ch
發送了 5 個字符串,getData()
按順序接收它們並打印出來。
如果 2 個協程需要通信,你必須給他們同一個通道作為參數才行。
我們發現協程之間的同步非常重要:
main()
等待了 1 秒讓兩個協程完成,如果不這樣,sendData()
就沒有機會輸出。getData()
使用了無限循環:它隨着sendData()
的發送完成和ch
變空也結束了。- 如果我們移除一個或所有
go
關鍵字,程序無法運行,Go 運行時會拋出 panic:
為什么會這樣?運行時(runtime)會檢查所有的協程(像本例中只有一個)是否在等待着什么東西(可從某個通道讀取或者寫入某個通道),這意味着程序將無法繼續執行。這是死鎖(deadlock)的一種形式,而運行時(runtime)可以為我們檢測到這種情況。
注意:不要使用打印狀態來表明通道的發送和接收順序:由於打印狀態和通道實際發生讀寫的時間延遲會導致和真實發生的順序不同。
2.2 通道阻塞
默認情況下,通信是同步且無緩沖的:在有接受者接收數據之前,發送不會結束。可以想象一個無緩沖的通道在沒有空間來保存數據的時候:必須要一個接收者准備好接收通道的數據然后發送者可以直接把數據發送給接收者。所以通道的發送/接收操作在對方准備好之前是阻塞的:
-
對於同一個通道,發送操作(協程或者函數中的),在接收者准備好之前是阻塞的:如果ch中的數據無人接收,就無法再給通道傳入其他數據:新的輸入無法在通道非空的情況下傳入。所以發送操作會等待 ch 再次變為可用狀態:就是通道值被接收時(可以傳入變量)。
-
對於同一個通道,接收操作是阻塞的(協程或函數中的),直到發送者可用:如果通道中沒有數據,接收者就阻塞了。
盡管這看上去是非常嚴格的約束,實際在大部分情況下工作的很不錯。
2.3 通過一個(或多個)通道交換數據進行協程同步。
通信是一種同步形式:通過通道,兩個協程在通信(協程會和)中某刻同步交換數據。無緩沖通道成為了多個協程同步的完美工具。
甚至可以在通道兩端互相阻塞對方,形成了叫做死鎖的狀態。Go 運行時會檢查並 panic,停止程序。死鎖幾乎完全是由糟糕的設計導致的。
無緩沖通道會被阻塞。設計無阻塞的程序可以避免這種情況,或者使用帶緩沖的通道。
2.4 同步通道-使用帶緩沖的通道
一個無緩沖通道只能包含 1 個元素,有時顯得很局限。我們給通道提供了一個緩存,可以在擴展的 make
命令中設置它的容量,如下:
buf := 100
ch1 := make(chan string, buf)
buf 是通道可以同時容納的元素(這里是 string)個數
在緩沖滿載(緩沖被全部使用)之前,給一個帶緩沖的通道發送數據是不會阻塞的,而從通道讀取數據也不會阻塞,直到緩沖空了。
緩沖容量和類型無關,所以可以(盡管可能導致危險)給一些通道設置不同的容量,只要他們擁有同樣的元素類型。內置的 cap
函數可以返回緩沖區的容量
如果容量大於 0,通道就是異步的了:緩沖滿載(發送)或變空(接收)之前通信不會阻塞,元素會按照發送的順序被接收。如果容量是0或者未設置,通信僅在收發雙方准備好的情況下才可以成功。
同步:ch :=make(chan type, value)
- value == 0 -> synchronous, unbuffered (阻塞)
- value > 0 -> asynchronous, buffered(非阻塞)取決於value元素
若使用通道的緩沖,你的程序會在“請求”激增的時候表現更好:更具彈性,專業術語叫:更具有伸縮性(scalable)。在設計算法時首先考慮使用無緩沖通道,只在不確定的情況下使用緩沖。
2.5 信號量模式
下邊的片段闡明:協程通過在通道 ch
中放置一個值來處理結束的信號。main
協程等待 <-ch
直到從中獲取到值。
我們期望從這個通道中獲取返回的結果,像這樣:
func compute(ch chan int){
ch <- someComputation() // when it completes, signal on the channel.
}
func main(){
ch := make(chan int) // allocate a channel.
go compute(ch) // start something in a goroutines
doSomethingElseForAWhile()
result := <- ch
}
這個信號也可以是其他的,不返回結果,比如下面這個協程中的匿名函數(lambda)協程:
ch := make(chan int)
go func(){
// doSomething
ch <- 1 // Send a signal; value does not matter
}()
doSomethingElseForAWhile()
<- ch // Wait for goroutine to finish; discard sent value.
或者等待兩個協程完成,每一個都會對切片s的一部分進行排序,片段如下:
done := make(chan bool)
// doSort is a lambda function, so a closure which knows the channel done:
doSort := func(s []int){
sort(s)
done <- true
}
i := pivot(s)
go doSort(s[:i])
go doSort(s[i:])
<-done
<-done
下邊的代碼,用完整的信號量模式對長度為N的 float64 切片進行了 N 個 doSomething()
計算並同時完成,通道 sem 分配了相同的長度(且包含空接口類型的元素),待所有的計算都完成后,發送信號(通過放入值)。在循環中從通道 sem 不停的接收數據來等待所有的協程完成。
type Empty interface {}
var empty Empty
...
data := make([]float64, N)
res := make([]float64, N)
sem := make(chan Empty, N)
...
for i, xi := range data {
go func (i int, xi float64) {
res[i] = doSomething(i, xi)
sem <- empty
} (i, xi)
}
// wait for goroutines to finish
for i := 0; i < N; i++ { <-sem }
注意上述代碼中閉合函數的用法:i
、xi
都是作為參數傳入閉合函數的,這一做法使得每個協程(譯者注:在其啟動時)獲得一份 i
和 xi
的單獨拷貝,從而向閉合函數內部屏蔽了外層循環中的 i
和 xi
變量;否則,for 循環的下一次迭代會更新所有協程中 i
和 xi
的值。另一方面,切片 res
沒有傳入閉合函數,因為協程不需要res
的單獨拷貝。切片 res
也在閉合函數中但並不是參數。
2.6 實現並行的 for 循環
for i, v := range data {
go func (i int, v float64) {
doSomething(i, v)
...
} (i, v)
}
在 for 循環中並行計算迭代可能帶來很好的性能提升。不過所有的迭代都必須是獨立完成的。有些語言比如 Fortress 或者其他並行框架以不同的結構實現了這種方式,在 Go 中用協程實現起來非常容易
2.7 用帶緩沖通道實現一個信號量
信號量是實現互斥鎖(排外鎖)常見的同步機制,限制對資源的訪問,解決讀寫問題,比如沒有實現信號量的 sync
的 Go 包,使用帶緩沖的通道可以輕松實現:
- 帶緩沖通道的容量和要同步的資源容量相同
- 通道的長度(當前存放的元素個數)與當前資源被使用的數量相同
- 容量減去通道的長度就是未處理的資源個數(標准信號量的整數值)
不用管通道中存放的是什么,只關注長度;因此我們創建了一個長度可變但容量為0(字節)的通道:
type Empty interface {}
type semaphore chan Empty
將可用資源的數量N來初始化信號量 semaphore
:sem = make(semaphore, N)
然后直接對信號量進行操作:
// acquire n resources
func (s semaphore) P(n int) {
e := new(Empty)
for i := 0; i < n; i++ {
s <- e
}
}
// release n resources
func (s semaphore) V(n int) {
for i:= 0; i < n; i++{
<- s
}
}
可以用來實現一個互斥的例子:
/* mutexes */
func (s semaphore) Lock() {
s.P(1)
}
func (s semaphore) Unlock(){
s.V(1)
}
/* signal-wait */
func (s semaphore) Wait(n int) {
s.P(n)
}
func (s semaphore) Signal() {
s.V(1)
}
3. 通道的方向
通道類型可以用注解來表示它只發送或者只接收:
var send_only chan<- int // channel can only receive data
var recv_only <-chan int // channel can only send data
只接收的通道(<-chan T
)無法關閉,因為關閉通道是發送者用來表示不再給通道發送值了,所以對只接收通道是沒有意義的。通道創建的時候都是雙向的,但也可以分配有方向的通道變量,就像以下代碼:
var c = make(chan int) // bidirectional
go source(c)
go sink(c)
func source(ch chan<- int){
for { ch <- 1 }
}
func sink(ch <-chan int) {
for { <-ch }
}
4. 使用 select 切換協程
從不同的並發執行的協程中獲取值可以通過關鍵字select
來完成,它和switch
控制語句非常相似(章節5.3)也被稱作通信開關;它的行為像是“你准備好了嗎”的輪詢機制;select
監聽進入通道的數據,也可以是用通道發送值的時候。
select {
case u:= <- ch1:
...
case v:= <- ch2:
...
...
default: // no value ready to be received
...
}
default
語句是可選的;fallthrough
行為,和普通的switch
相似,是不允許的。在任何一個case
中執行break
或者return
,select
就結束了。
select
做的就是:選擇處理列出的多個通信情況中的一個。
- 如果都阻塞了,會等待直到其中一個可以處理
- 如果多個可以處理,隨機選擇一個
- 如果沒有通道操作可以處理並且寫了
default
語句,它就會執行:default
永遠是可運行的(這就是准備好了,可以執行)。
在 select
中使用發送操作並且有 default
可以確保發送不被阻塞!如果沒有 default
,select 就會一直阻塞。
select
語句實現了一種監聽模式,通常用在(無限)循環中;在某種情況下,通過 break
語句使循環退出。