讀古今文學網 > Java程序員修煉之道 > 4.1 並發理論簡介 >

4.1 並發理論簡介

為了理解在Java中編寫並發程序的方法,我們來聊聊相關理論。先討論一下Java線程模型的基礎知識。

之後,我們會討論系統設計和實現中「設計原則」的影響以及其中最主要的兩個原則:安全性和活躍度。我們還會提到其他一些原則,然後討論這些原則經常相互衝突的原因,以及並發系統中為什麼會有開銷。

本節的最後我們會看一個多線程系統的例子,並向你證明java.util.concurrent是多麼自然的編碼方法。

4.1.1 解釋Java線程模型

Java線程模型建立在兩個基本概念之上:

  • 共享的、默認可見的可變狀態
  • 搶佔式線程調度

我們從幾個側面思考一下這兩個概念。

  • 所有線程可以很容易地共享同一進程中的對象。

  • 能夠引用這些對象的任何線程都可以修改這些對象。

  • 線程調度程序差不多任何時候都能在核心上調入或調出線程。

  • 必須能調出運行時的方法,否則無限循環的方法會一直佔用CPU。

    然而這種不可預料的線程調度可能會導致方法「半途而廢」,並出現狀態不一致的對象。某一線程對數據做出修改時,會讓其他線程無法見到本應可見的修改。為了緩解這些風險,Java提出了最後一點要求。

  • 為了保護脆弱的數據,對象可以被鎖住。

Java基於線程和鎖的並發非常底層,並且一般都比較難用。為了解決這個問題,Java 5引入了一組並發類庫java.util.concurrent。這個包中提供了一套編寫並發代碼的工具,很多程序員都覺得它要比傳統的塊結構並發原語易用。

經驗教訓

Java是第一個內置多線程編碼支持的主流編程語言。這在當時可以說是一個巨大的進步,但15年之後的今天,我們對於如何編寫並發代碼已經是瞭若指掌了。

事實證明,Java最初的一些設計決策給大多數程序員編寫多線程代碼帶來了很多困難。這的確很糟糕,因為硬件一直朝著多核處理器方向發展,而唯一能利用好這些核心的就是並發代碼。本章會討論一些在編寫並發代碼時所遇到的困難。現代處理器對並發編程有著合理的需求,我們在第6章討論性能時還會涉及其中的一些細節。

隨著開發人員編寫並發代碼的經驗越來越豐富,他們發現自己所關注的一些重要系統問題一再出現。我們把這些關注點稱為「設計原則」——存在於並發OO系統實際設計中的指導性原則(並經常相互衝突)。

在後面幾節,我們會花點時間瞭解一下其中最重要的幾個原則。

4.1.2 設計理念

Doug Lea在創造他那里程碑式的作品java.util.concurrent時列出了下面這些最重要的設計原則:

  • 安全性(也叫做並發類型安全性)
  • 活躍度
  • 性能
  • 重用性

下面我們來逐一解讀。

1.安全性與並發類型安全性

安全性是指不管同時發生多少操作都能確保對像保持自相一致。如果一個對像系統具備這一特性,那它就是並發類型安全的。

可能你從它的名字就猜出來了,並發可以看做是常規對像建模和類型安全概念的一種延伸。在非並發代碼中,要確保不管調用了對象中的什麼公開方法,對像最後總是處於一個定義良好並且一致的狀態下。通常用來達成這一點的做法是保證對像所有狀態都私有,並且開放出來的公開API方法只能以自相一致的方式修改對像狀態。

並發類型安全的概念跟對像類型安全一樣,但它用在更複雜的環境下。在這樣的環境中,其他線程在不同CPU內核上同時操作同一對象。

保證安全

保證安全的策略之一是在處於非一致狀態時絕不能從非私有方法中返回,也絕不能調用任何非私有方法,而且也絕不能調用其他任何對像中的方法。如果把這個策略跟某種對非一致對象的保護辦法(比如同步鎖或臨界區)結合起來,就可以保證系統是安全的。

2.活躍度

在一個活躍的系統中,所有做出嘗試的活動最終或者取得進展,或者失敗。

這個定義中的關鍵詞是「最終」——運行中的瞬時故障(儘管不理想,但單獨來看這不是問題)和永久故障是不同的。下面這幾種底層問題可能會導致系統出現瞬時故障:

  • 處於鎖定狀態或者在等待得到線程鎖
  • 等待輸入(比如網絡I/O)
  • 資源的暫時故障
  • CPU沒有足夠的空閒時間運行該線程

導致系統出現永久故障的原因較多,其中最常見的是:

  • 死鎖
  • 不可恢復的資源問題(比如NFS不可訪問)
  • 信號丟失

儘管你對它們可能都已經很熟悉了,但本章後續還是會討論一下鎖定和其他幾個問題。

3.性能

系統性能可以通過幾種不同的方式量化。我們會在第6章討論性能分析和優化技術,並且會介紹一些你應該瞭解的指標。現在,你可以把性能看成是測量系統用給定資源能做多少工作的辦法。

