本文首先簡單介紹了I/O相關的基礎概念,然後橫向比較了Node、PHP、Java、Go的I/O效能,並給出了選型建議。下面就來介紹,有需要的小夥伴可以參考一下。
了解應用程式的輸入/輸出(I/O)模型能夠更好的理解它在處理負載時理想情況與實際情況下的差異。也許你的應用程式很小,也不需要支撐太高的負載,所以這方面需要考慮的東西還比較少。但是,隨著應用程式流量負載的增加,使用錯誤的I/O模型可能會導致非常嚴重的後果。
在本文中,我們將把Node、Java、Go和PHP與Apache配對進行比較,討論不同語言如何對I/O進行建模、每個模型的優缺點,以及一些基本的效能評測。如果你比較關心自己下一個Web應用程式的I/O效能,本文將為你提供協助。
要了解與I/O相關的因素,我們必須先在作業系統層面上了解這些概念。雖然不太可能一上來就直接接觸到太多的概念,但在應用的運作過程中,不管是直接還是間接,總是會遇到它們。細節很重要。
首先,我們來認識系統調用,具體描述如下:
應用程式請求作業系統核心為其執行I/ O操作。
「系統呼叫」是指程式請求核心執行某些操作。其實作細節因作業系統而異,但基本概念是相同的。在執行「系統呼叫」時,將會有一些控製程式的特定指令轉移到核心中去。一般來說,系統呼叫是阻塞的,這意味著程式會一直等待直到核心返回結果。
核心在實體裝置(磁碟、網卡等)上執行底層I/O操作並回覆系統呼叫。在現實世界中,核心可能需要做很多事情來滿足你的請求,包括等待設備準備就緒、更新其內部狀態等等,但作為一名應用程式開發人員,你無需關心這些,這是核心的事情。
我在上面說過,系統呼叫一般來說是阻塞的。但是,有些呼叫卻屬於「非阻塞」的,這表示核心會將請求放入佇列或緩衝區中,然後立即返回而不等待實際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萬個週期。
核心提供了阻塞I/O(「從網路讀取資料」)和非阻塞I/O(「告訴我網路連線上什麼時候有新資料」)這兩種方法,並且兩種機制阻塞呼叫程序的時間長短完全不同。
第三個非常關鍵的事情是當有很多執行緒或進程開始出現阻塞時會發生什麼問題。
對我們而言,執行緒和進程之間並沒有太大的差別。而在現實中,與效能相關的最顯著的區別是,由於線程共享相同的內存,並且每個進程都有自己的內存空間,所以單個進程往往會佔用更多的內存。但是,在我們談論調度的時候,實際上講的是完成一系列的事情,並且每個事情都需要在可用的CPU核心上獲得一定的執行時間。
如果你有8個核心來運行300個線程,那麼你必須把時間分片,這樣,每個線程才能獲得屬於它的時間片,每個核心運行很短的時間,然後切換到下一個線程。這是透過「上下文切換」完成的,可以讓CPU從一個執行緒/進程切換到下一個執行緒/進程。
這種上下文切換有一定的成本,也就是需要一定的時間。快的時候可能會小於100奈秒,但如果實現細節、處理器速度/架構、CPU快取等軟硬體的不同,花個1000奈秒或更長的時間也很正常。
執行緒(或行程)數量越多,則上下文切換的次數也越多。如果存在成千上萬的線程,每個線程都要耗費幾百納秒的切換時間的時候,系統就會變得非常慢。
然而,非阻塞呼叫實質上告訴內核「只有在這些連接上有新的資料或事件到來時才呼叫我」。這些非阻塞呼叫可有效處理大I/O負載並減少上下文切換。
值得注意的是,雖然本文舉得例子很小,但資料庫存取、外部快取系統(memcache之類的)以及任何需要I/O的東西最終都會執行某種類型的I/O調用,這跟範例的原理是一樣的。
影響專案中程式語言選擇的因素很多,即使你只考慮效能方面,也存在很多的因素。但是,如果你擔心自己的程式主要受I/O的限制,而效能是決定專案成功或失敗的重要因素,那麼,下文提到的幾點建議就是你需要重點考慮的。
早在上世紀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'); ?>
很簡單:每個請求一個行程。 I/O呼叫是阻塞的。那麼優點呢?簡單又有效。缺點呢?如果有20000個客戶端並發,伺服器將會癱瘓。這種方法擴展起來比較難,因為核心提供的用於處理大量I/O(epoll等)的工具並沒有充分利用起來。更糟糕的是,為每個請求運行一個單獨的進程往往會佔用大量的系統資源,尤其是內存,這通常是第一個耗盡的。
*注意:在這一點上,Ruby的情況與PHP非常相似。
所以,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應用程式仍然按照上面所述的原理進行工作。
Java提供了許多在I/O方面開箱即用的功能,但如果遇到創建大量阻塞執行緒執行大量I/O操作的情況時,Java也沒有太好的解決方案。
在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调用然后得到一个回调的机制称为“事件循环”。这个机制非常不错。
然而,这个模型有一个问题。在底层,这个问题出现的原因跟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。
让我们看看它是如何处理I/O的吧。 Go语言的一个关键特性是它包含了自己的调度器。它并不会为每个执行线程对应一个操作系统线程,而是使用了“goroutines”这个概念。Go运行时会为一个goroutine分配一个操作系统线程,并控制它执行或暂停。Go HTTP服务器的每个请求都在一个单独的Goroutine中进行处理。
实际上,除了回调机制被内置到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使用率。
在这种存在大量连接和计算的情况下,我们看到的结果更多的是与语言本身的执行有关。请注意,“脚本语言”的执行速度最慢。
突然之间,由于每个请求中的CPU密集型操作相互阻塞,Node的性能显著下降。有趣的是,在这个测试中,PHP的性能变得更好了(相对于其他),甚至优于Java。 (值得注意的是,在PHP中,SHA-256的实现是用C语言编写的,但执行路径在这个循环中花费了更多的时间,因为我们这次做了1000次哈希迭代)。
我猜测,在较高的连接数量下,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应用程序和服务可能并不是最好的选择。
希望以上这些内容能够帮助你更清楚地了解底层发生的事情,并为你提供一些关于如何处理应用程序伸缩性的建议。
推荐学习:php视频教程
以上是Node、PHP、Java和Go服務端I/O效能大比拼,你覺得誰會贏?的詳細內容。更多資訊請關注PHP中文網其他相關文章!