1. 程式人生 > >服務端I/O效能大比拼:Node、PHP、Java和Go

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

正如大部分存在多種解決途徑的場景一樣,重點不在於哪一種途徑更好,而是在於理解如何進行權衡。讓我們來參觀下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()系統呼叫並等待結果返回。

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

PHP
1234567891011 <?php// 阻塞的檔案I/O$file_data=file_get_contents('/path/to/file.dat');// 阻塞的網路I/O$curl=curl_init('http://example.com/example-microservice');$result=curl_exec($curl);// 更多阻塞的網路I/O$result=$db->query('SELECT id, data FROM examples ORDER BY id DESC limit 100');?>

關於它如何與系統整合,就像這樣:

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

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

多執行緒的方式:Java

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

大多數Java網站伺服器通過為每個進來的請求啟動一個新的執行執行緒,然後在該執行緒中最終呼叫作為應用程式開發人員的你所編寫的函式。

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

JavaScript
1234567891011121314 publicvoiddoGet(HttpServletRequest request,HttpServletResponse response)throws ServletException,IOException{// 阻塞的檔案I/OInputStream fileIs=newFileInputStream("/path/to/file");// 阻塞的網路I/OURLConnection urlConnection=(newURL("http://example.com/example-microservice")).openConnection();InputStream netIs=urlConnection.getInputStream();// 更多阻塞的網路I/Oout.println("...");}

由於我們上面的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程式碼,如下所示:

123456 http.createServer(function(request,response){fs.readFile('/path/to/file','utf8',function(err,data){response.end(data);});});

可以看到,這裡有兩個回撥函式。第一個會在請求開始時被呼叫,而第二個會在檔案資料可用時被呼叫。

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

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

JavaScript
123456789101112131415 varhandler=function(request,response){connection.query('SELECT ...',function(err,rows){if(err){throwerr};for(vari=0;i<rows.length;i++){// 對每一行紀錄進行處理}response.end(...);// 輸出結果})};

雖然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執行緒上。最後程式碼類似這樣:

JavaScript
12345678910111213 func ServeHTTP(whttp.ResponseWriter,r*http.Request){// 這裡底層的網路呼叫是非阻塞的rows,err:=db.Query("SELECT ...")for_,row:=rangerows{// 處理rows// 每個請求在它自己的goroutine中}w.Write(...)// 輸出響應結果,也是非阻塞的}

正如你在上面見到的,我們的基本程式碼結構像是更簡單的方式,並且在背後實現了非阻塞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易用性

PHP 程序
Java 執行緒 可用 需要回調
Node.js 執行緒 需要回調
Go 執行緒(Goroutine) 不需要回調

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

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

希望以上內容可以幫助你更清楚地瞭解幕後所發生的事件,並就如何處理應用程式現實世界中的可擴充套件性為你提供的一些想法。快樂輸入,快樂輸出!