《Go語言聖經》學習筆記 第九章 基於共享變量的併發

《Go語言聖經》學習筆記 第九章 基於共享變量的併發


目錄

  1. 競爭條件
  2. sync.Mutex互斥鎖
  3. syn.RWMutex讀寫鎖
  4. 內存同步
  5. syn.Once初始化
  6. 競爭條件檢測
  7. 示例:併發的非阻塞緩存
  8. Gorountines和線程

注:學習《Go語言聖經》筆記,PDF點擊下載,建議看書。
Go語言小白學習筆記,書上的內容照搬,大佬看了勿噴,以後熟悉了會總結成自己的讀書筆記。


  1. 前一章我們介紹了一些使用goroutine和channel這樣直接而自然的方式來實現併發的方法。 然而這樣做我們實際上屏蔽掉了在寫併發代碼時必須處理的一些重要而且細微的問題。
  2. 在本章中, 我們會細緻地瞭解併發機制。 尤其是在多goroutine之間的共享變量, 併發問題的分析手段, 以及解決這些問題的基本模式。 最後我們會解釋goroutine和操作系統線程之間的技術上的一些區別。

1. 競爭條件

  1. 在一個線性(就是說只有一個goroutine的)的程序中, 程序的執行順序只由程序的邏輯來決定。例如, 我們有一段語句序列, 第一個在第二個之前(廢話), 以此類推。 在有兩個或更多goroutine的程序中, 每一個goroutine內的語句也是按照既定的順序去執行的, 但是一般情況下我們沒法去知道分別位於兩個goroutine的事件x和y的執行順序, x是在y之前還是之後還是同時發生是沒法判斷的。 當我們能夠沒有辦法自信地確認一個事件是在另一個事件的前面或者後面發生的話, 就說明x和y這兩個事件是併發的。
  2. 考慮一下, 一個函數在線性程序中可以正確地工作。 如果在併發的情況下, 這個函數依然可以正確地工作的話, 那麼我們就說這個函數是併發安全的, 併發安全的函數不需要額外的同步工作。 我們可以把這個概念概括爲一個特定類型的一些方法和操作函數, 如果這個類型是併發安全的話, 那麼所有它的訪問方法和操作就都是併發安全的。
  3. 在一個程序中有非併發安全的類型的情況下, 我們依然可以使這個程序併發安全。 確實, 併發安全的類型是例外, 而不是規則, 所以只有當文檔中明確地說明了其是併發安全的情況下, 你纔可以併發地去訪問它。 我們會避免併發訪問大多數的類型, 無論是將變量侷限在單一的一個goroutine內還是用互斥條件維持更高級別的不變性都是爲了這個目的。 我們會在本章中說明這些術語。
  4. 相反, 導出包級別的函數一般情況下都是併發安全的。 由於package級的變量沒法被限制在單
    一的gorouine, 所以修改這些變量“必須”使用互斥條件。
  5. 一個函數在併發調用時沒法工作的原因太多了, 比如死鎖(deadlock)、 活鎖(livelock)和餓死(resource starvation)。 我們沒有空去討論所有的問題, 這裏我們只聚焦在競爭條件上。
  6. 競爭條件指的是程序在多個goroutine交叉執行操作時, 沒有給出正確的結果。 競爭條件是很惡劣的一種場景, 因爲這種問題會一直潛伏在你的程序裏, 然後在非常少見的時候蹦出來,或許只是會在很大的負載時纔會發生, 又或許是會在使用了某一個編譯器、 某一種平臺或者某一種架構的時候纔會出現。 這些使得競爭條件帶來的問題非常難以復現而且難以分析診斷。
  7. 傳統上經常用經濟損失來爲競爭條件做比喻, 所以我們來看一個簡單的銀行賬戶程序。
    在這裏插入圖片描述
  8. (當然我們也可以把Deposit存款函數寫成balance += amount, 這種形式也是等價的, 不過長一些的形式解釋起來更方便一些。 )
  9. 對於這個具體的程序而言, 我們可以瞅一眼各種存款和查餘額的順序調用, 都能給出正確的結果。 也就是說, Balance函數會給出之前的所有存入的額度之和。 然而, 當我們併發地而不是順序地調用這些函數的話, Balance就再也沒辦法保證結果正確了。 考慮一下下面的兩個goroutine, 其代表了一個銀行聯合賬戶的兩筆交易:
    在這裏插入圖片描述
  10. Alice存了$200, 然後檢查她的餘額, 同時Bob存了$100。 因爲A1和A2是和B併發執行的, 我們沒法預測他們發生的先後順序。 直觀地來看的話, 我們會認爲其執行順序只有三種可能性: “Alice先”, “Bob先”以及“Alice/Bob/Alice”交錯執行。 下面的表格會展示經過每一步驟後balance變量的值。 引號裏的字符串表示餘額單。
    在這裏插入圖片描述
  11. 所有情況下最終的餘額都是$300。 唯一的變數是Alice的餘額單是否包含了Bob交易, 不過無論怎麼着客戶都不會在意。
  12. 但是事實是上面的直覺推斷是錯誤的。 第四種可能的結果是事實存在的, 這種情況下Bob的存款會在Alice存款操作中間, 在餘額被讀到(balance + amount)之後, 在餘額被更新之前(balance = …), 這樣會導致Bob的交易丟失。 而這是因爲Alice的存款操作A1實際上是兩個操作的一個序列, 讀取然後寫; 可以稱之爲A1r和A1w。 下面是交叉時產生的問題:
    在這裏插入圖片描述
  13. 在A1r之後, balance + amount會被計算爲200, 所以這是A1w會寫入的值, 並不受其它存款操作的干預。 最終的餘額是$200。 銀行的賬戶上的資產比Bob實際的資產多了$100。 (譯註:因爲丟失了Bob的存款操作, 所以其實是說Bob的錢丟了)
  14. 這個程序包含了一個特定的競爭條件, 叫作數據競爭。 無論任何時候, 只要有兩個goroutine併發訪問同一變量, 且至少其中的一個是寫操作的時候就會發生數據競爭。
  15. 如果數據競爭的對象是一個比一個機器字(譯註: 32位機器上一個字=4個字節)更大的類型時,事情就變得更麻煩了, 比如interface, string或者slice類型都是如此。 下面的代碼會併發地更新兩個不同長度的slice:
    在這裏插入圖片描述
  16. 最後一個語句中的x的值是未定義的; 其可能是nil, 或者也可能是一個長度爲10的slice, 也可能是一個程度爲1,000,000的slice。 但是回憶一下slice的三個組成部分: 指針(pointer)、 長度(length)和容量(capacity)。 如果指針是從第一個make調用來, 而長度從第二個make來, x就變成了一個混合體, 一個自稱長度爲1,000,000但實際上內部只有10個元素的slice。 這樣導致的結果是存儲999,999元素的位置會碰撞一個遙遠的內存位置, 這種情況下難以對值進行預測, 而且定位和debug也會變成噩夢。 這種語義雷區被稱爲未定義行爲, 對C程序員來說應該很熟悉; 幸運的是在Go語言裏造成的麻煩要比C裏小得多。
  17. 儘管併發程序的概念讓我們知道併發並不是簡單的語句交叉執行。 我們將會在9.4節中看到,數據競爭可能會有奇怪的結果。 許多程序員, 甚至一些非常聰明的人也還是會偶爾提出一些理由來允許數據競爭, 比如: “互斥條件代價太高”, “這個邏輯只是用來做logging”, “我不介意丟失一些消息”等等。 因爲在他們的編譯器或者平臺上很少遇到問題, 可能給了他們錯誤的信心。 一個好的經驗法則是根本就沒有什麼所謂的良性數據競爭。 所以我們一定要避免數據競爭, 那麼在我們的程序中要如何做到呢?
  18. 我們來重複一下數據競爭的定義, 因爲實在太重要了: 數據競爭會在兩個以上的goroutine併發訪問相同的變量且至少其中一個爲寫操作時發生。 根據上述定義, 有三種方式可以避免數據競爭:
  19. 第一種方法是不要去寫變量。 考慮一下下面的map, 會被“懶”填充, 也就是說在每個key被第一次請求到的時候纔會去填值。 如果Icon是被順序調用的話, 這個程序會工作很正常, 但如果Icon被併發調用, 那麼對於這個map來說就會存在數據競爭。
    在這裏插入圖片描述
  20. 反之, 如果我們在創建goroutine之前的初始化階段, 就初始化了map中的所有條目並且再也不去修改它們, 那麼任意數量的goroutine併發訪問Icon都是安全的, 因爲每一個goroutine都只是去讀取而已。
    在這裏插入圖片描述
  21. 上面的例子裏icons變量在包初始化階段就已經被賦值了, 包的初始化是在程序main函數開始執行之前就完成了的。 只要初始化完成了, icons就再也不會修改的或者不變量是本來就併發安全的, 這種變量不需要進行同步。 不過顯然我們沒法用這種方法, 因爲update操作是必要的操作, 尤其對於銀行賬戶來說。
  22. 第二種避免數據競爭的方法是, 避免從多個goroutine訪問變量。 這也是前一章中大多數程序所採用的方法。 例如前面的併發web爬蟲(§8.6)的main goroutine是唯一一個能夠訪問seenmap的goroutine, 而聊天服務器(§8.10)中的broadcaster goroutine是唯一一個能夠訪問clientsmap的goroutine。 這些變量都被限定在了一個單獨的goroutine中。
  23. 由於其它的goroutine不能夠直接訪問變量, 它們只能使用一個channel來發送給指定的goroutine請求來查詢更新變量。 這也就是Go的口頭禪“不要使用共享數據來通信; 使用通信來共享數據”。 一個提供對一個指定的變量通過cahnnel來請求的goroutine叫做這個變量的監控(monitor)goroutine。 例如broadcaster goroutine會監控(monitor)clients map的全部訪問。
  24. 下面是一個重寫了的銀行的例子, 這個例子中balance變量被限制在了monitor goroutine中,名爲teller:
  25. gopl.io/ch9/bank1
    在這裏插入圖片描述
  26. 即使當一個變量無法在其整個生命週期內被綁定到一個獨立的goroutine, 綁定依然是併發問題的一個解決方案。 例如在一條流水線上的goroutine之間共享變量是很普遍的行爲, 在這兩者間會通過channel來傳輸地址信息。 如果流水線的每一個階段都能夠避免在將變量傳送到下一階段時再去訪問它, 那麼對這個變量的所有訪問就是線性的。 其效果是變量會被綁定到流水線的一個階段, 傳送完之後被綁定到下一個, 以此類推。 這種規則有時被稱爲串行綁定。
  27. 下面的例子中, Cakes會被嚴格地順序訪問, 先是baker gorouine, 然後是icer gorouine:
    28.
  28. 第三種避免數據競爭的方法是允許很多goroutine去訪問變量, 但是在同一個時刻最多隻有一個goroutine在訪問。 這種方式被稱爲“互斥”, 在下一節來討論這個主題。

