天天看點

NGINX引入線程池 性能提升9倍

因為滿負載程序的數量很少(通常每核CPU隻有一個)而且恒定,是以任務切換隻消耗很少的記憶體,而且不會浪費CPU周期。通過NGINX本身的執行個體,這種方法的優點已經為衆人所知。NGINX可以非常好地處理百萬級規模的并發請求。

每個程序都消耗額外的記憶體,而且每次程序間的切換都會消耗CPU周期并丢棄CPU高速緩存中的資料。

但是,異步、事件驅動方法仍然存在問題。或者,我喜歡将這一問題稱為“敵兵”,這個敵兵的名字叫阻塞(blocking)。不幸的是,很多第三方子產品使用了阻塞調用,然而使用者(有時甚至是子產品的開發者)并不知道阻塞的缺點。阻塞操作可以毀掉NGINX的性能,我們必須不惜一切代價避免使用阻塞。

首先,為了更好地了解這一問題,我們用幾句話說明下NGINX是如何工作的。

通常情況下,NGINX是一個事件處理器,即一個接收來自核心的所有連接配接事件的資訊,然後向作業系統發出做什麼指令的控制器。實際上,NGINX幹了編排作業系統的全部髒活累活,而作業系統做的是讀取和發送位元組這樣的日常工作。是以,對于NGINX來說,快速和及時的響應是非常重要的。

工作程序監聽并處理來自核心的事件

事件可以是逾時、socket讀寫就緒的通知,或者發生錯誤的通知。NGINX接收大量的事件,然後一個接一個地處理它們,并執行必要的操作。是以,所有的處理過程是通過一個線程中的隊列,在一個簡單循環中完成的。NGINX從隊列中取出一個事件并對其做出響應,比如讀寫socket。在多數情況下,這種方式是非常快的(也許隻需要幾個CPU周期,将一些資料複制到記憶體中),NGINX可以在一瞬間處理掉隊列中的所有事件。

所有處理過程是在一個簡單的循環中,由一個線程完成

但是,如果NGINX要處理的操作是一些又長又重的操作,又會發生什麼呢?整個事件處理循環将會卡住,等待這個操作執行完畢。

是以,所謂“阻塞操作”是指任何導緻事件處理循環顯著停止一段時間的操作。操作可以由于各種原因成為阻塞操作。例如,NGINX可能因長時間、CPU密集型處理,或者可能等待通路某個資源(比如硬碟,或者一個互斥體,亦或要從處于同步方式的資料庫獲得相應的庫函數調用等)而繁忙。關鍵是在處理這樣的操作期間,工作程序無法做其他事情或者處理其他事件,即使有更多的可用系統資源可以被隊列中的一些事件所利用。

我們來打個比方,一個商店的營業員要接待他面前排起的一長隊顧客。隊伍中的第一位顧客想要的某件商品不在店裡而在倉庫中。這位營業員跑去倉庫把東西拿來。現在整個隊伍必須為這樣的配貨方式等待數個小時,隊伍中的每個人都很不爽。你可以想見人們的反應吧?隊伍中每個人的等待時間都要增加這些時間,除非他們要買的東西就在店裡。

隊伍中的每個人不得不等待第一個人的購買

在NGINX中會發生幾乎同樣的情況,比如當讀取一個檔案的時候,如果該檔案沒有緩存在記憶體中,就要從磁盤上讀取。從磁盤(特别是旋轉式的磁盤)讀取是很慢的,而當隊列中等待的其他請求可能不需要通路磁盤時,它們也得被迫等待。導緻的結果是,延遲增加并且系統資源沒有得到充分利用。

一個阻塞操作足以顯著地延緩所有接下來的操作

現在,讓我們走進線程池,看看它是什麼以及如何工作的。

讓我們回到那個可憐的,要從大老遠的倉庫去配貨的售貨員那兒。這回,他已經變聰明了(或者也許是在一群憤怒的顧客教訓了一番之後,他才變得聰明的?),雇用了一個配貨服務團隊。現在,當任何人要買的東西在大老遠的倉庫時,他不再親自去倉庫了,隻需要将訂單丢給配貨服務,他們将處理訂單,同時,我們的售貨員依然可以繼續為其他顧客服務。是以,隻有那些要買倉庫裡東西的顧客需要等待配貨,其他顧客可以得到即時服務。

傳遞訂單給配貨服務不會阻塞隊伍

