服務器端IO性能對比:Node, PHP, Java和Go
對于你的程序所采用的輸入/輸出(I/O)模型的理解決定了你對處理負載得心應手還是面對問題時束手無策。當你的程序規模很小并且負載不高時,這方面的問題并不突出。但當程序的訪問量陡增時,選用了錯誤的I/O模型可能會讓你舉步維艱。
大多數情況下,似乎很多種方法都可行,但哪種方法更好,需要你來權衡。讓我們一起回顧一下I/O的知識,看是否可以找到線索。
在這篇文章里,我們將會比較Node,Java, Go和運行于Apache環境的PHP, 討論每種語言分別采用何種I/O模型,每種模型的優劣所在,得出幾個粗略的指標。如果你關注你的下一個Web應用的I/O性能,這篇文章適合你。
I/O基礎: 快速回顧
為理解影響I/O的諸多因素, 我們首先要復習一下操作系統層面的一些概念。雖然很多概念看起來和我們的日常工作沒有直接關聯,但事實上,你間接地通過應用程序的運行環境使用他們。所以這些細節非常重要。
系統調用
首先,我們有系統調用,描述如下:
- 你的程序需要操作系統內核來代表自己進行I/O操作。
- 你的程序通過"系統調用"這種方式操作內核。雖然這項操作在不同的操作系統上實現機制不同,但基本概念都是一樣的。會有某個特定的指令將控制句柄從你的程序轉移到內核上來(和函數調用一樣,但是加了點別的東西)。一般來說,系統調用是阻塞的,意味著你的程序將等待直到內核返還控制句柄。
- 內核驅動物理設備進行操作,并向系統調用返回結果。在實際情況下,內核會做好幾件事情來響應你的請求,諸如等待設備就緒、更新內部狀態等等,但作為應用開發工程師,你無需關注這一點。這是內核要干的事情。
阻塞與非阻塞調用
上面說到了系統調用是阻塞的,這個說法一般說來是對的。但是,有些調用被認為是"非阻塞"的,意味著內核收到了你的請求,把他放進一個隊列或者是緩存中或別的地方,并且無需等待真正的I/O結束就立刻返回。所以只會有非常短時間的阻塞,僅僅是把請求加入隊列的時間。
一些Linux系統調用的例子可以幫助你理解: read()
是一個阻塞調用,當你告訴它讀取完畢后把數據存放到某個文件或緩存,這個調用會把數據放到指定的位置。這種方式的好處在于簡便。epoll_create()
,epoll_ctl()
和epoll_wait()
這三個調用分別可以讓你創建一個監聽組,為該組增加/移除處理程序,然后在有新進展之前阻塞。它可以使你得以在一個線程內有效地控制大量的I/O操作, 雖然這樣說有點言過其實。如果你需要這個特性的話,這個方式很好。但復雜度明顯提升了。
理解時間消耗的巨大差異是關鍵。一個3GHz且沒有經過優化的CPU,一秒鐘可以運行30億次。一個非阻塞的系統調用可能會花費10圈來完成,也就是說只需要幾納秒。一個阻塞并且等待通過網絡收到信息的調用可能會花費更長的時間,比如200ms。也就是說,非阻塞調用只花費20納秒,阻塞調用花費2億納秒。采用阻塞調用花費的時間會比非阻塞調用長100億倍。
內核同時提供了兩種方式。一種是阻塞I/O,即從當前網絡連接讀取并返回數據。另外一種是非阻塞I/O,當某個網絡連接有新數據的時候就通知我。采用何種方式決定了時間長短方面巨大的差異。
調度
第三個需要考慮的關鍵問題是當有很多線程或進程啟動阻塞時的情況。
對于我們要討論的這個問題,線程和進程之間并沒有太大差別。在實際情況下,有一點是和性能相關的,值得注意:線程共享內存,進程有他們自己的內存空間,所以多進程會消耗更多的內存。但說到調度,歸根結底不過是獲取CPU時間片的問題。如果在一個8核心計算機上運行300個線程,你需要把時間劃分,每個線程只能獲取到一份。在每個線程上運行一段時間后,CPU便會移動到下一個線程上去。這項操作是通過"上下文切換"實現的,它可以把CPU從運行一個線程的狀態切換到運行下一個的狀態。
上下文切換也是有開銷的。快則100納秒,用到1000納秒以上也是很常見的,這個時間和具體實現、處理器速度/架構和CPU緩存有關。
線程越多,上下文切換越頻繁。如果有成千上萬的線程,每個切換都需要花費上百納秒,運行速度自然會變慢。
總算說完理論部分了,接下來開始說點有趣的:看一下幾種流行語言這方面的實現機制,并且得出如何在性能和易用性之間做出權衡,當然也會有一些趣聞分享。
有一點需要說明一下, 下文中舉的例子都比較簡單(只是展示了必要的部分);數據庫訪問,外部緩存系統,還有其他終究會在底層進行類似的I/O調用的操作,都和例子中的情況類似。并且,在I/O被以阻塞方式呈現的場景(PHP,Java),或者是http請求和回復的讀寫請求自我阻塞的情況下,更多的連帶性能問題需要考慮在內。
在考慮選用何種編程語言時,有許多因素要考慮在內。即便是只考慮性能問題,也會有很多因素。但如果你的項目將首先考慮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 Model PHP
非常簡單,每個進程處理一個請求。I/O請求是阻塞的。優勢是什么?簡單好用。劣勢是什么?當2萬客戶端同事訪問服務器時,服務器會崩的。這種方式的可拓展性不好,因為沒有用到內核提供的專門用來處理大容量I/O的工具(epoll
等)。并且雪上加霜的是,為每個請求啟動一個隔離的進程會消耗掉很多系統資源,尤其是內存,往往會被先用完。
注意:
Ruby所采用的方式和PHP類似,針對我們將的這個話題,可以說是一樣的。
多線程的方式: Java
Java大概是你買第一個域名的時候出現的。Java有內置的多線程語言支持,這一點非常棒,尤其是在當年它剛被創造的時候。
大部分Java服務器通過為每個請求啟動一個線程的方式運行,然后在這個線程中會調用你寫的某個函數。
在JavaServlet中進行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
方法對應一個請求,并運行在自己的線程中。和每個請求分散在一個進程中并且需要擁有專屬的內存不同,Java擁有獨立的線程。這樣做的好處很多,比如可以共享狀態、緩存數據等等。但在調度方面和前面說到的PHP是基本類似的。每個請求獲得一個新的線程,各種I/O操作在線程內阻塞,直到請求被完全處理完成。線程會被池化,將會把創建和銷毀進程的開銷降到最低。但問題依然存在,成千上萬的鏈接意味著成千上萬的線程,這對于調度器來說非常不利。
Java的1.4版本是一個重要的里程碑(在1.7版本也是一個顯著的升級),增加了對非阻塞I/O調用的支持。大部分應用,不管是web還是別的,都可以用上這個功能了。一些Java Web服務器嘗試從多種途徑利用這個特性;然而,大量已經部署的Java應用還是按上面描述的舊有模式運行。
Java提供了更好的方案,也有一些自帶的非常好的特性。但它還是不能解決高強度I/O應用面臨的問題,原因便是它還是會創建大量的阻塞線程。
非阻塞I/O作為一等公民: Node
為了獲得更好的I/O性能,最具誘惑力的方案是Node.js。隨便哪個人,用最簡潔的話語介紹Node時,都會說它是『非阻塞』的,并且可以高效地處理I/O。通常,這樣說是沒問題的。但是,魔鬼藏在細節中,這個黑魔法在帶來性能提升的同時,也帶來了麻煩。
從根本上講,范式從『在這里寫下代碼,也在這里處理請求』變成了『在這里寫下代碼,從這里開始處理請求』。每次你要實現和I/O相關的功能,就需要發起請求并且傳入一個回掉函數,當任務完成之后,這個回掉函數會被調用。
典型的網絡請求中處理I/O的Node代碼如下:
http.createServer(function(request, response) {
fs.readFile('/path/to/file', 'utf8', function(err, data) {
response.end(data);
});
});
從上面的代碼你可以看出,有兩個回調函數存在。第一個在請求開始時被調用,另一個當文件數據讀取到之后被調用。
基本上這就給了Node一個機會來高效地在這些回調之間處理I/O。進行數據庫調用的場景更加典型,但我不會舉那個例子,以免引入過多的復雜性:啟動一個數據庫調用,傳給Node一個回調函數,它使用非阻塞系統調用執行I/O操作,當請求的數據到達之后,便調用回調函數。這種把I/O請求放入隊列,讓Node.js處理它,然后調用回調函數的方式叫做『事件循環』。它運行的非常好。
在底層,V8引擎的實現對于這種模式起到了決定性的作用。你寫的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循環。這意味著如果你有一萬個連接,這個循環會讓你的整個程序變慢,這取決于它所花費的時間長短。
整個概念的前提是I/O操作是最慢的部分,因此高效地處理這部分功能是最重要的,即便是要把其它操作按順序執行。這個說法在大部分情況下是對的,但也不絕對。
另一點,當然這只是一個觀點,寫一大串嵌套的回調是非常煩人的,有些人抱怨代碼很難讀懂。四五層的嵌套回調在Node里面非常常見。
我們回過頭來再討論一下方案的權衡。如果你的主要性能問題是I/O,用Node非常好。然而,如果你在處理HTTP請求的代碼中無意之間增加了CPU密集型的代碼的話,它會拖慢你的整個應用,這一點可以算是Node.js的阿喀琉斯之踵。
天生的非阻塞模型: Go
在開始Go的章節之前,我要先坦誠我是Go的粉絲。我已經在很多項目上使用過它,非常認同它的高生產力,在實際項目中已經充分感受到了。
我們現在來看一下它是怎樣處理I/O的。Go這門語言的關鍵特性之一便是它擁有自己的調度器。相對于每個執行線程對應一個操作系統線程的模式,它是基于"Go程"的概念工作的。Go運行環境可以把某個Go程賦給某個操作系統線程來使其執行,或者將它掛起,解除其和操作系統線程的關聯,這取決于這個Go程正在執行的操作是什么。每個來自于Go的HTTP服務器的請求都會在一個單獨的Go程中運行。
下圖展示了調度器是怎樣工作的:自己去原文看圖吧
在底層,是通過多項Go運行時的多個特性實現的,如I/O調用以進行請求的寫/讀/連接等, 使當前的Go程休眠,當進一步的操作可以可以進行的時候再把Go程喚醒。
事實上,Go運行環境做的事情和Node.js做的事情類似,除了callback機制是內置于I/O調用,并且和調度器的交互也是自動的。同時也可以免于同一個線程中運行所有的處理邏輯這一限制,Go會自動地映射Go程到足夠多的系統線程上,數量多少有調度器自行斟酌。示例代碼如下:
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調度器和系統調度器的通力合作把剩下的工作都完成了。這并不完全是魔法,如果你正在構建一個大型的系統,花點時間多了解一些它的細節還是非常值得的;但與此同時,開箱即用的環境的運行和延展效果非常好。
Go語言也有其弊端,但通常來說,它處理I/O的方式并沒有什么問題。
謊言,該死的謊言以及基準測試
對于這幾種模型,很難給出他們關于上下文切換的準確結論。同時我也覺得這些對你并沒有什么用處。相反,我將給出一些基本的基準測試,比較整體的HTTP服務器性能。請記住,從HTTP的請求/響應的這一端到另一端的沿途,有很多影響因素存在,這里展示的數據只是基于我找來的例子,以求給出一些基本的比較。
對于每一個環境,我寫了代碼從一個64Kb的文件中隨機讀取字節,對其執行N次SHA-256哈希運算。其中N通過URL的query string來指定。然后以hex格式打印哈希運算的結果。我選擇這個例子是因為用它來運行基準測試非常簡單,可以進行一致的I/O,可以以一種有效的方式增加CPU利用率。
基準測試的記錄里有針對環境的介紹。
首先看一下低并發的例子,以300的并發數運行2000次請求,每個請求只進行一次哈希操作,結果如下:
PHP用時0.467ms; Java用時0.295ms;Node用時0.499ms;Go用時0.224ms。
用時是指完成請求用時的平均值。越低越好。
僅從這一個圖很難得出結論;在這個量級,用時更多地取決于語言的運行效率而非I/O。我們一般說的『腳本語言』(動態類型,動態解釋)運行比較慢。
那如果我們把N變成1000,依舊是300個并發請求,負載相同,但是哈希操作變成了原來的100多倍。結果如下:
PHP用時110.97ms; Java用時128.096ms;Node用時206.978ms;Go用時99.658ms。
非常令人震驚,Node性能顯著下降,因為每個請求上CPU密集型的操作會相互阻塞。有趣的是,PHP的性能變得更好了(這與其它因素有關),并且擊敗了Java。(這并不能說明什么問題,因為PHP的SHA-256運算是用C語言寫的,因為現在是1000次哈希操作,執行路徑反倒會拖慢執行。)
現在我們試一下5000個并發連接、一次哈希運算,或者是接近于這個數值。不行的是,大部分環境都有不同程度的失敗率。下圖所示,我們來看一下每秒處理的請求數。越高越好:
PHP約900次,Java約2300次,Node約2200次,Go約4600次。
這幅圖看起來不同于以往。我猜是因為高連接數情況下,每個連接都需要進行創建新進程,會用到更多的內存,這可能是導致PHP變慢的主要原因。顯然,在這里Go是贏家,接下來是Java和Node,最后是PHP。
影響每個應用容量大小的因素不盡相同,你越理解你任務的核心,也就是底層發生的操作,以及要做出何種權衡,你就能把程序做的越好。
總結
基于上述所有內容,我們可以得出清晰的結論,隨著語言的演進,對于大規模應用的大量I/O問題的解決方案也在演進。
公平地講,對于PHP還是Java,除了文中的描述,確實有在Web應用中使用 非阻塞IO的實現。但是這些方案并不如上面說到的常見,而且使用這些方案帶來的運維成本也需要考慮。更不用說你的代碼要改變結構來適應某種環境;你的普通PHP或者Java應用如果不大改在這些環境里根本運行不起來。
如果考慮影響性能的幾個顯著特性已經易用性,我們會得出下面這張表:
語言 | 線程還是進程 | 非阻塞I/O | 易用性 |
---|---|---|---|
PHP | 進程 | 無 | |
Java | 線程 | 可以做到 | 需要回調 |
Node.js | 線程 | 有 | 需要回調 |
Go | 線程(Go程) | 有 | 不需要回調 |
線程一般比進程內存使用效率更高,原因便是線程可以共享內存,進程不能。除了這一點還有非阻塞I/O以及上面的其它因素綜合考慮,如果要選擇一個獲勝者的話,那肯定是Go。
即便如此,在實際情況下,開發環境的選取還取決于團隊對該環境的熟悉程度,在這個平臺上的整體效率。因此,并不是所有的團隊都開始用Node或Go來開發程序。事實上,開發人員的招募或者團隊成員對于技術的熟悉程度經常作為是否選擇某個語言的決定性因素。即便是如此,時間在之前的15年中還是改變了很多東西。
希望本文的內容可以為你描繪一幅清晰的藍圖,使你可以知曉底層發生了什么,是你可以應對現實中的可拓展性問題。高興地進行輸入和輸出操作!
文章列表