2. sync.Mutex互斥鎖

  1. 在8.6節中, 我們使用了一個buffered channel作爲一個計數信號量, 來保證最多隻有20個goroutine會同時執行HTTP請求。 同理, 我們可以用一個容量只有1的channel來保證最多隻有一個goroutine在同一時刻訪問一個共享變量。 一個只能爲1和0的信號量叫做二元信號量(binary semaphore)。
    gopl.io/ch9/bank2
    在這裏插入圖片描述
  2. 這種互斥很實用, 而且被sync包裏的Mutex類型直接支持。 它的Lock方法能夠獲取到token(這裏叫鎖), 並且Unlock方法會釋放這個token:
  3. gopl.io/ch9/bank3
    在這裏插入圖片描述
  4. 每次一個goroutine訪問bank變量時(這裏只有balance餘額變量), 它都會調用mutex的Lock方法來獲取一個互斥鎖。 如果其它的goroutine已經獲得了這個鎖的話, 這個操作會被阻塞直到其它goroutine調用了Unlock使該鎖變回可用狀態。 mutex會保護共享變量。 慣例來說, 被mutex所保護的變量是在mutex變量聲明之後立刻聲明的。 如果你的做法和慣例不符, 確保在文檔裏對你的做法進行說明。
  5. 在Lock和Unlock之間的代碼段中的內容goroutine可以隨便讀取或者修改, 這個代碼段叫做臨界區。 goroutine在結束後釋放鎖是必要的, 無論以哪條路徑通過函數都需要釋放, 即使是在錯誤路徑中, 也要記得釋放。
  6. 上面的bank程序例證了一種通用的併發模式。 一系列的導出函數封裝了一個或多個變量, 那麼訪問這些變量唯一的方式就是通過這些函數來做(或者方法, 對於一個對象的變量來說)。 每一個函數在一開始就獲取互斥鎖並在最後釋放鎖, 從而保證共享變量不會被併發訪問。 這種函數、 互斥鎖和變量的編排叫作監控monitor(這種老式單詞的monitor是受"monitorgoroutine"的術語啓發而來的。 兩種用法都是一個代理人保證變量被順序訪問)。
  7. 由於在存款和查詢餘額函數中的臨界區代碼這麼短–只有一行, 沒有分支調用–在代碼最後去調用Unlock就顯得更爲直截了當。 在更復雜的臨界區的應用中, 尤其是必須要儘早處理錯誤並返回的情況下, 就很難去(靠人)判斷對Lock和Unlock的調用是在所有路徑中都能夠嚴格配對的了。 Go語言裏的defer簡直就是這種情況下的救星: 我們用defer來調用Unlock, 臨界區會隱式地延伸到函數作用域的最後, 這樣我們就從“總要記得在函數返回之後或者發生錯誤返回時要記得調用一次Unlock”這種狀態中獲得瞭解放。 Go會自動幫我們完成這些事情。
    8.
  8. 上面的例子裏Unlock會在return語句讀取完balance的值之後執行, 所以Balance函數是併發安全的。 這帶來的另一點好處是, 我們再也不需要一個本地變量b了。
  9. 此外, 一個deferred Unlock即使在臨界區發生panic時依然會執行, 這對於用recover (§5.10)來恢復的程序來說是很重要的。 defer調用只會比顯式地調用Unlock成本高那麼一點點, 不過卻在很大程度上保證了代碼的整潔性。 大多數情況下對於併發程序來說, 代碼的整潔性比過度的優化更重要。 如果可能的話儘量使用defer來將臨界區擴展到函數的結束。
  10. 考慮一下下面的Withdraw函數。 成功的時候, 它會正確地減掉餘額並返回true。 但如果銀行記錄資金對交易來說不足, 那麼取款就會恢復餘額, 並返回false。
    在這裏插入圖片描述
  11. 函數終於給出了正確的結果, 但是還有一點討厭的副作用。 當過多的取款操作同時執行時,balance可能會瞬時被減到0以下。 這可能會引起一個併發的取款被不合邏輯地拒絕。 所以如果Bob嘗試買一輛sports car時, Alice可能就沒辦法爲她的早咖啡付款了。 這裏的問題是取款不是一個原子操作: 它包含了三個步驟, 每一步都需要去獲取並釋放互斥鎖, 但任何一次鎖都不會鎖上整個取款流程。
  12. 理想情況下, 取款應該只在整個操作中獲得一次互斥鎖。 下面這樣的嘗試是錯誤的:
    在這裏插入圖片描述
  13. 上面這個例子中, Deposit會調用mu.Lock()第二次去獲取互斥鎖, 但因爲mutex已經鎖上了,而無法被重入(譯註: go裏沒有重入鎖, 關於重入鎖的概念, 請參考java)–也就是說沒法對一個已經鎖上的mutex來再次上鎖–這會導致程序死鎖, 沒法繼續執行下去, Withdraw會永遠阻塞下去。
  14. 關於Go的互斥量不能重入這一點我們有很充分的理由。 互斥量的目的是爲了確保共享變量在程序執行時的關鍵點上能夠保證不變性。 不變性的其中之一是“沒有goroutine訪問共享變量”。但實際上對於mutex保護的變量來說, 不變性還包括其它方面。 當一個goroutine獲得了一個互斥鎖時, 它會斷定這種不變性能夠被保持。 其獲取並保持鎖期間, 可能會去更新共享變量,這樣不變性只是短暫地被破壞。 然而當其釋放鎖之後, 它必須保證不變性已經恢復原樣。 儘管一個可以重入的mutex也可以保證沒有其它的goroutine在訪問共享變量, 但這種方式沒法保證這些變量額外的不變性。 (譯註: 這段翻譯有點暈)
  15. 一個通用的解決方案是將一個函數分離爲多個函數, 比如我們把Deposit分離成兩個: 一個不導出的函數deposit, 這個函數假設鎖總是會被保持並去做實際的操作, 另一個是導出的函數Deposit, 這個函數會調用deposit, 但在調用前會先去獲取鎖。 同理我們可以將Withdraw也表示成這種形式:
    16.
  16. 當然, 這裏的存款deposit函數很小實際上取款withdraw函數不需要理會對它的調用, 儘管如此, 這裏的表達還是表明了規則。
  17. 封裝(§6.6), 用限制一個程序中的意外交互的方式, 可以使我們獲得數據結構的不變性。 因爲某種原因, 封裝還幫我們獲得了併發的不變性。 當你使用mutex時, 確保mutex和其保護的變量沒有被導出(在go裏也就是小寫, 且不要被大寫字母開頭的函數訪問啦), 無論這些變量是包級的變量還是一個struct的字段。

