服務端 IO 性能大比拼:Node、PHP、Java、Go哪家強?

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

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

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

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

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

系統調用

  首先,咱們有系統調用,它能夠描述成這樣:spring

  • 你的程序(在「用戶區域」,正如他們所說的)必須讓操做系統內核在它自身執行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

// 阻塞的文件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 中 4 種 I/O 模型

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

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

public void doGet(HttpServletRequest request,
    HttpServletResponse response) throws ServletException, IOException
{

    // 阻塞的文件I/O
    InputStream fileIs = new FileInputStream("/path/to/file");

    // 阻塞的網絡I/O
    URLConnection urlConnection = (new URL("http://example.com/example-microservice")).openConnection();
    InputStream netIs = urlConnection.getInputStream();

    // 更多阻塞的網絡I/O
    out.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代碼,以下所示:

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能夠在運行在單線程中,每一個代碼塊阻塞下一個。 一個常見的例子是循環數據庫記錄,在輸出到客戶端前以某種方式處理它們。如下是一個例子,演示了它如何工做:

var handler = function(request, response) {

    connection.query('SELECT ...', function (err, rows) {

        if (err) { throw err };

        for (var i = 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線程上。最後代碼相似這樣:

func ServeHTTP(w http.ResponseWriter, r *http.Request) {

    // 這裏底層的網絡調用是非阻塞的
    rows, err := db.Query("SELECT ...")

    for _, row := range rows {
        // 處理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應用程序一般不會在這樣的環境中進行重大改動。

做爲比較,若是隻考慮影響性能和易用性的幾個重要因素,能夠獲得:

image-20200528144056834

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

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

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

原文: https://www.toptal.com/back-e...
譯文: http://www.itran.cc/2017/05/1...

近期熱文推薦:

1.600+ 道 Java面試題及答案整理(2021最新版)

2.終於靠開源項目弄到 IntelliJ IDEA 激活碼了,真香!

3.阿里 Mock 工具正式開源,幹掉市面上全部 Mock 工具!

4.Spring Cloud 2020.0.0 正式發佈,全新顛覆性版本!

5.《Java開發手冊(嵩山版)》最新發布,速速下載!

以爲不錯,別忘了隨手點贊+轉發哦!

相關文章
相關標籤/搜索