服務端 I/O 效能:Node、PHP、Java、Go 的對比

wyfem發表於2021-09-09

瞭解應用程式的輸入/輸出(I/O)模型意味著理解應用程式處理其資料的載入差異,並揭示其在真實環境中表現。或許你的應用程式很小,在不承受很大的負載時,這並不是個嚴重的問題;但隨著應用程式的流量負載增加,可能因為使用了低效的 I/O 模型導致承受不了而崩潰。

和大多數情況一樣,處理這種問題的方法有多種方式,這不僅僅是一個擇優的問題,而是對權衡的理解問題。 接下來我們來看看 I/O 到底是什麼。

圖片描述

在本文中,我們將對 Node、Java、Go 和 PHP + Apache 進行對比,討論不同語言如何構造其 I/O ,每個模型的優缺點,並總結一些基本的規律。如果你擔心你的下一個 Web 應用程式的 I/O 效能,本文將給你最優的解答。

要了解 I/O 所涉及的因素,我們首先深入到作業系統層面複習這些概念。雖然看起來並不與這些概念直接打交道,但你會一直透過應用程式的執行時環境與它們間接接觸。瞭解細節很重要。

首先是系統呼叫,其被描述如下:

  • 程式(所謂“使用者端user land”)必須請求作業系統核心代表它執行 I/O 操作。

  • “系統呼叫syscall”是你的程式要求核心執行某些操作的方法。這些實現的細節在作業系統之間有所不同,但基本概念是相同的。有一些具體的指令會將控制權從你的程式轉移到核心(類似函式呼叫,但是使用專門用於處理這種情況的專用方式)。一般來說,系統呼叫會被阻塞,這意味著你的程式會等待核心返回(控制權到)你的程式碼。

  • 核心在所需的物理裝置( 磁碟、網路卡等 )上執行底層 I/O 操作,並回應系統呼叫。在實際情況中,核心可能需要做許多事情來滿足你的要求,包括等待裝置準備就緒、更新其內部狀態等,但作為應用程式開發人員,你不需要關心這些。這是核心的工作。

圖片描述

上面我們提到過,系統呼叫是阻塞的,一般來說是這樣的。然而,一些呼叫被歸類為“非阻塞”,這意味著核心會接收你的請求,將其放在佇列或緩衝區之類的地方,然後立即返回而不等待實際的 I/O 發生。所以它只是在很短的時間內“阻塞”,只需要排隊你的請求即可。

舉一些 Linux 系統呼叫的例子可能有助於理解:

  • read() 是一個阻塞呼叫 - 你傳遞一個控制程式碼,指出哪個檔案和緩衝區在哪裡傳送它所讀取的資料,當資料就緒時,該呼叫返回。這種方式的優點是簡單友好。

  • 分別呼叫 epoll_create()epoll_ctl() 和 epoll_wait() ,你可以建立一組控制程式碼來偵聽、新增/刪除該組中的處理程式、然後阻塞直到有任何事件發生。這允許你透過單個執行緒有效地控制大量的 I/O 操作,但是現在談這個還太早。如果你需要這個功能當然好,但須知道它使用起來是比較複雜的。

瞭解這裡的時間差異的數量級是很重要的。假設 CPU 核心執行在 3GHz,在沒有進行 CPU 最佳化的情況下,那麼它每秒執行 30 億次週期cycle(即每納秒 3 個週期)。非阻塞系統呼叫可能需要幾十個週期來完成,或者說 “相對少的納秒” 時間完成。而一個被跨網路接收資訊所阻塞的系統呼叫可能需要更長的時間 - 例如 200 毫秒(1/5 秒)。這就是說,如果非阻塞呼叫需要 20 納秒,阻塞呼叫需要 2 億納秒。你的程式因阻塞呼叫而等待了 1000 萬倍的時長!

圖片描述

