上一篇
1.2.2 開發適合多核架構的 web 應用程式
基於多執行緒的 web 伺服器依賴於多個執行緒池來為傳入的請求分配可用的 CPU 資源,但是這種機制對於開發人員是不可見的,這樣可以讓開發人員在開發時可以將這些多執行緒看作是隻有一個主執行緒在工作。可以說,將處理多執行緒的複雜性隱藏起來,將其抽象成只有一個主執行緒,在一開始可能會顯得比較簡單。確實,像 Servlet API 這樣的程式設計約定給人帶來了一種錯覺:即只有一個主執行緒來響應傳入的 HTTP 請求,並且呼叫了所有的資源都去處理她。但現實情況卻有所不同,這種漏洞百出的抽象形式也給其自身帶來了一系列的問題。
共享可變狀態與非同步程式設計
如果你已經構建了由執行緒伺服器提供的 web 應用程式,那麼你很有可能會發現自己正被一些「副作用」所困擾著,因為共享可變狀態會造成競爭條件進而就產生了副作用。JVM 上的執行緒在併發條件下,並不會彼此隔離:他們可以像其他執行緒一樣去訪問同樣的記憶體空間、開啟檔案控制程式碼以及其他共享資源。對於此行為所導致的問題,這裡有一個經典的例子,在一個 Java servlet 中使用 DateFormat 類的時候:
private static final DateFormat dateFormatter = new SimpleDateFormat();
複製程式碼
上面這行程式碼的問題就是 DateFormat 不是執行緒安全的。當她被兩個執行緒呼叫的時候,她的行為不會因為這是由兩個不同執行緒的呼叫而有所不同。相反,她會使用相同的變數來保持其內部的狀態。這將會導致不可預料的行為以及難以理解的 bug。就算一些有經驗的程式設計師也會花大量的時間去理解「競爭條件」、「死鎖」以及一些奇怪有趣但又令人抓狂的「副作用」,但這並不是說以事件驅動的方式編寫的應用程式不會受共享可變狀態的影響。
在大多數情況下,應用程式開源人員會決定是否去使用可變的資料結構,並且會思考對它們進行何種程度的闡述。但是,像 Play 這樣的框架以及像 Scala 這樣的語言不鼓勵開發人員使用共享的可變狀態。
語言設計與不可變狀態
對於有併發需求的 web 應用,若使用支援「不可變狀態」的語言和工具將會讓開發變得更加容易。
Scala 在設計的時候就是預設使用不可變的值,而不是可變的變數。當然,在 Java 中也能通過不可變的方式去編寫程式,但相比 Scala,Java 需要寫更多的樣板程式碼。例如,在 Scala 中宣告一個不可變的值是這樣的:
val theAnswer = 42
複製程式碼
在 Java 中,通過顯式地加上 final 關鍵字可以達到相同的結果
final int theAnswer = 42
複製程式碼
這雖然看上去並沒有什麼太大的區別,但在開發一個大型應用時,就意味著 final 關鍵字需要多次被使用。當涉及到更復雜的資料結構時,比如列表或者對映,Scala 就提供了這些資料結構的可變和不可變兩個版本。預設情況下,Scala 採用不可變的資料結構:
val a = List(1, 2, 3)
複製程式碼
相反,Java 在其集合庫中卻沒有提供庫不可變的資料結構,你必須使用第三方庫,比如 Google 的 [Guava](https:// github.com/google/guava) 來得到一組有用的不可變資料結構。
關於 Scala
Scala 的主要設計目標之一是使開發人員能夠把握多核程式設計和分散式系統的複雜性。它通過支援不可變的值和資料結構,提供了函式和高階函式,並將函式作為語言的一等公民,同時也使得程式設計風格更加簡潔。因此,本書的例子是用 Scala 而不是 Java 編寫的。(但是,請注意,Play、Akka 和 響應式流都擁有有 Java api)我們將在第3章中再次介紹 Scala 函數語言程式設計的核心概念。
鎖與競爭
為了避免併發訪問非執行緒安全資源導致的副作用,使用鎖來讓其他執行緒知道資源當前處於佔用狀態。如果一切順利,持有該鎖的執行緒將會釋放它,然後通知其他可能正在等待的執行緒,告訴他們現在可以依次訪問該資源了。
但是,在某些情況下,執行緒可能彼此等待對方釋放鎖進而產生死鎖。如果一個執行緒佔用資源的時間太長,那麼其他執行緒就會出現“飢餓”的狀態,當一個依賴鎖的 Web 應用程式的負載激增時,鎖的競爭就會頻繁出現,這樣會導致應用程式的效能下降。
CPU 廠商已經採用的新型多核架構並沒有更好地解決鎖帶來的問題,如果一個 CPU 提供超過 1000 個真正執行的執行緒,但是應用程式卻依賴鎖來同步訪問記憶體中的極少的幾個區域,我們能夠想象這個機制將會會造成多大的效能損失。顯然我們需要一個更適合多執行緒和多核正規化的程式設計模型。
看似複雜的非同步程式設計
在很長一段時間內,編寫非同步程式在開發人員中並不常見,因為它似乎比編寫優秀而經典的同步程式要困難得多。在同步程式中,各個操作是按順序執行的,但是非同步卻不是這樣,當以非同步的方式編寫程式時,某個請求處理過程可能會被拆分為好幾個片段。
編寫非同步程式碼的常用方法之一是使用回撥,因為需要在等待某個操作(比如從一個遠端服務中獲取資料)完成時保證程式不會出現阻塞,所以開發人員需要實現一個回撥方法,一旦資料可用,該方法就會被執行。倡導執行緒程式設計的人可能不太會採用這種方式,因為當處理過程稍微複雜一點時,就可能會出現“回撥地獄”。
var fetchPriceList = function() { // 入口方法,將商品和價格組合
$.get(`/items`, function(items) { // 第一層回撥,處理獲取到的商品列表
var priceList = [];
items.forEach(function(item, itemIndex) { // 第二層回撥,請求每個商品的資訊
$.get(`/prices`, { itemId: item.id }, function(price) { // 第三層回撥,獲取每個商品的價格
priceList.push({ item: item, price: price });
if ( priceList.length == items.length ) {
return priceList;
}
}).fail(function() { // 第四層回撥,當價格沒有被獲取到時的錯誤處理
priceList.push({ item: item });
if ( priceList.length == items.length ) {
return priceList;
}
});
})
}).fail(function() { //第五層回撥,當商品資訊沒有被獲取到時的錯誤處理
alert("Could not retrieve items");
}); }
複製程式碼
很容易想到,當必須從更多的資料來源中獲取資料時,回撥的巢狀級別就會進一步增加,這將會導致程式碼更加難以理解和維護。關於回撥地獄的文章不計其數,甚至還有人為此註冊了一個域名 callbackhell.com。在大型的 Node.js 應用中也經常會出現回撥地獄。
但是編寫非同步程式完全沒必要那麼複雜。雖然回撥有很多優點,但是她的抽象層次還是過於低階,以至於在編寫複雜的非同步流時顯得那麼無力。為了使非同步程式設計更加人性化,JavaScript 僅僅只是在工具和抽象層面緩緩地前進。但是一些其他程式語言,比如 Scala,在設計之初就考慮到了這些抽象,並利用了眾所周知的函數語言程式設計則,使得從不同的角度處理該類問題成為了可能。
非同步程式設計的新方式
受函數語言程式設計概念的啟發,一些工具, 比如 Java 8 的 lambdas 或者 Scala 的函式都極大的簡化了對多個回撥的處理(與 JavaScript 所提供的相當少的解決方案相比),除了建立在語言層面的這些工具外,像 futures 和 actors 也能為非同步程式設計提供強有力的支援,這些都極大地消除了回撥地獄的現象。
從命令式同步的編碼風格轉換到函式式且非同步的編碼風格不是一蹴而就的,我們將在第3章和第5章中討論非同步程式設計的工具,技術和思維模型。
通過採用事件驅動的請求處理模型,Play 能夠更好地利用計算機資源。但是,如果有一個非常高效的請求處理途徑,卻遇到了伺服器的硬體限制,會發生什麼呢?讓我們來看看 Play 是如何幫助我們橫向擴充套件伺服器的。