摘要:本文首先簡單介紹了I/O相關的基礎概念,然後橫向比較了Node、PHP、Java、Go的I/O效能,並給出了選型建議。以下是譯文。

瞭解應用程式的輸入/輸出(I/O)模型能夠更好的理解它在處理負載時理想情況與實際情況下的差異。也許你的應用程式很小,也無需支撐太高的負載,所以這方面需要考慮的東西還比較少。但是,隨著應用程式流量負載的增加,使用錯誤的I/O模型可能會導致非常嚴重的後果。


1.jpg

在本文中,我們將把Node、Java、Go和PHP與Apache配套進行比較,討論不同語言如何對I/O進行建模、每個模型的優缺點,以及一些基本的效能評測。如果你比較關心自己下一個Web應用程式的I/O效能,本文將為你提供幫助。

I/O基礎:快速回顧一下

要了解與I/O相關的因素,我們必須首先在作業系統層面上了解這些概念。雖然不太可能一上來就直接接觸到太多的概念,但在應用的執行過程中,不管是直接還是間接,總會遇到它們。細節很重要。

系統呼叫

首先,我們來認識下系統呼叫,具體描述如下:

  • 應用程式請求作業系統核心為其執行I/O操作。

  • “系統呼叫”是指程式請求核心執行某些操作。其實現細節因作業系統而異,但基本概念是相同的。在執行“系統呼叫”時,將會有一些控制程式的特定指令轉移到核心中去。一般來說,系統呼叫是阻塞的,這意味著程式會一直等待直到核心返回結果。

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


2.jpg

阻塞呼叫與非阻塞呼叫

我在上面說過,系統呼叫一般來說是阻塞的。但是,有些呼叫卻屬於“非阻塞”的,這意味著核心會將請求放入佇列或緩衝區中,然後立即返回而不等待實際I/O的發生。所以,它只會“阻塞”很短的時間,但排隊需要一定的時間。

為了說明這一點,下面給出幾個例子(Linux系統呼叫):

  • read()是一個阻塞呼叫。我們需要傳遞一個檔案控制代碼和用於儲存資料的緩衝區給它,當資料儲存到緩衝區之後返回。它的優點是優雅而又簡單。

  • epoll_create()epoll_ctl()epoll_wait()可用於建立一組控制代碼進行監聽,新增/刪除這個組中的控制代碼、阻塞程式直到控制代碼有任何的活動。這些系統呼叫能讓你只用單個執行緒就能高效地控制大量的I/O操作。這些功能雖然非常有用,但使用起來相當複雜。

瞭解這裡的時間差的數量級非常重要。如果一個沒有優化過的CPU核心以3GHz的頻率執行,那麼它可以每秒執行30億個週期(即每納秒3個週期)。一個非阻塞的系統呼叫可能需要大約10多個週期,或者說幾個納秒。對從網路接收資訊的呼叫進行阻塞可能需要更長的時間,比如說200毫秒(1/5秒)。比方說,非阻塞呼叫花了20納秒,阻塞呼叫花了200,000,000納秒。這樣,程序為了阻塞呼叫可能就要等待1000萬個週期。


3.jpg

核心提供了阻塞I/O(“從網路讀取資料”)和非阻塞I/O(“告訴我網路連線上什麼時候有新資料”)這兩種方法,並且兩種機制阻塞呼叫程序的時間長短完全不同。

排程

第三個非常關鍵的事情是當有很多執行緒或程序開始出現阻塞時會發生什麼問題。

對我們而言,執行緒和程序之間並沒有太大的區別。而在現實中,與效能相關的最顯著的區別是,由於執行緒共享相同的記憶體,並且每個程序都有自己的記憶體空間,所以單個程序往往會佔用更多的記憶體。但是,在我們談論排程的時候,實際上講的是完成一系列的事情,並且每個事情都需要在可用的CPU核心上獲得一定的執行時間。如果你有8個核心來執行300個執行緒,那麼你必須把時間分片,這樣,每個執行緒才能獲得屬於它的時間片,每一個核心執行很短的時間,然後切換到下一個執行緒。這是通過“上下文切換”完成的,可以讓CPU從一個執行緒/程序切換到下一個執行緒/程序。

這種上下文切換有一定的成本,即需要一定的時間。快的時候可能會小於100納秒,但如果實現細節、處理器速度/架構、CPU快取等軟硬體的不同,花個1000納秒或更長的時間也很正常。

執行緒(或程序)數量越多,則上下文切換的次數也越多。如果存在成千上萬的執行緒,每個執行緒都要耗費幾百納秒的切換時間的時候,系統就會變得非常慢。

然而,非阻塞呼叫實質上告訴核心“只有在這些連線上有新的資料或事件到來時才呼叫我”。這些非阻塞呼叫可有效地處理大I/O負載並減少上下文切換。

