《Go語言聖經》學習筆記 第八章 Groroutines和Channels
目錄
- Goroutines
- 實例:併發的Clock服務
- 實例:併發的Echo服務
- Channels
- 併發的循環
- 示例:併發Web爬蟲
- 基於select的多路複用
- 示例:併發的字典遍歷
- 併發的退出
- 示例:聊天服務
注:學習《Go語言聖經》筆記,PDF點擊下載,建議看書。
Go語言小白學習筆記,書上的內容照搬,大佬看了勿噴,以後熟悉了會總結成自己的讀書筆記。
- 併發程序指同時進行多個任務的程序, 隨着硬件的發展, 併發程序變得越來越重要。 Web服務器會一次處理成千上萬的請求。 平板電腦和手機app在渲染用戶畫面同時還會後臺執行各種計算任務和網絡請求。 即使是傳統的批處理問題–讀取數據, 計算, 寫輸出–現在也會用併發來隱藏掉I/O的操作延遲以充分利用現代計算機設備的多個核心。 計算機的性能每年都在以非線性的速度增長。
- Go語言中的併發程序可以用兩種手段來實現。 本章講解goroutine和channel, 其支持“順序通信進程”(communicating sequential processes)或被簡稱爲CSP。 CSP是一種現代的併發編程模型, 在這種編程模型中值會在不同的運行實例(goroutine)中傳遞, 儘管大多數情況下仍然是被限制在單一實例中。 第9章覆蓋更爲傳統的併發模型: 多線程共享內存, 如果你在其它的主流語言中寫過併發程序的話可能會更熟悉一些。 第9章也會深入介紹一些併發程序帶來的風險和陷阱。
- 儘管Go對併發的支持是衆多強力特性之一, 但跟蹤調試併發程序還是很困難, 在線性程序中形成的直覺往往還會使我們誤入歧途。 如果這是讀者第一次接觸併發, 推薦稍微多花一些時間來思考這兩個章節中的樣例
1. Goroutines
- 在Go語言中, 每一個併發的執行單元叫作一個goroutine。 設想這裏的一個程序有兩個函數,一個函數做計算, 另一個輸出結果, 假設兩個函數沒有相互之間的調用關係。 一個線性的程序會先調用其中的一個函數, 然後再調用另一個。 如果程序中包含多個goroutine, 對兩個函數的調用則可能發生在同一時刻。 馬上就會看到這樣的一個程序。
- 如果你使用過操作系統或者其它語言提供的線程, 那麼你可以簡單地把goroutine類比作一個線程, 這樣你就可以寫出一些正確的程序了。 goroutine和線程的本質區別會在9.8節中講。
- 當一個程序啓動時, 其主函數即在一個單獨的goroutine中運行, 我們叫它main goroutine。 新的goroutine會用go語句來創建。 在語法上, go語句是一個普通的函數或方法調用前加上關鍵字go。 go語句會使其語句中的函數在一個新創建的goroutine中運行。 而go語句本身會迅速地完成。
- 下面的例子, main goroutine將計算菲波那契數列的第45個元素值。 由於計算函數使用低效的遞歸, 所以會運行相當長時間, 在此期間我們想讓用戶看到一個可見的標識來表明程序依然在正常運行, 所以來做一個動畫的小圖標:
- gopl.io/ch8/spinner
- 動畫顯示了幾秒之後, fib(45)的調用成功地返回, 並且打印結果:
- 然後主函數返回。 主函數返回時, 所有的goroutine都會被直接打斷, 程序退出。 除了從主函數退出或者直接終止程序之外, 沒有其它的編程方法能夠讓一個goroutine來打斷另一個的執行, 但是之後可以看到一種方式來實現這個目的, 通過goroutine之間的通信來讓一個goroutine請求其它的goroutine, 並被請求的goroutine自行結束執行。
- 留意一下這裏的兩個獨立的單元是如何進行組合的, spinning和菲波那契的計算。 分別在獨立的函數中, 但兩個函數會同時執行。
2. 示例: 併發的Clock服務
-
網絡編程是併發大顯身手的一個領域, 由於服務器是最典型的需要同時處理很多連接的程序, 這些連接一般來自遠彼此獨立的客戶端。 在本小節中, 我們會講解go語言的net包, 這個包提供編寫一個網絡客戶端或者服務器程序的基本組件, 無論兩者間通信是使用TCP, UDP或者Unix domain sockets。 在第一章中我們已經使用過的net/http包裏的方法, 也算是net包的一部分。
-
我們的第一個例子是一個順序執行的時鐘服務器, 它會每隔一秒鐘將當前時間寫到客戶端:
-
gopl.io/ch8/clock1
package main import ( "io" "log" "net" "time" ) func main() { listener, err := net.Listen("tcp", "localhost:8082") if err != nil { log.Fatal(err) } for { conn, err := listener.Accept() if err != nil { log.Print(err) // 例如:連接中止 continue } handleConn(conn) // 一次處理一個連接 } } func handleConn(c net.Conn) { defer c.Close() for { _, err := io.WriteString(c, time.Now().Format("15:04:05\n")) if err != nil { return //例如:連接斷開 } time.Sleep(1 * time.Second) } }
-
Listen函數創建了一個net.Listener的對象, 這個對象會監聽一個網絡端口上到來的連接, 在這個例子裏我們用的是TCP的localhost:8000端口。 listener對象的Accept方法會直接阻塞, 直到一個新的連接被創建, 然後會返回一個net.Conn對象來表示這個連接。
-
handleConn函數會處理一個完整的客戶端連接。 在一個for死循環中, 將當前的時候用time.Now()函數得到, 然後寫到客戶端。 由於net.Conn實現了io.Writer接口, 我們可以直接向其寫入內容。 這個死循環會一直執行, 直到寫入失敗。 最可能的原因是客戶端主動斷開連接。 這種情況下handleConn函數會用defer調用關閉服務器側的連接, 然後返回到主函數, 繼續等待下一個連接請求。
-
time.Time.Format方法提供了一種格式化日期和時間信息的方式。 它的參數是一個格式化模板標識如何來格式化時間, 而這個格式化模板限定爲Mon Jan 2 03:04:05PM 2006 UTC-0700。有8個部分(周幾, 月份, 一個月的第幾天, 等等)。 可以以任意的形式來組合前面這個模板;出現在模板中的部分會作爲參考來對時間格式進行輸出。 在上面的例子中我們只用到了小時、 分鐘和秒。 time包裏定義了很多標準時間格式, 比如time.RFC1123。 在進行格式化的逆向操作time.Parse時, 也會用到同樣的策略。 (譯註: 這是go語言和其它語言相比比較奇葩的一個地方。 。 你需要記住格式化字符串是1月2日下午3點4分5秒零六年UTC-0700, 而不像其它語言那樣Y-m-d H:i:s一樣, 當然了這裏可以用1234567的方式來記憶, 倒是也不麻煩)
-
爲了連接例子裏的服務器, 我們需要一個客戶端程序, 比如netcat這個工具(nc命令), 這個工具可以用來執行網絡連接操作。
-
客戶端將服務器發來的時間顯示了出來, 我們用Control+C來中斷客戶端的執行, 在Unix系統上, 你會看到^C這樣的響應。 如果你的系統沒有裝nc這個工具, 你可以用telnet來實現同樣的效果, 或者也可以用我們下面的這個用go寫的簡單的telnet程序, 用net.Dial就可以簡單地創建一個TCP連接:
-
gopl.io/ch8/netcat1
-
這個程序會從連接中讀取數據, 並將讀到的內容寫到標準輸出中, 直到遇到end of file的條件或者發生錯誤。 mustCopy這個函數我們在本節的幾個例子中都會用到。 讓我們同時運行兩個客戶端來進行一個測試, 這裏可以開兩個終端窗口, 下面左邊的是其中的一個的輸出, 右邊的是另一個的輸出:
-
killall命令是一個Unix命令行工具, 可以用給定的進程名來殺掉所有名字匹配的進程。
-
第二個客戶端必須等待第一個客戶端完成工作, 這樣服務端才能繼續向後執行; 因爲我們這裏的服務器程序同一時間只能處理一個客戶端連接。 我們這裏對服務端程序做一點小改動,使其支持併發: 在handleConn函數調用的地方增加go關鍵字, 讓每一次handleConn的調用都進入一個獨立的goroutine。
-
gopl.io/ch8/clock2
-
現在多個客戶端可以同時接收到時間了:
3. 示例: 併發的Echo服務
- clock服務器每一個連接都會起一個goroutine。 在本節中我們會創建一個echo服務器, 這個服務在每個連接中會有多個goroutine。 大多數echo服務僅僅會返回他們讀取到的內容, 就像下面這個簡單的handleConn函數所做的一樣:
- 一個更有意思的echo服務應該模擬一個實際的echo的“迴響”, 並且一開始要用大寫HELLO來表示“聲音很大”, 之後經過一小段延遲返回一個有所緩和的Hello, 然後一個全小寫字母的hello表示聲音漸漸變小直至消失, 像下面這個版本的handleConn(譯註: 笑看作者腦洞大開):
gopl.io/ch8/reverb1
- 我們需要升級我們的客戶端程序, 這樣它就可以發送終端的輸入到服務器, 並把服務端的返回輸出到終端上, 這使我們有了使用併發的另一個好機會:
- 當main goroutine從標準輸入流中讀取內容並將其發送給服務器時, 另一個goroutine會讀取並打印服務端的響應。 當main goroutine碰到輸入終止時, 例如, 用戶在終端中按了ControlD(^D), 在windows上是Control-Z, 這時程就會被終止, 儘管其它goroutine中還有進行中的任務。 (在8.4.1中引入了channels後我們會明白如何讓程序等待兩邊都結束)。
- 下面這個會話中, 客戶端的輸入是左對齊的, 服務端的響應會用縮進來區別顯示。 客戶端會向服務器“喊三次話”:
- 注意客戶端的第三次shout在前一個shout處理完成之前一直沒有被處理, 這貌似看起來不是特別“現實”。 真實世界裏的迴響應該是會由三次shout的回聲組合而成的。 爲了模擬真實世界的迴響, 我們需要更多的goroutine來做這件事情。 這樣我們就再一次地需要go這個關鍵詞了,這次我們用它來調用echo:
- gopl.io/ch8/reverb2
- go後跟的函數的參數會在go語句自身執行時被求值; 因此input.Text()會在main goroutine中被求值。 現在迴響是併發並且會按時間來覆蓋掉其它響應了:
- 讓服務使用併發不只是處理多個客戶端的請求, 甚至在處理單個連接時也可能會用到, 就像我們上面的兩個go關鍵詞的用法。 然而在我們使用go關鍵詞的同時, 需要慎重地考慮net.Conn中的方法在併發地調用時是否安全, 事實上對於大多數類型來說也確實不安全。 我們會在下一章中詳細地探討併發安全性。
4. Channels
- 如果說goroutine是Go語音程序的併發體的話, 那麼channels它們之間的通信機制。 一個channels是一個通信機制, 它可以讓一個goroutine通過它給另一個goroutine發送值信息。 每個channel都有一個特殊的類型, 也就是channels可發送數據的類型。 一個可以發送int類型數據的channel一般寫爲chan int。
- 使用內置的make函數, 我們可以創建一個channel:
- 和map類似, channel也一個對應make創建的底層數據結構的引用。 當我們複製一個channel或用於函數參數傳遞時, 我們只是拷貝了一個channel引用, 因此調用者何被調用者將引用同一個channel對象。 和其它的引用類型一樣, channel的零值也是nil。
- 兩個相同類型的channel可以使用==運算符比較。 如果兩個channel引用的是相通的對象, 那麼比較的結果爲真。 一個channel也可以和nil進行比較。
- 一個channel有發送和接受兩個主要操作, 都是通信行爲。 一個發送語句將一個值從一個goroutine通過channel發送到另一個執行接收操作的goroutine。 發送和接收兩個操作都是用 <- 運算符。 在發送語句中, <- 運算符分割channel和要發送的值。 在接收語句中, <- 運算符寫在channel對象之前。 一個不使用接收結果的接收操作也是合法的。
- Channel還支持close操作, 用於關閉channel, 隨後對基於該channel的任何發送操作都將導致panic異常。 對一個已經被close過的channel之行接收操作依然可以接受到之前已經成功發送的數據; 如果channel中已經沒有數據的話講產生一個零值的數據。
- 使用內置的close函數就可以關閉一個channel:
- 以最簡單方式調用make函數創建的時一個無緩衝的channel, 但是我們也可以指定第二個整形參數, 對應channel的容量。 如果channel的容量大於零, 那麼該channel就是帶緩衝的channel。
- 我們將先討論無緩衝的channel, 然後在8.4.4節討論帶緩衝的channel。
1. 不帶緩存的Channels
- 一個基於無緩存Channels的發送操作將導致發送者goroutine阻塞, 直到另一個goroutine在相同的Channels上執行接收操作, 當發送的值通過Channels成功傳輸之後, 兩個goroutine可以繼續執行後面的語句。 反之, 如果接收操作先發生, 那麼接收者goroutine也將阻塞, 直到有另一個goroutine在相同的Channels上執行發送操作。
- 基於無緩存Channels的發送和接收操作將導致兩個goroutine做一次同步操作。 因爲這個原因, 無緩存Channels有時候也被稱爲同步Channels。 當通過一個無緩存Channels發送數據時, 接收者收到數據發生在喚醒發送者goroutine之前( 譯註: happens before, 這是Go語言併發內存模型的一個關鍵術語! )
- 當我們說x事件既不是在y事件之前發生也不是在y事件之後發生, 我們就說x事件和y事件是併發的。 這並不是意味着x事件和y事件就一定是同時發生的, 我們只是不能確定這兩個事件發生的先後順序。 在下一章中我們將看到, 當兩個goroutine併發訪問了相同的變量時, 我們有必要保證某些事件的執行順序, 以避免出現某些併發問題。
- 在8.3節的客戶端程序, 它在主goroutine中( 譯註: 就是執行main函數的goroutine) 將標準輸入複製到server, 因此當客戶端程序關閉標準輸入時, 後臺goroutine可能依然在工作。 我們需要讓主goroutine等待後臺goroutine完成工作後再退出, 我們使用了一個channel來同步兩個goroutine:
- gopl.io/ch8/netcat3
- 當用戶關閉了標準輸入, 主goroutine中的mustCopy函數調用將返回, 然後調用conn.Close()關閉讀和寫方向的網絡連接。 關閉網絡鏈接中的寫方向的鏈接將導致server程序收到一個文件( end-of-le) 結束的信號。 關閉網絡鏈接中讀方向的鏈接將導致後臺goroutine的io.Copy函數調用返回一個“read from closed connection”( “從關閉的鏈接讀”) 類似的錯誤, 因此我們臨時移除了錯誤日誌語句; 在練習8.3將會提供一個更好的解決方案。 ( 需要注意的是go語句調用了一個函數字面量, 這Go語言中啓動goroutine常用的形式。 )
- 在後臺goroutine返回之前, 它先打印一個日誌信息, 然後向done對應的channel發送一個值。主goroutine在退出前先等待從done對應的channel接收一個值。 因此, 總是可以在程序退出前正確輸出“done”消息。
- 基於channels發送消息有兩個重要方面。 首先每個消息都有一個值, 但是有時候通訊的事實和發生的時刻也同樣重要。 當我們更希望強調通訊發生的時刻時, 我們將它稱爲消息事件。有些消息事件並不攜帶額外的信息, 它僅僅是用作兩個goroutine之間的同步, 這時候我們可以用 struct{} 空結構體作爲channels元素的類型, 雖然也可以使用bool或int類型實現同樣的功能, done <- 1 語句也比 done <- struct{}{} 更短。
2. 串聯的Channels( Pipeline)
- Channels也可以用於將多個goroutine鏈接在一起, 一個Channels的輸出作爲下一個Channels的輸入。 這種串聯的Channels就是所謂的管道( pipeline) 。 下面的程序用兩個channels將三個goroutine串聯起來, 如圖8.1所示。
- 第一個goroutine是一個計數器, 用於生成0、 1、 2、 ……形式的整數序列, 然後通過channel將該整數序列發送給第二個goroutine; 第二個goroutine是一個求平方的程序, 對收到的每個整數求平方, 然後將平方後的結果通過第二個channel發送給第三個goroutine; 第三個goroutine是一個打印程序, 打印收到的每個整數。 爲了保持例子清晰, 我們有意選擇了非常簡單的函數, 當然三個goroutine的計算很簡單, 在現實中確實沒有必要爲如此簡單的運算構建三個goroutine。
- gopl.io/ch8/pipeline1
- 如您所料, 上面的程序將生成0、 1、 4、 9、 ……形式的無窮數列。 像這樣的串聯Channels的管道( Pipelines) 可以用在需要長時間運行的服務中, 每個長時間運行的goroutine可能會包含一個死循環, 在不同goroutine的死循環內部使用串聯的Channels來通信。 但是, 如果我們希望通過Channels只發送有限的數列該如何處理呢?
- 如果發送者知道, 沒有更多的值需要發送到channel的話, 那麼讓接收者也能及時知道沒有多餘的值可接收將是有用的, 因爲接收者可以停止不必要的接收等待。 這可以通過內置的close函數來關閉channel實現:
- 沒有辦法直接測試一個channel是否被關閉, 但是接收操作有一個變體形式: 它多接收一個結果, 多接收的第二個結果是一個布爾值ok, ture表示成功從channels接收到值, false表示channels已經被關閉並且裏面沒有值可接收。 使用這個特性, 我們可以修改squarer函數中的循環代碼, 當naturals對應的channel被關閉並沒有值可接收時跳出循環, 並且也關閉squares對應的channel.
- 因爲上面的語法是笨拙的, 而且這種處理模式很場景, 因此Go語言的range循環可直接在channels上面迭代。 使用range循環是上面處理模式的簡潔語法, 它依次從channel接收數據, 當channel被關閉並且沒有值可接收時跳出循環。
- 在下面的改進中, 我們的計數器goroutine只生成100個含數字的序列, 然後關閉naturals對應的channel, 這將導致計算平方數的squarer對應的goroutine可以正常終止循環並關閉squares對應的channel。 ( 在一個更復雜的程序中, 可以通過defer語句關閉對應的channel。 ) 最後, 主goroutine也可以正常終止循環並退出程序。
- opl.io/ch8/pipeline2
- 其實你並不需要關閉每一個channel。 只要當需要告訴接收者goroutine, 所有的數據已經全部發送時才需要關閉channel。 不管一個channel是否被關閉, 當它沒有被引用時將會被Go語言的垃圾自動回收器回收。 ( 不要將關閉一個打開文件的操作和關閉一個channel操作混淆。 對於每個打開的文件, 都需要在不使用的使用調用對應的Close方法來關閉文件。 )
- 試圖重複關閉一個channel將導致panic異常, 試圖關閉一個nil值的channel也將導致panic異常。 關閉一個channels還會觸發一個廣播機制, 我們將在8.9節討論。
3. 單方向的Channel
- 隨着程序的增長, 人們習慣於將大的函數拆分爲小的函數。 我們前面的例子中使用了三個goroutine, 然後用兩個channels連鏈接它們, 它們都是main函數的局部變量。 將三個goroutine拆分爲以下三個函數是自然的想法:
- 其中squarer計算平方的函數在兩個串聯Channels的中間, 因此擁有兩個channels類型的參數, 一個用於輸入一個用於輸出。 每個channels都用有相同的類型, 但是它們的使用方式想反: 一個只用於接收, 另一個只用於發送。 參數的名字in和out已經明確表示了這個意圖, 但是並無法保證squarer函數向一個in參數對應的channels發送數據或者從一個out參數對應的channels接收數據。
- 這種場景是典型的。 當一個channel作爲一個函數參數是, 它一般總是被專門用於只發送或者只接收。
- 爲了表明這種意圖並防止被濫用, Go語言的類型系統提供了單方向的channel類型, 分別用於只發送或只接收的channel。 類型 chan<- int 表示一個只發送int的channel, 只能發送不能接收。 相反, 類型 <-chan int 表示一個只接收int的channel, 只能接收不能發送。 ( 箭頭 <- 和關鍵字chan的相對位置表明了channel的方向。 ) 這種限制將在編譯期檢測。
- 因爲關閉操作只用於斷言不再向channel發送新的數據, 所以只有在發送者所在的goroutine纔會調用close函數, 因此對一個只接收的channel調用close將是一個編譯錯誤。
- 這是改進的版本, 這一次參數使用了單方向channel類型:
- 調用counter(naturals)將導致將 chan int 類型的naturals隱式地轉換爲 chan<- int 類型只發送型的channel。 調用printer(squares)也會導致相似的隱式轉換, 這一次是轉換爲 <-chanint 類型只接收型的channel。 任何雙向channel向單向channel變量的賦值操作都將導致該隱式轉換。 這裏並沒有反向轉換的語法: 也就是不能一個將類似 chan<- int 類型的單向型的channel轉換爲 chan int 類型的雙向型的channel
4. 帶緩存的Channels
- 帶緩存的Channel內部持有一個元素隊列。 隊列的最大容量是在調用make函數創建channel時通過第二個參數指定的。 下面的語句創建了一個可以持有三個字符串元素的帶緩存Channel。圖8.2是ch變量對應的channel的圖形表示形式。
- 向緩存Channel的發送操作就是向內部緩存隊列的尾部插入元素, 接收操作則是從隊列的頭部刪除元素。 如果內部緩存隊列是滿的, 那麼發送操作將阻塞直到因另一個goroutine執行接收操作而釋放了新的隊列空間。 相反, 如果channel是空的, 接收操作將阻塞直到有另一個goroutine執行發送操作而向隊列插入元素。
- 我們可以在無阻塞的情況下連續向新創建的channel發送三個值:
- 此刻, channel的內部緩存隊列將是滿的( 圖8.3) , 如果有第四個發送操作將發生阻塞。
- 如果我們接收一個值,
- 那麼channel的緩存隊列將不是滿的也不是空的( 圖8.4) , 因此對該channel執行的發送或接收操作都不會發送阻塞。 通過這種方式, channel的緩存隊列解耦了接收和發送的goroutine。
- 在某些特殊情況下, 程序可能需要知道channel內部緩存的容量, 可以用內置的cap函數獲取:
- 同樣, 對於內置的len函數, 如果傳入的是channel, 那麼將返回channel內部緩存隊列中有效元素的個數。 因爲在併發程序中該信息會隨着接收操作而失效, 但是它對某些故障診斷和性能優化會有幫助。
- 在繼續執行兩次接收操作後channel內部的緩存隊列將又成爲空的, 如果有第四個接收操作將發生阻塞:
- 在這個例子中, 發送和接收操作都發生在同一個goroutine中, 但是在真是的程序中它們一般由不同的goroutine執行。 Go語言新手有時候會將一個帶緩存的channel當作同一個goroutine中的隊列使用, 雖然語法看似簡單, 但實際上這是一個錯誤。 Channel和goroutine的調度器機制是緊密相連的, 一個發送操作——或許是整個程序——可能會永遠阻塞。 如果你只是需要一個簡單的隊列, 使用slice就可以了。
- 下面的例子展示了一個使用了帶緩存channel的應用。 它併發地向三個鏡像站點發出請求, 三個鏡像站點分散在不同的地理位置。 它們分別將收到的響應發送到帶緩存channel, 最後接收者只接收第一個收到的響應, 也就是最快的那個響應。 因此mirroredQuery函數可能在另外兩個響應慢的鏡像站點響應之前就返回了結果。 ( 順便說一下, 多個goroutines併發地向同一個channel發送數據, 或從同一個channel接收數據都是常見的用法。 )
- 如果我們使用了無緩存的channel, 那麼兩個慢的goroutines將會因爲沒有人接收而被永遠卡住。 這種情況, 稱爲goroutines泄漏, 這將是一個BUG。 和垃圾變量不同, 泄漏的goroutines並不會被自動回收, 因此確保每個不再需要的goroutine能正常退出是重要的。
- 關於無緩存或帶緩存channels之間的選擇, 或者是帶緩存channels的容量大小的選擇, 都可能影響程序的正確性。 無緩存channel更強地保證了每個發送操作與相應的同步接收操作; 但是對於帶緩存channel, 這些操作是解耦的。 同樣, 即使我們知道將要發送到一個channel的信息的數量上限, 創建一個對應容量大小帶緩存channel也是不現實的, 因爲這要求在執行任何接收操作之前緩存所有已經發送的值。 如果未能分配足夠的緩衝將導致程序死鎖。
- Channel的緩存也可能影響程序的性能。 想象一家蛋糕店有三個廚師, 一個烘焙, 一個上糖衣, 還有一個將每個蛋糕傳遞到它下一個廚師在生產線。 在狹小的廚房空間環境, 每個廚師在完成蛋糕後必須等待下一個廚師已經準備好接受它; 這類似於在一個無緩存的channel上進行溝通。
- 如果在每個廚師之間有一個放置一個蛋糕的額外空間, 那麼每個廚師就可以將一個完成的蛋糕臨時放在那裏而馬上進入下一個蛋糕在製作中; 這類似於將channel的緩存隊列的容量設置爲1。 只要每個廚師的平均工作效率相近, 那麼其中大部分的傳輸工作將是迅速的, 個體之間細小的效率差異將在交接過程中彌補。 如果廚師之間有更大的額外空間——也是就更大容量的緩存隊列——將可以在不停止生產線的前提下消除更大的效率波動, 例如一個廚師可以短暫地休息, 然後在加快趕上進度而不影響其其他人。
- 另一方面, 如果生產線的前期階段一直快於後續階段, 那麼它們之間的緩存在大部分時間都將是滿的。 相反, 如果後續階段比前期階段更快, 那麼它們之間的緩存在大部分時間都將是空的。 對於這類場景, 額外的緩存並沒有帶來任何好處。
- 生產線的隱喻對於理解channels和goroutines的工作機制是很有幫助的。 例如, 如果第二階段是需要精心製作的複雜操作, 一個廚師可能無法跟上第一個廚師的進度, 或者是無法滿足第階段廚師的需求。 要解決這個問題, 我們可以僱傭另一個廚師來幫助完成第二階段的工作,他執行相同的任務但是獨立工作。 這類似於基於相同的channels創建另一個獨立的goroutine。
- 我們沒有太多的空間展示全部細節, 但是gopl.io/ch8/cake包模擬了這個蛋糕店, 可以通過不同的參數調整。 它還對上面提到的幾種場景提供對應的基準測試( §11.4) 。
5. 併發的循環
-
本節中, 我們會探索一些用來在並行時循環迭代的常見併發模型。 我們會探究從全尺寸圖片生成一些縮略圖的問題。 gopl.io/ch8/thumbnail包提供了ImageFile函數來幫我們拉伸圖片。 我們不會說明這個函數的實現, 只需要從gopl.io下載它。
-
gopl.io/ch8/thumbnail
-
下面的程序會循環迭代一些圖片文件名, 併爲每一張圖片生成一個縮略圖:
-
gopl.io/ch8/thumbnail
-
顯然我們處理文件的順序無關緊要, 因爲每一個圖片的拉伸操作和其它圖片的處理操作都是彼此獨立的。 像這種子問題都是完全彼此獨立的問題被叫做易並行問題(譯註:embarrassingly parallel, 直譯的話更像是尷尬並行)。 易並行問題是最容易被實現成並行的一類問題(廢話), 並且是最能夠享受併發帶來的好處, 能夠隨着並行的規模線性地擴展。
-
下面讓我們並行地執行這些操作, 從而將文件IO的延遲隱藏掉, 並用上多核cpu的計算能力來拉伸圖像。 我們的第一個併發程序只是使用了一個go關鍵字。 這裏我們先忽略掉錯誤, 之後再進行處理。
-
這個版本運行的實在有點太快, 實際上, 由於它比最早的版本使用的時間要短得多, 即使當文件名的slice中只包含有一個元素。 這就有點奇怪了, 如果程序沒有併發執行的話, 那爲什麼一個併發的版本還是要快呢? 答案其實是makeThumbnails在它還沒有完成工作之前就已經返回了。 它啓動了所有的goroutine, 沒一個文件名對應一個, 但沒有等待它們一直到執行完畢。
-
沒有什麼直接的辦法能夠等待goroutine完成, 但是我們可以改變goroutine裏的代碼讓其能夠將完成情況報告給外部的goroutine知曉, 使用的方式是向一個共享的channel中發送事件。 因爲我們已經知道內部的goroutine只有len(filenames), 所以外部的goroutine只需要在返回之前對這些事件計數。
-
注意我們將f的值作爲一個顯式的變量傳給了函數, 而不是在循環的閉包中聲明:
-
回憶一下之前在5.6.1節中, 匿名函數中的循環變量快照問題。 上面這個單獨的變量f是被所有的匿名函數值所共享, 且會被連續的循環迭代所更新的。 當新的goroutine開始執行字面函數時, for循環可能已經更新了f並且開始了另一輪的迭代或者(更有可能的)已經結束了整個循環, 所以當這些goroutine開始讀取f的值時, 它們所看到的值已經是slice的最後一個元素了。顯式地添加這個參數, 我們能夠確保使用的f是當go語句執行時的“當前”那個f。
-
如果我們想要從每一個worker goroutine往主goroutine中返回值時該怎麼辦呢? 當我們調用thumbnail.ImageFile創建文件失敗的時候, 它會返回一個錯誤。 下一個版本的makeThumbnails會返回其在做拉伸操作時接收到的第一個錯誤:
-
這個程序有一個微秒的bug。 當它遇到第一個非nil的error時會直接將error返回到調用方, 使得沒有一個goroutine去排空errors channel。 這樣剩下的worker goroutine在向這個channel中發送值時, 都會永遠地阻塞下去, 並且永遠都不會退出。 這種情況叫做goroutine泄露(§8.4.4),可能會導致整個程序卡住或者跑出out of memory的錯誤。
-
最簡單的解決辦法就是用一個具有合適大小的buffered channel, 這樣這些worker goroutine向channel中發送測向時就不會被阻塞。 (一個可選的解決辦法是創建一個另外的goroutine, 當main goroutine返回第一個錯誤的同時去排空channel)
-
下一個版本的makeThumbnails使用了一個buffered channel來返回生成的圖片文件的名字,附帶生成時的錯誤。
-
我們最後一個版本的makeThumbnails返回了新文件們的大小總計數(bytes)。 和前面的版本都不一樣的一點是我們在這個版本里沒有把文件名放在slice裏, 而是通過一個string的channel傳過來, 所以我們無法對循環的次數進行預測。
-
爲了知道最後一個goroutine什麼時候結束(最後一個結束並不一定是最後一個開始), 我們需要一個遞增的計數器, 在每一個goroutine啓動時加一, 在goroutine退出時減一。 這需要一種特殊的計數器, 這個計數器需要在多個goroutine操作時做到安全並且提供提供在其減爲零之前一直等待的一種方法。 這種計數類型被稱爲sync.WaitGroup, 下面的代碼就用到了這種方法:
// makeThumbnails6 爲從通道接收到的每個文件生成縮略圖 // 它返回其生成的文件佔用的字節數 func makeThumbnails6(filenames <-chan string) int64 { sizes := make(chan int64) var wg sync.WaitGroup for f := range filenames { wg.Add(1) go func(f string) { defer wg.Done() thumb, err := thumbnail.ImageFile(f) if err != nil { log.Println(err) return } info, _ := os.Stat(thumb) sizes <- info.Size() }(f) } go func() { wg.Wait() close(sizes) }() var total int64 for size := range sizes { total += size } return total }
-
注意Add和Done方法的不對稱。 Add是爲計數器加一, 必須在worker goroutine開始之前調用, 而不是在goroutine中; 否則的話我們沒辦法確定Add是在"closer" goroutine調用Wait之前被調用。 並且Add還有一個參數, 但Done卻沒有任何參數; 其實它和Add(-1)是等價的。 我們使用defer來確保計數器即使是在出錯的情況下依然能夠正確地被減掉。 上面的程序代碼結構是當我們使用併發循環, 但又不知道迭代次數時很通常而且很地道的寫法。
-
sizes channel攜帶了每一個文件的大小到main goroutine, 在main goroutine中使用了rangeloop來計算總和。 觀察一下我們是怎樣創建一個closer goroutine, 並讓其等待worker們在關閉掉sizes channel之前退出的。 兩步操作: wait和close, 必須是基於sizes的循環的併發。 考慮一下另一種方案: 如果等待操作被放在了main goroutine中, 在循環之前, 這樣的話就永遠都不會結束了, 如果在循環之後, 那麼又變成了不可達的部分, 因爲沒有任何東西去關閉這個channel, 這個循環就永遠都不會終止。
-
圖8.5 表明了makethumbnails6函數中事件的序列。 縱列表示goroutine。 窄線段代表sleep,粗線段代表活動。 斜線箭頭代表用來同步兩個goroutine的事件。 時間向下流動。 注意maingoroutine是如何大部分的時間被喚醒執行其range循環, 等待worker發送值或者closer來關閉channel的。
6. 示例: 併發的Web爬蟲
- 在5.6節中, 我們做了一個簡單的web爬蟲, 用bfs(廣度優先)算法來抓取整個網站。 在本節中, 我們會讓這個這個爬蟲並行化, 這樣每一個彼此獨立的抓取命令可以並行進行IO, 最大化利用網絡資源。 crawl函數和gopl.io/ch5/findlinks3中的是一樣的。
- gopl.io/ch8/crawl1
- 主函數和5.6節中的breadthFirst(深度優先)類似。 像之前一樣, 一個worklist是一個記錄了需要處理的元素的隊列, 每一個元素都是一個需要抓取的URL列表, 不過這一次我們用channel代替slice來做這個隊列。 每一個對crawl的調用都會在他們自己的goroutine中進行並且會把他們抓到的鏈接發送回worklist。
- 注意這裏的crawl所在的goroutine會將link作爲一個顯式的參數傳入, 來避免“循環變量快照”的問題(在5.6.1中有講解)。 另外注意這裏將命令行參數傳入worklist也是在一個另外的goroutine中進行的, 這是爲了避免在main goroutine和crawler goroutine中同時向另一個goroutine通過channel發送內容時發生死鎖(因爲另一邊的接收操作還沒有準備好)。 當然, 這裏我們也可以用buffered channel來解決問題, 這裏不再贅述。
- 現在爬蟲可以高併發地運行起來, 並且可以產生一大坨的URL了, 不過還是會有倆問題。 一個問題是在運行一段時間後可能會出現在log的錯誤信息裏的:
- 最初的錯誤信息是一個讓人莫名的DNS查找失敗, 即使這個域名是完全可靠的。 而隨後的錯誤信息揭示了原因: 這個程序一次性創建了太多網絡連接, 超過了每一個進程的打開文件數限制, 既而導致了在調用net.Dial像DNS查找失敗這樣的問題
- 這個程序實在是太他媽並行了。 無窮無盡地並行化並不是什麼好事情, 因爲不管怎麼說, 你的系統總是會有一個些限制因素, 比如CPU核心數會限制你的計算負載, 比如你的硬盤轉軸和磁頭數限制了你的本地磁盤IO操作頻率, 比如你的網絡帶寬限制了你的下載速度上限, 或者是你的一個web服務的服務容量上限等等。 爲了解決這個問題, 我們可以限制併發程序所使用的資源來使之適應自己的運行環境。 對於我們的例子來說, 最簡單的方法就是限制對links.Extract在同一時間最多不會有超過n次調用, 這裏的n是fd的limit-20, 一般情況下。 這個一個夜店裏限制客人數目是一個道理, 只有當有客人離開時, 纔會允許新的客人進入店內(譯註: 作者你個老流氓)。
- 我們可以用一個有容量限制的buffered channel來控制併發, 這類似於操作系統裏的計數信號量概念。 從概念上講, channel裏的n個空槽代表n個可以處理內容的token(通行證), 從channel裏接收一個值會釋放其中的一個token, 並且生成一個新的空槽位。 這樣保證了在沒有接收介入時最多有n個發送操作。 (這裏可能我們拿channel裏填充的槽來做token更直觀一些, 不過還是這樣吧~)。 由於channel裏的元素類型並不重要, 我們用一個零值的struct{}來作爲其元素。
- 讓我們重寫crawl函數, 將對links.Extract的調用操作用獲取、 釋放token的操作包裹起來, 來確保同一時間對其只有20個調用。 信號量數量和其能操作的IO資源數量應保持接近。
- gopl.io/ch8/crawl2
- 第二個問題是這個程序永遠都不會終止, 即使它已經爬到了所有初始鏈接衍生出的鏈接。 (當然, 除非你慎重地選擇了合適的初始化URL或者已經實現了練習8.6中的深度限制, 你應該還沒有意識到這個問題)。 爲了使這個程序能夠終止, 我們需要在worklist爲空或者沒有crawl的goroutine在運行時退出主循環。
- 這個版本中, 計算器n對worklist的發送操作數量進行了限制。 每一次我們發現有元素需要被髮送到worklist時, 我們都會對n進行++操作, 在向worklist中發送初始的命令行參數之前, 我們也進行過一次++操作。 這裏的操作++是在每啓動一個crawler的goroutine之前。 主循環會在n 減爲0時終止, 這時候說明沒活可幹了。
- 現在這個併發爬蟲會比5.6節中的深度優先搜索版快上20倍, 而且不會出什麼錯, 並且在其完成任務時也會正確地終止。
- 下面的程序是避免過度併發的另一種思路。 這個版本使用了原來的crawl函數, 但沒有使用計數信號量, 取而代之用了20個長活的crawler goroutine, 這樣來保證最多20個HTTP請求在併發。
- 所有的爬蟲goroutine現在都是被同一個channel-unseenLinks餵飽的了。 主goroutine負責拆分它從worklist裏拿到的元素, 然後把沒有抓過的經由unseenLinks channel發送給一個爬蟲的goroutine。
- seen這個map被限定在main goroutine中; 也就是說這個map只能在main goroutine中進行訪問。 類似於其它的信息隱藏方式, 這樣的約束可以讓我們從一定程度上保證程序的正確性。例如, 內部變量不能夠在函數外部被訪問到; 變量(§2.3.4)在沒有被轉義的情況下是無法在函數外部訪問的; 一個對象的封裝字段無法被該對象的方法以外的方法訪問到。 在所有的情況下, 信息隱藏都可以幫助我們約束我們的程序, 使其不發生意料之外的情況。
- crawl函數爬到的鏈接在一個專有的goroutine中被髮送到worklist中來避免死鎖。 爲了節省空間, 這個例子的終止問題我們先不進行詳細闡述了
7. 基於select的多路複用
- 下面的程序會進行火箭發射的倒計時。 time.Tick函數返回一個channel, 程序會週期性地像一個節拍器一樣向這個channel發送事件。 每一個事件的值是一個時間戳, 不過更有意思的是其傳送方式。
- gopl.io/ch8/countdown1
- 現在我們讓這個程序支持在倒計時中, 用戶按下return鍵時直接中斷髮射流程。 首先, 我們啓動一個goroutine, 這個goroutine會嘗試從標準輸入中調入一個單獨的byte並且, 如果成功了, 會向名爲abort的channel發送一個值。
- gopl.io/ch8/countdown2
- 現在每一次計數循環的迭代都需要等待兩個channel中的其中一個返回事件了: ticker channel當一切正常時(就像NASA jorgon的"nominal", 譯註: 這梗估計我們是不懂了)或者異常時返回的abort事件。 我們無法做到從每一個channel中接收信息, 如果我們這麼做的話, 如果第一個channel中沒有事件發過來那麼程序就會立刻被阻塞, 這樣我們就無法收到第二個channel中發過來的事件。 這時候我們需要多路複用(multiplex)這些操作了, 爲了能夠多路複用, 我們使用了select語句。
- 上面是select語句的一般形式。 和switch語句稍微有點相似, 也會有幾個case和最後的default選擇支。 每一個case代表一個通信操作(在某個channel上進行發送或者接收)並且會包含一些語句組成的一個語句塊。 一個接收表達式可能只包含接收表達式自身(譯註: 不把接收到的值賦值給變量什麼的), 就像上面的第一個case, 或者包含在一個簡短的變量聲明中, 像第二個case裏一樣; 第二種形式讓你能夠引用接收到的值。
- select會等待case中有能夠執行的case時去執行。 當條件滿足時, select纔會去通信並執行case之後的語句; 這時候其它通信是不會執行的。 一個沒有任何case的select語句寫作select{}, 會永遠地等待下去。
- 讓我們回到我們的火箭發射程序。 time.After函數會立即返回一個channel, 並起一個新的goroutine在經過特定的時間後向該channel發送一個獨立的值。 下面的select語句會會一直等待到兩個事件中的一個到達, 無論是abort事件或者一個10秒經過的事件。 如果10秒經過了還沒有abort事件進入, 那麼火箭就會發射。
- 下面這個例子更微秒。 ch這個channel的buffer大小是1, 所以會交替的爲空或爲滿, 所以只有一個case可以進行下去, 無論i是奇數或者偶數, 它都會打印0 2 4 6 8。
- 如果多個case同時就緒時, select會隨機地選擇一個執行, 這樣來保證每一個channel都有平等的被select的機會。 增加前一個例子的buffer大小會使其輸出變得不確定, 因爲當buffer既不爲滿也不爲空時, select語句的執行情況就像是拋硬幣的行爲一樣是隨機的。
- gopl.io/ch8/countdown3
- time.Tick函數表現得好像它創建了一個在循環中調用time.Sleep的goroutine, 每次被喚醒時發送一個事件。 當countdown函數返回時, 它會停止從tick中接收事件, 但是ticker這個goroutine還依然存活, 繼續徒勞地嘗試從channel中發送值, 然而這時候已經沒有其它的goroutine會從該channel中接收值了–這被稱爲goroutine泄露(§8.4.4)。
- Tick函數挺方便, 但是隻有當程序整個生命週期都需要這個時間時我們使用它才比較合適。 否則的話, 我們應該使用下面的這種模式:
- 有時候我們希望能夠從channel中發送或者接收值, 並避免因爲發送或者接收導致的阻塞, 尤其是當channel沒有準備好寫或者讀時。 select語句就可以實現這樣的功能。 select會有一個default來設置當其它的操作都不能夠馬上被處理時程序需要執行哪些邏輯。
- 下面的select語句會在abort channel中有值時, 從其中接收值; 無值時什麼都不做。 這是一個非阻塞的接收操作; 反覆地做這樣的操作叫做“輪詢channel”。
- channel的零值是nil。 也許會讓你覺得比較奇怪, nil的channel有時候也是有一些用處的。 因爲對一個nil的channel發送和接收操作會永遠阻塞, 在select語句中操作nil的channel永遠都不會被select到
- 這使得我們可以用nil來激活或者禁用case, 來達成處理其它輸入或輸出事件時超時和取消的邏輯。 我們會在下一節中看到一個例子。
8. 示例: 併發的字典遍歷
-
在本小節中, 我們會創建一個程序來生成指定目錄的硬盤使用情況報告, 這個程序和Unix裏的du工具比較相似。 大多數工作用下面這個walkDir函數來完成, 這個函數使用dirents函數來枚舉一個目錄下的所有入口。
gopl.io/ch8/du1
-
ioutil.ReadDir函數會返回一個os.FileInfo類型的slice, os.FileInfo類型也是os.Stat這個函數的返回值。 對每一個子目錄而言, walkDir會遞歸地調用其自身, 並且會對每一個文件也遞歸調用。 walkDir函數會向fileSizes這個channel發送一條消息。 這條消息包含了文件的字節大小。
-
下面的主函數, 用了兩個goroutine。 後臺的goroutine調用walkDir來遍歷命令行給出的每一個路徑並最終關閉fileSizes這個channel。 主goroutine會對其從channel中接收到的文件大小進行累加, 並輸出其和。
import ( "flag" "fmt" "io/ioutil" "os" "path/filepath" ) func main() { // 確定初始目錄 flag.Parse() roots := flag.Args() if len(roots) == 0 { roots = []string{"."} } // 遍歷文件樹 fileSizes := make(chan int64) go func() { for _, root := range roots { walkDir(root, fileSizes) } close(fileSizes) }() //輸出結果 var nfiles, nbytes int64 for size := range fileSizes { nfiles++ nbytes += size } printDiskUsage(nfiles, nbytes) }
-
這個程序會在打印其結果之前卡住很長時間。
-
如果在運行的時候能夠讓我們知道處理進度的話想必更好。 但是, 如果簡單地把printDiskUsage函數調用移動到循環裏會導致其打印出成百上千的輸出。
-
下面這個du的變種會間歇打印內容, 不過只有在調用時提供了-v的flag纔會顯示程序進度信息。 在roots目錄上循環的後臺goroutine在這裏保持不變。 主goroutine現在使用了計時器來每500ms生成事件, 然後用select語句來等待文件大小的消息來更新總大小數據, 或者一個計時器的事件來打印當前的總大小數據。 如果-v的flag在運行時沒有傳入的話, tick這個channel會保持爲nil, 這樣在select裏的case也就相當於被禁用了。
-
gopl.io/ch8/du2
-
由於我們的程序不再使用range循環, 第一個select的case必須顯式地判斷fileSizes的channel是不是已經被關閉了, 這裏可以用到channel接收的二值形式。 如果channel已經被關閉了的話, 程序會直接退出循環。 這裏的break語句用到了標籤break, 這樣可以同時終結select和for兩個循環; 如果沒有用標籤就break的話只會退出內層的select循環, 而外層的for循環會使之進入下一輪select循環。
-
現在程序會悠閒地爲我們打印更新流:
-
然而這個程序還是會花上很長時間纔會結束。 無法對walkDir做並行化處理沒什麼別的原因,無非是因爲磁盤系統並行限制。 下面這個第三個版本的du, 會對每一個walkDir的調用創建一個新的goroutine。 它使用sync.WaitGroup (§8.5)來對仍舊活躍的walkDir調用進行計數, 另一個goroutine會在計數器減爲零的時候將fileSizes這個channel關閉。
-
gopl.io/ch8/du3
-
由於這個程序在高峯期會創建成百上千的goroutine, 我們需要修改dirents函數, 用計數信號量來阻止他同時打開太多的文件, 就像我們在8.7節中的併發爬蟲一樣:
-
這個版本比之前那個快了好幾倍, 儘管其具體效率還是和你的運行環境, 機器配置相關。
9. 併發的退出
- 有時候我們需要通知goroutine停止它正在乾的事情, 比如一個正在執行計算的web服務, 然而它的客戶端已經斷開了和服務端的連接。
- Go語言並沒有提供在一個goroutine中終止另一個goroutine的方法, 由於這樣會導致goroutine之間的共享變量落在未定義的狀態上。 在8.7節中的rocket launch程序中, 我們往名字叫abort的channel裏發送了一個簡單的值, 在countdown的goroutine中會把這個值理解爲自己的退出信號。 但是如果我們想要退出兩個或者任意多個goroutine怎麼辦呢?
- 一種可能的手段是向abort的channel裏發送和goroutine數目一樣多的事件來退出它們。 如果這些goroutine中已經有一些自己退出了, 那麼會導致我們的channel裏的事件數比goroutine還多, 這樣導致我們的發送直接被阻塞。 另一方面, 如果這些goroutine又生成了其它的goroutine, 我們的channel裏的數目又太少了, 所以有些goroutine可能會無法接收到退出消息。 一般情況下我們是很難知道在某一個時刻具體有多少個goroutine在運行着的。 另外, 當一個goroutine從abort channel中接收到一個值的時候, 他會消費掉這個值, 這樣其它的goroutine就沒法看到這條信息。 爲了能夠達到我們退出goroutine的目的, 我們需要更靠譜的策略, 來通過一個channel把消息廣播出去, 這樣goroutine們能夠看到這條事件消息, 並且在事件完成之後, 可以知道這件事已經發生過了。
- 只要一些小修改, 我們就可以把退出邏輯加入到前一節的du程序。 首先, 我們創建一個退出的channel, 這個channel不會向其中發送任何值, 但其所在的閉包內要寫明程序需要退出。我們同時還定義了一個工具函數, cancelled, 這個函數在被調用的時候會輪詢退出狀態。
- gopl.io/ch8/du4
- 下面我們創建一個從標準輸入流中讀取內容的goroutine, 這是一個比較典型的連接到終端的程序。 每當有輸入被讀到(比如用戶按了回車鍵), 這個goroutine就會把取消消息通過關閉done的channel廣播出去。
- 現在我們需要使我們的goroutine來對取消進行響應。 在main goroutine中, 我們添加了select的第三個case語句, 嘗試從done channel中接收內容。 如果這個case被滿足的話, 在select到的時候即會返回, 但在結束之前我們需要把fileSizes channel中的內容“排”空, 在channel被關閉之前, 捨棄掉所有值。 這樣可以保證對walkDir的調用不要被向fileSizes發送信息阻塞住,可以正確地完成。
- walkDir這個goroutine一啓動就會輪詢取消狀態, 如果取消狀態被設置的話會直接返回, 並且不做額外的事情。 這樣我們將所有在取消事件之後創建的goroutine改變爲無操作。
- 在walkDir函數的循環中我們對取消狀態進行輪詢可以帶來明顯的益處, 可以避免在取消事件發生時還去創建goroutine。 取消本身是有一些代價的; 想要快速的響應需要對程序邏輯進行侵入式的修改。 確保在取消發生之後不要有代價太大的操作可能會需要修改你代碼裏的很多地方, 但是在一些重要的地方去檢查取消事件也確實能帶來很大的好處。
- 對這個程序的一個簡單的性能分析可以揭示瓶頸在dirents函數中獲取一個信號量。 下面的select可以讓這種操作可以被取消, 並且可以將取消時的延遲從幾百毫秒降低到幾十毫秒。
- 現在當取消發生時, 所有後臺的goroutine都會迅速停止並且主函數會返回。 當然, 當主函數返回時, 一個程序會退出, 而我們又無法在主函數退出的時候確認其已經釋放了所有的資源(譯註: 因爲程序都退出了, 你的代碼都沒法執行了)。 這裏有一個方便的竅門我們可以一用:取代掉直接從主函數返回, 我們調用一個panic, 然後runtime會把每一個goroutine的棧dump下來。 如果main goroutine是唯一一個剩下的goroutine的話, 他會清理掉自己的一切資源。 但是如果還有其它的goroutine沒有退出, 他們可能沒辦法被正確地取消掉, 也有可能被取消但是取消操作會很花時間; 所以這裏的一個調研還是很有必要的。 我們用panic來獲取到足夠的信息來驗證我們上面的判斷, 看看最終到底是什麼樣的情況。
10. 示例: 聊天服務
- 我們用一個聊天服務器來終結本章節的內容, 這個程序可以讓一些用戶通過服務器向其它所有用戶廣播文本消息。 這個程序中有四種goroutine。 main和broadcaster各自是一個goroutine實例, 每一個客戶端的連接都會有一個handleConn和clientWriter的goroutine。 broadcaster是select用法的不錯的樣例, 因爲它需要處理三種不同類型的消息。
- 下面演示的main goroutine的工作, 是listen和accept(譯註: 網絡編程裏的概念)從客戶端過來的連接。 對每一個連接, 程序都會建立一個新的handleConn的goroutine, 就像我們在本章開頭的併發的echo服務器裏所做的那樣。
gopl.io/ch8/chat
- 然後是broadcaster的goroutine。 他的內部變量clients會記錄當前建立連接的客戶端集合。 其記錄的內容是每一個客戶端的消息發出channel的"資格"信息。
- broadcaster監聽來自全局的entering和leaving的channel來獲知客戶端的到來和離開事件。 當其接收到其中的一個事件時, 會更新clients集合, 當該事件是離開行爲時, 它會關閉客戶端的消息發出channel。 broadcaster也會監聽全局的消息channel, 所有的客戶端都會向這個channel中發送消息。 當broadcaster接收到什麼消息時, 就會將其廣播至所有連接到服務端的客戶端。
- 現在讓我們看看每一個客戶端的goroutine。 handleConn函數會爲它的客戶端創建一個消息發出channel並通過entering channel來通知客戶端的到來。 然後它會讀取客戶端發來的每一行文本, 並通過全局的消息channel來將這些文本發送出去, 併爲每條消息帶上發送者的前綴來標明消息身份。 當客戶端發送完畢後, handleConn會通過leaving這個channel來通知客戶端的離開並關閉連接。
- 現在讓我們看看每一個客戶端的goroutine。 handleConn函數會爲它的客戶端創建一個消息發出channel並通過entering channel來通知客戶端的到來。 然後它會讀取客戶端發來的每一行文本, 並通過全局的消息channel來將這些文本發送出去, 併爲每條消息帶上發送者的前綴來標明消息身份。 當客戶端發送完畢後, handleConn會通過leaving這個channel來通知客戶端的離開並關閉連接。
- 另外, handleConn爲每一個客戶端創建了一個clientWriter的goroutine來接收向客戶端發出消息channel中發送的廣播消息, 並將它們寫入到客戶端的網絡連接。 客戶端的讀取方循環會在broadcaster接收到leaving通知並關閉了channel後終止。
- 下面演示的是當服務器有兩個活動的客戶端連接, 並且在兩個窗口中運行的情況, 使用netcat來聊天:
- 當與n個客戶端保持聊天session時, 這個程序會有2n+2個併發的goroutine, 然而這個程序卻並不需要顯式的鎖(§9.2)。 clients這個map被限制在了一個獨立的goroutine中, broadcaster,所以它不能被併發地訪問。 多個goroutine共享的變量只有這些channel和net.Conn的實例, 兩個東西都是併發安全的。 我們會在下一章中更多地解決約束, 併發安全以及goroutine中共享變量的含義。