> 開發go程式的時候,時常需要使用goroutine並發處理任務,有時候這些goroutine是相互獨立的,而有的時候,多個goroutine之間常常是需要同步與通訊的。另一種情況,主goroutine需要控制它所屬的子goroutine,總結起來,實現多個goroutine間的同步與通訊大致有:- 全域共用變數- channel通訊(CSP模型)- Context包> 本文章通過goroutine同步與通訊的一個典型情境-通知子goroutine退出運行,來深入講解下golang的控制並發。# 通知多個子goroutine退出運行goroutine作為go語言的並發利器,不僅效能強勁而且使用方便:只需要一個關鍵字go即可將普通函數並發執行,且goroutine佔用記憶體極小(一個goroutine只佔2KB的記憶體),所以開發go程式的時候很多開發人員常常會使用這個並發工具,獨立的並發任務比較簡單,只需要用go關鍵字修飾函數就可以啟用一個goroutine直接運行;但是,實際的並發情境常常是需要進行協程間的同步與通訊,以及精確控制子goroutine開始和結束,其中一個典型情境就是主進程通知名下所有子goroutine優雅退出運行。由於goroutine的退出機制設計是,goroutine退出只能由本身控制,不允許從外部強制結束該goroutine。只有兩種情況例外,那就是main函數結束或者程式崩潰結束運行;所以,要實現主進程式控制制子goroutine的開始和結束,必須藉助其它工具來實現。# 控制並發的方法實現控制並發的方式,大致可分成以下三類:- 全域共用變數- channel通訊- Context包## 全域共用變數這是最簡單的實現控制並發的方式,實現步驟是:1. 聲明一個全域變數;2. 所有子goroutine共用這個變數,並不斷輪詢這個變數檢查是否有更新;3. 在主進程中變更該全域變數;4. 子goroutine檢測到全域變數更新,執行相應的邏輯。樣本如下:```gopackage mainimport ("fmt""time")func main() {running := truef := func() {for running {fmt.Println("sub proc running...")time.Sleep(1 * time.Second)}fmt.Println("sub proc exit")}go f()go f()go f()time.Sleep(2 * time.Second)running = falsetime.Sleep(3 * time.Second)fmt.Println("main proc exit")}```**全域變數的優勢是簡單方便,不需要過多繁雜的操作,通過一個變數就可以控制所有子goroutine的開始和結束;缺點是功能有限,由於架構所致,該全域變數只能是多讀一寫,否則會出現資料同步問題,當然也可以通過給全域變數加鎖來解決這個問題,但那就增加了複雜度,另外這種方式不適合用於子goroutine間的通訊,因為全域變數可以傳遞的資訊很小;還有就是主進程無法等待所有子goroutine退出,因為這種方式只能是單向通知,所以這種方法只適用於非常簡單的邏輯且並發量不太大的情境,一旦邏輯稍微複雜一點,這種方法就有點捉襟見肘。**## channel通訊另一種更為通用且靈活的實現控制並發的方式是使用channel進行通訊。 首先,我們先來瞭解下什麼是golang中的channel:Channel是Go中的一個核心類型,你可以把它看成一個管道,通過它並發核心單元就可以發送或者接收資料進行通訊(communication)。 要想理解 channel 要Crowdsourced Security Testing道 CSP 模型:> CSP 是 Communicating Sequential Process 的簡稱,中文可以叫做通訊順序進程,是一種並發編程模型,由 Tony Hoare 於 1977 年提出。簡單來說,CSP 模型由並發執行的實體(線程或者進程)所組成,實體之間通過發送訊息進行通訊,這裡發送訊息時使用的就是通道,或者叫 channel。CSP 模型的關鍵是關注 channel,而不關注發送訊息的實體。Go 語言實現了 CSP 部分理論,goroutine 對應 CSP 中並發執行的實體,channel 也就對應著 CSP 中的 channel。> 也就是說,CSP 描述這樣一種並行存取模型:多個Process 使用一個 Channel 進行通訊, 這個 Channel 連結的 Process 通常是匿名的,訊息傳遞通常是同步的(有別於 Actor Model)。先來看範例程式碼:```gopackage mainimport ( "fmt" "os" "os/signal" "sync" "syscall" "time")func consumer(stop <-chan bool) {for {select {case <-stop:fmt.Println("exit sub goroutine")returndefault:fmt.Println("running...")time.Sleep(500 * time.Millisecond)}}}func main() {stop := make(chan bool) var wg sync.WaitGroup // Spawn example consumers for i := 0; i < 3; i++ { wg.Add(1) go func(stop <-chan bool) { defer wg.Done() consumer(stop) }(stop) } waitForSignal() close(stop) fmt.Println("stopping all jobs!") wg.Wait()}func waitForSignal() { sigs := make(chan os.Signal) signal.Notify(sigs, os.Interrupt) signal.Notify(sigs, syscall.SIGTERM) <-sigs}```這裡可以實現優雅等待所有子goroutine完全結束之後主進程才結束退出,藉助了標準庫sync裡的Waitgroup,這是一種控制並發的方式,可以實現對多goroutine的等待,官方文檔是這樣描述的:> A WaitGroup waits for a collection of goroutines to finish. The main goroutine calls Add to set the number of goroutines to wait for. > Then each of the goroutines runs and calls Done when finished. At the same time, Wait can be used to block until all goroutines have finished.簡單來講,它的源碼裡實現了一個類似計數器的結構,記錄每一個在它那裡註冊過的協程,然後每一個協程完成任務之後需要到它那裡登出,然後在主進程那裡可以等待直至所有協程完成任務退出。使用步驟:1. 建立一個Waitgroup的執行個體wg;2. 在每個goroutine啟動的時候,調用wg.Add(1)註冊;3. 在每個goroutine完成任務後退出之前,調用wg.Done()登出。4. 在等待所有goroutine的地方調用wg.Wait()阻塞進程,知道所有goroutine都完成任務調用wg.Done()登出之後,Wait()方法會返回。該樣本程式是一種golang的select+channel的典型用法,我們來稍微深入一點分析一下這種典型用法:### channel首先瞭解下channel,可以理解為管道,它的主要功能點是:1. 佇列儲存體資料2. 阻塞和喚醒goroutinechannel 實現集中在檔案 [runtime/chan.go](https://github.com/golang/go/blob/master/src/runtime/chan.go) 中,channel底層資料結構是這樣的:```gotype hchan struct { qcount uint // 隊列中資料個數 dataqsiz uint // channel 大小 buf unsafe.Pointer // 存放資料的環形數組 elemsize uint16 // channel 中資料類型的大小 closed uint32 // 表示 channel 是否關閉 elemtype *_type // 元素資料類型 sendx uint // send 的數組索引 recvx uint // recv 的數組索引 recvq waitq // 由 recv 行為(也就是 <-ch)阻塞在 channel 上的 goroutine 隊列 sendq waitq // 由 send 行為 (也就是 ch<-) 阻塞在 channel 上的 goroutine 隊列 // lock protects all fields in hchan, as well as several // fields in sudogs blocked on this channel. // // Do not change another G's status while holding this lock // (in particular, do not ready a G), as this can deadlock // with stack shrinking. lock mutex}```從源碼可以看出它其實就是一個隊列加一個鎖(輕量),代碼本身不複雜,但涉及到上下文很多細節,故而不易通讀,有興趣的同學可以去看一下,我的建議是,從上面總結的兩個功能點出發,一個是 ring buffer,用於存資料; 一個是存放操作(讀寫)該channel的goroutine 的隊列。 - buf是一個通用指標,用於儲存資料,看源碼時重點關注對這個變數的讀寫- recvq 是讀操作阻塞在 channel 的 goroutine 列表,sendq 是寫操作阻塞在 channel 的 goroutine 列表。列表的實現是 sudog,其實就是一個對 g 的結構的封裝,看源碼時重點關注,是怎樣通過這兩個變數阻塞和喚醒goroutine的由於涉及源碼較多,這裡就不再深入。### select然後是select機制,golang 的 select 機制可以理解為是在語言層面實現了和 select, poll, epoll 相似的功能:監聽多個描述符的讀/寫等事件,一旦某個描述符就緒(一般是讀或者寫事件發生了),就能夠將發生的事件通知給關心的應用程式去處理該事件。 golang 的 select 機制是,監聽多個channel,每一個 case 是一個事件,可以是讀事件也可以是寫事件,隨機播放一個執行,可以設定default,它的作用是:當監聽的多個事件都阻塞住會執行default的邏輯。select的源碼在[runtime/select.go](https://github.com/golang/go/blob/master/src/runtime/select.go) ,看的時候建議是重點關注 pollorder 和 lockorder- pollorder儲存的是scase的序號,亂序是為了之後執行時的隨機性。- lockorder儲存了所有case中channel的地址,這裡按照地址大小堆排了一下lockorder對應的這片連續記憶體。**對chan排序是為了去重,保證之後對所有channel上鎖時不會重複上鎖。**因為我對這部分源碼研究得也不是很深,故而點到為止即可,有興趣的可以去看看源碼啦!具體到demo代碼:consumer為協程的具體代碼,裡面是只有一個不斷輪詢channel變數stop的迴圈,所以主進程是通過stop來通知子協程何時該結束啟動並執行,在main方法中,close掉stop之後,讀取已關閉的channel會立刻返回該channel資料類型的零值,因此子goroutine裡的<-stop操作會馬上返回,然後退出運行。事實上,通過channel控制子goroutine的方法可以總結為:迴圈監聽一個channel,一般來說是for迴圈裡放一個select監聽channel以達到通知子goroutine的效果。再藉助Waitgroup,主進程可以等待所有協程優雅退出後再結束自己的運行,這就通過channel實現了優雅控制goroutine並發的開始和結束。**channel通訊控制基於CSP模型,相比於傳統的線程與鎖並行存取模型,避免了大量的加鎖解鎖的效能消耗,而又比Actor模型更加靈活,使用Actor模型時,負責通訊的媒介與執行單元是緊耦合的–每個Actor都有一個信箱。而使用CSP模型,channel是第一對象,可以被獨立地建立,寫入和讀出資料,更容易進行擴充。**## 殺器Context> Context通常被譯作上下文,它是一個比較抽象的概念。在討論鏈式調用技術時也經常會提到上下文。一般理解為程式單元的一個運行狀態、現場、快照,而翻譯中上下又很好地詮釋了其本質,上下則是存在上下層的傳遞,上會把內容傳遞給下。在Go語言中,程式單元也就指的是Goroutine。> 每個Goroutine在執行之前,都要Crowdsourced Security Testing道程式當前的執行狀態,通常將這些執行狀態封裝在一個Context變數中,傳遞給要執行的Goroutine中。上下文則幾乎已經成為傳遞與請求同生存周期變數的標準方法。在網路編程下,當接收到一個網路請求Request,在處理這個Request的goroutine中,可能需要在當前gorutine繼續開啟多個新的Goroutine來擷取資料與邏輯處理(例如訪問資料庫、RPC服務等),即一個請求Request,會需要多個Goroutine中處理。而這些Goroutine可能需要共用Request的一些資訊;同時當Request被取消或者逾時的時候,所有從這個Request建立的所有Goroutine也應該被結束。context在go1.7之後被引入到標準庫中,1.7之前的go版本使用context需要安裝golang.org/x/net/context包,關於golang context的更詳細說明,可參考官方文檔:[context](https://golang.org/pkg/context/)### Context初試Context的建立和調用關係是層層遞進的,也就是我們通常所說的鏈式調用,類似資料結構裡的樹,從根節點開始,每一次調用就衍生一個葉子節點。首先,產生根節點,使用context.Background方法產生,而後可以進行鏈式調用使用context包裡的各類方法,context包裡的所有方法:- func Background() Context- func TODO() Context- func WithCancel(parent Context) (ctx Context, cancel CancelFunc)- func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)- func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)- func WithValue(parent Context, key, val interface{}) Context這裡僅以WithCancel和WithValue方法為例來實現控制並發和通訊:話不多說,上碼:```gopackage mainimport ("context""crypto/md5""fmt""io/ioutil""net/http""sync""time")type favContextKey stringfunc main() {wg := &sync.WaitGroup{}values := []string{"https://www.baidu.com/", "https://www.zhihu.com/"}ctx, cancel := context.WithCancel(context.Background())for _, url := range values {wg.Add(1)subCtx := context.WithValue(ctx, favContextKey("url"), url)go reqURL(subCtx, wg)}go func() {time.Sleep(time.Second * 3)cancel()}()wg.Wait()fmt.Println("exit main goroutine")}func reqURL(ctx context.Context, wg *sync.WaitGroup) {defer wg.Done()url, _ := ctx.Value(favContextKey("url")).(string)for {select {case <-ctx.Done():fmt.Printf("stop getting url:%s\n", url)returndefault:r, err := http.Get(url)if r.StatusCode == http.StatusOK && err == nil {body, _ := ioutil.ReadAll(r.Body)subCtx := context.WithValue(ctx, favContextKey("resp"), fmt.Sprintf("%s%x", url, md5.Sum(body)))wg.Add(1)go showResp(subCtx, wg)}r.Body.Close()//啟動子goroutine是為了不阻塞當前goroutine,這裡在實際情境中可以去執行其他邏輯,這裡為了方便直接sleep一秒// doSometing()time.Sleep(time.Second * 1)}}}func showResp(ctx context.Context, wg *sync.WaitGroup) {defer wg.Done()for {select {case <-ctx.Done():fmt.Println("stop showing resp")returndefault://子goroutine裡一般會處理一些IO任務,如讀寫資料庫或者rpc調用,這裡為了方便直接把資料列印fmt.Println("printing ", ctx.Value(favContextKey("resp")))time.Sleep(time.Second * 1)}}}```前面我們說過Context就是設計用來解決那種多個goroutine處理一個Request且這多個goroutine需要共用Request的一些資訊的情境,以上是一個簡單類比上述過程的demo。首先調用context.Background()產生根節點,然後調用withCancel方法,傳入根節點,得到新的子Context以及根節點的cancel方法(通知所有子節點結束運行),這裡要注意:該方法也返回了一個Context,這是一個新的子節點,與初始傳入的根節點不是同一個執行個體了,但是每一個子節點裡會儲存從最初的根節點到本節點的鏈路資訊 ,才能實現鏈式。程式的reqURL方法接收一個url,然後通過http請求該url獲得response,然後在當前goroutine裡再啟動一個子groutine把response列印出來,然後從ReqURL開始Context樹往下衍生葉子節點(每一個鏈式調用新產生的ctx),中間每個ctx都可以通過WithValue方式傳值(實現通訊),而每一個子goroutine都能通過Value方法從父goroutine取值,實現協程間的通訊,每個子ctx可以調用Done方法檢測是否有父節點調用cancel方法通知子節點退出運行,根節點的cancel調用會沿著鏈路通知到每一個子節點,因此實現了強並發控制,流程![Context調用鏈路](http://blog.taohuawu.club/upload/2018/05/044svco84sif9rjebqagmar0fp.png)該demo結合前面說的WaitGroup實現了優雅並發控制和通訊,關於WaitGroup的原理和使用前文已做解析,這裡便不再贅述,當然,實際的應用情境不會這麼簡單,處理Request的goroutine啟動多個子goroutine大多是處理IO密集的任務如讀寫資料庫或rpc調用,然後在主goroutine中繼續執行其他邏輯,這裡為了方便講解做了最簡單的處理。Context作為golang中並發控制和通訊的大殺器,被廣泛應用,一些使用go開發http服務的同學如果閱讀過這些很多 web framework的源碼就知道,Context在web framework隨處可見,因為http請求處理就是一個典型的鏈式過程以及並發情境,所以很多web framework都會藉助Context實現鏈式調用的邏輯。有興趣可以讀一下context包的源碼,會發現Context的實現其實是結合了Mutex鎖和channel而實現的,其實並發、同步的很多進階組件萬變不離其宗,都是通過最底層的資料結構組裝起來的,只要知曉了最基礎的概念,上遊的架構也可以一目瞭然。### context使用規範最後,Context雖然是神器,但開發人員使用也要遵循基本法,以下是一些Context使用的規範:- Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx;不要把Context存在一個結構體當中,顯式地傳入函數。Context變數需要作為第一個參數使用,一般命名為ctx;- Do not pass a nil Context, even if a function permits it. Pass context.TODO if you are unsure about which Context to use;即使方法允許,也不要傳入一個nil的Context,如果你不確定你要用什麼Context的時候傳一個context.TODO;- Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions;使用context的Value相關方法只應該用於在程式和介面中傳遞的和請求相關的中繼資料,不要用它來傳遞一些可選的參數;- The same Context may be passed to functions running in different goroutines; Contexts are safe for simultaneous use by multiple goroutines;同樣的Context可以用來傳遞到不同的goroutine中,Context在多個goroutine中是安全的;# 參考連結- [1] [https://deepzz.com/post/golang-context-package-notes.html](https://deepzz.com/post/golang-context-package-notes.html)- [2] [http://www.flysnow.org/2017/05/12/go-in-action-go-context.html](http://www.flysnow.org/2017/05/12/go-in-action-go-context.html)- [3] [https://golang.org/pkg/context/](https://golang.org/pkg/context/)- [4][http://www.moye.me/2017/05/05/go-concurrency-patterns/](http://www.moye.me/2017/05/05/go-concurrency-patterns/)181 次點擊