對NGINX而言,線程池執行的就是配貨服務的功能。它由一個任務隊列和一組處理這個隊列的線程組成。

當工作程序需要執行一個潛在的長操作時,工作程序不再自己執行這個操作,而是将任務放到線程池隊列中,任何空閑的線程都可以從隊列中擷取并執行這個任務。

工作程序将阻塞操作卸給線程池

那麼,這就像我們有了另外一個隊列。是這樣的,但是在這個場景中,隊列受限于特殊的資源。磁盤的讀取速度不能比磁盤産生資料的速度快。不管怎麼說,至少現在磁盤不再延誤其他事件,隻有通路檔案的請求需要等待。

“從磁盤讀取”這個操作通常是阻塞操作最常見的示例,但是實際上,NGINX中實作的線程池可用于處理任何不适合在主循環中執行的任務。

目前,解除安裝到線程池中執行的兩個基本操作是大多數作業系統中的read()系統調用和Linux中的sendfile()。接下來,我們将對線程池進行測試(test)和基準測試(benchmark),在未來的版本中,如果有明顯的優勢,我們可能會解除安裝其他操作到線程池中。

現在讓我們從理論過度到實踐。我們将進行一次模拟基準測試(synthetic benchmark),模拟在阻塞操作和非阻塞操作的最差混合條件下,使用線程池的效果。

另外,我們需要一個記憶體肯定放不下的資料集。在一台48GB記憶體的機器上,我們已經産生了每檔案大小為4MB的随機資料,總共256GB,然後配置NGINX,版本為1.9.0。

配置很簡單:

這台測試伺服器有2個Intel Xeon E5645處理器(共計:12核、24超線程)和10-Gbps的網絡接口。磁盤子系統是由4塊西部資料WD1003FBYX 磁盤組成的RAID10陣列。所有這些硬體由Ubuntu伺服器14.04.1 LTS供電。

為基準測試配置負載生成器和NGINX

在另一台用戶端機器上,我們将運作wrk的另一個副本,使用50個并行連接配接多次請求同一個檔案。因為這個檔案将被頻繁地通路,是以它會一直駐留在記憶體中。在正常情況下,NGINX能夠非常快速地服務這些請求,但是如果工作程序被其他請求阻塞的話,性能将會下降。我們将這種負載稱作恒定負載。

性能将由伺服器上ifstat監測的吞吐率(throughput)和從第二台用戶端擷取的wrk結果來度量。

現在,沒有使用線程池的第一次運作将不會帶給我們非常振奮的結果:

如上所示,使用這種配置,伺服器産生的總流量約為1Gbps。從下面所示的top輸出,我們可以看到,工作程序的大部分時間花在阻塞I/O上(它們處于top的D狀态):

在這種情況下,吞吐率受限于磁盤子系統,而CPU在大部分時間裡是空閑的。從wrk獲得的結果也非常低:

請記住,檔案是從記憶體送達的!第一個用戶端的200個連接配接建立的随機負載,使伺服器端的全部的工作程序忙于從磁盤讀取檔案,是以産生了過大的延遲,并且無法在合理的時間内處理我們的請求。

接着,執行NGINX reload重新加載配置。

然後,我們重複上述的測試:

現在,我們的伺服器産生的流量是9.5Gbps,相比之下,沒有使用線程池時隻有約1Gbps!

理論上還可以産生更多的流量,但是這已經達到了機器的最大網絡吞吐能力,是以在這次NGINX的測試中,NGINX受限于網絡接口。工作程序的大部分時間隻是休眠和等待新的事件(它們處于top的S狀态):

如上所示,基準測試中還有大量的CPU資源剩餘。

wrk的結果如下:

伺服器處理4MB檔案的平均時間從7.42秒降到226.32毫秒(減少了33倍),每秒請求處理數提升了31倍(250 vs 8)!

對此,我們的解釋是請求不再因為工作程序被阻塞在讀檔案,而滞留在事件隊列中,等待處理,它們可以被空閑的程序處理掉。隻要磁盤子系統能做到最好,就能服務好第一個用戶端上的随機負載,NGINX可以使用剩餘的CPU資源和網絡容量,從記憶體中讀取,以服務于上述的第二個用戶端的請求。

在抛出我們對阻塞操作的擔憂并給出一些令人振奮的結果後,可能大部分人已經打算在你的伺服器上配置線程池了。先别着急。