核心既提供了阻塞 I/O (“從網路連線讀取並給出資料”),也提供了非阻塞 I/O (“告知我何時這些網路連線具有新資料”)的方法。使用的是哪種機制對呼叫程式的阻塞時長有截然不同的影響。

關鍵的第三件事是當你有很多執行緒或程式開始阻塞時會發生什麼。

根據我們的理解,執行緒和程式之間沒有很大的區別。在現實生活中,最顯著的效能相關的差異在於,由於執行緒共享相同的記憶體,而程式每個都有自己的記憶體空間,使得單獨的程式往往佔用更多的記憶體。但是當我們談論排程Scheduling時,它真正歸結為一類事情(執行緒和程式類同),每個都需要在可用的 CPU 核心上獲得一段執行時間。如果你有 300 個執行緒執行在 8 個核心上,則必須將時間分成幾份,以便每個執行緒和程式都能分享它,每個執行一段時間,然後交給下一個。這是透過 “上下文切換context switch” 完成的,可以使 CPU 從執行到一個執行緒/程式到切換下一個。

這些上下文切換也有相關的成本 - 它們需要一些時間。在某些快速的情況下,它可能小於 100 納秒,但根據實際情況、處理器速度/體系結構、CPU 快取等,偶見花費 1000 納秒或更長時間。

而執行緒(或程式)越多,上下文切換就越多。當我們涉及數以千計的執行緒時,每個執行緒花費數百納秒,就會變得很慢。

然而,非阻塞呼叫實質上是告訴核心“僅在這些連線之一有新的資料或事件時再叫我”。這些非阻塞呼叫旨在有效地處理大量 I/O 負載並減少上下文交換。

這些你明白了麼?現在來到了真正有趣的部分:我們來看看一些流行的語言對那些工具的使用,並得出關於易用性和效能之間權衡的結論,以及一些其他有趣小東西。

宣告,本文中顯示的示例是零碎的(片面的,只能體現相關的資訊); 資料庫訪問、外部快取系統( memcache 等等)以及任何需要 I/O 的東西都將執行某種型別的 I/O 呼叫,其實質與上面所示的簡單示例效果相同。此外,對於將 I/O 描述為“阻塞”( PHP、Java )的情況,HTTP 請求和響應讀取和寫入本身就是阻塞呼叫:系統中隱藏著更多 I/O 及其伴生的效能問題需要考慮。

為一個專案選擇程式語言要考慮很多因素。甚至當你只考慮效率時,也有很多因素。但是,如果你擔心你的程式將主要受到 I/O 的限制,如果 I/O 效能影響到專案的成敗,那麼這些是你需要了解的。

早在 90 年代,很多人都穿著  鞋,用 Perl 寫著 CGI 指令碼。然後 PHP 來了,就像一些人喜歡咒罵的一樣,它使得動態網頁更容易。

PHP 使用的模型相當簡單。雖有一些出入,但你的 PHP 伺服器基本上是這樣:

HTTP 請求來自使用者的瀏覽器,並訪問你的 Apache Web 伺服器。Apache 為每個請求建立一個單獨的程式,有一些最佳化方式可以重新使用它們,以最大限度地減少建立次數( 相對而言,建立程式較慢 )。Apache 呼叫 PHP 並告訴它執行磁碟上合適的 .php 檔案。PHP 程式碼執行並阻塞 I/O 呼叫。你在 PHP 中呼叫 file_get_contents() ,其底層會呼叫 read() 系統呼叫並等待結果。

當然,實際的程式碼是直接嵌入到你的頁面,並且該操作被阻塞:

 blocking file I/O$file_data = file_get_contents(‘/path/to/file.dat’);// blocking network I/O$curl = curl_init('');
$result = curl_exec($curl);// some more blocking network 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 非常相似,在大致的方面上,它們可以被認為是相同的。

就在你購買你的第一個域名,在某個句子後很酷地隨機說出 “dot com” 的那個時候,Java 來了。而 Java 具有內建於該語言中的多執行緒功能,它非常棒(特別是在建立時)。

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