3. sync.RWMutex讀寫鎖

  1. 在100刀的存款消失時不做記錄多少還是會讓我們有一些恐慌, Bob寫了一個程序, 每秒運行幾百次來檢查他的銀行餘額。 他會在家, 在工作中, 甚至會在他的手機上來運行這個程序。銀行注意到這些陡增的流量使得存款和取款有了延時, 因爲所有的餘額查詢請求是順序執行的, 這樣會互斥地獲得鎖, 並且會暫時阻止其它的goroutine運行。
  2. 由於Balance函數只需要讀取變量的狀態, 所以我們同時讓多個Balance調用併發運行事實上是安全的, 只要在運行的時候沒有存款或者取款操作就行。 在這種場景下我們需要一種特殊類型的鎖, 其允許多個只讀操作並行執行, 但寫操作會完全互斥。 這種鎖叫作“多讀單寫”鎖(multiple readers, single writer lock), Go語言提供的這樣的鎖是sync.RWMutex:
    在這裏插入圖片描述
  3. Balance函數現在調用了RLock和RUnlock方法來獲取和釋放一個讀取或者共享鎖。 Deposit函數沒有變化, 會調用mu.Lock和mu.Unlock方法來獲取和釋放一個寫或互斥鎖。在這次修改後, Bob的餘額查詢請求就可以彼此並行地執行並且會很快地完成了。 鎖在更多的時間範圍可用, 並且存款請求也能夠及時地被響應了。
  4. RLock只能在臨界區共享變量沒有任何寫入操作時可用。 一般來說, 我們不應該假設邏輯上的只讀函數/方法也不會去更新某一些變量。 比如一個方法功能是訪問一個變量, 但它也有可能會同時去給一個內部的計數器+1(譯註: 可能是記錄這個方法的訪問次數啥的), 或者去更新緩存–使即時的調用能夠更快。 如果有疑惑的話, 請使用互斥鎖。
  5. RWMutex只有當獲得鎖的大部分goroutine都是讀操作, 而鎖在競爭條件下, 也就是說,goroutine們必須等待才能獲取到鎖的時候, RWMutex纔是最能帶來好處的。 RWMutex需要更復雜的內部記錄, 所以會讓它比一般的無競爭鎖的mutex慢一些。

