摘要:本文首先簡單介紹了 I/O 相關(guān)的基礎(chǔ)概念晰绎,然后橫向比較了 Node、PHP括丁、Java荞下、Go 的 I/O 性能,并給出了選型建議史飞。以下是譯文尖昏。
了解應(yīng)用程序的輸入 / 輸出(I/O)模型能夠更好的理解它在處理負(fù)載時理想情況與實際情況下的差異。也許你的應(yīng)用程序很小构资,也無需支撐太高的負(fù)載抽诉,所以這方面需要考慮的東西還比較少。但是吐绵,隨著應(yīng)用程序流量負(fù)載的增加迹淌,使用錯誤的 I/O 模型可能會導(dǎo)致非常嚴(yán)重的后果。
在本文中己单,我們將把 Node唉窃、Java、Go 和 PHP 與 Apache 配套進(jìn)行比較纹笼,討論不同語言如何對 I/O 進(jìn)行建模纹份、每個模型的優(yōu)缺點,以及一些基本的性能評測允乐。如果你比較關(guān)心自己下一個 Web 應(yīng)用程序的 I/O 性能矮嫉,本文將為你提供幫助削咆。
I/O 基礎(chǔ):快速回顧一下
要了解與 I/O 相關(guān)的因素牍疏,我們必須首先在操作系統(tǒng)層面上了解這些概念。雖然不太可能一上來就直接接觸到太多的概念拨齐,但在應(yīng)用的運行過程中鳞陨,不管是直接還是間接,總會遇到它們瞻惋。細(xì)節(jié)很重要厦滤。
系統(tǒng)調(diào)用
首先,我們來認(rèn)識下系統(tǒng)調(diào)用歼狼,具體描述如下:
應(yīng)用程序請求操作系統(tǒng)內(nèi)核為其執(zhí)行 I/O 操作掏导。
“系統(tǒng)調(diào)用” 是指程序請求內(nèi)核執(zhí)行某些操作。其實現(xiàn)細(xì)節(jié)因操作系統(tǒng)而異羽峰,但基本概念是相同的趟咆。在執(zhí)行 “系統(tǒng)調(diào)用” 時添瓷,將會有一些控制程序的特定指令轉(zhuǎn)移到內(nèi)核中去。一般來說值纱,系統(tǒng)調(diào)用是阻塞的鳞贷,這意味著程序會一直等待直到內(nèi)核返回結(jié)果。
內(nèi)核在物理設(shè)備(磁盤虐唠、網(wǎng)卡等)上執(zhí)行底層 I/O 操作并回復(fù)系統(tǒng)調(diào)用搀愧。在現(xiàn)實世界中,內(nèi)核可能需要做很多事情來滿足你的請求疆偿,包括等待設(shè)備準(zhǔn)備就緒咱筛、更新其內(nèi)部狀態(tài)等等,但作為一名應(yīng)用程序開發(fā)人員杆故,你無需關(guān)心這些眷蚓,這是內(nèi)核的事情。
阻塞調(diào)用與非阻塞調(diào)用
我在上面說過反番,系統(tǒng)調(diào)用一般來說是阻塞的沙热。但是,有些調(diào)用卻屬于 “非阻塞” 的罢缸,這意味著內(nèi)核會將請求放入隊列或緩沖區(qū)中篙贸,然后立即返回而不等待實際 I/O 的發(fā)生。所以枫疆,它只會 “阻塞” 很短的時間爵川,但排隊需要一定的時間。
為了說明這一點息楔,下面給出幾個例子(Linux 系統(tǒng)調(diào)用):
read()
是一個阻塞調(diào)用寝贡。我們需要傳遞一個文件句柄和用于保存數(shù)據(jù)的緩沖區(qū)給它,當(dāng)數(shù)據(jù)保存到緩沖區(qū)之后返回值依。它的優(yōu)點是優(yōu)雅而又簡單圃泡。epoll_create()
、epoll_ctl()
和epoll_wait()
可用于創(chuàng)建一組句柄進(jìn)行監(jiān)聽愿险,添加 / 刪除這個組中的句柄颇蜡、阻塞程序直到句柄有任何的活動。這些系統(tǒng)調(diào)用能讓你只用單個線程就能高效地控制大量的 I/O 操作辆亏。這些功能雖然非常有用风秤,但使用起來相當(dāng)復(fù)雜。
了解這里的時間差的數(shù)量級非常重要扮叨。如果一個沒有優(yōu)化過的 CPU 內(nèi)核以 3GHz 的頻率運行缤弦,那么它可以每秒執(zhí)行 30 億個周期(即每納秒 3 個周期)。一個非阻塞的系統(tǒng)調(diào)用可能需要大約 10 多個周期彻磁,或者說幾個納秒碍沐。對從網(wǎng)絡(luò)接收信息的調(diào)用進(jìn)行阻塞可能需要更長的時間惦费,比如說 200 毫秒(1/5 秒)。比方說抢韭,非阻塞調(diào)用花了 20 納秒薪贫,阻塞調(diào)用花了 200,000,000 納秒。這樣刻恭,進(jìn)程為了阻塞調(diào)用可能就要等待 1000 萬個周期瞧省。
內(nèi)核提供了阻塞 I/O(“從網(wǎng)絡(luò)讀取數(shù)據(jù)”)和非阻塞 I/O(“告訴我網(wǎng)絡(luò)連接上什么時候有新數(shù)據(jù)”)這兩種方法,并且兩種機(jī)制阻塞調(diào)用進(jìn)程的時間長短完全不同鳍贾。
調(diào)度
第三個非常關(guān)鍵的事情是當(dāng)有很多線程或進(jìn)程開始出現(xiàn)阻塞時會發(fā)生什么問題鞍匾。
對我們而言,線程和進(jìn)程之間并沒有太大的區(qū)別骑科。而在現(xiàn)實中橡淑,與性能相關(guān)的最顯著的區(qū)別是,由于線程共享相同的內(nèi)存咆爽,并且每個進(jìn)程都有自己的內(nèi)存空間梁棠,所以單個進(jìn)程往往會占用更多的內(nèi)存。但是斗埂,在我們談?wù)撜{(diào)度的時候符糊,實際上講的是完成一系列的事情,并且每個事情都需要在可用的 CPU 內(nèi)核上獲得一定的執(zhí)行時間呛凶。如果你有 8 個內(nèi)核來運行 300 個線程男娄,那么你必須把時間分片,這樣漾稀,每個線程才能獲得屬于它的時間片模闲,每一個內(nèi)核運行很短的時間,然后切換到下一個線程崭捍。這是通過 “上下文切換” 完成的尸折,可以讓 CPU 從一個線程 / 進(jìn)程切換到下一個線程 / 進(jìn)程。
這種上下文切換有一定的成本缕贡,即需要一定的時間翁授〖鸩ィ快的時候可能會小于 100 納秒晾咪,但如果實現(xiàn)細(xì)節(jié)、處理器速度 / 架構(gòu)贮配、CPU 緩存等軟硬件的不同谍倦,花個 1000 納秒或更長的時間也很正常。
線程(或進(jìn)程)數(shù)量越多泪勒,則上下文切換的次數(shù)也越多昼蛀。如果存在成千上萬的線程宴猾,每個線程都要耗費幾百納秒的切換時間的時候,系統(tǒng)就會變得非常慢叼旋。
然而仇哆,非阻塞調(diào)用實質(zhì)上告訴內(nèi)核 “只有在這些連接上有新的數(shù)據(jù)或事件到來時才調(diào)用我”。這些非阻塞調(diào)用可有效地處理大 I/O 負(fù)載并減少上下文切換夫植。
值得注意的是讹剔,雖然本文舉得例子很小,但數(shù)據(jù)庫訪問详民、外部緩存系統(tǒng)(memcache 之類的)以及任何需要 I/O 的東西最終都會執(zhí)行某種類型的 I/O 調(diào)用延欠,這跟示例的原理是一樣的。
影響項目中編程語言選擇的因素有很多沈跨,即使你只考慮性能方面由捎,也存在很多的因素。但是饿凛,如果你擔(dān)心自己的程序主要受 I/O 的限制狞玛,并且性能是決定項目成功或者失敗的重要因素,那么涧窒,下文提到的幾點建議就是你需要重點考慮的为居。
“保持簡單”:PHP
早在上世紀(jì) 90 年代,有很多人穿著 Converse 鞋子使用 Perl 編寫 CGI 腳本杀狡。然后蒙畴,PHP 來了,很多人都喜歡它呜象,它使得動態(tài)網(wǎng)頁的制作更加容易膳凝。
PHP 使用的模型非常簡單。雖然不可能完全相同恭陡,但一般的 PHP 服務(wù)器原理是這樣的:
用戶瀏覽器發(fā)出一個 HTTP 請求蹬音,請求進(jìn)入到 Apache web 服務(wù)器中。 Apache 為每個請求創(chuàng)建一個單獨的進(jìn)程休玩,并通過一些優(yōu)化手段對這些進(jìn)程進(jìn)行重用著淆,從而最大限度地減少原本需要執(zhí)行的操作(創(chuàng)建進(jìn)程相對而言是比較慢的)。
Apache 調(diào)用 PHP 并告訴它運行磁盤上的某個.php
文件拴疤。
PHP 代碼開始執(zhí)行永部,并阻塞 I/O 調(diào)用。你在 PHP 中調(diào)用的file_get_contents()
呐矾,在底層實際上是調(diào)用了read()
系統(tǒng)調(diào)用并等待返回的結(jié)果苔埋。
<?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');
?>
與系統(tǒng)的集成示意圖是這樣的:
很簡單:每個請求一個進(jìn)程。 I/O 調(diào)用是阻塞的蜒犯。那么優(yōu)點呢组橄?簡單而又有效荞膘。缺點呢?如果有 20000 個客戶端并發(fā)玉工,服務(wù)器將會癱瘓羽资。這種方法擴(kuò)展起來比較難,因為內(nèi)核提供的用于處理大量 I/O(epoll 等)的工具并沒有充分利用起來遵班。更糟糕的是削罩,為每個請求運行一個單獨的進(jìn)程往往會占用大量的系統(tǒng)資源,尤其是內(nèi)存费奸,這通常是第一個耗盡的弥激。
- 注意:在這一點上,Ruby 的情況與 PHP 非常相似愿阐。
多線程:Java
所以微服,Java 就出現(xiàn)了。而且 Java 在語言中內(nèi)置了多線程缨历,特別是在創(chuàng)建線程時非常得棒。
大多數(shù)的 Java Web 服務(wù)器都會為每個請求啟動一個新的執(zhí)行線程辛孵,然后在這個線程中調(diào)用開發(fā)人員編寫的函數(shù)丛肮。
在 Java Servlet 中執(zhí)行 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
方法對應(yīng)于一個請求,并且在自己的線程中運行魄缚,而不是在需要有獨立內(nèi)存的單獨進(jìn)程中運行宝与,所以我們將創(chuàng)建一個單獨的線程。每個請求都會得到一個新的線程冶匹,并在該線程內(nèi)部阻塞各種 I/O 操作习劫,直到請求處理完成。應(yīng)用會創(chuàng)建一個線程池以最小化創(chuàng)建和銷毀線程的成本嚼隘,但是诽里,成千上萬的連接意味著有成千上萬的線程,這對于調(diào)度器來說并不件好事情飞蛹。
值得注意的是谤狡,1.4 版本的 Java(1.7 版本中又重新做了升級)增加了非阻塞 I/O 調(diào)用的能力。雖然大多數(shù)的應(yīng)用程序都沒有使用這個特性卧檐,但它至少是可用的墓懂。一些 Java Web 服務(wù)器正在嘗試使用這個特性,但絕大部分已經(jīng)部署的 Java 應(yīng)用程序仍然按照上面所述的原理進(jìn)行工作泄隔。
Java 提供了很多在 I/O 方面開箱即用的功能拒贱,但如果遇到創(chuàng)建大量阻塞線程執(zhí)行大量 I/O 操作的情況時,Java 也沒有太好的解決方案佛嬉。
把非阻塞 I/O 作為頭等大事:Node
在 I/O 方面表現(xiàn)比較好的逻澳、比較受用戶歡迎的是 Node.js。任何一個對 Node 有簡單了解的人都知道暖呕,它是 “非阻塞” 的斜做,并且能夠高效地處理 I/O。這在一般意義上是正確的湾揽。但是細(xì)節(jié)和實現(xiàn)的方式至關(guān)重要瓤逼。
在需要做一些涉及 I/O 的操作的時候,你需要發(fā)出請求库物,并給出一個回調(diào)函數(shù)霸旗,Node 會在處理完請求之后調(diào)用這個函數(shù)。
在請求中執(zhí)行 I/O 操作的典型代碼如下所示:
http.createServer(function(request, response) {
fs.readFile('/path/to/file', 'utf8', function(err, data) {
response.end(data);
});
});
如上所示戚揭,這里有兩個回調(diào)函數(shù)诱告。當(dāng)請求開始時,第一個函數(shù)會被調(diào)用民晒,而第二個函數(shù)是在文件數(shù)據(jù)可用時被調(diào)用精居。
這樣,Node 就能更有效地處理這些回調(diào)函數(shù)的 I/O潜必。有一個更能說明問題的例子:在 Node 中調(diào)用數(shù)據(jù)庫操作靴姿。首先,你的程序開始調(diào)用數(shù)據(jù)庫操作磁滚,并給 Node 一個回調(diào)函數(shù)佛吓,Node 會使用非阻塞調(diào)用來單獨執(zhí)行 I/O 操作,然后在請求的數(shù)據(jù)可用時調(diào)用你的回調(diào)函數(shù)垂攘。這種對 I/O 調(diào)用進(jìn)行排隊并讓 Node 處理 I/O 調(diào)用然后得到一個回調(diào)的機(jī)制稱為 “事件循環(huán)”辈毯。這個機(jī)制非常不錯。
然而搜贤,這個模型有一個問題谆沃。在底層,這個問題出現(xiàn)的原因跟 V8 JavaScript 引擎(Node 使用的是 Chrome 的 JS 引擎)的實現(xiàn)有關(guān)仪芒,即:你寫的 JS 代碼都運行在一個線程中唁影。請思考一下。這意味著掂名,盡管使用高效的非阻塞技術(shù)來執(zhí)行 I/O据沈,但是 JS 代碼在單個線程操作中運行基于 CPU 的操作,每個代碼塊都會阻塞下一個代碼塊的運行饺蔑。有一個常見的例子:在數(shù)據(jù)庫記錄上循環(huán)锌介,以某種方式處理記錄,然后將它們輸出到客戶端。下面這段代碼展示了這個例子的原理:
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
循環(huán)在一個主線程中使用了 CPU 周期隆敢。這意味著如果你有 10000 個連接,那么這個循環(huán)就可能會占用整個應(yīng)用程序的時間崔慧。每個請求都必須要在主線程中占用一小段時間拂蝎。
這整個概念的前提是 I/O 操作是最慢的部分,因此惶室,即使串行處理是不得已的温自,但對它們進(jìn)行有效處理也是非常重要的。這在某些情況下是成立的皇钞,但并非一成不變悼泌。
另一點觀點是,寫一堆嵌套的回調(diào)很麻煩夹界,有些人認(rèn)為這樣的代碼很丑陋馆里。在 Node 代碼中嵌入四個、五個甚至更多層的回調(diào)并不罕見掉盅。
又到了權(quán)衡利弊的時候了也拜。如果你的主要性能問題是 I/O 的話,那么這個 Node 模型能幫到你趾痘。但是慢哈,它的缺點在于,如果你在一個處理 HTTP 請求的函數(shù)中放入了 CPU 處理密集型代碼的話永票,一不小心就會讓每個連接都出現(xiàn)擁堵卵贱。
原生無阻塞:Go
在介紹 Go 之前改衩,我透露一下敷燎,我是一個 Go 的粉絲。我已經(jīng)在許多項目中使用了 Go搪柑。
讓我們看看它是如何處理 I/O 的吧世分。 Go 語言的一個關(guān)鍵特性是它包含了自己的調(diào)度器编振。它并不會為每個執(zhí)行線程對應(yīng)一個操作系統(tǒng)線程,而是使用了 “goroutines” 這個概念臭埋。Go 運行時會為一個 goroutine 分配一個操作系統(tǒng)線程踪央,并控制它執(zhí)行或暫停。Go HTTP 服務(wù)器的每個請求都在一個單獨的 Goroutine 中進(jìn)行處理瓢阴。
調(diào)度程序的工作原理如下所示:
實際上畅蹂,除了回調(diào)機(jī)制被內(nèi)置到 I/O 調(diào)用的實現(xiàn)中并自動與調(diào)度器交互之外,Go 運行時正在做的事情與 Node 不同荣恐。它也不會受到必須讓所有的處理代碼在同一個線程中運行的限制液斜,Go 會根據(jù)其調(diào)度程序中的邏輯自動將你的 Goroutine 映射到它認(rèn)為合適的操作系統(tǒng)線程中累贤。因此,它的代碼是這樣的:
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
}
如上所示少漆,這樣的基本代碼結(jié)構(gòu)更為簡單臼膏,而且還實現(xiàn)了非阻塞 I/O。
在大多數(shù)情況下检疫,這真正做到了 “兩全其美”讶请。非阻塞 I/O 可用于所有重要的事情祷嘶,但是代碼卻看起來像是阻塞的屎媳,因此這樣往往更容易理解和維護(hù)。 剩下的就是 Go 調(diào)度程序和 OS 調(diào)度程序之間的交互處理了论巍。這并不是魔法烛谊,如果你正在建立一個大型系統(tǒng),那么還是值得花時間去了解它的工作原理的嘉汰。同時丹禀,“開箱即用” 的特點使它能夠更好地工作和擴(kuò)展。
Go 可能也有不少缺點鞋怀,但總的來說双泪,它處理 I/O 的方式并沒有明顯的缺點。
性能評測
對于這些不同模型的上下文切換密似,很難進(jìn)行準(zhǔn)確的計時焙矛。當(dāng)然,我也可以說這對你并沒有多大的用處残腌。這里村斟,我將對這些服務(wù)器環(huán)境下的 HTTP 服務(wù)進(jìn)行基本的性能評測比較。請記住抛猫,端到端的 HTTP 請求 / 響應(yīng)性能涉及到的因素有很多蟆盹。
我針對每一個環(huán)境都寫了一段代碼來讀取 64k 文件中的隨機(jī)字節(jié),然后對其運行 N 次 SHA-256 散列(在 URL 的查詢字符串中指定 N闺金,例如.../test.php?n=100
)并以十六進(jìn)制打印結(jié)果逾滥。我之所以選擇這個,是因為它可以很容易運行一些持續(xù)的 I/O 操作败匹,并且可以通過受控的方式來增加 CPU 使用率寨昙。
首先,我們來看一些低并發(fā)性的例子哎壳。使用 300 個并發(fā)請求運行 2000 次迭代毅待,每個請求哈希一次(N=1),結(jié)果如下:
Times 是完成所有并發(fā)請求的平均毫秒數(shù)归榕。越低越好尸红。
從單單這一張圖中很難得到結(jié)論,但我個人認(rèn)為,在這種存在大量連接和計算的情況下外里,我們看到的結(jié)果更多的是與語言本身的執(zhí)行有關(guān)怎爵。請注意,“腳本語言” 的執(zhí)行速度最慢盅蝗。
但是如果我們將 N 增加到 1000鳖链,但仍然是 300 個并發(fā)請求,即在相同的負(fù)載的情況下將散列的迭代次數(shù)增加了 1000 倍(CPU 負(fù)載明顯更高)墩莫,會發(fā)生什么情況呢:
Times 是完成所有并發(fā)請求的平均毫秒數(shù)芙委。越低越好。
突然之間狂秦,由于每個請求中的 CPU 密集型操作相互阻塞灌侣,Node 的性能顯著下降。有趣的是裂问,在這個測試中侧啼,PHP 的性能變得更好了(相對于其他),甚至優(yōu)于 Java堪簿。 (值得注意的是痊乾,在 PHP 中,SHA-256 的實現(xiàn)是用 C 語言編寫的椭更,但執(zhí)行路徑在這個循環(huán)中花費了更多的時間哪审,因為我們這次做了 1000 次哈希迭代)。
現(xiàn)在甜孤,讓我們試試 5000 個并發(fā)連接(N=1) 协饲。不幸的是,對于大多數(shù)的環(huán)境來說缴川,失敗率并不明顯茉稠。我們來看看這個圖表中每秒處理的請求數(shù),越高越好:
每秒處理的請求數(shù)把夸,越高越好而线。
這個圖看起來跟上面的不太一樣。我猜測恋日,在較高的連接數(shù)量下膀篮,PHP + Apache 中產(chǎn)生新進(jìn)程和內(nèi)存的申請似乎成為了影響 PHP 性能的主要因素。 很顯然岂膳,Go 是這次的贏家誓竿,其次是 Java,Node谈截,最后是 PHP筷屡。
雖然涉及到整體吞吐量的因素很多涧偷,而且應(yīng)用程序和應(yīng)用程序之間也存在著很大的差異,但是毙死,越是了解底層的原理和所涉及的權(quán)衡問題燎潮,應(yīng)用程序的表現(xiàn)就會越好。
總結(jié)
綜上所述扼倘,隨著語言的發(fā)展确封,處理大量 I/O 大型應(yīng)用程序的解決方案也隨之發(fā)展。
公平地說再菊,PHP 和 Java 在 web 應(yīng)用方面都有可用的非阻塞 I/O 的實現(xiàn)爪喘。但是這些實現(xiàn)并不像上面描述的方法那么使用廣泛,并且還需要考慮維護(hù)上的開銷袄简。更不用說應(yīng)用程序的代碼必須以適合這種環(huán)境的方式來構(gòu)建腥放。
我們來比較一下幾個影響性能和易用性的重要因素:
語言 線程與進(jìn)程 非阻塞 I/O 易于使用
| PHP | 進(jìn)程 | 否 | - |
| Java | 線程 | 有效 | 需要回調(diào) |
| Node.js | 線程 | 是 | 需要回調(diào) |
| Go | 線程 (Goroutines) | 是 | 無需回調(diào) |
因為線程會共享相同的內(nèi)存空間泛啸,而進(jìn)程不會绿语,所以線程通常要比進(jìn)程的內(nèi)存效率高得多。在上面的列表中候址,從上往下看吕粹,與 I/O 相關(guān)的因素一個比一個好。所以岗仑,如果我不得不在上面的比較中選擇一個贏家匹耕,那肯定選 Go。
即便如此荠雕,在實踐中稳其,選擇構(gòu)建應(yīng)用程序的環(huán)境與你團(tuán)隊對環(huán)境的熟悉程度以及團(tuán)隊可以實現(xiàn)的整體生產(chǎn)力密切相關(guān)。所以炸卑,對于團(tuán)隊來說既鞠,使用 Node 或 Go 來開發(fā) Web 應(yīng)用程序和服務(wù)可能并不是最好的選擇。
希望以上這些內(nèi)容能夠幫助你更清楚地了解底層發(fā)生的事情盖文,并為你提供一些關(guān)于如何處理應(yīng)用程序伸縮性的建議嘱蛋。strong text
原文 :Server-side I/O Performance: Node vs. PHP vs. Java vs. Go
作者:BRAD PEABODY
翻譯:雁驚寒