在 Java Servlet 中執行 I/O 往往看起來像:

public void doGet(HttpServletRequest request,
    HttpServletResponse response) throws ServletException, IOException{    // blocking file I/O
    InputStream fileIs = new FileInputStream("/path/to/file");    // blocking network I/O
    URLConnection urlConnection = (new URL("")).openConnection();
    InputStream netIs = urlConnection.getInputStream();    // some more blocking network I/Oout.println("...");
}

由於我們上面的 doGet 方法對應於一個請求,並且在其自己的執行緒中執行,而不是每個請求一個單獨的程式,申請自己的記憶體。這樣有一些好處,比如線上程之間共享狀態、快取資料等,因為它們可以訪問彼此的記憶體,但是它與排程的互動影響與之前的 PHP 的例子幾乎相同。每個請求獲得一個新執行緒,該執行緒內的各種 I/O 操作阻塞線上程內,直到請求被完全處理為止。執行緒被池化以最小化建立和銷燬它們的成本,但是數千個連線仍然意味著數千個執行緒,這對排程程式是不利的。

重要的里程碑出現在 Java 1.4 版本(以及 1.7 的重要升級)中,它獲得了執行非阻塞 I/O 呼叫的能力。大多數應用程式、web 應用和其它用途不會使用它,但至少它是可用的。一些 Java Web 伺服器嘗試以各種方式利用這一點;然而,絕大多數部署的 Java 應用程式仍然如上所述工作。

圖片描述

肯定有一些很好的開箱即用的 I/O 功能,Java 讓我們更接近,但它仍然沒有真正解決當你有一個大量的 I/O 繫結的應用程式被數千個阻塞執行緒所壓垮的問題。

當更好的 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 處理它然後獲取回撥的機制稱為“事件迴圈”。它工作的很好。

圖片描述

然而,這個模型有一個陷阱,究其原因,很多是與 V8 JavaScript 引擎(Node 用的是 Chrome 瀏覽器的 JS 引擎)如何實現的有關^注1 。你編寫的所有 JS 程式碼都執行在單個執行緒中。你可以想想,這意味著當使用高效的非阻塞技術執行 I/O 時,你的 JS 可以在單個執行緒中執行計算密集型的操作,每個程式碼塊都會阻塞下一個。可能出現這種情況的一個常見例子是以某種方式遍歷資料庫記錄,然後再將其輸出到客戶端。這是一個示例,展示了其是如何工作:

var handler = function(request, response) {

    connection.query('SELECT ...', function (err, rows) {        if (err) { throw err };        for (var i = 0; i 

雖然 Node 確實有效地處理了 I/O ,但是上面的例子中 for 迴圈是在你的唯一的一個主執行緒中佔用 CPU 週期。這意味著如果你有 10,000 個連線,則該迴圈可能會使你的整個應用程式像爬行般緩慢,具體取決於其會持續多久。每個請求必須在主執行緒中分享一段時間,一次一段。

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

另一點是,雖然這只是一個觀點,但是寫一堆巢狀的回撥可能是相當令人討厭的,有些則認為它使程式碼更難以追蹤。在 Node 程式碼中看到回撥巢狀 4 層、5 層甚至更多層並不罕見。

我們再次來權衡一下。如果你的主要效能問題是 I/O,則 Node 模型工作正常。然而,它的關鍵是,你可以在一個處理 HTTP 請求的函式里面放置 CPU 密集型的程式碼,而且不小心的話會導致每個連線都很慢。

在我進入 Go 部分之前,我應該披露我是一個 Go 的粉絲。我已經在許多專案中使用過它,我是一個其生產力優勢的公開支持者,我在我的工作中使用它。

那麼,讓我們來看看它是如何處理 I/O 的。Go 語言的一個關鍵特徵是它包含自己的排程程式。在 Go 中,不是每個執行執行緒對應於一個單一的 OS 執行緒,其透過一種叫做 “協程goroutine” 的概念來工作。而 Go 的執行時可以將一個協程分配給一個 OS 執行緒,使其執行或暫停它,並且它不與一個 OS 執行緒相關聯——這要基於那個協程正在做什麼。來自 Go 的 HTTP 伺服器的每個請求都在單獨的協程中處理。

排程程式的工作原理如圖所示:

圖片描述

在底層,這是透過 Go 執行時中的各個部分實現的,它透過對請求的寫入/讀取/連線等操作來實現 I/O 呼叫,將當前協程休眠,並當採取進一步動作時喚醒該協程。

從效果上看,Go 執行時做的一些事情與 Node 做的沒有太大不同,除了回撥機制是內建到 I/O 呼叫的實現中,並自動與排程程式互動。它也不會受到必須讓所有處理程式程式碼在同一個執行緒中執行的限制,Go 將根據其排程程式中的邏輯自動將協程對映到其認為適當的 OS 執行緒。結果是這樣的程式碼:

func ServeHTTP(w http.ResponseWriter, r *http.Request) {    // the underlying network call here is non-blocking
    rows, err := db.Query("SELECT ...")    for _, row := range rows {        // do something with the rows,// each request in its own goroutine
    }

    w.Write(...) // write the response, also non-blocking}

如上所述,我們重構基本的程式碼結構為更簡化的方式,並在底層仍然實現了非阻塞 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 個併發請求,相同的任務,但是雜湊迭代是 1000 倍(顯著增加了 CPU 負載):

圖片描述

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

突然間, Node 效能顯著下降,因為每個請求中的 CPU 密集型操作都相互阻塞。有趣的是,在這個測試中,PHP 的效能要好得多(相對於其他的),並且打敗了 Java。(值得注意的是,在 PHP 中,SHA-256 實現是用 C 編寫的,在那個迴圈中執行路徑花費了更多的時間,因為現在我們正在進行 1000 個雜湊迭代)。

現在讓我們嘗試 5000 個併發連線(N = 1) - 或者是我可以發起的最大連線。不幸的是,對於大多數這些環境,故障率並不顯著。對於這個圖表,我們來看每秒的請求總數。 越高越好 :

圖片描述

每秒請求數。越高越好。

這個圖看起來有很大的不同。我猜測,但是看起來像在高連線量時,產生新程式所涉及的每連線開銷以及與 PHP + Apache 相關聯的附加記憶體似乎成為主要因素,並阻止了 PHP 的效能。顯然,Go 是這裡的贏家,其次是 Java,Node,最後是 PHP。

雖然與你的整體吞吐量相關的因素很多,並且在應用程式之間也有很大的差異,但是你對底層發生什麼的事情以及所涉及的權衡瞭解更多,你將會得到更好的結果。

以上所有這一切,很顯然,隨著語言的發展,處理大量 I/O 的大型應用程式的解決方案也隨之發展。

為了公平起見,PHP 和 Java,儘管這篇文章中的描述,確實  在  中   。但是這些方法並不像上述方法那麼常見,並且需要考慮使用這種方法來維護伺服器的隨之而來的操作開銷。更不用說你的程式碼必須以與這些環境相適應的方式進行結構化;你的 “正常” PHP 或 Java Web 應用程式通常不會在這樣的環境中進行重大修改。

作為比較,如果我們考慮影響效能和易用性的幾個重要因素,我們得出以下結論:

語言

執行緒與程式

非阻塞 I/O

使用便捷性

PHP

程式

Java

執行緒

可用

需要回撥

Node.js

執行緒

需要回撥

Go

執行緒 (協程)

不需要回撥

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

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

希望以上內容可以幫助你更清楚地瞭解底層發生的情況,併為你提供如何處理應用程式的現實可擴充套件性的一些想法。



via: 

作者: 譯者: 校對:


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/2894/viewspace-2804850/,如需轉載,請註明出處,否則將追究法律責任。

相關文章