值得注意的是,雖然本文舉得例子很小,但資料庫訪問、外部快取系統(memcache之類的)以及任何需要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');

?>

與系統的整合示意圖是這樣的:


4.jpg

很簡單:每個請求一個程序。 I/O呼叫是阻塞的。那麼優點呢?簡單而又有效。缺點呢?如果有20000個客戶端併發,伺服器將會癱瘓。這種方法擴充套件起來比較難,因為核心提供的用於處理大量I/O(epoll等)的工具並沒有充分利用起來。更糟糕的是,為每個請求執行一個單獨的程序往往會佔用大量的系統資源,尤其是記憶體,這通常是第一個耗盡的。

*注意:在這一點上,Ruby的情況與PHP非常相似。

多執行緒:Java

所以,Java就出現了。而且Java在語言中內建了多執行緒,特別是在建立執行緒時非常得棒。

大多數的Java Web伺服器都會為每個請求啟動一個新的執行執行緒,然後在這個執行緒中呼叫開發人員編寫的函式。

在Java Servlet中執行I/O往往是這樣的:

publicvoiddoGet(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方法對應於一個請求,並且在自己的執行緒中執行,而不是在需要有獨立記憶體的單獨程序中執行,所以我們將建立一個單獨的執行緒。每個請求都會得到一個新的執行緒,並在該執行緒內部阻塞各種I/O操作,直到請求處理完成。應用會建立一個執行緒池以最小化建立和銷燬執行緒的成本,但是,成千上萬的連線意味著有成千上萬的執行緒,這對於排程器來說並不件好事情。

值得注意的是,1.4版本的Java(1.7版本中又重新做了升級)增加了非阻塞I/O呼叫的能力。雖然大多數的應用程式都沒有使用這個特性,但它至少是可用的。一些Java Web伺服器正在嘗試使用這個特性,但絕大部分已經部署的Java應用程式仍然按照上面所述的原理進行工作。


5.jpg

Java提供了很多在I/O方面開箱即用的功能,但如果遇到建立大量阻塞執行緒執行大量I/O操作的情況時,Java也沒有太好的解決方案。

把非阻塞I/O作為頭等大事:Node

在I/O方面表現比較好的、比較受使用者歡迎的是Node.js。任何一個對Node有簡單瞭解的人都知道,它是“非阻塞”的,並且能夠高效地處理I/O。這在一般意義上是正確的。但是細節和實現的方式至關重要。

在需要做一些涉及I/O的操作的時候,你需要發出請求,並給出一個回撥函式,Node會在處理完請求之後呼叫這個函式。

在請求中執行I/O操作的典型程式碼如下所示:

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

如上所示,這裡有兩個回撥函式。當請求開始時,第一個函式會被呼叫,而第二個函式是在檔案資料可用時被呼叫。

這樣,Node就能更有效地處理這些回撥函式的I/O。有一個更能說明問題的例子:在Node中呼叫資料庫操作。首先,你的程式開始呼叫資料庫操作,並給Node一個回撥函式,Node會使用非阻塞呼叫來單獨執行I/O操作,然後在請求的資料可用時呼叫你的回撥函式。這種對I/O呼叫進行排隊並讓Node處理I/O呼叫然後得到一個回撥的機制稱為“事件迴圈”。這個機制非常不錯。


6.jpg

然而,這個模型有一個問題。在底層,這個問題出現的原因跟V8 JavaScript引擎(Node使用的是Chrome的JS引擎)的實現有關,即:你寫的JS程式碼都執行在一個執行緒中。請思考一下。這意味著,儘管使用高效的非阻塞技術來執行I/O,但是JS程式碼在單個執行緒操作中執行基於CPU的操作,每個程式碼塊都會阻塞下一個程式碼塊的執行。有一個常見的例子:在資料庫記錄上迴圈,以某種方式處理記錄,然後將它們輸出到客戶端。下面這段程式碼展示了這個例子的原理:

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週期。這意味著如果你有10000個連線,那麼這個迴圈就可能會佔用整個應用程式的時間。每個請求都必須要在主執行緒中佔用一小段時間。

這整個概念的前提是I/O操作是最慢的部分,因此,即使序列處理是不得已的,但對它們進行有效處理也是非常重要的。這在某些情況下是成立的,但並非一成不變。

另一點觀點是,寫一堆巢狀的回撥很麻煩,有些人認為這樣的程式碼很醜陋。在Node程式碼中嵌入四個、五個甚至更多層的回撥並不罕見。

又到了權衡利弊的時候了。如果你的主要效能問題是I/O的話,那麼這個Node模型能幫到你。但是,它的缺點在於,如果你在一個處理HTTP請求的函式中放入了CPU處理密集型程式碼的話,一不小心就會讓每個連線都出現擁堵。

原生無阻塞:Go

在介紹Go之前,我透露一下,我是一個Go的粉絲。我已經在許多專案中使用了Go。

讓我們看看它是如何處理I/O的吧。 Go語言的一個關鍵特性是它包含了自己的排程器。它並不會為每個執行執行緒對應一個作業系統執行緒,而是使用了“goroutines”這個概念。Go執行時會為一個goroutine分配一個作業系統執行緒,並控制它執行或暫停。Go HTTP伺服器的每個請求都在一個單獨的Goroutine中進行處理。

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


7.jpg

實際上,除了回撥機制被內建到I/O呼叫的實現中並自動與排程器互動之外,Go執行時正在做的事情與Node不同。它也不會受到必須讓所有的處理程式碼在同一個執行緒中執行的限制,Go會根據其排程程式中的邏輯自動將你的Goroutine對映到它認為合適的作業系統執行緒中。因此,它的程式碼是這樣的:

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檔案中的隨機位元組,然後對其執行N次SHA-256雜湊(在URL的查詢字串中指定N,例如.../test.php?n=100)並以十六進位制列印結果。我之所以選擇這個,是因為它可以很容易執行一些持續的I/O操作,並且可以通過受控的方式來增加CPU使用率。

首先,我們來看一些低併發性的例子。使用300個併發請求執行2000次迭代,每個請求雜湊一次(N=1),結果如下:


8.jpg
Times是完成所有併發請求的平均毫秒數。越低越好。

從單單這一張圖中很難得到結論,但我個人認為,在這種存在大量連線和計算的情況下,我們看到的結果更多的是與語言本身的執行有關。請注意,“指令碼語言”的執行速度最慢。

但是如果我們將N增加到1000,但仍然是300個併發請求,即在相同的負載的情況下將雜湊的迭代次數增加了1000倍(CPU負載明顯更高),會發生什麼情況呢:


9.jpg
Times是完成所有併發請求的平均毫秒數。越低越好。

突然之間,由於每個請求中的CPU密集型操作相互阻塞,Node的效能顯著下降。有趣的是,在這個測試中,PHP的效能變得更好了(相對於其他),甚至優於Java。 (值得注意的是,在PHP中,SHA-256的實現是用C語言編寫的,但執行路徑在這個迴圈中花費了更多的時間,因為我們這次做了1000次雜湊迭代)。

現在,讓我們試試5000個併發連線(N=1) 。不幸的是,對於大多數的環境來說,失敗率並不明顯。我們來看看這個圖表中每秒處理的請求數,越高越好


10.jpg
每秒處理的請求數,越高越好。

這個圖看起來跟上面的不太一樣。我猜測,在較高的連線數量下,PHP + Apache中產生新程序和記憶體的申請似乎成為了影響PHP效能的主要因素。 很顯然,Go是這次的贏家,其次是Java,Node,最後是PHP。

雖然涉及到整體吞吐量的因素很多,而且應用程式和應用程式之間也存在著很大的差異,但是,越是瞭解底層的原理和所涉及的權衡問題,應用程式的表現就會越好。

總結

綜上所述,隨著語言的發展,處理大量I/O大型應用程式的解決方案也隨之發展。

公平地說,PHP和Java在web應用方面都有可用非阻塞I/O實現。但是這些實現並不像上面描述的方法那麼使用廣泛,並且還需要考慮維護上的開銷。更不用說應用程式的程式碼必須以適合這種環境的方式來構建。

我們來比較一下幾個影響效能和易用性的重要因素:

語言 執行緒與程序 非阻塞I/O 易於使用
PHP 程序 -
Java 執行緒 有效 需要回調
Node.js 執行緒 需要回調
Go 執行緒 (Goroutines) 無需回撥

因為執行緒會共享相同的記憶體空間,而程序不會,所以執行緒通常要比程序的記憶體效率高得多。在上面的列表中,從上往下看,與I/O相關的因素一個比一個好。所以,如果我不得不在上面的比較中選擇一個贏家,那肯定選Go。

即便如此,在實踐中,選擇構建應用程式的環境與你團隊對環境的熟悉程度以及團隊可以實現的整體生產力密切相關。所以,對於團隊來說,使用Node或Go來開發Web應用程式和服務可能並不是最好的選擇。

希望以上這些內容能夠幫助你更清楚地瞭解底層發生的事情,併為你提供一些關於如何處理應用程式伸縮性的建議。

SDCC 2017之資料庫線上峰會即將強勢來襲,秉承乾貨實料(案例)的內容原則,邀請了來自阿里巴巴、騰訊、微博、網易等多家企業的資料庫專家及高校研究學者,圍繞Oracle、MySQL、PostgreSQL、Redis等熱點資料庫技術展開,從核心技術的深挖到高可用實踐的剖析,打造精華壓縮式分享,舉一反三,思辨互搏,報名及更多詳情可點選此處檢視
這裡寫圖片描述