4. 內存同步

  1. 你可能比較糾結爲什麼Balance方法需要用到互斥條件, 無論是基於channel還是基於互斥量。 畢竟和存款不一樣, 它只由一個簡單的操作組成, 所以不會碰到其它goroutine在其執行"中"執行其它的邏輯的風險。 這裏使用mutex有兩方面考慮。 第一Balance不會在其它操作比如Withdraw“中間”執行。 第二(更重要)的是"同步"不僅僅是一堆goroutine執行順序的問題;同樣也會涉及到內存的問題。
  2. 在現代計算機中可能會有一堆處理器, 每一個都會有其本地緩存(local cache)。 爲了效率, 對內存的寫入一般會在每一個處理器中緩衝, 並在必要時一起flush到主存。 這種情況下這些數據可能會以與當初goroutine寫入順序不同的順序被提交到主存。 像channel通信或者互斥量操作這樣的原語會使處理器將其聚集的寫入flush並commit, 這樣goroutine在某個時間點上的執行結果才能被其它處理器上運行的goroutine得到。
  3. 考慮一下下面代碼片段的可能輸出:
    在這裏插入圖片描述
  4. 因爲兩個goroutine是併發執行, 並且訪問共享變量時也沒有互斥, 會有數據競爭, 所以程序的運行結果沒法預測的話也請不要驚訝。 我們可能希望它能夠打印出下面這四種結果中的一種, 相當於幾種不同的交錯執行時的情況:
    在這裏插入圖片描述
  5. 第四行可以被解釋爲執行順序A1,B1,A2,B2或者B1,A1,A2,B2的執行結果。 然而實際的運行時還是有些情況讓我們有點驚訝:
    在這裏插入圖片描述
  6. 但是根據所使用的編譯器, CPU, 或者其它很多影響因子, 這兩種情況也是有可能發生的。那麼這兩種情況要怎麼解釋呢?
  7. 在一個獨立的goroutine中, 每一個語句的執行順序是可以被保證的; 也就是說goroutine是順序連貫的。 但是在不使用channel且不使用mutex這樣的顯式同步操作時, 我們就沒法保證事件在不同的goroutine中看到的執行順序是一致的了。 儘管goroutine A中一定需要觀察到x=1執行成功之後纔會去讀取y, 但它沒法確保自己觀察得到goroutine B中對y的寫入, 所以A還可能會打印出y的一箇舊版的值。
  8. 儘管去理解併發的一種嘗試是去將其運行理解爲不同goroutine語句的交錯執行, 但看看上面的例子, 這已經不是現代的編譯器和cpu的工作方式了。 因爲賦值和打印指向不同的變量, 編譯器可能會斷定兩條語句的順序不會影響執行結果, 並且會交換兩個語句的執行順序。 如果兩個goroutine在不同的CPU上執行, 每一個核心有自己的緩存, 這樣一個goroutine的寫入對於其它goroutine的Print, 在主存同步之前就是不可見的了。
  9. 所有併發的問題都可以用一致的、 簡單的既定的模式來規避。 所以可能的話, 將變量限定在goroutine內部; 如果是多個goroutine都需要訪問的變量, 使用互斥條件來訪問。

