服務端 I/O 性能大比拼:Node、PHP、Java 和 Go

  理解應用程序的輸入/輸出(I/O)模型,意味着其在計劃處理負載與殘酷的實際使用場景之間的差異。若應用程序比較小,也沒有服務於很高的負載,也許它影響甚微。但隨着應用程序的負載逐漸上漲,採用錯誤的I/O模型有可能會讓你到處踩坑,傷痕累累。

  正如大部分存在多種解決途徑的場景一樣,重點不在於哪一種途徑更好,而是在於理解如何進行權衡。讓我們來參觀下I/O的景觀,看下可以從中竊取點什麼。

   

  在這篇文章,我們將會結合Apache分別比較Node,Java,Go,和PHP,討論這些不同的語言如何對他們的I/O進行建模,各個模型的優點和缺點,並得出一些初步基準的結論。如果關心你下一個Web應用的I/O性能,那你就找對文章了。

  I/O基礎知識:快速回顧

  爲了理解與I/O密切相關的因素,必須先來回顧在操作系統底層的概念。雖然不會直接處理這些概念的大部分,但通過應用程序的運行時環境你一直在間接地處理他們。而關鍵在於細節。

  系統調用

  首先,我們有系統調用,它可以描述成這樣:

  你的程序(在“用戶區域”,正如他們所說的)必須讓操作系統內核在它自身執行I/O操作。

  “系統調用”(syscall)意味着你的程序要求內核做某事。不同的操作系統,實現系統調用的細節有所不同,但基本的概念是一樣的。這將會有一些特定的指令,把控制權從你的程序轉交到內核(類似函數調用但有一些專門用於處理這種場景的特殊sauce)。通常來說,系統調用是阻塞的,意味着你的程序需要等待內核返回到你的代碼。

  內核在我們所說的物理設備(硬盤、網卡等)上執行底層的I/O操作,並回復給系統調用。在現實世界中,內核可能需要做很多事情才能完成你的請求,包括等待設備準備就緒,更新它的內部狀態等,但作爲一名應用程序開發人員,你可以不用關心這些。以下是內核的工作情況。

  

  阻塞調用與非阻塞調用

  好了,我剛剛在上面說系統調用是阻塞的,通常來說這是對的。然而,有些調用被分類爲“非阻塞”,意味着內核接收了你的請求後,把它放進了隊列或者緩衝的某個地方,然後立即返回而並沒有等待實際的I/O調用。所以它只是“阻塞”了一段非常短的時間,短到只是把你的請求入列而已。

  這裏有一些有助於解釋清楚的(Linux系統調用)例子:-read()是阻塞調用——你傳給它一個文件句柄和一個存放所讀到數據的緩衝,然後此調用會在當數據好後返回。注意這種方式有着優雅和簡單的優點。-epoll_create(),epoll_ctl(),和epoll_wait()這些調用分別是,讓你創建一組用於偵聽的句柄,從該組添加/刪除句柄,和然後直到有活動時才阻塞。這使得你可以通過一個線程有效地控制一系列I/O操作。如果需要這些功能,這非常棒,但也正如你所看到的,使用起來當然也相當複雜。

  理解這裏分時差異的數量級是很重要的。如果一個CPU內核運行在3GHz,在沒有優化的情況下,它每秒執行30億次循環(或者每納秒3次循環)。非阻塞系統調用可能需要10納秒這樣數量級的週期才能完成——或者“相對較少的納秒”。對於正在通過網絡接收信息的阻塞調用可能需要更多的時間——例如200毫秒(0.2秒)。例如,假設非阻塞調用消耗了20納秒,那麼阻塞調用消耗了200,000,000納秒。對於阻塞調用,你的程序多等待了1000萬倍的時間。

   

  內核提供了阻塞I/O(“從網絡連接中讀取並把數據給我”)和非阻塞I/O(“當這些網絡連接有新數據時就告訴我”)這兩種方法。而使用何種機制,對應調用過程的阻塞時間明顯長度不同。

  調度

  接下來第三件關鍵的事情是,當有大量線程或進程開始阻塞時怎麼辦。

  出於我們的目的,線程和進程之間沒有太大的區別。實際上,最顯而易見的執行相關的區別是,線程共享相同的內存,而每個進程則擁有他們獨自的內存空間,使得分離的進程往往佔據了大量的內存。但當我們討論調度時,它最終可歸結爲一個事件清單(線程和進程類似),其中每個事件需要在有效的CPU內核上獲得一片執行時間。如果你有300個線程正在運行並且運行在8核上,那麼你得通過每個內核運行一段很短的時間然後切換到下一個線程的方式,把這些時間劃分開來以便每個線程都能獲得它的分時。這是通過“上下文切換”來實現的,使得CPU可以從正在運行的某個線程/進程切換到下一個。

  這些上下文切換有一定的成本——它們消耗了一些時間。在快的時候,可能少於100納秒,但是根據實現的細節,處理器速度/架構,CPU緩存等,消耗1000納秒甚至更長的時間也並不罕見。

  線程(或者進程)越多,上下文切換就越多。當我們談論成千上萬的線程,並且每一次切換需要數百納秒時,速度將會變得非常慢。

  然而,非阻塞調用本質上是告訴內核“當你有一些新的數據或者這些連接中的任意一個有事件時才調用我”。這些非阻塞調用設計於高效地處理大量的I/O負載,以及減少上下文切換。

  到目前爲止你還在看這篇文章嗎?因爲現在來到了有趣的部分:讓我們來看下一些流利的語言如何使用這些工具,並就在易用性和性能之間的權衡作出一些結論……以及其他有趣的點評。

  請注意,雖然在這篇文章中展示的示例是瑣碎的(並且是不完整的,只是顯示了相關部分的代碼),但數據庫訪問,外部緩存系統(memcache等全部)和需要I/O的任何東西,都以執行某些背後的I/O操作而結束,這些和展示的示例一樣有着同樣的影響。同樣地,對於I/O被描述爲“阻塞”(PHP,Java)這樣的情節,HTTP請求與響應的讀取與寫入本身是阻塞的調用:再一次,更多隱藏在系統中的I/O及其伴隨的性能問題需要考慮。

  爲項目選擇編程語言要考慮的因素有很多。當你只考慮性能時,要考慮的因素甚至有更多。但是,如果你關注的是程序主要受限於I/O,如果I/O性能對於你的項目至關重要,那這些都是你需要了解的。“保持簡單”的方法:PHP。

  回到90年代的時候,很多人穿着匡威鞋,用Perl寫着CGI腳本。隨後出現了PHP,很多人喜歡使用它,它使得製作動態網頁更爲容易。

  PHP使用的模型相當簡單。雖然有一些變化,但基本上PHP服務器看起來像:

  HTTP請求來自用戶的瀏覽器,並且訪問了你的Apache網站服務器。Apache爲每個請求創建一個單獨的進程,通過一些優化來重用它們,以便最大程度地減少其需要執行的次數(創建進程相對來說較慢)。Apache調用PHP並告訴它在磁盤上運行相應的.php文件。PHP代碼執行並做一些阻塞的I/O調用。若在PHP中調用了file_get_contents(),那在背後它會觸發read()系統調用並等待結果返回。

  當然,實際的代碼只是簡單地嵌在你的頁面中,並且操作是阻塞的:

   

  關於它如何與系統集成,就像這樣:

   

  相當簡單:一個請求,一個進程。I/O是阻塞的。優點是什麼呢?簡單,可行。那缺點是什麼呢?同時與20,000個客戶端連接,你的服務器就掛了。由於內核提供的用於處理大容量I/O(epoll等)的工具沒有被使用,所以這種方法不能很好地擴展。更糟糕的是,爲每個請求運行一個單獨的過程往往會使用大量的系統資源,尤其是內存,這通常是在這樣的場景中遇到的第一件事情。

  注意:Ruby使用的方法與PHP非常相似,在廣泛而普遍的方式下,我們可以將其視爲是相同的。

  多線程的方式:Java

  所以就在你買了你的第一個域名的時候,Java來了,並且在一個句子之後隨便說一句“dot com”是很酷的。而Java具有語言內置的多線程(特別是在創建時),這一點非常棒。

  大多數Java網站服務器通過爲每個進來的請求啓動一個新的執行線程,然後在該線程中最終調用作爲應用程序開發人員的你所編寫的函數。

  在Java的Servlet中執行I/O操作,往往看起來像是這樣:

   

  由於我們上面的doGet方法對應於一個請求並且在自己的線程中運行,而不是每次請求都對應需要有自己專屬內存的單獨進程,所以我們會有一個單獨的線程。這樣會有一些不錯的優點,例如可以在線程之間共享狀態、共享緩存的數據等,因爲它們可以相互訪問各自的內存,但是它如何與調度進行交互的影響,仍然與前面PHP例子中所做的內容幾乎一模一樣。每個請求都會產生一個新的線程,而在這個線程中的各種I/O操作會一直阻塞,直到這個請求被完全處理爲止。爲了最小化創建和銷燬它們的成本,線程會被彙集在一起,但是依然,有成千上萬個連接就意味着成千上萬個線程,這對於調度器是不利的。

  一個重要的里程碑是,在Java 1.4 版本(和再次顯著升級的1.7 版本)中,獲得了執行非阻塞I/O調用的能力。大多數應用程序,網站和其他程序,並沒有使用它,但至少它是可獲得的。一些Java網站服務器嘗試以各種方式利用這一點; 然而,絕大多數已經部署的Java應用程序仍然如上所述那樣工作。

   

  Java讓我們更進了一步,當然對於I/O也有一些很好的“開箱即用”的功能,但它仍然沒有真正解決問題:當你有一個嚴重I/O綁定的應用程序正在被數千個阻塞線程狂拽着快要墜落至地面時怎麼辦。

  作爲一等公民的非阻塞I/O:Node

  當談到更好的I/O時,Node.js無疑是新寵。任何曾經對Node有過最簡單瞭解的人都被告知它是“非阻塞”的,並且它能有效地處理I/O。在一般意義上,這是正確的。但魔鬼藏在細節中,當談及性能時這個巫術的實現方式至關重要。

  本質上,Node實現的範式不是基本上說“在這裏編寫代碼來處理請求”,而是轉變成“在這裏寫代碼開始處理請求”。每次你都需要做一些涉及I/O的事情,發出請求或者提供一個當完成時Node會調用的回調函數。

  在求中進行I/O操作的典型Node代碼,如下所示:

   

  可以看到,這裏有兩個回調函數。第一個會在請求開始時被調用,而第二個會在文件數據可用時被調用。

  這樣做的基本上給了Node一個在這些回調函數之間有效地處理I/O的機會。一個更加相關的場景是在Node中進行數據庫調用,但我不想再列出這個煩人的例子,因爲它是完全一樣的原則:啓動數據庫調用,並提供一個回調函數給Node,它使用非阻塞調用單獨執行I/O操作,然後在你所要求的數據可用時調用回調函數。這種I/O調用隊列,讓Node來處理,然後獲取回調函數的機制稱爲“事件循環”。它工作得非常好。

   

  然而,這個模型中有一道關卡。在幕後,究其原因,更多是如何實現JavaScript V8 引擎(Chrome的JS引擎,用於Node)1,而不是其他任何事情。你所編寫的JS代碼全部都運行在一個線程中。思考一下。這意味着當使用有效的非阻塞技術執行I/O時,正在進行CPU綁定操作的JS可以在運行在單線程中,每個代碼塊阻塞下一個。 一個常見的例子是循環數據庫記錄,在輸出到客戶端前以某種方式處理它們。以下是一個例子,演示了它如何工作:

   

  雖然Node確實可以有效地處理I/O,但上面的例子中的for循環使用的是在你主線程中的CPU週期。這意味着,如果你有10,000個連接,該循環有可能會讓你整個應用程序慢如蝸牛,具體取決於每次循環需要多長時間。每個請求必須分享在主線程中的一段時間,一次一個。

  這個整體概念的前提是I/O操作是最慢的部分,因此最重要是有效地處理這些操作,即使意味着串行進行其他處理。這在某些情況下是正確的,但不是全都正確。

  另一點是,雖然這只是一個意見,但是寫一堆嵌套的回調可能會令人相當討厭,有些人認爲它使得代碼明顯無章可循。在Node代碼的深處,看到嵌套四層、嵌套五層、甚至更多層級的嵌套並不罕見。

  我們再次回到了權衡。如果你主要的性能問題在於I/O,那麼Node模型能很好地工作。然而,它的阿喀琉斯之踵(譯者注:來自希臘神話,表示致命的弱點)是如果不小心的話,你可能會在某個函數裏處理HTTP請求並放置CPU密集型代碼,最後使得每個連接慢得如蝸牛。

  真正的非阻塞:Go

  在進入Go這一章節之前,我應該披露我是一名Go粉絲。我已經在許多項目中使用Go,是其生產力優勢的公開支持者,並且在使用時我在工作中看到了他們。

  也就是說,我們來看看它是如何處理I/O的。Go語言的一個關鍵特性是它包含自己的調度器。並不是每個線程的執行對應於一個單一的OS線程,Go採用的是“goroutines”這一概念。Go運行時可以將一個goroutine分配給一個OS線程並使其執行,或者把它掛起而不與OS線程關聯,這取決於goroutine做的是什麼。來自Go的HTTP服務器的每個請求都在單獨的Goroutine中處理。

  此調度器工作的示意圖,如下所示:

   

  這是通過在Go運行時的各個點來實現的,通過將請求寫入/讀取/連接/等實現I/O調用,讓當前的goroutine進入睡眠狀態,當可採取進一步行動時用信息把goroutine重新喚醒。

  實際上,除了回調機制內置到I/O調用的實現中並自動與調度器交互外,Go運行時做的事情與Node做的事情並沒有太多不同。它也不受必須把所有的處理程序代碼都運行在同一個線程中這一限制,Go將會根據其調度器的邏輯自動將Goroutine映射到其認爲合適的OS線程上。最後代碼類似這樣:

   

  正如你在上面見到的,我們的基本代碼結構像是更簡單的方式,並且在背後實現了非阻塞I/O。

  在大多數情況下,這最終是“兩個世界中最好的”。非阻塞I/O用於全部重要的事情,但是你的代碼看起來像是阻塞,因此往往更容易理解和維護。Go調度器和OS調度器之間的交互處理了剩下的部分。這不是完整的魔法,如果你建立的是一個大型的系統,那麼花更多的時間去理解它工作原理的更多細節是值得的; 但與此同時,“開箱即用”的環境可以很好地工作和很好地進行擴展。

  Go可能有它的缺點,但一般來說,它處理I/O的方式不在其中。

  謊言,詛咒的謊言和基準

  對這些各種模式的上下文切換進行準確的定時是很困難的。也可以說這對你來沒有太大作用。所以取而代之,我會給出一些比較這些服務器環境的HTTP服務器性能的基準。請記住,整個端對端的HTTP請求/響應路徑的性能與很多因素有關,而這裏我放在一起所提供的數據只是一些樣本,以便可以進行基本的比較。

  對於這些環境中的每一個,我編寫了適當的代碼以隨機字節讀取一個64k大小的文件,運行一個SHA-256哈希N次(N在URL的查詢字符串中指定,例如.../test.php?n=100),並以十六進制形式打印生成的散列。我選擇了這個示例,是因爲使用一些一致的I/O和一個受控的方式增加CPU使用率來運行相同的基準測試是一個非常簡單的方式。

  關於環境使用,更多細節請參考這些基準要點。

  首先,來看一些低併發的例子。運行2000次迭代,併發300個請求,並且每次請求只做一次散列(N = 1),可以得到:

   

  時間是在全部併發請求中完成請求的平均毫秒數。越低越好。

  很難從一個圖表就得出結論,但對於我來說,似乎與連接和計算量這些方面有關,我們看到時間更多地與語言本身的一般執行有關,因此更多在於I/O。請注意,被認爲是“腳本語言”(輸入隨意,動態解釋)的語言執行速度最慢。

  但是如果將N增加到1000,仍然併發300個請求,會發生什麼呢 —— 相同的負載,但是hash迭代是之前的100倍(顯着增加了CPU負載):

   

  時間是在全部併發請求中完成請求的平均毫秒數。越低越好。

  忽然之間,Node的性能顯着下降了,因爲每個請求中的CPU密集型操作都相互阻塞了。有趣的是,在這個測試中,PHP的性能要好得多(相對於其他的語言),並且打敗了Java。(值得注意的是,在PHP中,SHA-256實現是用C編寫的,執行路徑在這個循環中花費更多的時間,因爲這次我們進行了1000次哈希迭代)。

  現在讓我們嘗試5000個併發連接(並且N = 1)—— 或者接近於此。不幸的是,對於這些環境的大多數,失敗率並不明顯。對於這個圖表,我們會關注每秒的請求總數。越高越好:

  每秒的請求總數。越高越好。


  這張照片看起來截然不同。這是一個猜測,但是看起來像是對於高連接量,每次連接的開銷與產生新進程有關,而與PHP + Apache相關聯的額外內存似乎成爲主要的因素並制約了PHP的性能。顯然,Go是這裏的冠軍,其次是Java和Node,最後是PHP。

  結論

  綜上所述,很顯然,隨着語言的演進,處理大量I/O的大型應用程序的解決方案也隨之不斷演進。

  爲了公平起見,暫且拋開本文的描述,PHP和Java確實有可用於Web應用程序的非阻塞I/O的實現。 但是這些方法並不像上述方法那麼常見,並且需要考慮使用這種方法來維護服務器的伴隨的操作開銷。更不用說你的代碼必須以與這些環境相適應的方式進行結構化; “正常”的PHP或Java Web應用程序通常不會在這樣的環境中進行重大改動。

  作爲比較,如果只考慮影響性能和易用性的幾個重要因素,可以得到:

  

 

  線程通常要比進程有更高的內存效率,因爲它們共享相同的內存空間,而進程則沒有。結合與非阻塞I/O相關的因素,當我們向下移動列表到一般的啓動時,因爲它與改善I/O有關,可以看到至少與上面考慮的因素一樣。如果我不得不在上面的比賽中選出一個冠軍,那肯定會是Go。

  即便這樣,在實踐中,選擇構建應用程序的環境與你的團隊對於所述環境的熟悉程度以及可以實現的總體生產力密切相關。因此,每個團隊只是一味地扎進去並開始用Node或Go開發Web應用程序和服務可能沒有意義。事實上,尋找開發人員或內部團隊的熟悉度通常被認爲是不使用不同的語言和/或不同的環境的主要原因。也就是說,過去的十五年來,時代已經發生了巨大的變化。

  希望以上內容可以幫助你更清楚地瞭解幕後所發生的事件,並就如何處理應用程序現實世界中的可擴展性爲你提供的一些想法。本文由健康大部落std.jkdbl.com整理髮布

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章