實際上,最幸運的情況是,讀取和發送檔案操作不去處理緩慢的硬碟驅動器。如果我們有足夠多的記憶體來存儲資料集,那麼作業系統将會足夠聰明地在被稱作“頁面緩存”的地方,緩存頻繁使用的檔案。

“頁面緩存”的效果很好,可以讓NGINX在幾乎所有常見的用例中展示優異的性能。從頁面緩存中讀取比較快,沒有人會說這種操作是“阻塞”。而另一方面,解除安裝任務到一個線程池是有一定開銷的。

是以,如果記憶體有合理的大小并且待處理的資料集不是很大的話,那麼無需使用線程池,NGINX已經工作在最優化的方式下。

解除安裝讀操作到線程池是一種适用于非常特殊任務的技術。隻有當經常請求的内容的大小,不适合作業系統的虛拟機緩存時,這種技術才是最有用的。至于可能适用的場景,比如,基于NGINX的高負載流媒體伺服器。這正是我們已經模拟的基準測試的場景。

我們如果可以改進解除安裝讀操作到線程池,将會非常有意義。我們隻需要知道所需的檔案資料是否在記憶體中,隻有不在記憶體中時,讀操作才應該解除安裝到一個單獨的線程中。

再回到售貨員那個比喻的場景中,這回,售貨員不知道要買的商品是否在店裡,他必須要麼總是将所有的訂單送出給配貨服務,要麼總是親自處理它們。

另一方面,FreeBSD的使用者完全不必擔心。FreeBSD已經具備足夠好的異步讀取檔案接口,我們應該用這個接口而不是線程池。

是以,如果你确信在你的場景中使用線程池可以帶來好處,那麼現在是時候深入了解線程池的配置了。

這是線程池的最簡配置。實際上的精簡版本示例如下:

這裡定義了一個名為“default”,包含32個線程,任務隊列最多支援65536個請求的線程池。如果任務隊列過載,NGINX将輸出如下錯誤日志并拒絕請求:

錯誤輸出意味着線程處理作業的速度有可能低于任務入隊的速度了。你可以嘗試增加隊列的最大值,但是如果這無濟于事,那麼這說明你的系統沒有能力處理如此多的請求了。

如果沒有指定<code>max_queue</code>參數的值,預設使用的值是65536。如上所示,可以設定<code>max_queue</code>為0。在這種情況下,線程池将使用配置中全部數量的線程,盡可能地同時處理多個任務;隊列中不會有等待的任務。

現在,假設我們有一台伺服器,挂了3塊硬碟,我們希望把該伺服器用作“緩存代理”,緩存後端伺服器的全部響應資訊。預期的緩存資料量遠大于可用的記憶體。它實際上是我們個人CDN的一個緩存節點。毫無疑問,在這種情況下,最重要的事情是發揮硬碟的最大性能。

我們的選擇之一是配置一個RAID陣列。這種方法毀譽參半,現在,有了NGINX,我們可以有其他的選擇:

在這份配置中,使用了3個獨立的緩存,每個緩存專用一塊硬碟,另外,3個獨立的線程池也各自專用一塊硬碟。

這些調優将帶給我們磁盤子系統的最大性能,因為NGINX通過單獨的線程池并行且獨立地與每塊磁盤互動。每塊磁盤由16個獨立線程和讀取和發送檔案專用任務隊列提供服務。

我敢打賭,你的客戶喜歡這種量身定制的方法。請確定你的磁盤也持有同樣的觀點。

這個示例很好地證明了NGINX可以為硬體專門調優的靈活性。這就像你給NGINX下了一道指令,讓機器和資料用最佳姿勢來搞基。而且,通過NGINX在使用者空間中細粒度的調優,我們可以確定軟體、作業系統和硬體工作在最優模式下,盡可能有效地利用系統資源。

綜上所述,線程池是一個偉大的功能,将NGINX推向了新的性能水準,除掉了一個衆所周知的長期危害——阻塞——尤其是當我們真正面對大量内容的時候。

甚至,還有更多的驚喜。正如前面提到的,這個全新的接口,有可能沒有任何性能損失地解除安裝任何長期阻塞操作。NGINX在擁有大量的新子產品和新功能方面,開辟了一方新天地。許多流行的庫仍然沒有提供異步非阻塞接口,此前,這使得它們無法與NGINX相容。我們可以花大量的時間和資源,去開發我們自己的無阻塞原型庫,但這麼做始終都是值得的嗎?現在,有了線程池,我們可以相對容易地使用這些庫,而不會影響這些子產品的性能。