這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
正如大部分存在多種解決途徑的情境一樣,重點不在於哪一種途徑更好,而是在於理解如何進行權衡。讓我們來參觀下I/O的景觀,看下可以從中竊取點什麼。
在這篇文章,我們將會結合Apache分別比較Node,Java,Go,和PHP,討論這些不同的語言如何對他們的I/O進行建模,各個模型的優點和缺點,並得出一些初步基準的結論。如果關心你下一個Web應用的I/O效能,那你就找對文章了。
I/O基礎知識:快速回顧
為了理解與I/O密切相關的因素,必須先來回顧在作業系統底層的概念。雖然不會直接處理這些概念的大部分,但通過應用程式的運行時環境你一直在間接地處理他們。而關鍵在於細節。
系統調用
首先,我們有系統調用,它可以描述成這樣:
- 你的程式(在“使用者地區”,正如他們所說的)必須讓作業系統核心在它自身執行I/O操作。
- “系統調用”(syscall)意味著你的程式要求核心做某事。不同的作業系統,實現系統調用的細節有所不同,但基本的概念是一樣的。這將會有一些特定的指令,把控制權從你的程式轉交到核心(類似函數調用但有一些專門用於處理這種情境的特殊sauce)。通常來說,系統調用是阻塞的,意味著你的程式需要等待核心返回到你的代碼。
- 核心在我們所說的物理裝置(硬碟、網卡等)上執行底層的I/O操作,並回複給系統調用。在現實世界中,核心可能需要做很多事情才能完成你的請求,包括等待裝置準備就緒,更新它的內部狀態等,但作為一名應用程式開發人員,你可以不用關心這些。以下是核心的工作情況。
阻塞調用與非阻塞調用
好了,我剛剛在上面說系統調用是阻塞的,通常來說這是對的。然而,有些調用被分類為“非阻塞”,意味著核心接收了你的請求後,把它放進了隊列或者緩衝的某個地方,然後立即返回而並沒有等待實際的I/O調用。所以它只是“阻塞”了一段非常短的時間,短到只是把你的請求入列而已。
這裡有一些有助於解釋清楚的(Linux系統調用)例子:-read() 是阻塞調用——你傳給它一個檔案控制代碼和一個存放所讀到資料的緩衝,然後此調用會在當資料好後返回。注意這種方式有著優雅和簡單的優點。-epoll_create(),epoll_ctl() ,和 epoll_wait()這些調用分別是,讓你建立一組用於偵聽的控制代碼,從該組添加/刪除控制代碼,和然後直到有活動時才阻塞。這使得你可以通過一個線程有效地控制一系列I/O操作。如果需要這些功能,這非常棒,但也正如你所看到的,使用起來當然也相當複雜。
理解這裡分時差異的數量級是很重要的。如果一個CPU核心運行在3GHz,在沒有最佳化的情況下,它每秒執行30億次迴圈(或者每納秒3次迴圈)。非阻塞系統調用可能需要10納秒這樣數量級的周期才能完成——或者“相對較少的納秒”。對於正在通過網路接收資訊的阻塞調用可能需要更多的時間——例如200毫秒(0.2秒)。例如,假設非阻塞調用消耗了20納秒,那麼阻塞調用消耗了200,000,000納秒。對於阻塞調用,你的程式多等待了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年代的時候,很多人穿著匡威鞋,用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是阻塞的。優點是什麼呢?簡單,可行。那缺點是什麼呢?同時與20,000個用戶端串連,你的伺服器就掛了。由於核心提供的用於處理大容量I/O(epoll等)的工具沒有被使用,所以這種方法不能很好地擴充。更糟糕的是,為每個請求運行一個單獨的過程往往會使用大量的系統資源,尤其是記憶體,這通常是在這樣的情境中遇到的第一件事情。
注意:Ruby使用的方法與PHP非常相似,在廣泛而普遍的方式下,我們可以將其視為是相同的。
多線程的方式:Java
所以就在你買了你的第一個網域名稱的時候,Java來了,並且在一個句子之後隨便說一句“dot com”是很酷的。而Java具有語言內建的多線程(特別是在建立時),這一點非常棒。
大多數Java網站伺服器通過為每個進來的請求啟動一個新的執行線程,然後在該線程中最終調用作為應用程式開發人員的你所編寫的函數。
在Java的Servlet中執行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 方法對應於一個請求並且在自己的線程中運行,而不是每次請求都對應需要有自己專屬記憶體的單獨進程,所以我們會有一個單獨的線程。這樣會有一些不錯的優點,例如可以線上程之間共用狀態、共用快取的資料等,因為它們可以相互訪問各自的記憶體,但是它如何與調度進行互動的影響,仍然與前面PHP例子中所做的內容幾乎一模一樣。每個請求都會產生一個新的線程,而在這個線程中的各種I/O操作會一直阻塞,直到這個請求被完全處理為止。為了最小化建立和銷毀它們的成本,線程會被彙集在一起,但是依然,有成千上萬個串連就意味著成千上萬個線程,這對於調度器是不利的。
一個重要的裡程碑是,在Java 1.4 版本(和再次顯著升級的1.7 版本)中,獲得了執行非阻塞I/O調用的能力。大多數應用程式,網站和其他程式,並沒有使用它,但至少它是可獲得的。一些Java網站伺服器嘗試以各種方式利用這一點; 然而,絕大多數已經部署的Java應用程式仍然如上所述那樣工作。
Java讓我們更進了一步,當然對於I/O也有一些很好的“開箱即用”的功能,但它仍然沒有真正解決問題:當你有一個嚴重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來處理,然後擷取回呼函數的機制稱為“事件迴圈”。它工作得非常好。
然而,這個模型中有一道關卡。在幕後,究其原因,更多是如何?JavaScript V8 引擎(Chrome的JS引擎,用於Node)1,而不是其他任何事情。你所編寫的JS代碼全部都運行在一個線程中。思考一下。這意味著當使用有效非阻塞技術執行I/O時,進行中CPU綁定操作的JS可以在運行在單線程中,每個代碼塊阻塞下一個。 一個常見的例子是迴圈資料庫記錄,在輸出到用戶端前以某種方式處理它們。以下是一個例子,示範了它如何工作:
var handler = function(request, response) { connection.query('SELECT ...', function (err, rows) { if (err) { throw err }; for (var i = 0; i < rows.length; i++) { // 對每一行紀錄進行處理 } response.end(...); // 輸出結果 })};
雖然Node確實可以有效地處理I/O,但上面的例子中的for 迴圈使用的是在你主線程中的CPU周期。這意味著,如果你有10,000個串連,該迴圈有可能會讓你整個應用程式慢如蝸牛,具體取決於每次迴圈需要多長時間。每個請求必須分享在主線程中的一段時間,一次一個。
這個整體概念的前提是I/O操作是最慢的部分,因此最重要是有效地處理這些操作,即使意味著串列進行其他處理。這在某些情況下是正確的,但不是全都正確。
另一點是,雖然這隻是一個意見,但是寫一堆嵌套的回調可能會令人相當討厭,有些人認為它使得代碼明顯無章可循。在Node代碼的深處,看到嵌套四層、嵌套五層、甚至更多層級的嵌套並不罕見。
我們再次回到了權衡。如果你主要的效能問題在於I/O,那麼Node模型能很好地工作。然而,它的阿喀琉斯之踵(譯者註:來自希臘神話,表示致命的弱點)是如果不小心的話,你可能會在某個函數裡處理HTTP請求並放置CPU密集型代碼,最後使得每個串連慢得如蝸牛。
真正的非阻塞:Go
在進入Go這一章節之前,我應該披露我是一名Go粉絲。我已經在許多項目中使用Go,是其生產力優勢的公開支援者,並且在使用時我在工作中看到了他們。
也就是說,我們來看看它是如何處理I/O的。Go語言的一個關鍵特性是它包含自己的調度器。並不是每個線程的執行對應於一個單一的OS線程,Go採用的是“goroutines”這一概念。Go運行時可以將一個goroutine分配給一個OS線程並使其執行,或者把它掛起而不與OS線程關聯,這取決於goroutine做的是什麼。來自Go的HTTP伺服器的每個請求都在單獨的Goroutine中處理。
此調度器工作的,如下所示:
這是通過在Go運行時的各個點來實現的,通過將請求寫入/讀取/串連/等實現I/O調用,讓當前的goroutine進入睡眠狀態,當可採取進一步行動時用資訊把goroutine重新喚醒。
實際上,除了回調機制內建到I/O調用的實現中並自動與調度器互動外,Go運行時做的事情與Node做的事情並沒有太多不同。它也不受必須把所有的處理常式代碼都運行在同一個線程中這一限制,Go將會根據其調度器的邏輯自動將Goroutine映射到其認為合適的OS線程上。最後代碼類似這樣:
func ServeHTTP(w http.ResponseWriter, r *http.Request) { // 這裡底層的網路調用是非阻塞的 rows, err := db.Query("SELECT ...") for _, row := range rows { // 處理rows // 每個請求在它自己的goroutine中 } w.Write(...) // 輸出響應結果,也是非阻塞的}
正如你在上面見到的,我們的基本代碼結構像是更簡單的方式,並且在背後實現了非阻塞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個請求,會發生什麼呢 —— 相同的負載,但是hash迭代是之前的100倍(顯著增加了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 |
線程(Goroutine) |
是 |
|
不需要回調 |
線程通常要比進程有更高的記憶體效率,因為它們共用相同的記憶體空間,而進程則沒有。結合與非阻塞I/O相關的因素,當我們向下移動列表到一般的啟動時,因為它與改善I/O有關,可以看到至少與上面考慮的因素一樣。如果我不得不在上面的比賽中選出一個冠軍,那肯定會是Go。
即便這樣,在實踐中,選擇構建應用程式的環境與你的團隊對於所述環境的熟悉程度以及可以實現的總體生產力密切相關。因此,每個團隊只是一味地紮進去並開始用Node或Go開發Web應用程式和服務可能沒有意義。事實上,尋找開發人員或內部團隊的熟悉度通常被認為是不使用不同的語言和/或不同的環境的主要原因。也就是說,過去的十五年來,時代已經發生了巨大的變化。
希望以上內容可以協助你更清楚地瞭解幕後所發生的事件,並就如何處理應用程式現實世界中的可擴充性為你提供的一些想法。快樂輸入,快樂輸出!
原文來自:http://www.codeceo.com/article/server-i-o-performance-competition.html
本文地址: http://www.linuxprobe.com/server-io-pk.html