5. sync.Once初始化

  1. 如果初始化成本比較大的話, 那麼將初始化延遲到需要的時候再去做就是一個比較好的選擇。 如果在程序啓動的時候就去做這類的初始化的話會增加程序的啓動時間並且因爲執行的時候可能也並不需要這些變量所以實際上有一些浪費。 讓我們在本章早一些時候看到的icons變量:
    在這裏插入圖片描述
  2. 這個版本的Icon用到了懶初始化(lazy initialization)
    在這裏插入圖片描述
  3. 如果一個變量只被一個單獨的goroutine所訪問的話, 我們可以使用上面的這種模板, 但這種模板在Icon被併發調用時並不安全。 就像前面銀行的那個Deposit(存款)函數一樣, Icon函數也是由多個步驟組成的: 首先測試icons是否爲空, 然後load這些icons, 之後將icons更新爲一個非空的值。 直覺會告訴我們最差的情況是loadIcons函數被多次訪問會帶來數據競爭。 當第一個goroutine在忙着loading這些icons的時候, 另一個goroutine進入了Icon函數, 發現變量是nil, 然後也會調用loadIcons函數。
  4. 不過這種直覺是錯誤的。 (我們希望現在你從現在開始能夠構建自己對併發的直覺, 也就是說對併發的直覺總是不能被信任的! )回憶一下9.4節。 因爲缺少顯式的同步, 編譯器和CPU是可以隨意地去更改訪問內存的指令順序, 以任意方式, 只要保證每一個goroutine自己的執行順序一致。 其中一種可能loadIcons的語句重排是下面這樣。 它會在填寫icons變量的值之前先用一個空map來初始化icons變量。
    在這裏插入圖片描述
  5. 因此, 一個goroutine在檢查icons是非空時, 也並不能就假設這個變量的初始化流程已經走完了(譯註: 可能只是塞了個空map, 裏面的值還沒填完, 也就是說填值的語句都沒執行完呢)。
  6. 最簡單且正確的保證所有goroutine能夠觀察到loadIcons效果的方式, 是用一個mutex來同步檢查。
    7.
  7. 然而使用互斥訪問icons的代價就是沒有辦法對該變量進行併發訪問, 即使變量已經被初始化完畢且再也不會進行變動。 這裏我們可以引入一個允許多讀的鎖:
    在這裏插入圖片描述
  8. 上面的代碼有兩個臨界區。 goroutine首先會獲取一個寫鎖, 查詢map, 然後釋放鎖。 如果條目被找到了(一般情況下), 那麼會直接返回。 如果沒有找到, 那goroutine會獲取一個寫鎖。 不釋放共享鎖的話, 也沒有任何辦法來將一個共享鎖升級爲一個互斥鎖, 所以我們必須重新檢查icons變量是否爲nil, 以防止在執行這一段代碼的時候, icons變量已經被其它gorouine初始化過了。
  9. 上面的模板使我們的程序能夠更好的併發, 但是有一點太複雜且容易出錯。 幸運的是, sync包爲我們提供了一個專門的方案來解決這種一次性初始化的問題: sync.Once。 概念上來講,一次性的初始化需要一個互斥量mutex和一個boolean變量來記錄初始化是不是已經完成了;互斥量用來保護boolean變量和客戶端數據結構。 Do這個唯一的方法需要接收初始化函數作爲其參數。 讓我們用sync.Once來簡化前面的Icon函數吧:
    在這裏插入圖片描述
  10. 每一次對Do(loadIcons)的調用都會鎖定mutex, 並會檢查boolean變量。 在第一次調用時, 變量的值是false, Do會調用loadIcons並會將boolean設置爲true。 隨後的調用什麼都不會做,但是mutex同步會保證loadIcons對內存(這裏其實就是指icons變量啦)產生的效果能夠對所有goroutine可見。 用這種方式來使用sync.Once的話, 我們能夠避免在變量被構建完成之前和其它goroutine共享該變量。