4.可重用性

可重用性是第四個設計原則,其他它原則中並沒涉及這一點。儘管有時不容易實現,但我們還是非常希望能設計出易於重用的並發系統。用可重用工具集(比如java.util.concurrent),並把不可重用的應用代碼構建在工具集之上是一種可行的辦法。

4.1.3 這些原則如何以及為何會相互衝突

設計原則經常相互對立,這種緊張關係使得並發系統的設計很難達到優秀的水準。

  • 安全性與活躍度相互對立——安全性是為了確保壞事不會發生,而活躍度要求見到進展。
  • 可重用的系統傾向於對外開放其內核,可這會引發安全問題。
  • 一個安全但編寫方式幼稚的系統性能通常都不會太好,因為裡面一般會用大量的鎖來保證安全性。

最終應該盡量讓代碼達到一種平衡的狀態,使其能夠靈活地適用於各種問題,卻又能保證安全性,同時活躍度和性能也可以達到一定水平。這種境界相當高,但你很幸運,我們馬上教你一些實戰技巧。下面是幾個最常見的粗淺辦法。

  • 盡可能限制子系統之間的通信。隱藏數據對安全性非常有幫助。

  • 盡可能保證子系統內部結構的確定性。比如說,即便子系統會以並發的、非確定性的方式進行交互,子系統內部的設計也應該參照線程和對象的靜態知識。

  • 採用客戶端應用必須遵守的策略方針。這個技巧雖然強大,卻依賴於用戶應用程序的合作程度,並且如果某個糟糕的應用不遵守規則,便很難發現問題所在。

  • 在文檔中記錄所要求的行為。這是最遜的辦法,但如果代碼要部署在非常通用的環境中,就必須採用這個辦法。

開發人員應該瞭解所有可能的安全機制,而且盡可能採用最強的技術,但同時你也應該知道,在某些情況下只能採用那些比較遜的辦法。

4.1.4 系統開銷之源

並發系統中的系統開銷是與生俱來的,這些開銷來自:

  • 鎖與監測
  • 環境切換的次數
  • 線程的個數
  • 調度
  • 內存的局部性1
  • 算法設計

1 局部性指的是程序行為的一種規律:在程序運行中的短時間內,程序訪問數據位置的集合限於局部範圍。局部性有兩種基本形式:時間局部性與空間局部性。時間局部性指的是反覆訪問同一個位置的數據;空間局部性指的是反覆訪問相鄰的數據。——譯者注

你應該以此為基礎在大腦中列一個檢查列表。在編寫並發代碼時,應該確保自己對列表中的每一項都認真考慮過了,然後再來「搞定」代碼。

算法設計

這是一個能讓開發人員脫穎而出的領域。無論用什麼語言,學習算法設計都能讓你成為更好的程序員。在這裡我們向你推薦由Thomas H. Corman等人編著的《算法導論》(MIT,2009)和Steven Skiena寫的《算法設計手冊》(Springer-Verlag,2008)。無論你是想瞭解單線程算法還是想學習並發算法,它們都是值得閱讀的好書。

本章會提到許多系統開銷的源頭(還有第6章討論性能的部分)。

4.1.5 一個事務處理的例子

本節前面的內容都太理論化了,所以我們補充一個並發程序設計的例子來實證一下。在這個例子中你將看到如何用java.util.concurrent中的高層類完成這個任務。

假設有一個基本事務處理系統。構建這種程序有個簡單的標準辦法,就是先將業務流程的不同環節對應到應用程序的不同階段,然後用不同的線程池表示不同的應用階段,每個線程池逐一接受工作項,在對每個工作項進行一系列的處理後,交給下一個線程池。通常來說,好的設計會讓每個線程池所做的處理集中在一個特定功能區內。如圖4-1所示。

圖4-1 多線程應用程序示例

如果你設計成這樣的程序,就可以提高吞吐量,因為可以設計成同時處理幾個工作項。比如在檢查一個工作項的信用情況時,可以檢查另一個工作項的庫存。根據應用程序的處理細節不同,甚至可以同時檢查多個訂單的庫存。

這種設計非常適合用java.util.concurrent包中的類來實現。這個包裡有用於執行任務的線程池(Executors類中有一套工廠方法可以創建它們)和在不同線程池之間傳遞工作的隊列,還有並發數據結構(可以用來構建共享緩存,或用於其他用途)和很多其他底層工具。

你可能會問,在Java 5之前,還沒有這些類時是怎麼辦的?一般情況下,開發小組會自己編寫並發編程類庫,最終會構建出跟java.util.concurrent類似的組件。但這種定制組件大多存在設計缺陷,還會有難以捉摸的並發bug。如果沒有java.util.concurrent,開發人員就得重複實現其中的大部分組件(可能會有很多bug,測試也不充分)。

請記住這個例子,我們要轉入下一主題——溫習一下Java的「傳統」並發,並深入瞭解用它編程困難的原因。