在編寫并發代碼時,超時和取消會頻繁出現。我們將在本節中看到,超時對于創建一個可以健壯易讀的程序至關重要。取消是對超時的回應。我們還將探討在并發進程中引發取消的其他原因。
那么,我們為什么需要并發程序支持超時呢?
***系統飽和***
正如我們在“隊列”部分所討論的那樣,如果系統已經達到最大負荷(即,它的處理請求的能力達到了極限),我們可能希望系統的請求超時而不是花很長時間等待。你選擇哪條路線取決于你的實際業務,但這里有一些關于何時觸發超時的一般性指導:
* 如果請求在超時情況下不太可能重復發送。
* 如果沒有資源來存儲請求(例如,臨時隊列的內存,持久隊列的磁盤空間)。
* ?如果請求或其發送的數據的過期(我們將在下面討論)。 如果一個請求可能會重復發生,那么系統將會接受并超時請求。 如果開銷超過我們系統的容量,這可能導致死亡螺旋。 但是,如果我們缺乏將請求存儲在隊列中所需的系統資源,這是一個有爭議的問題。 即使我們符合這兩條準則,將該請求加入隊列也沒有什么意義,只要我們可以處理請求,請求就會過期。 這給我們帶來了超時的下一個理由。
***數據過期***
有時數據有一個窗口,在這個窗口中必須優先處理部分相關數據,或者處理數據的需求已過期。如果一個并發進程比這個窗口花費更長的時間來處理數據,我們希望超時并取消該進程。例如,如果某個并發進程在長時間等待后發起請求,則在隊列中請求或其數據可能已過期。
如果這個窗口已經事先知曉,那么將context.WithDeadline或context.WithTimeout創建的context.Context傳遞給我們的并發進程是有意義的。 如果不是,我們希望并發進程的父節點能夠在需求不再需要時取消并發進程。 context.WithCancel完美適用于此目的。
***防止死鎖***
在大型系統中,尤其是分布式系統中,有時難以理解數據流動的方式或系統邊界可能出現的情況。這并非毫無道理,甚至有人建議將超時放置在所有并發操作上,以確保系統不會發生死鎖。 超時時間不一定要接近執行并發操作所需的實際時間。 設置超時時間的目的僅僅是為了防止死鎖,所以它只需要足夠短以滿足死鎖系統會在合理的時間內解鎖即可。
我們在“死鎖,活鎖和鎖的饑餓問題”章節中提到過,通過設置超時來避免死鎖可能會將問題從死鎖變為活鎖。在大型系統中,由于存在更多的移動部件,因此與死鎖相比,系統遇到不同的時序配置文件的可能性更大。因此,最好有機會鎖定并修復進程,而非直接讓系統死鎖最終不得不重啟。
請注意,這不是關于如何正確構建系統的建議。而是建議你考思考在開發和測試期間的時間、時序問題。我建議你使用超時,但是目標應該集中在一個沒有死鎖的系統上,在這種系統中,超時基本不會觸發。
現在我們了解了何時使用超時,讓我們將注意力轉向取消,以及如何構建并發進程以優雅地處理取消。并發進程可能被取消的原因有很多:
***超時***
超時是隱式的取消操作。
***用戶干預***
為了獲得良好的用戶體驗,通常建議如果啟動長時間運行的進程時,向服務器做輪詢將狀態報告給用戶,或允許用戶查看他們的狀態。當面向用戶的并發操作時,有時需要允許用戶取消他們已經開始的操作。
***父節點取消***
如果作為子節點的任何父節點停止,我們應當執行取消。
***重復請求***
我們可能希望將數據發送到多個并發進程,以嘗試從其中一個進程獲得更快的響應。 當收到響應時,需要取消其余的處理。 我們將在“重復請求”一節中詳細討論。
此外,也可能有其他的原因。然而,“為什么”這個問題并不像“如何”這樣的問題那么困難或有趣。在第4章中,我們探討了兩種取消并發進程的方法:使用done通道和context.Context類型。 但這里我們要探索更復雜的問題:當一個并發進程被取消時,這對正在執行的算法及其下游消費者意味著什么?在編寫可隨時終止的并發代碼時,需要考慮哪些事項?
為了回答這些問題,我們需要探索的第一件事是并發進程的可搶占性。下面是一個簡單的例子:
```
var value interface{}
select {
case <-done:
return
case value = <-valueStream:
}
result := reallyLongCalculation(value)
select {
case <-done:
return
case resultStream <- result:
}
```
我們已經將valueStream的讀取和resultStream的寫入耦合起來,并檢查done通道,看看goroutine是否已被取消,但這里存在問題。reallyLongCalculation看起來并不會執行搶占操作,而且根據名字,它看起來可能需要很長時間。這意味著,如果在reallyLongCalculation正在執行時某些事件試圖取消這個goroutine,則可能需要很長時間才能確認取消并停止。讓我們試著讓reallyLongCalculation搶占進程,看看會發生什么:
```
reallyLongCalculation := func(done <-chan interface{}, value interface{}) interface{} {
intermediateResult := longCalculation(value)
select {
case <-done:
return nil
default:
}
return longCaluclation(intermediateResult)
}
```
我們已經取得了一些進展:reallyLongCalculation現在可以搶占進程。但問題依然存在:我們只能對調用該函數的地方進行搶占。為了解決這個問題,我們需要繼續調整
```
reallyLongCalculation := func(done <-chan interface{}, value interface{}) interface{} {
intermediateResult := longCalculation(done, value)
return longCaluclation(done, intermediateResult)
}
```
如果將這一推理結果歸納一下,我們會看到當前必須做兩件事:定義并發進程可搶占的時間段,并確保任何花費比此時間段更多時間的函數本身是可搶占的。一個簡單的方法就是將你的goroutine分解成更小的部分。 你應該瞄準所有不可搶占的原子操作,以便在更短的時間內完成。
這里還存在另一個問題:如果goroutine恰好修改共享狀態(例如,數據庫,文件,內存數據結構),那么當goroutine被取消時會發生什么?goroutine是否嘗試回滾? 這項工作需要多長時間?既然goroutine已經開始運行,它應該停下來,所以這個時間不應該花太長的時間來執行回滾,對吧?
如何處理這個問題很難給出一般性的建議,因為算法的性質決定了你如何處理這種情況; 如果在較小的范圍內保留對任何共享狀態的修改,無論是否需要確保這些修改回滾,通常都可以很好地處理。如果可能的話,在內存中建立臨時存儲,然后盡可能快地修改狀態。作為一個例子,這是錯誤的做法:
```
result := add(1, 2, 3)
writeTallyToState(result)
result = add(result, 4, 5, 6)
writeTallyToState(result)
result = add(result, 7, 8, 9)
writeTallyToState(result)
```
我們在這里向state寫如三次。如果運行此代碼的goroutine在最終寫入之前被取消,我們需要以某種方式回滾之前的寫入。對比這種方法:
```
result := add(1, 2, 3, 4, 5, 6, 7, 8, 9)
writeTallyToState(result)
```
這里需要擔心的回滾范圍要小得多。 如果在我們調用writeToState之后取消,仍然需要一種方法來退出更改,但發生這種情況的可能性會很小,因為我們只修改一次狀態。
你需要關心的另一個問題是消息重復。假設你的管道有三個階段:generator階段,A階段和B階段。generator階段監控A階段,跟蹤自上次從其通道讀取以來的時間長度,如果當前實例不正常,就創建一個新實例 A2。如果發生這種情況,階段B可能會收到重復的消息(圖5-1)。
:-: 
可以在此看到,如果在階段A已經將階段B的結果發送到階段B后取消消息進入,則階段B可能會收到重復的消息。
有幾種方法可以避免這樣的情況發生。最簡單的方法(以及我推薦的方法)是,在子例程已經報告結果后,父例程不再發送取消信號。這需要各個階段之間的雙向通信,我們將在“心跳”一節中詳細介紹。其他方法是:
***接受返回的第一個或最后一個結果***
如果你的算法允許,或者你的并發進程是冪等的,那么可以簡單地在下游進程中允許重復消息,并選擇是否接受你收到的第一條或最后一條消息。
***檢查goroutine許可***
可以使用與父節點的雙向通信來明確請求發送消息的權限。這種方法類似于心跳。它看起來像這樣。
:-: 
因為我們明確要求許可執行寫入B的通道,所以這是比心跳更安全的方法。然而在實踐中很少這樣做,因為它比心跳更復雜,所以我建議你只是使用心跳。
在設計并發進程時,一定要考慮超時和取消。像軟件工程中的許多其他技術問題一樣,如果在項目初期忽略超時和取消,然后嘗試將它們放在項目后期加入,有點像試圖在蛋糕烘烤后再將蛋添加到蛋糕中。
* * * * *
學識淺薄,錯誤在所難免。我是長風,歡迎來Golang中國的群(211938256)就本書提出修改意見。
- 前序
- 誰適合讀這本書
- 章節導讀
- 在線資源
- 第一章 并發編程介紹
- 摩爾定律,可伸縮網絡和我們所處的困境
- 為什么并發編程如此困難
- 數據競爭
- 原子性
- 內存訪問同步
- 死鎖,活鎖和鎖的饑餓問題
- 死鎖
- 活鎖
- 饑餓
- 并發安全性
- 優雅的面對復雜性
- 第二章 代碼建模:序列化交互處理
- 并發與并行
- 什么是CSP
- CSP在Go中的衍生物
- Go的并發哲學
- 第三章 Go的并發構建模塊
- Goroutines
- sync包
- WaitGroup
- Mutex和RWMutex
- Cond
- Once
- Pool
- Channels
- select語句
- GOMAXPROCS
- 結論
- 第四章 Go的并發編程范式
- 訪問范圍約束
- fo-select循環
- 防止Goroutine泄漏
- or-channel
- 錯誤處理
- 管道
- 構建管道的最佳實踐
- 便利的生成器
- 扇入扇出
- or-done-channel
- tee-channel
- bridge-channel
- 隊列
- context包
- 小結
- 第五章 可伸縮并發設計
- 錯誤傳遞
- 超時和取消
- 心跳
- 請求并發復制處理
- 速率限制
- Goroutines異常行為修復
- 本章小結
- 第六章 Goroutines和Go運行時
- 任務調度