6. 競爭條件檢測

  1. 即使我們小心到不能再小心, 但在併發程序中犯錯還是太容易了。 幸運的是, Go的runtime和工具鏈爲我們裝備了一個複雜但好用的動態分析工具, 競爭檢查器(the race detector)。
  2. 只要在go build, go run或者go test命令後面加上-race的flag, 就會使編譯器創建一個你的應用的“修改”版或者一個附帶了能夠記錄所有運行期對共享變量訪問工具的test, 並且會記錄下每一個讀或者寫共享變量的goroutine的身份信息。 另外, 修改版的程序會記錄下所有的同步事件, 比如go語句, channel操作, 以及對(*sync.Mutex).Lock, (*sync.WaitGroup).Wait等等的調用。 (完整的同步事件集合是在The Go Memory Model文檔中有說明, 該文檔是和語言文檔放在一起的。 譯註: https://golang.org/ref/mem)
  3. 競爭檢查器會檢查這些事件, 會尋找在哪一個goroutine中出現了這樣的case, 例如其讀或者寫了一個共享變量, 這個共享變量是被另一個goroutine在沒有進行干預同步操作便直接寫入的。 這種情況也就表明了是對一個共享變量的併發訪問, 即數據競爭。 這個工具會打印一份報告, 內容包含變量身份, 讀取和寫入的goroutine中活躍的函數的調用棧。 這些信息在定位問題時通常很有用。 9.7節中會有一個競爭檢查器的實戰樣例。
  4. 競爭檢查器會報告所有的已經發生的數據競爭。 然而, 它只能檢測到運行時的競爭條件; 並不能證明之後不會發生數據競爭。 所以爲了使結果儘量正確, 請保證你的測試併發地覆蓋到了你到包。
  5. 由於需要額外的記錄, 因此構建時加了競爭檢測的程序跑起來會慢一些, 且需要更大的內存, 即時是這樣, 這些代價對於很多生產環境的工作來說還是可以接受的。 對於一些偶發的競爭條件來說, 讓競爭檢查器來幹活可以節省無數日夜的debugging。 (譯註: 多少服務端C和C艹程序員爲此盡折腰)

7. 示例: 併發的非阻塞緩存

  1. 本節中我們會做一個無阻塞的緩存, 這種工具可以幫助我們來解決現實世界中併發程序出現但沒有現成的庫可以解決的問題。 這個問題叫作緩存(memoizing)函數(譯註: Memoization的定義: memoization 一詞是Donald Michie 根據拉丁語memorandum杜撰的一個詞。 相應的動詞、 過去分詞、 ing形式有memoiz、 memoized、 memoizing.), 也就是說, 我們需要緩存函數的返回結果, 這樣在對函數進行調用的時候, 我們就只需要一次計算, 之後只要返回計算的結果就可以了。 我們的解決方案會是併發安全且會避免對整個緩存加鎖而導致所有操作都去爭一個鎖的設計。
  2. 我們將使用下面的httpGetBody函數作爲我們需要緩存的函數的一個樣例。 這個函數會去進行HTTP GET請求並且獲取http響應body。 對這個函數的調用本身開銷是比較大的, 所以我們儘量儘量避免在不必要的時候反覆調用。
    在這裏插入圖片描述
  3. 最後一行稍微隱藏了一些細節。 ReadAll會返回兩個結果, 一個[]byte數組和一個錯誤, 不過這兩個對象可以被賦值給httpGetBody的返回聲明裏的interface{}和error類型, 所以我們也就可以這樣返回結果並且不需要額外的工作了。 我們在httpGetBody中選用這種返回類型是爲了使其可以與緩存匹配。
  4. 下面是我們要設計的cache的第一個“草稿”:
  5. gopl.io/ch9/memo1
    在這裏插入圖片描述
  6. Memo實例會記錄需要緩存的函數f(類型爲Func), 以及緩存內容(裏面是一個string到result映射的map)。 每一個result都是都是簡單的函數返回的值對兒–一個值和一個錯誤值。 繼續下去我們會展示一些Memo的變種, 不過所有的例子都會遵循這些上面的這些方面。
  7. 下面是一個使用Memo的例子。 對於流入的URL的每一個元素我們都會調用Get, 並打印調用延時以及其返回的數據大小的log:
    在這裏插入圖片描述
  8. 我們可以使用測試包(第11章的主題)來系統地鑑定緩存的效果。 從下面的測試輸出, 我們可以看到URL流包含了一些重複的情況, 儘管我們第一次對每一個URL的(*Memo).Get的調用都會花上幾百毫秒, 但第二次就只需要花1毫秒就可以返回完整的數據了。
    在這裏插入圖片描述
  9. 這個測試是順序地去做所有的調用的。
  10. 由於這種彼此獨立的HTTP請求可以很好地併發, 我們可以把這個測試改成併發形式。 可以使用sync.WaitGroup來等待所有的請求都完成之後再返回。
    在這裏插入圖片描述
  11. 這次測試跑起來更快了, 然而不幸的是貌似這個測試不是每次都能夠正常工作。 我們注意到有一些意料之外的cache miss(緩存未命中), 或者命中了緩存但卻返回了錯誤的值, 或者甚至會直接崩潰。
  12. 但更糟糕的是, 有時候這個程序還是能正確的運行(譯: 也就是最讓人崩潰的偶發bug), 所以我們甚至可能都不會意識到這個程序有bug。 。 但是我們可以使用-race這個flag來運行程序,競爭檢測器(§9.6)會打印像下面這樣的報告:
    在這裏插入圖片描述
  13. memo.go的32行出現了兩次, 說明有兩個goroutine在沒有同步干預的情況下更新了cachemap。 這表明Get不是併發安全的, 存在數據競爭。
    在這裏插入圖片描述
  14. 最簡單的使cache併發安全的方式是使用基於監控的同步。 只要給Memo加上一個mutex, 在Get的一開始獲取互斥鎖, return的時候釋放鎖, 就可以讓cache的操作發生在臨界區內了:
  15. opl.io/ch9/memo2
    在這裏插入圖片描述
  16. 測試依然併發進行, 但這回競爭檢查器“沉默”了。 不幸的是對於Memo的這一點改變使我們完全喪失了併發的性能優點。 每次對f的調用期間都會持有鎖, Get將本來可以並行運行的I/O操作串行化了。 我們本章的目的是完成一個無鎖緩存, 而不是現在這樣的將所有請求串行化的函數的緩存。
  17. 下一個Get的實現, 調用Get的goroutine會兩次獲取鎖: 查找階段獲取一次, 如果查找沒有返回任何內容, 那麼進入更新階段會再次獲取。 在這兩次獲取鎖的中間階段, 其它goroutine可以隨意使用cache。
  18. gopl.io/ch9/memo3
    在這裏插入圖片描述
  19. 這些修改使性能再次得到了提升, 但有一些URL被獲取了兩次。 這種情況在兩個以上的goroutine同一時刻調用Get來請求同樣的URL時會發生。 多個goroutine一起查詢cache, 發現沒有值, 然後一起調用f這個慢不拉嘰的函數。 在得到結果後, 也都會去去更新map。 其中一個獲得的結果會覆蓋掉另一個的結果。
  20. 理想情況下是應該避免掉多餘的工作的。 而這種“避免”工作一般被稱爲duplicatesuppression(重複抑制/避免)。 下面版本的Memo每一個map元素都是指向一個條目的指針。每一個條目包含對函數f調用結果的內容緩存。 與之前不同的是這次entry還包含了一個叫ready的channel。 在條目的結果被設置之後, 這個channel就會被關閉, 以向其它goroutine廣播(§8.9)去讀取該條目內的結果是安全的了。
  21. gopl.io/ch9/memo4
    在這裏插入圖片描述
  22. 現在Get函數包括下面這些步驟了: 獲取互斥鎖來保護共享變量cache map, 查詢map中是否存在指定條目, 如果沒有找到那麼分配空間插入一個新條目, 釋放互斥鎖。 如果存在條目的話且其值沒有寫入完成(也就是有其它的goroutine在調用f這個慢函數)時, goroutine必須等待值ready之後才能讀到條目的結果。 而想知道是否ready的話, 可以直接從ready channel中讀取, 由於這個讀取操作在channel關閉之前一直是阻塞。
  23. 如果沒有條目的話, 需要向map中插入一個沒有ready的條目, 當前正在調用的goroutine就需要負責調用慢函數、 更新條目以及向其它所有goroutine廣播條目已經ready可讀的消息了。
  24. 條目中的e.res.value和e.res.err變量是在多個goroutine之間共享的。 創建條目的goroutine同時也會設置條目的值, 其它goroutine在收到"ready"的廣播消息之後立刻會去讀取條目的值。儘管會被多個goroutine同時訪問, 但卻並不需要互斥鎖。 ready channel的關閉一定會發生在其它goroutine接收到廣播事件之前, 因此第一個goroutine對這些變量的寫操作是一定發生在這些讀操作之前的。 不會發生數據競爭。
  25. 這樣併發、 不重複、 無阻塞的cache就完成了。
  26. 上面這樣Memo的實現使用了一個互斥量來保護多個goroutine調用Get時的共享map變量。 不妨把這種設計和前面提到的把map變量限制在一個單獨的monitor goroutine的方案做一些對比, 後者在調用Get時需要發消息。
  27. Func、 result和entry的聲明和之前保持一致:
    28.
  28. 然而Memo類型現在包含了一個叫做requests的channel, Get的調用方用這個channel來和monitor goroutine來通信。 requests channel中的元素類型是request。 Get的調用方會把這個結構中的兩組key都填充好, 實際上用這兩個變量來對函數進行緩存的。 另一個叫response的channel會被拿來發送響應結果。 這個channel只會傳回一個單獨的值。
  29. gopl.io/ch9/memo5
    在這裏插入圖片描述
  30. 上面的Get方法, 會創建一個response channel, 把它放進request結構中, 然後發送給monitor goroutine, 然後馬上又會接收到它。
  31. cache變量被限制在了monitor goroutine (*Memo).server中, 下面會看到。 monitor會在循環中一直讀取請求, 直到request channel被Close方法關閉。 每一個請求都會去查詢cache, 如果沒有找到條目的話, 那麼就會創建/插入一個新的條目。
    在這裏插入圖片描述
  32. 和基於互斥量的版本類似, 第一個對某個key的請求需要負責去調用函數f並傳入這個key, 將結果存在條目裏, 並關閉ready channel來廣播條目的ready消息。 使用(*entry).call來完成上述工作。
  33. 緊接着對同一個key的請求會發現map中已經有了存在的條目, 然後會等待結果變爲ready,並將結果從response發送給客戶端的goroutien。 上述工作是用(*entry).deliver來完成的。 對call和deliver方法的調用必須在自己的goroutine中進行以確保monitor goroutines不會因此而被阻塞住而沒法處理新的請求。
  34. 這個例子說明我們無論可以用上鎖, 還是通信來建立併發程序都是可行的。上面的兩種方案並不好說特定情境下哪種更好, 不過了解他們還是有價值的。 有時候從一種方式切換到另一種可以使你的代碼更爲簡潔。 (譯註: 不是說好的golang推崇通信併發麼)

8. Goroutines和線程

  1. 在上一章中我們說goroutine和操作系統的線程區別可以先忽略。 儘管兩者的區別實際上只是一個量的區別, 但量變會引起質變的道理同樣適用於goroutine和線程。 現在正是我們來區分開兩者的最佳時機。

1. 動態棧

  1. 每一個OS線程都有一個固定大小的內存塊(一般會是2MB)來做棧, 這個棧會用來存儲當前正在被調用或掛起(指在調用其它函數時)的函數的內部變量。 這個固定大小的棧同時很大又很小。 因爲2MB的棧對於一個小小的goroutine來說是很大的內存浪費, 比如對於我們用到的,一個只是用來WaitGroup之後關閉channel的goroutine來說。 而對於go程序來說, 同時創建成百上千個gorutine是非常普遍的, 如果每一個goroutine都需要這麼大的棧的話, 那這麼多的goroutine就不太可能了。 除去大小的問題之外, 固定大小的棧對於更復雜或者更深層次的遞歸函數調用來說顯然是不夠的。 修改固定的大小可以提升空間的利用率允許創建更多的線程, 並且可以允許更深的遞歸調用, 不過這兩者是沒法同時兼備的。
  2. 相反, 一個goroutine會以一個很小的棧開始其生命週期, 一般只需要2KB。 一個goroutine的棧, 和操作系統線程一樣, 會保存其活躍或掛起的函數調用的本地變量, 但是和OS線程不太一樣的是一個goroutine的棧大小並不是固定的; 棧的大小會根據需要動態地伸縮。 而goroutine的棧的最大值有1GB, 比傳統的固定大小的線程棧要大得多, 儘管一般情況下, 大多goroutine都不需要這麼大的棧。

2. Goroutine調度

  1. OS線程會被操作系統內核調度。 每幾毫秒, 一個硬件計時器會中斷處理器, 這會調用一個叫作scheduler的內核函數。 這個函數會掛起當前執行的線程並保存內存中它的寄存器內容, 檢查線程列表並決定下一次哪個線程可以被運行, 並從內存中恢復該線程的寄存器信息, 然後恢復執行該線程的現場並開始執行線程。 因爲操作系統線程是被內核所調度, 所以從一個線程向另一個“移動”需要完整的上下文切換, 也就是說, 保存一個用戶線程的狀態到內存, 恢復另一個線程的到寄存器, 然後更新調度器的數據結構。 這幾步操作很慢, 因爲其局部性很差需要幾次內存訪問, 並且會增加運行的cpu週期。
  2. Go的運行時包含了其自己的調度器, 這個調度器使用了一些技術手段, 比如m:n調度, 因爲其會在n個操作系統線程上多工(調度)m個goroutine。 Go調度器的工作和內核的調度是相似的, 但是這個調度器只關注單獨的Go程序中的goroutine(譯註: 按程序獨立)。
  3. 和操作系統的線程調度不同的是, Go調度器並不是用一個硬件定時器而是被Go語言"建築"本身進行調度的。 例如當一個goroutine調用了time.Sleep或者被channel調用或者mutex操作阻塞時, 調度器會使其進入休眠並開始執行另一個goroutine直到時機到了再去喚醒第一個goroutine。 因爲因爲這種調度方式不需要進入內核的上下文, 所以重新調度一個goroutine比調度一個線程代價要低得多。

3. GOMAXPROCS

  1. Go的調度器使用了一個叫做GOMAXPROCS的變量來決定會有多少個操作系統的線程同時執行Go的代碼。 其默認的值是運行機器上的CPU的核心數, 所以在一個有8個核心的機器上時, 調度器一次會在8個OS線程上去調度GO代碼。 (GOMAXPROCS是前面說的m:n調度中的n)。 在休眠中的或者在通信中被阻塞的goroutine是不需要一個對應的線程來做調度的。 在I/O中或系統調用中或調用非Go語言函數時, 是需要一個對應的操作系統線程的, 但是GOMAXPROCS並不需要將這幾種情況計數在內。
  2. 你可以用GOMAXPROCS的環境變量呂顯式地控制這個參數, 或者也可以在運行時用runtime.GOMAXPROCS函數來修改它。 我們在下面的小程序中會看到GOMAXPROCS的效果, 這個程序會無限打印0和1。
    在這裏插入圖片描述
  3. 在第一次執行時, 最多同時只能有一個goroutine被執行。 初始情況下只有main goroutine被執行, 所以會打印很多1。 過了一段時間後, GO調度器會將其置爲休眠, 並喚醒另一個goroutine, 這時候就開始打印很多0了, 在打印的時候, goroutine是被調度到操作系統線程上的。 在第二次執行時, 我們使用了兩個操作系統線程, 所以兩個goroutine可以一起被執行,以同樣的頻率交替打印0和1。 我們必須強調的是goroutine的調度是受很多因子影響的, 而runtime也是在不斷地發展演進的, 所以這裏的你實際得到的結果可能會因爲版本的不同而與我們運行的結果有所不同。

4. Goroutine沒有ID號

  1. 在大多數支持多線程的操作系統和程序語言中, 當前的線程都有一個獨特的身份(id), 並且這個身份信息可以以一個普通值的形式被被很容易地獲取到, 典型的可以是一個integer或者指針值。 這種情況下我們做一個抽象化的thread-local storage(線程本地存儲, 多線程編程中不希望其它線程訪問的內容)就很容易, 只需要以線程的id作爲key的一個map就可以解決問題,每一個線程以其id就能從中獲取到值, 且和其它線程互不衝突。
  2. goroutine沒有可以被程序員獲取到的身份(id)的概念。 這一點是設計上故意而爲之, 由於thread-local storage總是會被濫用。 比如說, 一個web server是用一種支持tls的語言實現的,而非常普遍的是很多函數會去尋找HTTP請求的信息, 這代表它們就是去其存儲層(這個存儲層有可能是tls)查找的。 這就像是那些過分依賴全局變量的程序一樣, 會導致一種非健康的“距離外行爲”, 在這種行爲下, 一個函數的行爲可能不是由其自己內部的變量所決定, 而是由其所運行在的線程所決定。 因此, 如果線程本身的身份會改變——比如一些worker線程之類的——那麼函數的行爲就會變得神祕莫測。
  3. Go鼓勵更爲簡單的模式, 這種模式下參數對函數的影響都是顯式的。 這樣不僅使程序變得更易讀, 而且會讓我們自由地向一些給定的函數分配子任務時不用擔心其身份信息影響行爲。你現在應該已經明白了寫一個Go程序所需要的所有語言特性信息。 在後面兩章節中, 我們會回顧一些之前的實例和工具, 支持我們寫出更大規模的程序: 如何將一個工程組織成一系列的包, 如果獲取, 構建, 測試, 性能測試, 剖析, 寫文檔, 並且將這些包分享出去。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章