服務端 I/O 性能:Node、PHP、Java、Go 的對比
在本文中,我們將對 Node、Java、Go 和 PHP + Apache 進行對比,討論不同語言如何構造其 I/O ,每個模型的優缺點,並總結一些基本的規律。如果你擔心你的下一個 Web 應用程序的 I/O 性能,本文將給你最優的解答。
I/O 基礎知識: 快速複習
要了解 I/O 所涉及的因素,我們首先深入到操作系統層面複習這些概念。雖然看起來並不與這些概念直接打交道,但你會一直通過應用程序的運行時環境與它們間接接觸。了解細節很重要。
系統調用
首先是系統調用,其被描述如下:
- 程序(所謂「 用戶端 」)必須請求操作系統內核代表它執行 I/O 操作。
- 「 系統調用 」是你的程序要求內核執行某些操作的方法。這些實現的細節在操作系統之間有所不同,但基本概念是相同的。有一些具體的指令會將控制權從你的程序轉移到內核(類似函數調用,但是使用專門用於處理這種情況的專用方式)。一般來說,系統調用會被阻塞,這意味著你的程序會等待內核返回(控制權到)你的代碼。
- 內核在所需的物理設備( 磁碟、網卡等 )上執行底層 I/O 操作,並回應系統調用。在實際情況中,內核可能需要做許多事情來滿足你的要求,包括等待設備準備就緒、更新其內部狀態等,但作為應用程序開發人員,你不需要關心這些。這是內核的工作。
阻塞與非阻塞
上面我們提到過,系統調用是阻塞的,一般來說是這樣的。然而,一些調用被歸類為「非阻塞」,這意味著內核會接收你的請求,將其放在隊列或緩衝區之類的地方,然後立即返回而不等待實際的 I/O 發生。所以它只是在很短的時間內「阻塞」,只需要排隊你的請求即可。
舉一些 Linux 系統調用的例子可能有助於理解:
read()
是一個阻塞調用 - 你傳遞一個句柄,指出哪個文件和緩衝區在哪裡傳送它所讀取的數據,當數據就緒時,該調用返回。這種方式的優點是簡單友好。- 分別調用
epoll_create()
、epoll_ctl()
和epoll_wait()
,你可以創建一組句柄來偵聽、添加/刪除該組中的處理程序、然後阻塞直到有任何事件發生。這允許你通過單個線程有效地控制大量的 I/O 操作,但是現在談這個還太早。如果你需要這個功能當然好,但須知道它使用起來是比較複雜的。
了解這裡的時間差異的數量級是很重要的。假設 CPU 內核運行在 3GHz,在沒有進行 CPU 優化的情況下,那麼它每秒執行 30 億次 周期 (即每納秒 3 個周期)。非阻塞系統調用可能需要幾十個周期來完成,或者說 「相對少的納秒」 時間完成。而一個被跨網路接收信息所阻塞的系統調用可能需要更長的時間 - 例如 200 毫秒(1/5 秒)。這就是說,如果非阻塞調用需要 20 納秒,阻塞調用需要 2 億納秒。你的進程因阻塞調用而等待了 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 年代,很多人都穿著 Converse 鞋,用 Perl 寫著 CGI 腳本。然後 PHP 來了,就像一些人喜歡咒罵的一樣,它使得動態網頁更容易。
PHP 使用的模型相當簡單。雖有一些出入,但你的 PHP 伺服器基本上是這樣:
HTTP 請求來自用戶的瀏覽器,並訪問你的 Apache Web 伺服器。Apache 為每個請求創建一個單獨的進程,有一些優化方式可以重新使用它們,以最大限度地減少創建次數( 相對而言,創建進程較慢 )。Apache 調用 PHP 並告訴它運行磁碟上合適的 .php
文件。PHP 代碼執行並阻塞 I/O 調用。你在 PHP 中調用 file_get_contents()
,其底層會調用 read()
系統調用並等待結果。
當然,實際的代碼是直接嵌入到你的頁面,並且該操作被阻塞:
<?php
// blocking file I/O
$file_data = file_get_contents(『/path/to/file.dat』);
// blocking network I/O
$curl = curl_init('http://example.com/example-microservice');
$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 非常相似,在大致的方面上,它們可以被認為是相同的。
多線程方法: Java
就在你購買你的第一個域名,在某個句子後很酷地隨機說出 「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("http://example.com/example-microservice")).openConnection();
InputStream netIs = urlConnection.getInputStream();
// some more blocking network I/O
out.println("...");
}
由於我們上面的 doGet
方法對應於一個請求,並且在其自己的線程中運行,而不是每個請求一個單獨的進程,申請自己的內存。這樣有一些好處,比如在線程之間共享狀態、緩存數據等,因為它們可以訪問彼此的內存,但是它與調度的交互影響與之前的 PHP 的例子幾乎相同。每個請求獲得一個新線程,該線程內的各種 I/O 操作阻塞在線程內,直到請求被完全處理為止。線程被池化以最小化創建和銷毀它們的成本,但是數千個連接仍然意味著數千個線程,這對調度程序是不利的。
重要的里程碑出現在 Java 1.4 版本(以及 1.7 的重要升級)中,它獲得了執行非阻塞 I/O 調用的能力。大多數應用程序、web 應用和其它用途不會使用它,但至少它是可用的。一些 Java Web 伺服器嘗試以各種方式利用這一點;然而,絕大多數部署的 Java 應用程序仍然如上所述工作。
肯定有一些很好的開箱即用的 I/O 功能,Java 讓我們更接近,但它仍然沒有真正解決當你有一個大量的 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 處理它然後獲取回調的機制稱為「事件循環」。它工作的很好。
然而,這個模型有一個陷阱,究其原因,很多是與 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 < rows.length; i++) {
// do processing on each row
}
response.end(...); // write out the results
})
};
雖然 Node 確實有效地處理了 I/O ,但是上面的例子中 for
循環是在你的唯一的一個主線程中佔用 CPU 周期。這意味著如果你有 10,000 個連接,則該循環可能會使你的整個應用程序像爬行般緩慢,具體取決於其會持續多久。每個請求必須在主線程中分享一段時間,一次一段。
這整個概念的前提是 I/O 操作是最慢的部分,因此最重要的是要有效地處理這些操作,即使這意味著要連續進行其他處理。這在某些情況下是正確的,但不是全部。
另一點是,雖然這只是一個觀點,但是寫一堆嵌套的回調可能是相當令人討厭的,有些則認為它使代碼更難以追蹤。在 Node 代碼中看到回調嵌套 4 層、5 層甚至更多層並不罕見。
我們再次來權衡一下。如果你的主要性能問題是 I/O,則 Node 模型工作正常。然而,它的關鍵是,你可以在一個處理 HTTP 請求的函數裡面放置 CPU 密集型的代碼,而且不小心的話會導致每個連接都很慢。
最自然的非阻塞:Go
在我進入 Go 部分之前,我應該披露我是一個 Go 的粉絲。我已經在許多項目中使用過它,我是一個其生產力優勢的公開支持者,我在我的工作中使用它。
那麼,讓我們來看看它是如何處理 I/O 的。Go 語言的一個關鍵特徵是它包含自己的調度程序。在 Go 中,不是每個執行線程對應於一個單一的 OS 線程,其通過一種叫做 「 協程 」 的概念來工作。而 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,儘管這篇文章中的描述,確實 實現了 在 web 應用程序 中 可使用的 非阻塞 I/O 。但是這些方法並不像上述方法那麼常見,並且需要考慮使用這種方法來維護伺服器的隨之而來的操作開銷。更不用說你的代碼必須以與這些環境相適應的方式進行結構化;你的 「正常」 PHP 或 Java Web 應用程序通常不會在這樣的環境中進行重大修改。
作為比較,如果我們考慮影響性能和易用性的幾個重要因素,我們得出以下結論:
語言 | 線程與進程 | 非阻塞 I/O | 使用便捷性 |
---|---|---|---|
PHP | 進程 | 否 | |
Java | 線程 | 可用 | 需要回調 |
Node.js | 線程 | 是 | 需要回調 |
Go | 線程 (協程) | 是 | 不需要回調 |
線程通常要比進程有更高的內存效率,因為它們共享相同的內存空間,而進程則沒有。結合與非阻塞 I/O 相關的因素,我們可以看到,至少考慮到上述因素,當我們從列表往下看時,與 I/O 相關的一般設置得到改善。所以如果我不得不在上面的比賽中選擇一個贏家,那肯定會是 Go。
即使如此,在實踐中,選擇構建應用程序的環境與你的團隊對所述環境的熟悉程度以及你可以實現的總體生產力密切相關。因此,每個團隊都深入並開始在 Node 或 Go 中開發 Web 應用程序和服務可能就沒有意義。事實上,尋找開發人員或你內部團隊的熟悉度通常被認為是不使用不同語言和/或環境的主要原因。也就是說,過去十五年來,時代已經發生了變化。
希望以上內容可以幫助你更清楚地了解底層發生的情況,並為你提供如何處理應用程序的現實可擴展性的一些想法。
via: https://www.toptal.com/back-end/server-side-io-performance-node-php-java-go
作者:BRAD PEABODY 譯者:MonkeyDEcho 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive