第十三章 并發編程
"對象是過程的抽象。線程是調度的抽象。"
--James O Coplien
13.1 為什么要并發?
????????并發是一種解耦策略。它幫助我們把做什么(目的)和何時(時機)做分解開。在單線
程應用中,目的與時機緊密耦合,很多時候只要查看堆棧追路即可斷定應用程序的狀態。
????????解耦目的與時機能明顯地改進應用程序的吞吐量和結構。從結構的角度來看,應用程序看起來更像是許多臺協同工作的計算機,而不是一個大循環。系統因此會更易于被理解,給出了許多切分關注面的有力手段。
迷思與誤解
- 并發總能改進性能
- 編寫并發程序無需修改設計
- 在采用Web或EJB容器的時候,理解并發問題并不重要
中肯說法
- 并發會在性能和編寫額外代碼上增加一些開銷;
- 正確的并發是復雜的,即便對于簡單的問題也是如此;
- 并發缺陷并非總能重現,所以常被看做偶發事件而忽略,未被當做真正的缺陷看待;
- 并發常常需要對設計策略的根本性修改。
13.2 挑戰
public class X{private int lastIdUsed;public int getNextId(){return ++lastIdUsed;}
}
比如,創建x的一個實體,將lastIdUsed設置為42,在兩個線程中共享這個實體。假設這兩個線程都調用getNextId()方法,結果可能有三種輸出:
- 線程一得到值43,線程二得到值44,lastIdUsed為44;
- 線程一得到值44,線程二得到值43,lastIdUsed為44;
- 線程一得到值43,線程二得到值43,lastIdUsed為43。
????????就生成的字節碼而言,對于在getNextId方法中執行的那兩個線程,有12870種不同的可能執行路徑。如果lastIdUsed的類型從int變為long,則可能路徑的數量將增至2704156種。當然,多數路徑都得到正確結果。問題是其中一些不能得到正確結果。
13.3 并發防御原則
13.3.1 單一權責原則
問題:
- 并發相關代碼有自己的開發、修改和調優生命周期;
- 開發相關代碼有自己要對付的挑戰,和非并發相關代碼不同,而且往往更為困難;
- 即便沒有周邊應用程序增加的負擔,寫得不好的并發代碼可能的出錯方式數量也已經足具挑戰性。
建議:分離并發相關代碼與其他代碼。
13.3.2 推論:限制數據作用域
????????兩個線程修改共享對象的同一字段時,可能互相干擾,導致未預期的行為。解決方案之一是采用synchronized關鍵字在代碼中保護一塊使用共享對象的臨界區(criticalsection)。
可能出現的問題:
- 你會忘記保護一個或多個臨界區——破壞了修改共享數據的代碼碼;
- 得多花力氣保證一切都受到有效防護(破壞了DRY原則);
- 很難找到錯誤源,也很難判斷錯誤源。
建議:謹記數據封裝;嚴格限制對可能被共享的數據的訪問。
13.3.3 推論:使用數據復本
????????避免共享數據的好方法之一就是一開始就避免共享數據。在某些情形下,有可能復制對象并以只讀方式對待。在另外的情況下,有可能復制對象,從多個個線程收集所有復本的結果,并在單個線程中合并這些結果。
13.3.4 推論:線程應盡可能地獨立
????????讓每個線程在自己的世界中存在,不與其他線程共享數據。每個線程處理一個客戶端請求,從不共享的源頭接納所有請求數據,存儲為本地變量。這樣一來,每個線程都像是世界中的唯一線程,沒有同步需要。
建議:嘗試將數據分解到可被獨立線程(可能在不同處理器上)操作的獨立子集。
13.4 了解Java庫
- 使用類庫提供的線程安全群集;
- 使用executor框架(executorframework)執行無關任務;
- 盡可能使用非鎖定解決方案;
- 有幾個類并不是線程安全的。
13.5 了解執行模型
13.5.1 生產者-消費者模型
????????生產者和消費者之間的隊列是一種限定資源。
13.5.2 讀者-作者模型
????????當存在一個主要為讀者線程提供信息源,但只偶爾被作者線程更更新的共享資源,吞吐量就會是個問題。增加吞吐量,會導致線程饑餓和過時信息的累積。更新會影響吞吐量。
????????挑戰之處在于平衡讀者線程和作者線程的需求,實現正確操作,提供合理的吞吐量,避免線程饑餓。
13.5.3 宴席哲學家
????????如果沒有用心設計,這種競爭式系統就會遭遇死鎖、活鎖、吞吐量和效率降低等問題。
可能遇到的并發問題,大多數都是這三個問題的變種。
建議:學習這些基礎算法,理解其解決方案。
13.6 警惕同步方法之間的依賴
????????建議:避免使用一個共享對象的多個方法。
必須使用一個共享對象的多個方法的3種手段:
- 基于客戶端的鎖定——客戶端代碼在調用第一個方法前鎖定服務端,確保鎖的范圍覆蓋了調用最后一個方法的代碼;
- 基于服務端的鎖定——在服務端內創建鎖定服務端的方法,調用所有方法,然后解鎖。讓客戶端代碼調用新方法;
- 適配服務端——創建執行鎖定的中間層。這是一種基于服務端的的鎖定的例子,但不修改原始服務端代碼。
13.7 保持同步區域微小
????????關鍵字synchronized制造了鎖。鎖是昂貴的,因為它們帶來了延遲和額外開銷。
????????另一方面,臨界區應該被保護起來。所以,應該盡可能少地設計臨界區。
????????將同步延展到最小臨界區范圍之外,會增加資源爭用、降低執行效率。
13.8 很難編寫正確的關閉代碼
????????平靜關閉很難做到。常見問題與死鎖有關,線程一直等待永遠不會到來的信號。
????????建議:盡早考慮關閉問題,盡早令其工作正常。這會花費比你預期更多的時間。檢視既有算法,因為這可能會比想象中難得多。
13.9 測試線程代碼
????????建議:編寫有潛力曝露問題的測試,在不同的編程配置、系統配置和負載條件下頻繁運行。如果測試失敗,跟蹤錯誤。別因為后來測試通過了后來的運行就忽略失敗。
- 將偽失敗看作可能的線程問題?=> 不要將系統錯誤歸咎于偶發事件
- 先使非線程代碼可工作?=> 不要同時追蹤非線程缺陷和線程缺陷。
- 編寫可插拔的線程代碼
- 編寫可調整的線程代碼
- 運行多于處理器數量的線程
- 在不同平臺上運行
- 調整代碼并強迫錯誤發生。
13.10 小結
????????第一要訣是遵循單一權責原則。
????????了解并發問題的可能原因。
????????學習類庫,了解基本算法。
????????學習如何找到必須鎖定的代碼區域并鎖定之。不要鎖定不必針鎖定的代碼。
????????要能在不同平臺上、以不同配置持續重復運行線程代碼。
???????如果花點時間裝置代碼,就能極大地提升發現錯誤代碼的機會。