導讀 | 理解應用程式的輸入/輸出(I/O)模型,意味著其在計畫處理負載與殘酷的實際使用場景之間的差異。若應用程式比較小,也沒有服務於很高的負載,也許它影響甚微。但隨著應用程式的負載逐漸上漲,採用錯誤的I/O模型有可能會讓你到處踩坑,傷痕累累。 |
正如大部分存在多種解決途徑的場景一樣,重點不在於哪一種途徑更好,而是在於理解如何進行權衡。讓我們來參觀下I/O的景觀,看看可以從中竊取點什麼。
#在這篇文章,我們將會結合Apache分別比較Node,Java,Go,和PHP,討論這些不同的語言如何對他們的I/O進行建模,各個模型的優點和缺點,並得出一些初步基準的結論。如果關心你下一個Web應用程式的I/O效能,那你就找對文章了。
I/O基礎:快速回顧為了理解與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("https://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模型能很好地工作。然而,它的阿喀琉斯之踵(
真正的非阻塞: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應用程式和服務可能沒有意義。事實上,尋找開發人員或內部團隊的熟悉度通常被認為是不使用不同的語言和/或不同的環境的主要原因。也就是說,過去的十五年來,時代已經發生了巨大的變化。
希望以上內容可以幫助你更清楚地了解幕後所發生的事件,並就如何處理應用程式現實世界中的可擴展性為你提供的一些想法。快樂輸入,快樂輸出!
以上是比較Node、PHP、Java和Go的伺服器I/O效能的詳細內容。更多資訊請關注PHP中文網其他相關文章!