第09章 Go語言併發,Golang併發
併發指在同一時間內可以執行多個任務。併發程式設計含義比較廣泛,包含多執行緒程式設計、多程式程式設計及分散式程式等。本章講解的併發含義屬於多執行緒程式設計。
Go 語言通過編譯器執行時(runtime),從語言上支援了併發的特性。Go 語言的併發通過 goroutine 特性完成。goroutine 類似於執行緒,但是可以根據需要建立多個 goroutine 併發工作。goroutine 是由 Go 語言的執行時排程完成,而執行緒是由作業系統排程完成。
Go 語言還提供 channel 在多個 goroutine 間進行通訊。goroutine 和 channel 是 Go 語言秉承的 CSP(Communicating Sequential Process)併發模式的重要實現基礎。本章中,將詳細為大家講解 goroutine 和 channel 及相關特性。
本章內容:
9.1 Go語言併發簡述(併發的優勢)
有人把Go語言比作 21 世紀的C語言,第一是因為Go語言設計簡單,第二則是因為 21 世紀最重要的就是併發程式設計,而 Go 從語言層面就支援併發。同時實現了自動垃圾回收機制。
Go語言的併發機制運用起來非常簡便,在啟動併發的方式上直接新增了語言級的關鍵字就可以實現,和其他程式語言相比更加輕量。
下面來介紹幾個概念:
程式/執行緒
程式是程式在作業系統中的一次執行過程,系統進行資源分配和排程的一個獨立單位。
執行緒是程式的一個執行實體,是 CPU 排程和分派的基本單位,它是比程式更小的能獨立執行的基本單位。
一個程式可以建立和撤銷多個執行緒,同一個程式中的多個執行緒之間可以併發執行。
併發/並行
多執行緒程式在單核心的 cpu 上執行,稱為併發;多執行緒程式在多核心的 cpu 上執行,稱為並行。
併發與並行並不相同,併發主要由切換時間片來實現“同時”執行,並行則是直接利用多核實現多執行緒的執行,Go程式可以設定使用核心數,以發揮多核計算機的能力。
協程/執行緒
協程:獨立的棧空間,共享堆空間,排程由使用者自己控制,本質上有點類似於使用者級執行緒,這些使用者級執行緒的排程也是自己實現的。
執行緒:一個執行緒上可以跑多個協程,協程是輕量級的執行緒。
優雅的併發程式設計正規化,完善的併發支援,出色的併發效能是Go語言區別於其他語言的一大特色。使用Go語言開發伺服器程式時,就需要對它的併發機制有深入的瞭解。
Goroutine 介紹
goroutine 是一種非常輕量級的實現,可在單個程式裡執行成千上萬的併發任務,它是Go語言併發設計的核心。
說到底 goroutine 其實就是執行緒,但是它比執行緒更小,十幾個 goroutine 可能體現在底層就是五六個執行緒,而且Go語言內部也實現了 goroutine 之間的記憶體共享。
使用 go 關鍵字就可以建立 goroutine,將 go 宣告放到一個需呼叫的函式之前,在相同地址空間呼叫執行這個函式,這樣該函式執行時便會作為一個獨立的併發執行緒,這種執行緒在Go語言中則被稱為 goroutine。
goroutine 的用法如下:
- //go 關鍵字放在方法呼叫前新建一個 goroutine 並執行方法體
- go GetThingDone(param1, param2);
- //新建一個匿名方法並執行
- go func(param1, param2) {
- }(val1, val2)
- //直接新建一個 goroutine 並在 goroutine 中執行程式碼塊
- go {
- //do someting...
- }
因為 goroutine 在多核 cpu 環境下是並行的,如果程式碼塊在多個 goroutine 中執行,那麼我們就實現了程式碼的並行。
如果需要了解程式的執行情況,怎麼拿到並行的結果呢?需要配合使用channel進行。
channel
channel 是Go語言在語言級別提供的 goroutine 間的通訊方式。我們可以使用 channel 在兩個或多個 goroutine 之間傳遞訊息。
channel 是程式內的通訊方式,因此通過 channel 傳遞物件的過程和呼叫函式時的引數傳遞行為比較一致,比如也可以傳遞指標等。如果需要跨程式通訊,我們建議用分散式系統的方法來解決,比如使用 Socket 或者 HTTP 等通訊協議。Go語言對於網路方面也有非常完善的支援。
channel 是型別相關的,也就是說,一個 channel 只能傳遞一種型別的值,這個型別需要在宣告 channel 時指定。如果對 Unix 管道有所瞭解的話,就不難理解 channel,可以將其認為是一種型別安全的管道。
定義一個 channel 時,也需要定義傳送到 channel 的值的型別,注意,必須使用 make 建立 channel,程式碼如下所示:
- ci := make(chan int)
- cs := make(chan string)
- cf := make(chan interface{})
回到在 Windows 和 Linux 出現之前的古老年代,在開發程式時並沒有併發的概念,因為命令式程式設計語言是以序列為基礎的,程式會順序執行每一條指令,整個程式只有一個執行上下文,即一個呼叫棧,一個堆。
併發則意味著程式在執行時有多個執行上下文,對應著多個呼叫棧。我們知道每一個程式在執行時,都有自己的呼叫棧和堆,有一個完整的上下文,而作業系統在排程程式的時候,會儲存被排程程式的上下文環境,等該程式獲得時間片後,再恢復該程式的上下文到系統中。
從整個作業系統層面來說,多個程式是可以併發的,那麼併發的價值何在?下面我們先看以下幾種場景。
1) 一方面我們需要靈敏響應的圖形使用者介面,一方面程式還需要執行大量的運算或者 IO 密集操作,而我們需要讓介面響應與運算同時執行。
2) 當我們的 Web 伺服器面對大量使用者請求時,需要有更多的“Web 伺服器工作單元”來分別響應使用者。
3) 我們的事務處於分散式環境上,相同的工作單元在不同的計算機上處理著被分片的資料,計算機的 CPU 從單核心(core)向多核心發展,而我們的程式都是序列的,計算機硬體的能力沒有得到發揮。
4) 我們的程式因為 IO 操作被阻塞,整個程式處於停滯狀態,其他 IO 無關的任務無法執行。
從以上幾個例子可以看到,序列程式在很多場景下無法滿足我們的要求。下面我們歸納了併發程式的幾條優點,讓大家認識到併發勢在必行:
- 併發能更客觀地表現問題模型;
- 併發可以充分利用 CPU 核心的優勢,提高程式的執行效率;
- 併發能充分利用 CPU 與其他硬體裝置固有的非同步性。
在編寫 Socket 網路程式時,需要提前準備一個執行緒池為每一個 Socket 的收發包分配一個執行緒。開發人員需要線上程數量和 CPU 數量間建立一個對應關係,以保證每個任務能及時地被分配到 CPU 上進行處理,同時避免多個任務頻繁地線上程間切換執行而損失效率。
雖然,執行緒池為邏輯編寫者提供了執行緒分配的抽象機制。但是,如果面對隨時隨地可能發生的併發和執行緒處理需求,執行緒池就不是非常直觀和方便了。能否有一種機制:使用者分配足夠多的任務,系統能自動幫助使用者把任務分配到 CPU 上,讓這些任務儘量併發運作。這種機制在 Go語言中被稱為 goroutine。
goroutine 是 Go語言中的輕量級執行緒實現,由 Go 執行時(runtime)管理。Go 程式會智慧地將 goroutine 中的任務合理地分配給每個 CPU。
Go 程式從 main 包的 main() 函式開始,在程式啟動時,Go 程式就會為 main() 函式建立一個預設的 goroutine。
使用普通函式建立 goroutine
Go 程式中使用 go 關鍵字為一個函式建立一個 goroutine。一個函式可以被建立多個 goroutine,一個 goroutine 必定對應一個函式。
1) 格式
為一個普通函式建立 goroutine 的寫法如下:
go 函式名( 引數列表 )
- 函式名:要呼叫的函式名。
- 引數列表:呼叫函式需要傳入的引數。
使用 go 關鍵字建立 goroutine 時,被呼叫函式的返回值會被忽略。
如果需要在 goroutine 中返回資料,請使用後面介紹的通道(channel)特性,通過通道把資料從 goroutine 中作為返回值傳出。
2) 例子
使用 go 關鍵字,將 running() 函式併發執行,每隔一秒列印一次計數器,而 main 的 goroutine 則等待使用者輸入,兩個行為可以同時進行。請參考下面程式碼:
- package main
- import (
- "fmt"
- "time"
- )
- func running() {
- var times int
- // 構建一個無限迴圈
- for {
- times++
- fmt.Println("tick", times)
- // 延時1秒
- time.Sleep(time.Second)
- }
- }
- func main() {
- // 併發執行程式
- go running()
- // 接受命令列輸入, 不做任何事情
- var input string
- fmt.Scanln(&input)
- }
命令列輸出如下:
tick 1
tick 2
tick 3
tick 4
tick 5
程式碼執行後,命令列會不斷地輸出 tick,同時可以使用 fmt.Scanln() 接受使用者輸入。兩個環節可以同時進行。
程式碼說明如下:
第 12 行,使用 for 形成一個無限迴圈。
第 13 行,times 變數在迴圈中不斷自增。
第 14 行,輸出 times 變數的值。
第 17 行,使用 time.Sleep 暫停 1 秒後繼續迴圈。
第 25 行,使用 go 關鍵字讓 running() 函式併發執行。
第 29 行,接受使用者輸入,直到按 Enter 鍵時將輸入的內容寫入 input 變數中並返回,整個程式終止。
這段程式碼的執行順序如下圖所示。
圖:併發執行圖
這個例子中,Go 程式在啟動時,執行時(runtime)會預設為 main() 函式建立一個 goroutine。在 main() 函式的 goroutine 中執行到 go running 語句時,歸屬於 running() 函式的 goroutine 被建立,running() 函式開始在自己的 goroutine 中執行。此時,main() 繼續執行,兩個 goroutine 通過 Go 程式的排程機制同時運作。
使用匿名函式建立goroutine
go 關鍵字後也可以為匿名函式或閉包啟動 goroutine。
1) 使用匿名函式建立goroutine的格式
使用匿名函式或閉包建立 goroutine 時,除了將函式定義部分寫在 go 的後面之外,還需要加上匿名函式的呼叫引數,格式如下:
go func( 引數列表 ){
函式體
}( 呼叫引數列表 )
其中:
- 引數列表:函式體內的引數變數列表。
- 函式體:匿名函式的程式碼。
- 呼叫引數列表:啟動 goroutine 時,需要向匿名函式傳遞的呼叫引數。
2) 使用匿名函式建立goroutine的例子
在 main() 函式中建立一個匿名函式併為匿名函式啟動 goroutine。匿名函式沒有引數。程式碼將並行執行定時列印計數的效果。參見下面的程式碼:
- package main
- import (
- "fmt"
- "time"
- )
- func main() {
- go func() {
- var times int
- for {
- times++
- fmt.Println("tick", times)
- time.Sleep(time.Second)
- }
- }()
- var input string
- fmt.Scanln(&input)
- }
程式碼說明如下:
- 第 10 行,go 後面接匿名函式啟動 goroutine。
- 第 12~19 行的邏輯與前面程式的 running() 函式一致。
- 第 21 行的括號的功能是呼叫匿名函式的引數列表。由於第 10 行的匿名函式沒有引數,因此第 21 行的引數列表也是空的。
提示
所有 goroutine 在 main() 函式結束時會一同結束。
goroutine 雖然類似於執行緒概念,但是從排程效能上沒有執行緒細緻,而細緻程度取決於 Go 程式的 goroutine 排程器的實現和執行環境。
終止 goroutine 的最好方法就是自然返回 goroutine 對應的函式。雖然可以用 golang.org/x/net/context 包進行 goroutine 生命期深度控制,但這種方法仍然處於內部試驗階段,並不是官方推薦的特性。
截止 Go 1.9 版本,暫時沒有標準介面獲取 goroutine 的 ID。
9.3 Go語言併發通訊
通過上一節《Go語言goroutine》的學習,關鍵字 go 的引入使得在Go語言中併發程式設計變得簡單而優雅,但我們同時也應該意識到併發程式設計的原生複雜性,並時刻對併發中容易出現的問題保持警惕。
事實上,不管是什麼平臺,什麼程式語言,不管在哪,併發都是一個大話題。併發程式設計的難度在於協調,而協調就要通過交流,從這個角度看來,併發單元間的通訊是最大的問題。
在工程上,有兩種最常見的併發通訊模型:共享資料和訊息。
共享資料是指多個併發單元分別保持對同一個資料的引用,實現對該資料的共享。被共享的資料可能有多種形式,比如記憶體資料塊、磁碟檔案、網路資料等。在實際工程應用中最常見的無疑是記憶體了,也就是常說的共享記憶體。
先看看我們在C語言中通常是怎麼處理執行緒間資料共享的,程式碼如下所示。
- #include <stdio.h>
- #include <stdlib.h>
- #include <pthread.h>
- void *count();
- pthread_mutex_t mutex1 = PTHREAD_MUTEX_INITIALIZER;
- int counter = 0;
- int main()
- {
- int rc1, rc2;
- pthread_t thread1, thread2;
- /* 建立執行緒,每個執行緒獨立執行函式functionC */
- if((rc1 = pthread_create(&thread1, NULL, &count, NULL)))
- {
- printf("Thread creation failed: %d\n", rc1);
- }
- if((rc2 = pthread_create(&thread2, NULL, &count, NULL)))
- {
- printf("Thread creation failed: %d\n", rc2);
- }
- /* 等待所有執行緒執行完畢 */
- pthread_join( thread1, NULL);
- pthread_join( thread2, NULL);
- exit(0);
- }
- void *count()
- {
- pthread_mutex_lock( &mutex1 );
- counter++;
- printf("Counter value: %d\n",counter);
- pthread_mutex_unlock( &mutex1 );
- }
現在我們嘗試將這段C語言程式碼直接翻譯為Go語言程式碼,程式碼如下所示。
- package main
- import (
- "fmt"
- "runtime"
- "sync"
- )
- var counter int = 0
- func Count(lock *sync.Mutex) {
- lock.Lock()
- counter++
- fmt.Println(counter)
- lock.Unlock()
- }
- func main() {
- lock := &sync.Mutex{}
- for i := 0; i < 10; i++ {
- go Count(lock)
- }
- for {
- lock.Lock()
- c := counter
- lock.Unlock()
- runtime.Gosched()
- if c >= 10 {
- break
- }
- }
- }
在上面的例子中,我們在 10 個 goroutine 中共享了變數 counter。每個 goroutine 執行完成後,會將 counter 的值加 1。因為 10 個 goroutine 是併發執行的,所以我們還引入了鎖,也就是程式碼中的 lock 變數。每次對 n 的操作,都要先將鎖鎖住,操作完成後,再將鎖開啟。
在 main 函式中,使用 for 迴圈來不斷檢查 counter 的值(同樣需要加鎖)。當其值達到 10 時,說明所有 goroutine 都執行完畢了,這時主函式返回,程式退出。
事情好像開始變得糟糕了。實現一個如此簡單的功能,卻寫出如此臃腫而且難以理解的程式碼。想象一下,在一個大的系統中具有無數的鎖、無數的共享變數、無數的業務邏輯與錯誤處理分支,那將是一場噩夢。這噩夢就是眾多 C/C++ 開發者正在經歷的,其實 Java 和 C# 開發者也好不到哪裡去。
Go語言既然以併發程式設計作為語言的最核心優勢,當然不至於將這樣的問題用這麼無奈的方式來解決。Go語言提供的是另一種通訊模型,即以訊息機制而非共享記憶體作為通訊方式。
訊息機制認為每個併發單元是自包含的、獨立的個體,並且都有自己的變數,但在不同併發單元間這些變數不共享。每個併發單元的輸入和輸出只有一種,那就是訊息。這有點類似於程式的概念,每個程式不會被其他程式打擾,它只做好自己的工作就可以了。不同程式間靠訊息來通訊,它們不會共享記憶體。
Go語言提供的訊息通訊機制被稱為 channel,關於 channel 的介紹將在後續的學習中為大家講解。
9.4 Go語言競爭狀態簡述
有併發,就有資源競爭,如果兩個或者多個 goroutine 在沒有相互同步的情況下,訪問某個共享的資源,比如同時對該資源進行讀寫時,就會處於相互競爭的狀態,這就是併發中的資源競爭。
併發本身並不複雜,但是因為有了資源競爭的問題,就使得我們開發出好的併發程式變得複雜起來,因為會引起很多莫名其妙的問題。
下面的程式碼中就會出現競爭狀態:
- package main
- import (
- "fmt"
- "runtime"
- "sync"
- )
- var (
- count int32
- wg sync.WaitGroup
- )
- func main() {
- wg.Add(2)
- go incCount()
- go incCount()
- wg.Wait()
- fmt.Println(count)
- }
- func incCount() {
- defer wg.Done()
- for i := 0; i < 2; i++ {
- value := count
- runtime.Gosched()
- value++
- count = value
- }
- }
這是一個資源競爭的例子,大家可以將程式多執行幾次,會發現結果可能是 2,也可以是 3,還可能是 4。這是因為 count 變數沒有任何同步保護,所以兩個 goroutine 都會對其進行讀寫,會導致對已經計算好的結果被覆蓋,以至於產生錯誤結果。
程式碼中的 runtime.Gosched() 是讓當前 goroutine 暫停的意思,退回執行佇列,讓其他等待的 goroutine 執行,目的是為了使資源競爭的結果更明顯。
下面我們來分析一下程式的執行過程,將兩個 goroutine 分別假設為 g1 和 g2:
- g1 讀取到 count 的值為 0;
- 然後 g1 暫停了,切換到 g2 執行,g2 讀取到 count 的值也為 0;
- g2 暫停,切換到 g1,g1 對 count+1,count 的值變為 1;
- g1 暫停,切換到 g2,g2 剛剛已經獲取到值 0,對其 +1,最後賦值給 count,其結果還是 1;
- 可以看出 g1 對 count+1 的結果被 g2 給覆蓋了,兩個 goroutine 都 +1 而結果還是 1。
通過上面的分析可以看出,之所以出現上面的問題,是因為兩個 goroutine 相互覆蓋結果。
所以我們對於同一個資源的讀寫必須是原子化的,也就是說,同一時間只能允許有一個 goroutine 對共享資源進行讀寫操作。
共享資源競爭的問題,非常複雜,並且難以察覺,好在 Go 為我們提供了一個工具幫助我們檢查,這個就是go build -race
命令。在專案目錄下執行這個命令,生成一個可以執行檔案,然後再執行這個可執行檔案,就可以看到列印出的檢測資訊。
在go build
命令中多加了一個-race
標誌,這樣生成的可執行程式就自帶了檢測資源競爭的功能,執行生成的可執行檔案,效果如下所示:
==================
WARNING: DATA RACE
Read at 0x000000619cbc by goroutine 8:
main.incCount()
D:/code/src/main.go:25 +0x80
Previous write at 0x000000619cbc by goroutine 7:
main.incCount()
D:/code/src/main.go:28 +0x9f
Goroutine 8 (running) created at:
main.main()
D:/code/src/main.go:17 +0x7e
Goroutine 7 (finished) created at:
main.main()
D:/code/src/main.go:16 +0x66
==================
4
Found 1 data race(s)
通過執行結果可以看出 goroutine 8 在程式碼 25 行讀取共享資源value := count
,而這時 goroutine 7 在程式碼 28 行修改共享資源count = value
,而這兩個 goroutine 都是從 main 函式的 16、17 行通過 go 關鍵字啟動的。
鎖住共享資源
Go語言提供了傳統的同步 goroutine 的機制,就是對共享資源加鎖。atomic 和 sync 包裡的一些函式就可以對共享的資源進行加鎖操作。
原子函式
原子函式能夠以很底層的加鎖機制來同步訪問整型變數和指標,示例程式碼如下所示:
- package main
- import (
- "fmt"
- "runtime"
- "sync"
- "sync/atomic"
- )
- var (
- counter int64
- wg sync.WaitGroup
- )
- func main() {
- wg.Add(2)
- go incCounter(1)
- go incCounter(2)
- wg.Wait() //等待goroutine結束
- fmt.Println(counter)
- }
- func incCounter(id int) {
- defer wg.Done()
- for count := 0; count < 2; count++ {
- atomic.AddInt64(&counter, 1) //安全的對counter加1
- runtime.Gosched()
- }
- }
上述程式碼中使用了 atmoic 包的 AddInt64 函式,這個函式會同步整型值的加法,方法是強制同一時刻只能有一個 gorountie 執行並完成這個加法操作。當 goroutine 試圖去呼叫任何原子函式時,這些 goroutine 都會自動根據所引用的變數做同步處理。
另外兩個有用的原子函式是 LoadInt64 和 StoreInt64。這兩個函式提供了一種安全地讀和寫一個整型值的方式。下面是程式碼就使用了 LoadInt64 和 StoreInt64 函式來建立一個同步標誌,這個標誌可以向程式裡多個 goroutine 通知某個特殊狀態。
- package main
- import (
- "fmt"
- "sync"
- "sync/atomic"
- "time"
- )
- var (
- shutdown int64
- wg sync.WaitGroup
- )
- func main() {
- wg.Add(2)
- go doWork("A")
- go doWork("B")
- time.Sleep(1 * time.Second)
- fmt.Println("Shutdown Now")
- atomic.StoreInt64(&shutdown, 1)
- wg.Wait()
- }
- func doWork(name string) {
- defer wg.Done()
- for {
- fmt.Printf("Doing %s Work\n", name)
- time.Sleep(250 * time.Millisecond)
- if atomic.LoadInt64(&shutdown) == 1 {
- fmt.Printf("Shutting %s Down\n", name)
- break
- }
- }
- }
上面程式碼中 main 函式使用 StoreInt64 函式來安全地修改 shutdown 變數的值。如果哪個 doWork goroutine 試圖在 main 函式呼叫 StoreInt64 的同時呼叫 LoadInt64 函式,那麼原子函式會將這些呼叫互相同步,保證這些操作都是安全的,不會進入競爭狀態。
互斥鎖
另一種同步訪問共享資源的方式是使用互斥鎖,互斥鎖這個名字來自互斥的概念。互斥鎖用於在程式碼上建立一個臨界區,保證同一時間只有一個 goroutine 可以執行這個臨界程式碼。
示例程式碼如下所示:
- package main
- import (
- "fmt"
- "runtime"
- "sync"
- )
- var (
- counter int64
- wg sync.WaitGroup
- mutex sync.Mutex
- )
- func main() {
- wg.Add(2)
- go incCounter(1)
- go incCounter(2)
- wg.Wait()
- fmt.Println(counter)
- }
- func incCounter(id int) {
- defer wg.Done()
- for count := 0; count < 2; count++ {
- //同一時刻只允許一個goroutine進入這個臨界區
- mutex.Lock()
- {
- value := counter
- runtime.Gosched()
- value++
- counter = value
- }
- mutex.Unlock() //釋放鎖,允許其他正在等待的goroutine進入臨界區
- }
- }
同一時刻只有一個 goroutine 可以進入臨界區。之後直到呼叫 Unlock 函式之後,其他 goroutine 才能進去臨界區。當呼叫 runtime.Gosched 函式強制將當前 goroutine 退出當前執行緒後,排程器會再次分配這個 goroutine 繼續執行。
在 Go語言程式執行時(runtime)實現了一個小型的任務排程器。這套排程器的工作原理類似於作業系統排程執行緒,Go 程式排程器可以高效地將 CPU 資源分配給每一個任務。傳統邏輯中,開發者需要維護執行緒池中執行緒與 CPU 核心數量的對應關係。同樣的,Go 地中也可以通過 runtime.GOMAXPROCS() 函式做到,格式為:
runtime.GOMAXPROCS(邏輯CPU數量)
這裡的邏輯CPU數量可以有如下幾種數值:
- <1:不修改任何數值。
- =1:單核心執行。
- >1:多核併發執行。
一般情況下,可以使用 runtime.NumCPU() 查詢 CPU 數量,並使用 runtime.GOMAXPROCS() 函式進行設定,例如:
- runtime.GOMAXPROCS(runtime.NumCPU())
Go 1.5 版本之前,預設使用的是單核心執行。從 Go 1.5 版本開始,預設執行上面語句以便讓程式碼併發執行,最大效率地利用 CPU。
GOMAXPROCS 同時也是一個環境變數,在應用程式啟動前設定環境變數也可以起到相同的作用。
9.6 併發和並行的區別
在講解併發概念時,總會涉及另外一個概念並行。下面讓我們來了解併發和並行之間的區別。
- 併發(concurrency):把任務在不同的時間點交給處理器進行處理。在同一時間點,任務並不會同時執行。
- 並行(parallelism):把每一個任務分配給每一個處理器獨立完成。在同一時間點,任務一定是同時執行。
併發不是並行。並行是讓不同的程式碼片段同時在不同的物理處理器上執行。並行的關鍵是同時做很多事情,而併發是指同時管理很多事情,這些事情可能只做了一半就被暫停去做別的事情了。
在很多情況下,併發的效果比並行好,因為作業系統和硬體的總資源一般很少,但能支援系統同時做很多事情。這種“使用較少的資源做更多的事情”的哲學,也是指導 Go語言設計的哲學。
如果希望讓 goroutine 並行,必須使用多於一個邏輯處理器。當有多個邏輯處理器時,排程器會將 goroutine 平等分配到每個邏輯處理器上。這會讓 goroutine 在不同的執行緒上執行。不過要想真的實現並行的效果,使用者需要讓自己的程式執行在有多個物理處理器的機器上。否則,哪怕 Go語言執行時使用多個執行緒,goroutine 依然會在同一個物理處理器上併發執行,達不到並行的效果。
下圖展示了在一個邏輯處理器上併發執行 goroutine 和在兩個邏輯處理器上並行執行兩個併發的 goroutine 之間的區別。排程器包含一些聰明的演算法,這些演算法會隨著 Go語言的釋出被更新和改進,所以不推薦盲目修改語言執行時對邏輯處理器的預設設定。如果真的認為修改邏輯處理器的數量可以改進效能,也可以對語言執行時的引數進行細微調整。
圖:併發與並行的區別
Go語言在 GOMAXPROCS 數量與任務數量相等時,可以做到並行執行,但一般情況下都是併發執行。
C#、Lua、Python 語言都支援 coroutine 特性。coroutine 與 goroutine 在名字上類似,都可以將函式或者語句在獨立的環境中執行,但是它們之間有兩點不同:
- goroutine 可能發生並行執行;
- 但 coroutine 始終順序執行。
goroutines 意味著並行(或者可以以並行的方式部署),coroutines 一般來說不是這樣的,goroutines 通過通道來通訊;coroutines 通過讓出和恢復操作來通訊,goroutines 比 coroutines 更強大,也很容易從 coroutines 的邏輯複用到 goroutines。
狹義地說,goroutine 可能發生在多執行緒環境下,goroutine 無法控制自己獲取高優先度支援;coroutine 始終發生在單執行緒,coroutine 程式需要主動交出控制權,宿主才能獲得控制權並將控制權交給其他 coroutine。
goroutine 間使用 channel 通訊,coroutine 使用 yield 和 resume 操作。
goroutine 和 coroutine 的概念和執行機制都是脫胎於早期的作業系統。
coroutine 的執行機制屬於協作式任務處理,早期的作業系統要求每一個應用必須遵守作業系統的任務處理規則,應用程式在不需要使用 CPU 時,會主動交出 CPU 使用權。如果開發者無意間或者故意讓應用程式長時間佔用 CPU,作業系統也無能為力,表現出來的效果就是計算機很容易失去響應或者當機。
goroutine 屬於搶佔式任務處理,已經和現有的多執行緒和多程式任務處理非常類似。應用程式對 CPU 的控制最終還需要由作業系統來管理,作業系統如果發現一個應用程式長時間大量地佔用 CPU,那麼使用者有權終止這個任務。
9.8 Go語言通道(chan)——goroutine之間通訊的管道
如果說 goroutine 是 Go語言程式的併發體的話,那麼 channels 就是它們之間的通訊機制。一個 channels 是一個通訊機制,它可以讓一個 goroutine 通過它給另一個 goroutine 傳送值資訊。每個 channel 都有一個特殊的型別,也就是 channels 可傳送資料的型別。一個可以傳送 int 型別資料的 channel 一般寫為 chan int。
Go語言提倡使用通訊的方法代替共享記憶體,當一個資源需要在 goroutine 之間共享時,通道在 goroutine 之間架起了一個管道,並提供了確保同步交換資料的機制。宣告通道時,需要指定將要被共享的資料的型別。可以通過通道共享內建型別、命名型別、結構型別和引用型別的值或者指標。
這裡通訊的方法就是使用通道(channel),如下圖所示。
圖:goroutine 與 channel 的通訊
在地鐵站、食堂、洗手間等公共場所人很多的情況下,大家養成了排隊的習慣,目的也是避免擁擠、插隊導致的低效的資源使用和交換過程。程式碼與資料也是如此,多個 goroutine 為了爭搶資料,勢必造成執行的低效率,使用佇列的方式是最高效的,channel 就是一種佇列一樣的結構。
通道的特性
Go語言中的通道(channel)是一種特殊的型別。在任何時候,同時只能有一個 goroutine 訪問通道進行傳送和獲取資料。goroutine 間通過通道就可以通訊。
通道像一個傳送帶或者佇列,總是遵循先入先出(First In First Out)的規則,保證收發資料的順序。
宣告通道型別
通道本身需要一個型別進行修飾,就像切片型別需要標識元素型別。通道的元素型別就是在其內部傳輸的資料型別,宣告如下:
var 通道變數 chan 通道型別
- 通道型別:通道內的資料型別。
- 通道變數:儲存通道的變數。
chan 型別的空值是 nil,宣告後需要配合 make 後才能使用。
建立通道
通道是引用型別,需要使用 make 進行建立,格式如下:
通道例項 := make(chan 資料型別)
- 資料型別:通道內傳輸的元素型別。
- 通道例項:通過make建立的通道控制程式碼。
請看下面的例子:
- ch1 := make(chan int) // 建立一個整型型別的通道
- ch2 := make(chan interface{}) // 建立一個空介面型別的通道, 可以存放任意格式
- type Equip struct{ /* 一些欄位 */ }
- ch2 := make(chan *Equip) // 建立Equip指標型別的通道, 可以存放*Equip
使用通道傳送資料
通道建立後,就可以使用通道進行傳送和接收操作。
1) 通道傳送資料的格式
通道的傳送使用特殊的操作符<-
,將資料通過通道傳送的格式為:
通道變數 <- 值
- 通道變數:通過make建立好的通道例項。
- 值:可以是變數、常量、表示式或者函式返回值等。值的型別必須與ch通道的元素型別一致。
2) 通過通道傳送資料的例子
使用 make 建立一個通道後,就可以使用<-
向通道傳送資料,程式碼如下:
- // 建立一個空介面通道
- ch := make(chan interface{})
- // 將0放入通道中
- ch <- 0
- // 將hello字串放入通道中
- ch <- "hello"
3) 傳送將持續阻塞直到資料被接收
把資料往通道中傳送時,如果接收方一直都沒有接收,那麼傳送操作將持續阻塞。Go 程式執行時能智慧地發現一些永遠無法傳送成功的語句並做出提示,程式碼如下:
- package main
- func main() {
- // 建立一個整型通道
- ch := make(chan int)
- // 嘗試將0通過通道傳送
- ch <- 0
- }
執行程式碼,報錯:
fatal error: all goroutines are asleep - deadlock!
報錯的意思是:執行時發現所有的 goroutine(包括main)都處於等待 goroutine。也就是說所有 goroutine 中的 channel 並沒有形成傳送和接收對應的程式碼。
使用通道接收資料
通道接收同樣使用<-
操作符,通道接收有如下特性:
① 通道的收發操作在不同的兩個 goroutine 間進行。
由於通道的資料在沒有接收方處理時,資料傳送方會持續阻塞,因此通道的接收必定在另外一個 goroutine 中進行。
② 接收將持續阻塞直到傳送方傳送資料。
如果接收方接收時,通道中沒有傳送方傳送資料,接收方也會發生阻塞,直到傳送方傳送資料為止。
③ 每次接收一個元素。
通道一次只能接收一個資料元素。
通道的資料接收一共有以下 4 種寫法。
1) 阻塞接收資料
阻塞模式接收資料時,將接收變數作為<-
操作符的左值,格式如下:
data := <-ch
執行該語句時將會阻塞,直到接收到資料並賦值給 data 變數。
2) 非阻塞接收資料
使用非阻塞方式從通道接收資料時,語句不會發生阻塞,格式如下:
data, ok := <-ch
- data:表示接收到的資料。未接收到資料時,data 為通道型別的零值。
- ok:表示是否接收到資料。
非阻塞的通道接收方法可能造成高的 CPU 佔用,因此使用非常少。如果需要實現接收超時檢測,可以配合 select 和計時器 channel 進行,可以參見後面的內容。
3) 接收任意資料,忽略接收的資料
阻塞接收資料後,忽略從通道返回的資料,格式如下:
<-ch
執行該語句時將會發生阻塞,直到接收到資料,但接收到的資料會被忽略。這個方式實際上只是通過通道在 goroutine 間阻塞收發實現併發同步。
使用通道做併發同步的寫法,可以參考下面的例子:
- package main
- import (
- "fmt"
- )
- func main() {
- // 構建一個通道
- ch := make(chan int)
- // 開啟一個併發匿名函式
- go func() {
- fmt.Println("start goroutine")
- // 通過通道通知main的goroutine
- ch <- 0
- fmt.Println("exit goroutine")
- }()
- fmt.Println("wait goroutine")
- // 等待匿名goroutine
- <-ch
- fmt.Println("all done")
- }
執行程式碼,輸出如下:
wait goroutine
start goroutine
exit goroutine
all done
程式碼說明如下:
- 第 10 行,構建一個同步用的通道。
- 第 13 行,開啟一個匿名函式的併發。
- 第 18 行,匿名 goroutine 即將結束時,通過通道通知 main 的 goroutine,這一句會一直阻塞直到 main 的 goroutine 接收為止。
- 第 27 行,開啟 goroutine 後,馬上通過通道等待匿名 goroutine 結束。
4) 迴圈接收
通道的資料接收可以借用 for range 語句進行多個元素的接收操作,格式如下:
- for data := range ch {
- }
通道 ch 是可以進行遍歷的,遍歷的結果就是接收到的資料。資料型別就是通道的資料型別。通過 for 遍歷獲得的變數只有一個,即上面例子中的 data。
遍歷通道資料的例子請參考下面的程式碼。
使用 for 從通道中接收資料:
- package main
- import (
- "fmt"
- "time"
- )
- func main() {
- // 構建一個通道
- ch := make(chan int)
- // 開啟一個併發匿名函式
- go func() {
- // 從3迴圈到0
- for i := 3; i >= 0; i-- {
- // 傳送3到0之間的數值
- ch <- i
- // 每次傳送完時等待
- time.Sleep(time.Second)
- }
- }()
- // 遍歷接收通道資料
- for data := range ch {
- // 列印通道資料
- fmt.Println(data)
- // 當遇到資料0時, 退出接收迴圈
- if data == 0 {
- break
- }
- }
- }
執行程式碼,輸出如下:
3
2
1
0
程式碼說明如下:
- 第 12 行,通過 make 生成一個整型元素的通道。
- 第 15 行,將匿名函式併發執行。
- 第 18 行,用迴圈生成 3 到 0 之間的數值。
- 第 21 行,將 3 到 0 之間的數值依次傳送到通道 ch 中。
- 第 24 行,每次傳送後暫停 1 秒。
- 第 30 行,使用 for 從通道中接收資料。
- 第 33 行,將接收到的資料列印出來。
- 第 36 行,當接收到數值 0 時,停止接收。如果繼續傳送,由於接收 goroutine 已經退出,沒有 goroutine 傳送到通道,因此執行時將會觸發當機報錯。
9.9 Go語言併發列印(藉助通道實現)
前面的例子建立的都是無緩衝通道。使用無緩衝通道往裡面裝入資料時,裝入方將被阻塞,直到另外通道在另外一個 goroutine 中被取出。同樣,如果通道中沒有放入任何資料,接收方試圖從通道中獲取資料時,同樣也是阻塞。傳送和接收的操作是同步完成的。
下面通過一個併發列印的例子,將 goroutine 和 channel 放在一起展示它們的用法。
- package main
- import (
- "fmt"
- )
- func printer(c chan int) {
- // 開始無限迴圈等待資料
- for {
- // 從channel中獲取一個資料
- data := <-c
- // 將0視為資料結束
- if data == 0 {
- break
- }
- // 列印資料
- fmt.Println(data)
- }
- // 通知main已經結束迴圈(我搞定了!)
- c <- 0
- }
- func main() {
- // 建立一個channel
- c := make(chan int)
- // 併發執行printer, 傳入channel
- go printer(c)
- for i := 1; i <= 10; i++ {
- // 將資料通過channel投送給printer
- c <- i
- }
- // 通知併發的printer結束迴圈(沒資料啦!)
- c <- 0
- // 等待printer結束(搞定喊我!)
- <-c
- }
執行程式碼,輸出如下:
1
2
3
4
5
6
7
8
9
10
程式碼說明如下:
- 第 10 行,建立一個無限迴圈,只有當第 16 行獲取到的資料為 0 時才會退出迴圈。
- 第 13 行,從函式引數傳入的通道中獲取一個整型數值。
- 第 21 行,列印整型數值。
- 第 25 行,在退出迴圈時,通過通道通知 main() 函式已經完成工作。
- 第 32 行,建立一個整型通道進行跨 goroutine 的通訊。
- 第 35 行,建立一個 goroutine,併發執行 printer() 函式。
- 第 37 行,構建一個數值迴圈,將 1~10 的數通過通道傳送給 printer 構造出的 goroutine。
- 第 44 行,給通道傳入一個 0,表示將前面的資料處理完成後,退出迴圈。
- 第 47 行,在資料傳送過去後,因為併發和排程的原因,任務會併發執行。這裡需要等待 printer 的第 25 行返回資料後,才可以退出 main()。
本例的設計模式就是典型的生產者和消費者。生產者是第 37 行的迴圈,而消費者是 printer() 函式。整個例子使用了兩個 goroutine,一個是 main(),一個是通過第 35 行 printer() 函式建立的 goroutine。兩個 goroutine 通過第 32 行建立的通道進行通訊。這個通道有下面兩重功能。
- 資料傳送:第 40 行中傳送資料和第 13 行接收資料。
- 控制指令:類似於訊號量的功能。同步 goroutine 的操作。功能簡單描述為:
- 第 44 行:“沒資料啦!”
- 第 25 行:“我搞定了!”
- 第 47 行:“搞定喊我!”
9.10 Go語言單向通道——通道中的單行道
Go語言的型別系統提供了單方向的 channel 型別,顧名思義,單向 channel 就是隻能用於寫入或者只能用於讀取資料。當然 channel 本身必然是同時支援讀寫的,否則根本沒法用。
假如一個 channel 真的只能讀取資料,那麼它肯定只會是空的,因為你沒機會往裡面寫資料。同理,如果一個 channel 只允許寫入資料,即使寫進去了,也沒有絲毫意義,因為沒有辦法讀取到裡面的資料。所謂的單向 channel 概念,其實只是對 channel 的一種使用限制。
單向通道的宣告格式
我們在將一個 channel 變數傳遞到一個函式時,可以通過將其指定為單向 channel 變數,從而限制該函式中可以對此 channel 的操作,比如只能往這個 channel 中寫入資料,或者只能從這個 channel 讀取資料。
單向 channel 變數的宣告非常簡單,只能寫入資料的通道型別為chan<-
,只能讀取資料的通道型別為<-chan
,格式如下:
var 通道例項 chan<- 元素型別 // 只能寫入資料的通道
var 通道例項 <-chan 元素型別 // 只能讀取資料的通道
- 元素型別:通道包含的元素型別。
- 通道例項:宣告的通道變數。
單向通道的使用例子
示例程式碼如下:
- ch := make(chan int)
- // 宣告一個只能寫入資料的通道型別, 並賦值為ch
- var chSendOnly chan<- int = ch
- //宣告一個只能讀取資料的通道型別, 並賦值為ch
- var chRecvOnly <-chan int = ch
上面的例子中,chSendOnly 只能寫入資料,如果嘗試讀取資料,將會出現如下報錯:
invalid operation: <-chSendOnly (receive from send-only type chan<- int)
同理,chRecvOnly 也是不能寫入資料的。
當然,使用 make 建立通道時,也可以建立一個只寫入或只讀取的通道:
- ch := make(<-chan int)
- var chReadOnly <-chan int = ch
- <-chReadOnly
上面程式碼編譯正常,執行也是正確的。但是,一個不能寫入資料只能讀取的通道是毫無意義的。
time包中的單向通道
time 包中的計時器會返回一個 timer 例項,程式碼如下:
- timer := time.NewTimer(time.Second)
timer的Timer型別定義如下:
- type Timer struct {
- C <-chan Time
- r runtimeTimer
- }
第 2 行中 C 通道的型別就是一種只能讀取的單向通道。如果此處不進行通道方向約束,一旦外部向通道寫入資料,將會造成其他使用到計時器的地方邏輯產生混亂。
因此,單向通道有利於程式碼介面的嚴謹性。
關閉 channel
關閉 channel 非常簡單,直接使用Go語言內建的 close() 函式即可:
close(ch)
在介紹瞭如何關閉 channel 之後,我們就多了一個問題:如何判斷一個 channel 是否已經被關閉?我們可以在讀取的時候使用多重返回值的方式:
x, ok := <-ch
這個用法與 map 中的按鍵獲取 value 的過程比較類似,只需要看第二個 bool 返回值即可,如果返回值是 false 則表示 ch 已經被關閉。
9.11 Go語言無緩衝的通道
Go語言中無緩衝的通道(unbuffered channel)是指在接收前沒有能力儲存任何值的通道。這種型別的通道要求傳送 goroutine 和接收 goroutine 同時準備好,才能完成傳送和接收操作。
如果兩個 goroutine 沒有同時準備好,通道會導致先執行傳送或接收操作的 goroutine 阻塞等待。這種對通道進行傳送和接收的互動行為本身就是同步的。其中任意一個操作都無法離開另一個操作單獨存在。
阻塞指的是由於某種原因資料沒有到達,當前協程(執行緒)持續處於等待狀態,直到條件滿足才解除阻塞。
同步指的是在兩個或多個協程(執行緒)之間,保持資料內容一致性的機制。
下圖展示兩個 goroutine 如何利用無緩衝的通道來共享一個值。
圖:使用無緩衝的通道在 goroutine 之間同步
在第 1 步,兩個 goroutine 都到達通道,但哪個都沒有開始執行傳送或者接收。在第 2 步,左側的 goroutine 將它的手伸進了通道,這模擬了向通道傳送資料的行為。這時,這個 goroutine 會在通道中被鎖住,直到交換完成。
在第 3 步,右側的 goroutine 將它的手放入通道,這模擬了從通道里接收資料。這個 goroutine 一樣也會在通道中被鎖住,直到交換完成。在第 4 步和第 5 步,進行交換,並最終在第 6 步,兩個 goroutine 都將它們的手從通道里拿出來,這模擬了被鎖住的 goroutine 得到釋放。兩個 goroutine 現在都可以去做別的事情了。
為了講得更清楚,讓我們來看兩個完整的例子。這兩個例子都會使用無緩衝的通道在兩個 goroutine 之間同步交換資料。
【示例 1】在網球比賽中,兩位選手會把球在兩個人之間來回傳遞。選手總是處在以下兩種狀態之一,要麼在等待接球,要麼將球打向對方。可以使用兩個 goroutine 來模擬網球比賽,並使用無緩衝的通道來模擬球的來回,程式碼如下所示。
- // 這個示例程式展示如何用無緩衝的通道來模擬
- // 2 個goroutine 間的網球比賽
- package main
- import (
- "fmt"
- "math/rand"
- "sync"
- "time"
- )
- // wg 用來等待程式結束
- var wg sync.WaitGroup
- func init() {
- rand.Seed(time.Now().UnixNano())
- }
- // main 是所有Go 程式的入口
- func main() {
- // 建立一個無緩衝的通道
- court := make(chan int)
- // 計數加 2,表示要等待兩個goroutine
- wg.Add(2)
- // 啟動兩個選手
- go player("Nadal", court)
- go player("Djokovic", court)
- // 發球
- court <- 1
- // 等待遊戲結束
- wg.Wait()
- }
- // player 模擬一個選手在打網球
- func player(name string, court chan int) {
- // 在函式退出時呼叫Done 來通知main 函式工作已經完成
- defer wg.Done()
- for {
- // 等待球被擊打過來
- ball, ok := <-court
- if !ok {
- // 如果通道被關閉,我們就贏了
- fmt.Printf("Player %s Won\n", name)
- return
- }
- // 選隨機數,然後用這個數來判斷我們是否丟球
- n := rand.Intn(100)
- if n%13 == 0 {
- fmt.Printf("Player %s Missed\n", name)
- // 關閉通道,表示我們輸了
- close(court)
- return
- }
- // 顯示擊球數,並將擊球數加1
- fmt.Printf("Player %s Hit %d\n", name, ball)
- ball++
- // 將球打向對手
- court <- ball
- }
- }
執行這個程式,輸出結果如下所示。
Player Nadal Hit 1
Player Djokovic Hit 2
Player Nadal Hit 3
Player Djokovic Missed
Player Nadal Won
程式碼說明如下:
- 第 22 行,建立了一個 int 型別的無緩衝的通道,讓兩個 goroutine 在擊球時能夠互相同步。
- 第 28 行和第 29 行,建立了參與比賽的兩個 goroutine。在這個時候,兩個 goroutine 都阻塞住等待擊球。
- 第 32 行,將球發到通道里,程式開始執行這個比賽,直到某個 goroutine 輸掉比賽。
- 第 43 行可以找到一個無限迴圈的 for 語句。在這個迴圈裡,是玩遊戲的過程。
- 第 45 行,goroutine 從通道接收資料,用來表示等待接球。這個接收動作會鎖住 goroutine,直到有資料傳送到通道里。通道的接收動作返回時。
- 第 46 行會檢測 ok 標誌是否為 false。如果這個值是 false,表示通道已經被關閉,遊戲結束。
- 第 53 行到第 60 行,會產生一個隨機數,用來決定 goroutine 是否擊中了球。
- 第 58 行如果某個 goroutine 沒有打中球,關閉通道。之後兩個 goroutine 都會返回,通過 defer 宣告的 Done 會被執行,程式終止。
- 第 64 行,如果擊中了球 ball 的值會遞增 1,並在第 67 行,將 ball 作為球重新放入通道,傳送給另一位選手。在這個時刻,兩個 goroutine 都會被鎖住,直到交換完成。
【示例 2】用不同的模式,使用無緩衝的通道,在 goroutine 之間同步資料,來模擬接力比賽。在接力比賽裡,4 個跑步者圍繞賽道輪流跑。第二個、第三個和第四個跑步者要接到前一位跑步者的接力棒後才能起跑。比賽中最重要的部分是要傳遞接力棒,要求同步傳遞。在同步接力棒的時候,參與接力的兩個跑步者必須在同一時刻準備好交接。程式碼如下所示。
- // 這個示例程式展示如何用無緩衝的通道來模擬
- // 4 個goroutine 間的接力比賽
- package main
- import (
- "fmt"
- "sync"
- "time"
- )
- // wg 用來等待程式結束
- var wg sync.WaitGroup
- // main 是所有Go 程式的入口
- func main() {
- // 建立一個無緩衝的通道
- baton := make(chan int)
- // 為最後一位跑步者將計數加1
- wg.Add(1)
- // 第一位跑步者持有接力棒
- go Runner(baton)
- // 開始比賽
- baton <- 1
- // 等待比賽結束
- wg.Wait()
- }
- // Runner 模擬接力比賽中的一位跑步者
- func Runner(baton chan int) {
- var newRunner int
- // 等待接力棒
- runner := <-baton
- // 開始繞著跑道跑步
- fmt.Printf("Runner %d Running With Baton\n", runner)
- // 建立下一位跑步者
- if runner != 4 {
- newRunner = runner + 1
- fmt.Printf("Runner %d To The Line\n", newRunner)
- go Runner(baton)
- }
- // 圍繞跑道跑
- time.Sleep(100 * time.Millisecond)
- // 比賽結束了嗎?
- if runner == 4 {
- fmt.Printf("Runner %d Finished, Race Over\n", runner)
- wg.Done()
- return
- }
- // 將接力棒交給下一位跑步者
- fmt.Printf("Runner %d Exchange With Runner %d\n",
- runner,
- newRunner)
- baton <- newRunner
- }
執行這個程式,輸出結果如下所示。
Runner 1 Running With Baton
Runner 1 To The Line
Runner 1 Exchange With Runner 2
Runner 2 Running With Baton
Runner 2 To The Line
Runner 2 Exchange With Runner 3
Runner 3 Running With Baton
Runner 3 To The Line
Runner 3 Exchange With Runner 4
Runner 4 Running With Baton
Runner 4 Finished, Race Over
程式碼說明如下:
- 第 17 行,建立了一個無緩衝的 int 型別的通道 baton,用來同步傳遞接力棒。
- 第 20 行,我們給 WaitGroup 加 1,這樣 main 函式就會等最後一位跑步者跑步結束。
- 第 23 行建立了一個 goroutine,用來表示第一位跑步者來到跑道。
- 第 26 行,將接力棒交給這個跑步者,比賽開始。
- 第 29 行,main 函式阻塞在 WaitGroup,等候最後一位跑步者完成比賽。
- 第 37 行,goroutine 對 baton 通道執行接收操作,表示等候接力棒。
- 第 46 行,一旦接力棒傳了進來,就會建立一位新跑步者,準備接力下一棒,直到 goroutine 是第四個跑步者。
- 第 50 行,跑步者圍繞跑道跑 100 ms。
- 第 55 行,如果第四個跑步者完成了比賽,就呼叫 Done,將 WaitGroup 減 1,之後 goroutine 返回。
- 第 64 行,如果這個 goroutine 不是第四個跑步者,接力棒會交到下一個已經在等待的跑步者手上。在這個時候,goroutine 會被鎖住,直到交接完成。
在這兩個例子裡,我們使用無緩衝的通道同步 goroutine,模擬了網球和接力賽。程式碼的流程與這兩個活動在真實世界中的流程完全一樣,這樣的程式碼很容易讀懂。
現在知道了無緩衝的通道是如何工作的,下一節我們將為大家介紹帶緩衝的通道。
9.12 Go語言帶緩衝的通道
Go語言中有緩衝的通道(buffered channel)是一種在被接收前能儲存一個或者多個值的通道。這種型別的通道並不強制要求 goroutine 之間必須同時完成傳送和接收。通道會阻塞傳送和接收動作的條件也會不同。只有在通道中沒有要接收的值時,接收動作才會阻塞。只有在通道沒有可用緩衝區容納被髮送的值時,傳送動作才會阻塞。
這導致有緩衝的通道和無緩衝的通道之間的一個很大的不同:無緩衝的通道保證進行傳送和接收的 goroutine 會在同一時間進行資料交換;有緩衝的通道沒有這種保證。
在無緩衝通道的基礎上,為通道增加一個有限大小的儲存空間形成帶緩衝通道。帶緩衝通道在傳送時無需等待接收方接收即可完成傳送過程,並且不會發生阻塞,只有當儲存空間滿時才會發生阻塞。同理,如果緩衝通道中有資料,接收時將不會發生阻塞,直到通道中沒有資料可讀時,通道將會再度阻塞。
無緩衝通道保證收發過程同步。無緩衝收發過程類似於快遞員給你電話讓你下樓取快遞,整個遞交快遞的過程是同步發生的,你和快遞員不見不散。但這樣做快遞員就必須等待所有人下樓完成操作後才能完成所有投遞工作。如果快遞員將快遞放入快遞櫃中,並通知使用者來取,快遞員和使用者就成了非同步收發過程,效率可以有明顯的提升。帶緩衝的通道就是這樣的一個“快遞櫃”。
建立帶緩衝通道
如何建立帶緩衝的通道呢?參見如下程式碼:
通道例項 := make(chan 通道型別, 緩衝大小)
- 通道型別:和無緩衝通道用法一致,影響通道傳送和接收的資料型別。
- 緩衝大小:決定通道最多可以儲存的元素數量。
- 通道例項:被建立出的通道例項。
下面通過一個例子中來理解帶緩衝通道的用法,參見下面的程式碼:
- package main
- import "fmt"
- func main() {
- // 建立一個3個元素緩衝大小的整型通道
- ch := make(chan int, 3)
- // 檢視當前通道的大小
- fmt.Println(len(ch))
- // 傳送3個整型元素到通道
- ch <- 1
- ch <- 2
- ch <- 3
- // 檢視當前通道的大小
- fmt.Println(len(ch))
- }
程式碼輸出如下:
0
3
程式碼說明如下:
- 第 8 行,建立一個帶有 3 個元素緩衝大小的整型型別的通道。
- 第 11 行,檢視當前通道的大小。帶緩衝的通道在建立完成時,內部的元素是空的,因此使用 len() 獲取到的返回值為 0。
- 第 14~16 行,傳送 3 個整型元素到通道。因為使用了緩衝通道。即便沒有 goroutine 接收,傳送者也不會發生阻塞。
- 第 19 行,由於填充了 3 個通道,此時的通道長度變為 3。
阻塞條件
帶緩衝通道在很多特性上和無緩衝通道是類似的。無緩衝通道可以看作是長度永遠為 0 的帶緩衝通道。因此根據這個特性,帶緩衝通道在下面列舉的情況下依然會發生阻塞:
- 帶緩衝通道被填滿時,嘗試再次傳送資料時發生阻塞。
- 帶緩衝通道為空時,嘗試接收資料時發生阻塞。
為什麼Go語言對通道要限制長度而不提供無限長度的通道?
我們知道通道(channel)是在兩個 goroutine 間通訊的橋樑。使用 goroutine 的程式碼必然有一方提供資料,一方消費資料。當提供資料一方的資料供給速度大於消費方的資料處理速度時,如果通道不限制長度,那麼記憶體將不斷膨脹直到應用崩潰。因此,限制通道的長度有利於約束資料提供方的供給速度,供給資料量必須在消費方處理量+通道長度的範圍內,才能正常地處理資料。
9.13 Go語言channel超時機制
Go語言沒有提供直接的超時處理機制,所謂超時可以理解為當我們上網瀏覽一些網站時,如果一段時間之後不作操作,就需要重新登入。
那麼我們應該如何實現這一功能呢,這時就可以使用 select 來設定超時。
雖然 select 機制不是專門為超時而設計的,卻能很方便的解決超時問題,因為 select 的特點是隻要其中有一個 case 已經完成,程式就會繼續往下執行,而不會考慮其他 case 的情況。
超時機制本身雖然也會帶來一些問題,比如在執行比較快的機器或者高速的網路上執行正常的程式,到了慢速的機器或者網路上執行就會出問題,從而出現結果不一致的現象,但從根本上來說,解決死鎖問題的價值要遠大於所帶來的問題。
select 的用法與 switch 語言非常類似,由 select 開始一個新的選擇塊,每個選擇條件由 case 語句來描述。
與 switch 語句相比,select 有比較多的限制,其中最大的一條限制就是每個 case 語句裡必須是一個 IO 操作,大致的結構如下:
select {
case <-chan1:
// 如果chan1成功讀到資料,則進行該case處理語句
case chan2 <- 1:
// 如果成功向chan2寫入資料,則進行該case處理語句
default:
// 如果上面都沒有成功,則進入default處理流程
}
在一個 select 語句中,Go語言會按順序從頭至尾評估每一個傳送和接收的語句。
如果其中的任意一語句可以繼續執行(即沒有被阻塞),那麼就從那些可以執行的語句中任意選擇一條來使用。
如果沒有任意一條語句可以執行(即所有的通道都被阻塞),那麼有如下兩種可能的情況:
- 如果給出了 default 語句,那麼就會執行 default 語句,同時程式的執行會從 select 語句後的語句中恢復;
- 如果沒有 default 語句,那麼 select 語句將被阻塞,直到至少有一個通訊可以進行下去。
示例程式碼如下所示:
- package main
- import (
- "fmt"
- "time"
- )
- func main() {
- ch := make(chan int)
- quit := make(chan bool)
- //新開一個協程
- go func() {
- for {
- select {
- case num := <-ch:
- fmt.Println("num = ", num)
- case <-time.After(3 * time.Second):
- fmt.Println("超時")
- quit <- true
- }
- }
- }() //別忘了()
- for i := 0; i < 5; i++ {
- ch <- i
- time.Sleep(time.Second)
- }
- <-quit
- fmt.Println("程式結束")
- }
執行結果如下:
num = 0
num = 1
num = 2
num = 3
num = 4
超時
程式結束
9.14 Go語言通道的多路複用——同時處理接收和傳送多個通道的資料
9.15 Go語言RPC(模擬遠端過程呼叫)
9.16 Go語言使用通道響應計時器的事件
9.17 Go語言關閉通道後繼續使用通道
9.18 Go語言多核並行化
Go語言具有支援高併發的特性,可以很方便地實現多執行緒運算,充分利用多核心 cpu 的效能。
眾所周知伺服器的處理器大都是單核頻率較低而核心數較多,對於支援高併發的程式語言,可以充分利用伺服器的多核優勢,從而降低單核壓力,減少效能浪費。
Go語言實現多核多執行緒併發執行是非常方便的,下面舉個例子:
- package main
- import (
- "fmt"
- )
- func main() {
- for i := 0; i < 5; i++ {
- go AsyncFunc(i)
- }
- }
- func AsyncFunc(index int) {
- sum := 0
- for i := 0; i < 10000; i++ {
- sum += 1
- }
- fmt.Printf("執行緒%d, sum為:%d\n", index, sum)
- }
執行結果如下:
執行緒0, sum為:10000
執行緒2, sum為:10000
執行緒3, sum為:10000
執行緒1, sum為:10000
執行緒4, sum為:10000
在執行一些昂貴的計算任務時,我們希望能夠儘量利用現代伺服器普遍具備的多核特性來儘量將任務並行化,從而達到降低總計算時間的目的。此時我們需要了解 CPU 核心的數量,並針對性地分解計算任務到多個 goroutine 中去並行執行。
下面我們來模擬一個完全可以並行的計算任務:計算 N 個整型數的總和。我們可以將所有整型數分成 M 份,M 即 CPU 的個數。讓每個 CPU 開始計算分給它的那份計算任務,最後將每個 CPU 的計算結果再做一次累加,這樣就可以得到所有 N 個整型數的總和:
- type Vector []float64
- // 分配給每個CPU的計算任務
- func (v Vector) DoSome(i, n int, u Vector, c chan int) {
- for ; i < n; i++ {
- v[i] += u.Op(v[i])
- }
- c <- 1 // 發訊號告訴任務管理者我已經計算完成了
- }
- const NCPU = 16 // 假設總共有16核
- func (v Vector) DoAll(u Vector) {
- c := make(chan int, NCPU) // 用於接收每個CPU的任務完成訊號
- for i := 0; i < NCPU; i++ {
- go v.DoSome(i*len(v)/NCPU, (i+1)*len(v)/NCPU, u, c)
- }
- // 等待所有CPU的任務完成
- for i := 0; i < NCPU; i++ {
- <-c // 獲取到一個資料,表示一個CPU計算完成了
- }
- // 到這裡表示所有計算已經結束
- }
這兩個函式看起來設計非常合理,其中 DoAll() 會根據 CPU 核心的數目對任務進行分割,然後開闢多個 goroutine 來並行執行這些計算任務。
是否可以將總的計算時間降到接近原來的 1/N 呢?答案是不一定。如果掐秒錶,會發現總的執行時間沒有明顯縮短。再去觀察 CPU 執行狀態,你會發現儘管我們有 16 個 CPU 核心,但在計算過程中其實只有一個 CPU 核心處於繁忙狀態,這是會讓很多Go語言初學者迷惑的問題。
官方給出的答案是,這是當前版本的 Go 編譯器還不能很智慧地去發現和利用多核的優勢。雖然我們確實建立了多個 goroutine,並且從執行狀態看這些 goroutine 也都在並行執行,但實際上所有這些 goroutine 都執行在同一個 CPU 核心上,在一個 goroutine 得到時間片執行的時候,其他 goroutine 都會處於等待狀態。從這一點可以看出,雖然 goroutine 簡化了我們寫並行程式碼的過程,但實際上整體執行效率並不真正高於單執行緒程式。
雖然Go語言還不能很好的利用多核心的優勢,我們可以先通過設定環境變數 GOMAXPROCS 的值來控制使用多少個 CPU 核心。具體操作方法是通過直接設定環境變數 GOMAXPROCS 的值,或者在程式碼中啟動 goroutine 之前先呼叫以下這個語句以設定使用 16 個 CPU 核心:
runtime.GOMAXPROCS(16)
到底應該設定多少個 CPU 核心呢,其實 runtime 包中還提供了另外一個 NumCPU() 函式來獲取核心數,示例程式碼如下:
- package main
- import (
- "fmt"
- "runtime"
- )
- func main() {
- cpuNum := runtime.NumCPU() //獲得當前裝置的cpu核心數
- fmt.Println("cpu核心數:", cpuNum)
- runtime.GOMAXPROCS(cpuNum) //設定需要用到的cpu數量
- }
執行結果如下:
cpu核心數: 4
9.19 Go語言Telnet迴音伺服器——TCP伺服器的基本結構
9.20 Go語言競態檢測——檢測程式碼在併發環境下可能出現的問題
9.21 Go語言互斥鎖(sync.Mutex)和讀寫互斥鎖(sync.RWMutex)
Go語言包中的 sync 包提供了兩種鎖型別:sync.Mutex 和 sync.RWMutex。
Mutex 是最簡單的一種鎖型別,同時也比較暴力,當一個 goroutine 獲得了 Mutex 後,其他 goroutine 就只能乖乖等到這個 goroutine 釋放該 Mutex。
RWMutex 相對友好些,是經典的單寫多讀模型。在讀鎖佔用的情況下,會阻止寫,但不阻止讀,也就是多個 goroutine 可同時獲取讀鎖(呼叫 RLock() 方法;而寫鎖(呼叫 Lock() 方法)會阻止任何其他 goroutine(無論讀和寫)進來,整個鎖相當於由該 goroutine 獨佔。從 RWMutex 的實現看,RWMutex 型別其實組合了 Mutex:
type RWMutex struct {
w Mutex
writerSem uint32
readerSem uint32
readerCount int32
readerWait int32
}
對於這兩種鎖型別,任何一個 Lock() 或 RLock() 均需要保證對應有 Unlock() 或 RUnlock() 呼叫與之對應,否則可能導致等待該鎖的所有 goroutine 處於飢餓狀態,甚至可能導致死鎖。鎖的典型使用模式如下:
- package main
- import (
- "fmt"
- "sync"
- )
- var (
- // 邏輯中使用的某個變數
- count int
- // 與變數對應的使用互斥鎖
- countGuard sync.Mutex
- )
- func GetCount() int {
- // 鎖定
- countGuard.Lock()
- // 在函式退出時解除鎖定
- defer countGuard.Unlock()
- return count
- }
- func SetCount(c int) {
- countGuard.Lock()
- count = c
- countGuard.Unlock()
- }
- func main() {
- // 可以進行併發安全的設定
- SetCount(1)
- // 可以進行併發安全的獲取
- fmt.Println(GetCount())
- }
程式碼說明如下:
- 第 10 行是某個邏輯步驟中使用到的變數,無論是包級的變數還是結構體成員欄位,都可以。
- 第 13 行,一般情況下,建議將互斥鎖的粒度設定得越小越好,降低因為共享訪問時等待的時間。這裡筆者習慣性地將互斥鎖的變數命名為以下格式:
變數名+Guard
以表示這個互斥鎖用於保護這個變數。 - 第 16 行是一個獲取 count 值的函式封裝,通過這個函式可以併發安全的訪問變數 count。
- 第 19 行,嘗試對 countGuard 互斥量進行加鎖。一旦 countGuard 發生加鎖,如果另外一個 goroutine 嘗試繼續加鎖時將會發生阻塞,直到這個 countGuard 被解鎖。
- 第 22 行使用 defer 將 countGuard 的解鎖進行延遲呼叫,解鎖操作將會發生在 GetCount() 函式返回時。
- 第 27 行在設定 count 值時,同樣使用 countGuard 進行加鎖、解鎖操作,保證修改 count 值的過程是一個原子過程,不會發生併發訪問衝突。
在讀多寫少的環境中,可以優先使用讀寫互斥鎖(sync.RWMutex),它比互斥鎖更加高效。sync 包中的 RWMutex 提供了讀寫互斥鎖的封裝。
我們將互斥鎖例子中的一部分程式碼修改為讀寫互斥鎖,參見下面程式碼:
- var (
- // 邏輯中使用的某個變數
- count int
- // 與變數對應的使用互斥鎖
- countGuard sync.RWMutex
- )
- func GetCount() int {
- // 鎖定
- countGuard.RLock()
- // 在函式退出時解除鎖定
- defer countGuard.RUnlock()
- return count
- }
程式碼說明如下:
- 第 6 行,在宣告 countGuard 時,從 sync.Mutex 互斥鎖改為 sync.RWMutex 讀寫互斥鎖。
- 第 12 行,獲取 count 的過程是一個讀取 count 資料的過程,適用於讀寫互斥鎖。在這一行,把 countGuard.Lock() 換做 countGuard.RLock(),將讀寫互斥鎖標記為讀狀態。如果此時另外一個 goroutine 併發訪問了 countGuard,同時也呼叫了 countGuard.RLock() 時,並不會發生阻塞。
- 第 15 行,與讀模式加鎖對應的,使用讀模式解鎖。
Go語言中除了可以使用通道(channel)和互斥鎖進行兩個併發程式間的同步外,還可以使用等待組進行多個任務的同步,等待組可以保證在併發環境中完成指定數量的任務
在 sync.WaitGroup(等待組)型別中,每個 sync.WaitGroup 值在內部維護著一個計數,此計數的初始預設值為零。
等待組有下面幾個方法可用,如下表所示。
方法名 | 功能 |
---|---|
(wg * WaitGroup) Add(delta int) | 等待組的計數器 +1 |
(wg * WaitGroup) Done() | 等待組的計數器 -1 |
(wg * WaitGroup) Wait() | 當等待組計數器不等於 0 時阻塞直到變 0。 |
對於一個可定址的 sync.WaitGroup 值 wg:
- 我們可以使用方法呼叫 wg.Add(delta) 來改變值 wg 維護的計數。
- 方法呼叫 wg.Done() 和 wg.Add(-1) 是完全等價的。
- 如果一個 wg.Add(delta) 或者 wg.Done() 呼叫將 wg 維護的計數更改成一個負數,一個恐慌將產生。
- 當一個協程呼叫了 wg.Wait() 時,
- 如果此時 wg 維護的計數為零,則此 wg.Wait() 此操作為一個空操作(noop);
- 否則(計數為一個正整數),此協程將進入阻塞狀態。當以後其它某個協程將此計數更改至 0 時(一般通過呼叫 wg.Done()),此協程將重新進入執行狀態(即 wg.Wait() 將返回)。
等待組內部擁有一個計數器,計數器的值可以通過方法呼叫實現計數器的增加和減少。當我們新增了 N 個併發任務進行工作時,就將等待組的計數器值增加 N。每個任務完成時,這個值減 1。同時,在另外一個 goroutine 中等待這個等待組的計數器值為 0 時,表示所有任務已經完成。
下面的程式碼演示了這一過程:
- package main
- import (
- "fmt"
- "net/http"
- "sync"
- )
- func main() {
- // 宣告一個等待組
- var wg sync.WaitGroup
- // 準備一系列的網站地址
- var urls = []string{
- "http://www.github.com/",
- "https://www.qiniu.com/",
- "https://www.golangtc.com/",
- }
- // 遍歷這些地址
- for _, url := range urls {
- // 每一個任務開始時, 將等待組增加1
- wg.Add(1)
- // 開啟一個併發
- go func(url string) {
- // 使用defer, 表示函式完成時將等待組值減1
- defer wg.Done()
- // 使用http訪問提供的地址
- _, err := http.Get(url)
- // 訪問完成後, 列印地址和可能發生的錯誤
- fmt.Println(url, err)
- // 通過引數傳遞url地址
- }(url)
- }
- // 等待所有的任務完成
- wg.Wait()
- fmt.Println("over")
- }
程式碼說明如下:
- 第 12 行,宣告一個等待組,對一組等待任務只需要一個等待組,而不需要每一個任務都使用一個等待組。
- 第 15 行,準備一系列可訪問的網站地址的字串切片。
- 第 22 行,遍歷這些字串切片。
- 第 25 行,將等待組的計數器加1,也就是每一個任務加 1。
- 第 28 行,將一個匿名函式開啟併發。
- 第 31 行,在匿名函式結束時會執行這一句以表示任務完成。wg.Done() 方法等效於執行 wg.Add(-1)。
- 第 34 行,使用 http 包提供的 Get() 函式對 url 進行訪問,Get() 函式會一直阻塞直到網站響應或者超時。
- 第 37 行,在網站響應和超時後,列印這個網站的地址和可能發生的錯誤。
- 第 40 行,這裡將 url 通過 goroutine 的引數進行傳遞,是為了避免 url 變數通過閉包放入匿名函式後又被修改的問題。
- 第 44 行,等待所有的網站都響應或者超時後,任務完成,Wait 就會停止阻塞。
9.23 Go語言死鎖、活鎖和飢餓概述
本節我們來介紹一下死鎖、活鎖和飢餓這三個概念。
死鎖
死鎖是指兩個或兩個以上的程式(或執行緒)在執行過程中,因爭奪資源而造成的一種互相等待的現象,若無外力作用,它們都將無法推進下去。此時稱系統處於死鎖狀態或系統產生了死鎖,這些永遠在互相等待的程式稱為死鎖程式。
死鎖發生的條件有如下幾種:
1) 互斥條件
執行緒對資源的訪問是排他性的,如果一個執行緒對佔用了某資源,那麼其他執行緒必須處於等待狀態,直到該資源被釋放。
2) 請求和保持條件
執行緒 T1 至少已經保持了一個資源 R1 佔用,但又提出使用另一個資源 R2 請求,而此時,資源 R2 被其他執行緒 T2 佔用,於是該執行緒 T1 也必須等待,但又對自己保持的資源 R1 不釋放。
3) 不剝奪條件
執行緒已獲得的資源,在未使用完之前,不能被其他執行緒剝奪,只能在使用完以後由自己釋放。
4) 環路等待條件
在死鎖發生時,必然存在一個“程式 - 資源環形鏈”,即:{p0,p1,p2,...pn},程式 p0(或執行緒)等待 p1 佔用的資源,p1 等待 p2 佔用的資源,pn 等待 p0 佔用的資源。
最直觀的理解是,p0 等待 p1 佔用的資源,而 p1 而在等待 p0 佔用的資源,於是兩個程式就相互等待。
死鎖解決辦法:
- 如果併發查詢多個表,約定訪問順序;
- 在同一個事務中,儘可能做到一次鎖定獲取所需要的資源;
- 對於容易產生死鎖的業務場景,嘗試升級鎖顆粒度,使用表級鎖;
- 採用分散式事務鎖或者使用樂觀鎖。
死鎖程式是所有併發程式彼此等待的程式,在這種情況下,如果沒有外界的干預,這個程式將永遠無法恢復。
為了便於大家理解死鎖是什麼,我們先來看一個例子(忽略程式碼中任何不知道的型別,函式,方法或是包,只理解什麼是死鎖即可),程式碼如下所示:
- package main
- import (
- "fmt"
- "runtime"
- "sync"
- "time"
- )
- type value struct {
- memAccess sync.Mutex
- value int
- }
- func main() {
- runtime.GOMAXPROCS(3)
- var wg sync.WaitGroup
- sum := func(v1, v2 *value) {
- defer wg.Done()
- v1.memAccess.Lock()
- time.Sleep(2 * time.Second)
- v2.memAccess.Lock()
- fmt.Printf("sum = %d\n", v1.value+v2.value)
- v2.memAccess.Unlock()
- v1.memAccess.Unlock()
- }
- product := func(v1, v2 *value) {
- defer wg.Done()
- v2.memAccess.Lock()
- time.Sleep(2 * time.Second)
- v1.memAccess.Lock()
- fmt.Printf("product = %d\n", v1.value*v2.value)
- v1.memAccess.Unlock()
- v2.memAccess.Unlock()
- }
- var v1, v2 value
- v1.value = 1
- v2.value = 1
- wg.Add(2)
- go sum(&v1, &v2)
- go product(&v1, &v2)
- wg.Wait()
- }
執行上面的程式碼,可能會看到:
fatal error: all goroutines are asleep - deadlock!
為什麼呢?如果仔細觀察,就可以在此程式碼中看到時機問題,以下是執行時的圖形表示。
圖 :一個因時間問題導致死鎖的演示
活鎖
活鎖是另一種形式的活躍性問題,該問題儘管不會阻塞執行緒,但也不能繼續執行,因為執行緒將不斷重複同樣的操作,而且總會失敗。
例如執行緒 1 可以使用資源,但它很禮貌,讓其他執行緒先使用資源,執行緒 2 也可以使用資源,但它同樣很紳士,也讓其他執行緒先使用資源。就這樣你讓我,我讓你,最後兩個執行緒都無法使用資源。
活鎖通常發生在處理事務訊息中,如果不能成功處理某個訊息,那麼訊息處理機制將回滾事務,並將它重新放到佇列的開頭。這樣,錯誤的事務被一直回滾重複執行,這種形式的活鎖通常是由過度的錯誤恢復程式碼造成的,因為它錯誤地將不可修復的錯誤認為是可修復的錯誤。
當多個相互協作的執行緒都對彼此進行相應而修改自己的狀態,並使得任何一個執行緒都無法繼續執行時,就導致了活鎖。這就像兩個過於禮貌的人在路上相遇,他們彼此讓路,然後在另一條路上相遇,然後他們就一直這樣避讓下去。
要解決這種活鎖問題,需要在重試機制中引入隨機性。例如在網路上傳送資料包,如果檢測到衝突,都要停止並在一段時間後重發,如果都在 1 秒後重發,還是會衝突,所以引入隨機性可以解決該類問題。
下面通過示例來演示一下活鎖:
- package main
- import (
- "bytes"
- "fmt"
- "runtime"
- "sync"
- "sync/atomic"
- "time"
- )
- func main() {
- runtime.GOMAXPROCS(3)
- cv := sync.NewCond(&sync.Mutex{})
- go func() {
- for range time.Tick(1 * time.Second) { // 通過tick控制兩個人的步調
- cv.Broadcast()
- }
- }()
- takeStep := func() {
- cv.L.Lock()
- cv.Wait()
- cv.L.Unlock()
- }
- tryDir := func(dirName string, dir *int32, out *bytes.Buffer) bool {
- fmt.Fprintf(out, " %+v", dirName)
- atomic.AddInt32(dir, 1)
- takeStep() //走上一步
- if atomic.LoadInt32(dir) == 1 { //走成功就返回
- fmt.Fprint(out, ". Success!")
- return true
- }
- takeStep() // 沒走成功,再走回來
- atomic.AddInt32(dir, -1)
- return false
- }
- var left, right int32
- tryLeft := func(out *bytes.Buffer) bool {
- return tryDir("向左走", &left, out)
- }
- tryRight := func(out *bytes.Buffer) bool {
- return tryDir("向右走", &right, out)
- }
- walk := func(walking *sync.WaitGroup, name string) {
- var out bytes.Buffer
- defer walking.Done()
- defer func() { fmt.Println(out.String()) }()
- fmt.Fprintf(&out, "%v is trying to scoot:", name)
- for i := 0; i < 5; i++ {
- if tryLeft(&out) || tryRight(&out) {
- return
- }
- }
- fmt.Fprintf(&out, "\n%v is tried!", name)
- }
- var trail sync.WaitGroup
- trail.Add(2)
- go walk(&trail, "男人") // 男人在路上走
- go walk(&trail, "女人") // 女人在路上走
- trail.Wait()
- }
輸出結果如下:
go run main.go
女人 is trying to scoot: 向左走 向右走 向左走 向右走 向左走 向右走 向左走 向右走 向左走 向右走
女人 is tried!
男人 is trying to scoot: 向左走 向右走 向左走 向右走 向左走 向右走 向左走 向右走 向左走 向右走
男人 is tried!
這個例子演示了使用活鎖的一個十分常見的原因,兩個或兩個以上的併發程式試圖在沒有協調的情況下防止死鎖。這就好比,如果走廊裡的人都同意,只有一個人會移動,那就不會有活鎖;一個人會站著不動,另一個人會移到另一邊,他們就會繼續移動。
活鎖和死鎖的區別在於,處於活鎖的實體是在不斷的改變狀態,所謂的“活”,而處於死鎖的實體表現為等待,活鎖有可能自行解開,死鎖則不能。
飢餓
飢餓是指一個可執行的程式儘管能繼續執行,但被排程器無限期地忽視,而不能被排程執行的情況。
與死鎖不同的是,飢餓鎖在一段時間內,優先順序低的執行緒最終還是會執行的,比如高優先順序的執行緒執行完之後釋放了資源。
活鎖與飢餓是無關的,因為在活鎖中,所有併發程式都是相同的,並且沒有完成工作。更廣泛地說,飢餓通常意味著有一個或多個貪婪的併發程式,它們不公平地阻止一個或多個併發程式,以儘可能有效地完成工作,或者阻止全部併發程式。
下面的示例程式中包含了一個貪婪的 goroutine 和一個平和的 goroutine:
- package main
- import (
- "fmt"
- "runtime"
- "sync"
- "time"
- )
- func main() {
- runtime.GOMAXPROCS(3)
- var wg sync.WaitGroup
- const runtime = 1 * time.Second
- var sharedLock sync.Mutex
- greedyWorker := func() {
- defer wg.Done()
- var count int
- for begin := time.Now(); time.Since(begin) <= runtime; {
- sharedLock.Lock()
- time.Sleep(3 * time.Nanosecond)
- sharedLock.Unlock()
- count++
- }
- fmt.Printf("Greedy worker was able to execute %v work loops\n", count)
- }
- politeWorker := func() {
- defer wg.Done()
- var count int
- for begin := time.Now(); time.Since(begin) <= runtime; {
- sharedLock.Lock()
- time.Sleep(1 * time.Nanosecond)
- sharedLock.Unlock()
- sharedLock.Lock()
- time.Sleep(1 * time.Nanosecond)
- sharedLock.Unlock()
- sharedLock.Lock()
- time.Sleep(1 * time.Nanosecond)
- sharedLock.Unlock()
- count++
- }
- fmt.Printf("Polite worker was able to execute %v work loops\n", count)
- }
- wg.Add(2)
- go greedyWorker()
- go politeWorker()
- wg.Wait()
- }
輸出如下:
Greedy worker was able to execute 276 work loops
Polite worker was able to execute 92 work loops
貪婪的 worker 會貪婪地搶佔共享鎖,以完成整個工作迴圈,而平和的 worker 則試圖只在需要時鎖定。兩種 worker 都做同樣多的模擬工作(sleeping 時間為 3ns),可以看到,在同樣的時間裡,貪婪的 worker 工作量幾乎是平和的 worker 工作量的兩倍!
假設兩種 worker 都有同樣大小的臨界區,而不是認為貪婪的 worker 的演算法更有效(或呼叫 Lock 和 Unlock 的時候,它們也不是緩慢的),我們得出這樣的結論,貪婪的 worker 不必要地擴大其持有共享鎖上的臨界區,井阻止(通過飢餓)平和的 worker 的 goroutine 高效工作。
總結
不適用鎖肯定會出問題。如果用了,雖然解了前面的問題,但是又出現了更多的新問題。
- 死鎖:是因為錯誤的使用了鎖,導致異常;
- 活鎖:是飢餓的一種特殊情況,邏輯上感覺對,程式也一直在正常的跑,但就是效率低,邏輯上進行不下去;
- 飢餓:與鎖使用的粒度有關,通過計數取樣,可以判斷程式的工作效率。
只要有共享資源的訪問,必定要使其邏輯上進行順序化和原子化,確保訪問一致,這繞不開鎖這個概念。
9.24 Go語言封裝qsort快速排序函式
9.25 Go語言CSP:通訊順序程式簡述
Go實現了兩種併發形式,第一種是大家普遍認知的多執行緒共享記憶體,其實就是 Java 或 C++ 等語言中的多執行緒開發;另外一種是Go語言特有的,也是Go語言推薦的 CSP(communicating sequential processes)併發模型。
CSP 併發模型是上個世紀七十年代提出的,用於描述兩個獨立的併發實體通過共享 channel(管道)進行通訊的併發模型。
Go語言就是借用 CSP 併發模型的一些概念為之實現併發的,但是Go語言並沒有完全實現了 CSP 併發模型的所有理論,僅僅是實現了 process 和 channel 這兩個概念。
process 就是Go語言中的 goroutine,每個 goroutine 之間是通過 channel 通訊來實現資料共享。
這裡我們要明確的是“併發不是並行”。併發更關注的是程式的設計層面,併發的程式完全是可以順序執行的,只有在真正的多核 CPU 上才可能真正地同時執行;並行更關注的是程式的執行層面,並行一般是簡單的大量重複,例如 GPU 中對影像處理都會有大量的並行運算。
為了更好地編寫併發程式,從設計之初Go語言就注重如何在程式語言層級上設計一個簡潔安全高效的抽象模型,讓開發人員專注於分解問題和組合方案,而且不用被執行緒管理和訊號互斥這些煩瑣的操作分散精力。
在併發程式設計中,對共享資源的正確訪問需要精確地控制,在目前的絕大多數語言中,都是通過加鎖等執行緒同步方案來解決這一困難問題,而Go語言卻另闢蹊徑,它將共享的值通過通道傳遞(實際上多個獨立執行的執行緒很少主動共享資源)。
併發程式設計的核心概念是同步通訊,但是同步的方式卻有多種。先以大家熟悉的互斥量 sync.Mutex 來實現同步通訊,示例程式碼如下所示:
- package main
- import (
- "fmt"
- "sync"
- )
- func main() {
- var mu sync.Mutex
- go func() {
- fmt.Println("C語言中文網")
- mu.Lock()
- }()
- mu.Unlock()
- }
由於 mu.Lock() 和 mu.Unlock() 並不在同一個 Goroutine 中,所以也就不滿足順序一致性記憶體模型。同時它們也沒有其他的同步事件可以參考,也就是說這兩件事是可以併發的。
因為可能是併發的事件,所以 main() 函式中的 mu.Unlock() 很有可能先發生,而這個時刻 mu 互斥物件還處於未加鎖的狀態,因而會導致執行時異常。
下面是修復後的程式碼:
- package main
- import (
- "fmt"
- "sync"
- )
- func main() {
- var mu sync.Mutex
- mu.Lock()
- go func() {
- fmt.Println("C語言中文網")
- mu.Unlock()
- }()
- mu.Lock()
- }
修復的方式是在 main() 函式所線上程中執行兩次 mu.Lock(),當第二次加鎖時會因為鎖已經被佔用(不是遞迴鎖)而阻塞,main() 函式的阻塞狀態驅動後臺執行緒繼續向前執行。
當後臺執行緒執行到 mu.Unlock() 時解鎖,此時列印工作已經完成了,解鎖會導致 main() 函式中的第二個 mu.Lock() 阻塞狀態取消,此時後臺執行緒和主執行緒再沒有其他的同步事件參考,它們退出的事件將是併發的,在 main() 函式退出導致程式退出時,後臺執行緒可能已經退出了,也可能沒有退出。雖然無法確定兩個執行緒退出的時間,但是列印工作是可以正確完成的。
使用 sync.Mutex 互斥鎖同步是比較低階的做法,我們現在改用無快取通道來實現同步:
- package main
- import (
- "fmt"
- )
- func main() {
- done := make(chan int)
- go func() {
- fmt.Println("C語言中文網")
- <-done
- }()
- done <- 1
- }
根據Go語言記憶體模型規範,對於從無快取通道進行的接收,發生在對該通道進行的傳送完成之前。因此,後臺執行緒<-done
接收操作完成之後,main 執行緒的done <- 1
傳送操作才可能完成(從而退出 main、退出程式),而此時列印工作已經完成了。
上面的程式碼雖然可以正確同步,但是對通道的快取大小太敏感,如果通道有快取,就無法保證 main() 函式退出之前後臺執行緒能正常列印了,更好的做法是將通道的傳送和接收方向調換一下,這樣可以避免同步事件受通道快取大小的影響:
- package main
- import (
- "fmt"
- )
- func main() {
- done := make(chan int, 1) // 帶快取通道
- go func() {
- fmt.Println("C語言中文網")
- done <- 1
- }()
- <-done
- }
對於帶快取的通道,對通道的第 K 個接收完成操作發生在第 K+C 個傳送操作完成之前,其中 C 是通道的快取大小。雖然通道是帶快取的,但是 main 執行緒接收完成是在後臺執行緒傳送開始但還未完成的時刻,此時列印工作也是已經完成的。
基於帶快取通道,我們可以很容易將列印執行緒擴充套件到 N 個,下面的示例是開啟 10 個後臺執行緒分別列印:
- package main
- import (
- "fmt"
- )
- func main() {
- done := make(chan int, 10) // 帶10個快取
- // 開N個後臺列印執行緒
- for i := 0; i < cap(done); i++ {
- go func() {
- fmt.Println("C語言中文網")
- done <- 1
- }()
- }
- // 等待N個後臺執行緒完成
- for i := 0; i < cap(done); i++ {
- <-done
- }
- }
對於這種要等待 N 個執行緒完成後再進行下一步的同步操作有一個簡單的做法,就是使用 sync.WaitGroup 來等待一組事件:
- package main
- import (
- "fmt"
- "sync"
- )
- func main() {
- var wg sync.WaitGroup
- // 開N個後臺列印執行緒
- for i := 0; i < 10; i++ {
- wg.Add(1)
- go func() {
- fmt.Println("C語言中文網")
- wg.Done()
- }()
- }
- // 等待N個後臺執行緒完成
- wg.Wait()
- }
其中 wg.Add(1) 用於增加等待事件的個數,必須確保在後臺執行緒啟動之前執行(如果放到後臺執行緒之中執行則不能保證被正常執行到)。當後臺執行緒完成列印工作之後,呼叫 wg.Done() 表示完成一個事件,main() 函式的 wg.Wait() 是等待全部的事件完成。
9.26 Go語言聊天伺服器
本節將帶領大家結合我們們前面所學的知識開發一個聊天的示例程式,它可以在幾個使用者之間相互廣播文字訊息。
服務端程式
服務端程式中包含 4 個 goroutine,分別是一個主 goroutine 和廣播(broadcaster)goroutine,每一個連線裡面又包含一個連線處理(handleConn)goroutine 和一個客戶寫入(clientwriter)goroutine。
廣播器(broadcaster)是用於如何使用 select 的一個規範說明,因為它需要對三種不同的訊息進行響應。
主 goroutine 的工作是監聽埠,接受連線客戶端的網路連線,對每一個連線,它將建立一個新的 handleConn goroutine。
完整的示例程式碼如下所示:
- package main
- import (
- "bufio"
- "fmt"
- "log"
- "net"
- )
- func main() {
- listener, err := net.Listen("tcp", "localhost:8000")
- if err != nil {
- log.Fatal(err)
- }
- go broadcaster()
- for {
- conn, err := listener.Accept()
- if err != nil {
- log.Print(err)
- continue
- }
- go handleConn(conn)
- }
- }
- type client chan<- string // 對外傳送訊息的通道
- var (
- entering = make(chan client)
- leaving = make(chan client)
- messages = make(chan string) // 所有連線的客戶端
- )
- func broadcaster() {
- clients := make(map[client]bool)
- for {
- select {
- case msg := <-messages:
- // 把所有接收到的訊息廣播給所有客戶端
- // 傳送訊息通道
- for cli := range clients {
- cli <- msg
- }
- case cli := <-entering:
- clients[cli] = true
- case cli := <-leaving:
- delete(clients, cli)
- close(cli)
- }
- }
- }
- func handleConn(conn net.Conn) {
- ch := make(chan string) // 對外傳送客戶訊息的通道
- go clientWriter(conn, ch)
- who := conn.RemoteAddr().String()
- ch <- "歡迎 " + who
- messages <- who + " 上線"
- entering <- ch
- input := bufio.NewScanner(conn)
- for input.Scan() {
- messages <- who + ": " + input.Text()
- }
- // 注意:忽略 input.Err() 中可能的錯誤
- leaving <- ch
- messages <- who + " 下線"
- conn.Close()
- }
- func clientWriter(conn net.Conn, ch <-chan string) {
- for msg := range ch {
- fmt.Fprintln(conn, msg) // 注意:忽略網路層面的錯誤
- }
- }
程式碼中 main 函式裡面寫的程式碼非常簡單,其實伺服器要做的事情總結一下無非就是獲得 listener 物件,然後不停的獲取連結上來的 conn 物件,最後把這些物件丟給處理連結函式去進行處理。
在使用 handleConn 方法處理 conn 物件的時候,對不同的連結都啟一個 goroutine 去併發處理每個 conn 這樣則無需等待。
由於要給所有線上的使用者傳送訊息,而不同使用者的 conn 物件都在不同的 goroutine 裡面,但是Go語言中有 channel 來處理各不同 goroutine 之間的訊息傳遞,所以在這裡我們選擇使用 channel 在各不同的 goroutine 中傳遞廣播訊息。
下面來介紹一下 broadcaster 廣播器,它使用區域性變數 clients 來記錄當前連線的客戶集合,每個客戶唯一被記錄的資訊是其對外傳送訊息通道的 ID,下面是細節:
- type client chan<- string // 對外傳送訊息的通道
- var (
- entering = make(chan client)
- leaving = make(chan client)
- messages = make(chan string) // 所有連線的客戶端
- )
- func broadcaster() {
- clients := make(map[client]bool)
- for {
- select {
- case msg := <-messages:
- // 把所有接收到的訊息廣播給所有客戶端
- // 傳送訊息通道
- for cli := range clients {
- cli <- msg
- }
- case cli := <-entering:
- clients[cli] = true
- case cli := <-leaving:
- delete(clients, cli)
- close(cli)
- }
- }
- }
在 main 函式裡面使用 goroutine 開啟了一個 broadcaster 函式來負責廣播所有使用者傳送的訊息。
這裡使用一個字典來儲存使用者 clients,字典的 key 是各連線申明的單向併發佇列。
使用一個 select 開啟一個多路複用:
- 每當有廣播訊息從 messages 傳送進來,都會迴圈 cliens 對裡面的每個 channel 發訊息。
- 每當有訊息從 entering 裡面傳送過來,就生成一個新的 key - value,相當於給 clients 裡面增加一個新的 client。
- 每當有訊息從 leaving 裡面傳送過來,就刪掉這個 key - value 對,並關閉對應的 channel。
下面再來看一下每個客戶自己的 goroutine。
handleConn 函式建立一個對外傳送訊息的新通道,然後通過 entering 通道通知廣播者新客戶到來,接著它讀取客戶發來的每一行文字,通過全域性接收訊息通道將每一行傳送給廣播者,傳送時在每條訊息前面加上傳送者 ID 作為字首。一旦從客戶端讀取完畢訊息,handleConn 通過 leaving 通道通知客戶離開,然後關閉連線。
- func handleConn(conn net.Conn) {
- ch := make(chan string) // 對外傳送客戶訊息的通道
- go clientWriter(conn, ch)
- who := conn.RemoteAddr().String()
- ch <- "歡迎 " + who
- messages <- who + " 上線"
- entering <- ch
- input := bufio.NewScanner(conn)
- for input.Scan() {
- messages <- who + ": " + input.Text()
- }
- // 注意:忽略 input.Err() 中可能的錯誤
- leaving <- ch
- messages <- who + " 下線"
- conn.Close()
- }
- func clientWriter(conn net.Conn, ch <-chan string) {
- for msg := range ch {
- fmt.Fprintln(conn, msg) // 注意:忽略網路層面的錯誤
- }
- }
handleConn 函式會為每個過來處理的 conn 都建立一個新的 channel,開啟一個新的 goroutine 去把傳送給這個 channel 的訊息寫進 conn。
handleConn 函式的執行過程可以簡單總結為如下幾個步驟:
- 獲取連線過來的 ip 地址和埠號;
- 把歡迎資訊寫進 channel 返回給客戶端;
- 生成一條廣播訊息寫進 messages 裡;
- 把這個 channel 加入到客戶端集合,也就是 entering <- ch;
- 監聽客戶端往 conn 裡寫的資料,每掃描到一條就將這條訊息傳送到廣播 channel 中;
- 如果關閉了客戶端,那麼把佇列離開寫入 leaving 交給廣播函式去刪除這個客戶端並關閉這個客戶端;
- 廣播通知其他客戶端該客戶端已關閉;
- 最後關閉這個客戶端的連線 Conn.Close()。
客戶端程式
前面對服務端做了簡單的介紹,下面介紹客戶端,這裡將其命名為“netcat.go”,完整程式碼如下所示:
- // netcat 是一個簡單的TCP伺服器讀/寫客戶端
- package main
- import (
- "io"
- "log"
- "net"
- "os"
- )
- func main() {
- conn, err := net.Dial("tcp", "localhost:8000")
- if err != nil {
- log.Fatal(err)
- }
- done := make(chan struct{})
- go func() {
- io.Copy(os.Stdout, conn) // 注意:忽略錯誤
- log.Println("done")
- done <- struct{}{} // 向主Goroutine發出訊號
- }()
- mustCopy(conn, os.Stdin)
- conn.Close()
- <-done // 等待後臺goroutine完成
- }
- func mustCopy(dst io.Writer, src io.Reader) {
- if _, err := io.Copy(dst, src); err != nil {
- log.Fatal(err)
- }
- }
當有 n 個客戶 session 在連線的時候,程式併發執行著2n+2
個相互通訊的 goroutine,它不需要隱式的加鎖操作。clients map 限制在廣播器這一個 goroutine 中被訪問,所以不會併發訪問它。唯一被多個 goroutine 共享的變數是通道以及 net.Conn 的例項,它們又都是併發安全的。
使用go build
命令編譯服務端和客戶端,並執行生成的可執行檔案。
下圖中展示了在同一臺計算機上執行的一個服務端和三個客戶端:
9.27 goroutine(Go語言併發)如何使用才更加高效?
9.28 Go語言使用select切換協程
9.29 Go語言加密通訊
相關文章
- GO語言併發Go
- 《快學 Go 語言》第 13 課 —— 併發與安全Go
- Go語言併發程式設計Go程式設計
- 十九、Go語言基礎之併發Go
- 【Golang詳解】go語言中併發安全和鎖Golang
- golang開發:go併發的建議Golang
- Go高效併發 11 | 併發模式:Go 語言中即學即用的高效併發模式Go模式
- 2018年第18周-Java語言思想-併發Java
- Go語言專案實戰:併發爬蟲Go爬蟲
- GO 語言的併發模式你瞭解多少?Go模式
- golang併發Golang
- Go語言 | CSP併發模型與Goroutine的基本使用Go模型
- Go語言併發程式設計簡單入門Go程式設計
- Golang語言goroutine協程併發安全及鎖機制Golang
- Go語言中的併發模式Go模式
- Go語言之併發示例(Runner)Go
- 《Go 語言併發之道》讀後感 - 第二章Go
- Go 為什麼不在語言層面支援 map 併發?Go
- 「Golang成長之路」併發之併發模式Golang模式
- 「Golang成長之路」併發之併發模式篇Golang模式
- go併發 - channelGo
- Go 併發操作Go
- go 併發 mapGo
- Go 併發 -- 通道Go
- Go併發原理Go
- Go語言 | 併發設計中的同步鎖與waitgroup用法GoAI
- 《Go 語言併發之道》讀後感 - 第一章Go
- 《Go 語言併發之道》讀後感 - 第三章Go
- 《Go 語言併發之道》讀後感 - 第四章Go
- Go 併發程式設計 - 併發安全(二)Go程式設計
- Golang(go語言)開發環境配置Golang開發環境
- 優步爆Go語言容易發生的資料併發爭奪問題Go
- 持續發燒,聊聊Dart語言的併發處理,能挑戰Go不?DartGo
- golang併發ping主機Golang
- golang併發程式設計Golang程式設計
- Golang 併發程式設計Golang程式設計
- [Golang併發]Sync.mapGolang
- Go 高階併發Go