Nginx 引入執行緒池,提升 9 倍效能

喬永琪發表於2015-07-07

介紹

眾所周知,NGINX 採用非同步、事件驅動的方式處理連線。意味著無需對每個請求建立專門的程式或執行緒,它用一個工作程式(worker process)處理多個連線和請求。為了達到這個目的,NGINX採用非阻塞模式的 socket,並利用諸如 epoll 和 kqueue 的高效方法。

全量程式(full-weight process)數很少(通常是一個 CPU 核只有一個)而且恆定、記憶體開銷少、CPU 週期不會浪費在任務切換上。此方法的優勢因為NGINX而廣為人知。它能同時處理成千上萬請求,而且容易擴充套件。

Each process consumes additional memory, and each switch between them consumes CPU cycles and trashes L-caches

每個程式消耗額外的記憶體,程式之間的每次切換都會消耗 CPU 週期和丟棄 CPU 快取

不過非同步、事件驅動方式依然存在一個問題,或者可以說是敵人。其名字就是:阻塞。不幸的是,許多第三方模組採用阻塞方式呼叫,使用者(有時甚至這些模組的開發者)都沒有意識到這個缺陷。阻塞操作會毀掉 NGINX 效能,必須採取一切手段避免這樣的問題。

甚至在當前 NGINX 官方程式碼中,也無法在每個例子中避免阻塞操作,為了解決這個問題,NGINX 1.7.11 版實現了新的執行緒池機制。它是什麼,如何使用?我會在後面說明。我們先看看我們的敵人。

問題

首先,為了更好的理解問題,我們先簡單看看NGINX是如何工作的。

總體來說,NGINX 是一個事件處理器,一個從核心接收所有發生在連線上的事件資訊的控制器,然後給作業系統釋出命令。實際上,NGINX 通過編排作業系統做了全部的辛苦工作,作業系統則做了讀位元組和傳送位元組等日常工作。可見 NGINX 快速及時響應是如此重要。

NGINX-Event-Loop2

工作程式監聽、處理來自核心中的事件。

事件可能是某個超時,或者socket準備讀取或者寫入的通知,或者錯誤發生的通知。NGINX 接收一串事件,接著挨個處理,做一些必要的動作。這些處理都線上程佇列的簡單迴圈中完成。NGINX 從佇列中放出一個事件,接著做出反應,例如寫或者讀一個 socket。在許多案例中,這非常快(也許只需要很少的CPU 週期就可以將資料複製到記憶體中),並且 NGINX  會立即處理佇列中所有的事件。

Events Queue Processing Cycle

所有處理是在一個簡單的迴圈中由某個執行緒完成的

但是如果遇到某些又長又重操作,又會怎樣呢?整個事件處理週期可能會卡在那裡等待此操作結束。

我們說的“阻塞操作”是指會讓處理迴圈明顯停止一段時間的操作。阻塞的原因多種多樣。比如,NGINX忙於漫長的 CPU 密集型處理,或者不得不等待獲取某個資源,比如硬體驅動、某個互斥鎖、庫函式以同步方式呼叫資料庫響應。最關鍵的是處理諸如此類的操作,工作程式就沒有辦法做其他的事情,處理其他的事件,即使系統有更多可用資源可供佇列中某些事件使用。

試想商店裡售貨員,面前排著一個很長的佇列。排第一的顧客需要的貨物在倉庫,不是在店裡,售貨員去倉庫搬運貨物。為此整個佇列需要等待幾個小時,等待的人都會不高興的。你能想象人們的反應麼?佇列中每個人等待時間因為這幾個小時而增加,但他們想買的貨物或許就在商店裡。

Faraway Warehouse

佇列中的每一個人不得不等待第一個人的訂單

幾乎同樣的場景發生在 NGINX 中,需要讀取一個檔案,但它沒有快取在記憶體,不得不從硬碟中讀取。硬碟很慢(特別是旋轉的機械硬碟),然而佇列中其他等待的請求即使無需讀取硬碟,也被迫等待。結果增加了延遲,系統資源沒有被充分利用。

Blocking-Operation

僅僅一個阻塞操作就能長時間地延遲接下來所有的操作

某些作業系統(比如 FreeBSD)提供了一個讀檔案和傳送檔案的非同步介面,NGINX可以呼叫這個介面(見 aio 指令)。不幸的是,Linux 並非都如此。儘管 Linux系統也提供了讀取檔案的非同步介面,但它有兩個重大缺陷。其一是檔案讀取和快取時需要對齊,不過 NGINX 能處理地很好。第二個問題更糟,非同步介面需要在檔案描述符上作 O_DIRECT 標記,這樣任何獲取檔案的操作越過記憶體級的快取,增加了硬碟負載。在很多例子中,這真不是一個好的選擇。

為解決這個問題,NGINX 1.7.11 引入了執行緒池。NGINX Plus 預設狀態下沒有執行緒池,如果你想給 NGINX Plus R6 構建一個執行緒池,請聯絡銷售。

讓我們深入探究什麼是執行緒池、它是如何工作的。

執行緒池

讓我們回到剛才那個可憐的銷售助理,從很遠的倉庫取貨物。但是他變聰明瞭,也或許因為憤怒地顧客鄙視變得聰明瞭?購買了一套配送服務。現在有人想購買遠距離倉庫中的貨物,銷售助理無需前往,只需要將訂單轉給配送服務,後者會處理這個訂單,銷售助理可以繼續為其他顧客服務。由此只有貨物不再商鋪的顧客需要等待貨物提取,其他顧客能夠快速得到服務。

Your Order Next
把訂單轉給配送服務,這樣就不會阻塞佇列了

對 NGINX 而言,執行緒池就是充當配送服務的角色,它由一個任務佇列和一組處理佇列的執行緒組成。一旦工作程式需要處理某個可能的長操作,不用自己操作,將其作為一個任務放出執行緒池的佇列,接著會被某個空閒執行緒提取處理。

Thread Pool

工作程式把阻塞操作轉給執行緒池

像是擁有了一個新的佇列,不過本例中的佇列侷限於某個特定的資源。從硬碟中讀取資料的速度不會超過硬碟生成資料的速度。硬碟沒有延遲處理其他事件,僅僅需要獲取檔案的請求在等待。

硬碟讀取操作通常就是阻塞操作,不過NGINX中的執行緒池可以用來處理任何在主工作週期不適合處理的任務。

此刻分派給執行緒池的任務主要有兩個:許多作業系統上 read() 方法的系統呼叫,以及 Linux 系統的 sendfile()方法。我們會繼續測試(test)和基準測試(benchmark),未來發布的版本或許將其他的操作分派給執行緒池。

 

基準測試

I到了從理論到實踐的時候了,為了展示利用執行緒池的效果,我們打算設定一個合成基準模擬最糟糕的阻塞或者非阻塞操作。

資料集不能超出記憶體,在一個 48GB 記憶體機器上,生成 256GB 隨機資料,每個檔案大小 4MB ,接著配置 NGINX 1.9.0 為其提供服務。

配置及其簡單:

正如你所看到的,為了獲得更好的效能,我們做了一些調優:關閉了 loggin 和 accept_mutex,同時開啟了 sendfile(),設定 sendfile_max_chunk 大小為512K。最後面的指令可以減少阻塞方法 sendfile() 呼叫的所花費的最大時間,即 NGINX 每次無需傳送整個檔案,只傳送 512KB 的塊資料。

計算機含有兩個英特爾至強 E5645 處理器(Intel Xeon E5645),以及 10Gbps 網路介面。硬碟子系統由四個西部資料 WD1003FBYX 硬碟按放在一個 RAID10 陣列中。所有硬體由Ubuntu Server 14.04.1 LTS進行管理。

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

客戶端由兩個配置相同的計算機組成,其中一臺,wrk 通過用 Lua 指令碼建立負載。指令碼通過  200 個並行連線,隨機向伺服器請求檔案。每一個請求可能導致快取失效、產生一個硬碟阻塞讀操作。姑且稱這種負載叫隨機負載。

在第二臺客戶端計算機上,我們執行另一個 wrk 拷貝,用 50 個並行連線多次訪問同一個檔案。因為檔案高頻訪問,它會一直留在記憶體中。通常,NGINX可以非常快地處理這些請求,不過工作程式一旦阻塞被其他請求阻塞,效能就會下滑。姑且稱這種負載為恆定負載。

利用 ifstat 命令獲取第二臺客戶端的 wrk 結果,來監控伺服器吞吐量,並以此測定伺服器效能。

第一次沒有執行緒池參與的執行,並沒有帶給我們什麼驚喜的結果:

正如你所看到的,如此配置的伺服器產生流量總計大約為 1 Gbps。從 top 的輸出資訊,我們可以看出所有工作程式在阻塞輸入輸出上花費的時間:

I本例中,吞吐量的短板為硬碟子系統,CPU大多數時間都在空閒。wrk 輸出結果看吞吐量很低:

記住,應該從記憶體送達檔案。巨大的延遲是因為所有的工作程式忙於從硬碟讀取檔案,響應第一個客戶端的 200 個連線建立的隨機負載,無法處理我們的請求。

是時候讓執行緒登場了,為此我們給 location 模組新增了 aio threads 指令:

請求NGINX過載其配置

重新測試結果:

此時伺服器產生 9.5 Gbps 流量,而沒有執行緒池參與時只產生大約 1 Gbps 的流量。

甚至可以產生更多流量,不過這已經達到實際網路最大容量。由此可見本測試中,制約NGINX因素為網路介面。工作程式大部分時間在休眠和等待新事件,參見top輸出S state

仍有充裕的CPU資源。

wrk執行結果:

處理一個 4 MB檔案的平均時間由 7.42 秒降到 226.32 毫秒,降低至少33倍。同時,每秒請求數提高31倍。

這是因為我們的請求無需在事件佇列中等待處理,即使工作程式阻塞在讀操作上,請求可以由空閒的執行緒來完成處理。只要硬碟子系統表現出色,NGINX很好地為來自第一個客戶端的隨機負載服務,它就可以利用剩餘的CPU資源和網路容量,從記憶體讀取,為第二個客戶端的請求服務。

 

這並非是銀彈

當我們經歷了阻塞操作的帶來的恐懼以及執行緒池帶來的興奮感之後,或許我們中的多數人已經打算在伺服器中配置執行緒池。別急!

幸運的是,多數讀寫檔案操作無需處理緩慢的硬碟,如果你有足夠的記憶體,作業系統會足夠聰明把那些高頻次訪問的檔案快取到一個稱之為“頁面快取”(page cache)中。

頁面快取表現優異,使得 NGINX 幾乎在通常的用例中效能表現突出。從頁面快取中讀取速度非常快,沒有人認為類操作是“阻塞”的。換言之,分派負載給執行緒池會帶來一些開銷。

所以,如果有合適的記憶體,並且資料集不大,那麼無需執行緒池,NGINX 就可以在最佳效能下工作。

分派讀操作給執行緒池是一種對針對特定任務的技術。頻次非常高的請求內容不適合放入作業系統虛擬快取中,這時候執行緒池就很有了。或許就是如此,例如,重量級基於NGINX負載流媒體伺服器。我們的基準測試已模仿這個場景。

如果能將讀操作分派給執行緒池是極好的,我們所要做的是需要的檔案資料是否在記憶體中,如果不在記憶體中,那麼我們就應該將讀操作分派給某個執行緒。

回到銷售的例子,當下銷售員面臨的情況是,不知道請求物品是否在店鋪,要麼將所有的訂單傳給提取貨物服務,要麼他自己處理這些訂單。

要命的是,作業系統可能永遠沒有這個功能。第一次嘗試是 2010 年 linux 中引入 fincore() 系統呼叫方法,沒有成功。接著做了一系列嘗試,例如引入新的帶有 RWF_NONBLOCK 標記的 preadv2() 系統呼叫方法。所有的這些補丁前景依舊不明朗。比較悲劇的是,因為持續的口水戰,導致這些補丁一直沒有被核心接受。

另一個原因是,FreeBSD使用者根本不會關心這個。因為 FreeBSD 已經有一個非常高效的非同步檔案讀取介面,完全可以不用執行緒池。

 

配置執行緒池

如果確信你的用例採用執行緒池可以獲利,那麼是時候深入其配置了。

執行緒池配置非常容易而且靈活。首先你需要 NGINX 1.7.11 版,或者更新的版本,採用配置檔案中的引數 –with-threads 進行編譯。最簡單的例子,配置看起來相當的容易,所有你需要做的的事情就是給http、server或者location上下文中新增 aio threads 指令。

這可能是最簡短的執行緒池配置了,實際上,下面這個配置是一個簡化版的:

它定義一個名為 default 的執行緒池,擁有 32 個工作執行緒,任務佇列容納的最大請求數為 65536。一旦任務佇列過載,NGINX日誌會報錯並拒絕這一請求:

報錯意味著執行緒可能處理工作的速度跟不上任務新增進佇列的速度,你可以試著增加佇列的到最大容量。如果還是不起作用,可能是系統服務請求的數量已達到了上線。

正如你所看到的,可以用thread_pool指令設定執行緒數量、佇列最大容量、為某個執行緒池命名。為某個執行緒池命名意味著你可以設定多個獨立的執行緒池,在不同的配置檔案用於不同目的。

如果沒有指定max_queue引數,它的預設值為65536。如上面所展示的,可以將max_queue設定為0。這意味這,如在本例,執行緒池只能處理分派給執行緒那些任務;因為佇列中沒有儲存任何等待的任務。

試想你的伺服器有三個硬碟,你希望伺服器能像快取代理一樣作用,快取所有來自後端的響應,預期快取的資料量遠遠超過了現有的記憶體。這個快取節點為私人內容分發網路(CDN)服務,當然本例中最重要的事情就是從硬碟那裡獲取最大效能。

一種選擇是設定一個磁碟陣列,這種方式有其優點和缺點。NGINX採用另外一種方式:

在這個設定中,用到了三個獨立的快取,對應一個硬碟。同樣也有三個獨立的執行緒池對應某個硬碟。

split_clients 模組用於快取之間的負載平衡,很好地滿足這個任務。

proxy_cache_path 指令中的引數 use_temp_path=off 指示 NGINX 儲存臨時檔案到快取資料對應的相同目錄中;在快取更新時,避免磁碟之間拷貝響應資料。

做的所有這一切,都是為了使當前硬碟子系統效能達到最大,因為NGINX中每個執行緒池與磁碟的互動都是獨立並行的。每個磁碟由 16 個獨立的執行緒為其服務,即處理某個特定任務佇列中檔案的讀取和傳送。

我猜測你的客戶端採用類似客戶定製的方式,那麼同樣確保你的硬碟驅動也採用類似的方式。

本例很好的展示了NGINX如何靈活地針對特定硬碟做出調優,就像你給出指令,告訴NGINX與計算機以及資料集的最佳互動方式。通過細粒度的NGINX調優,可以確保軟體、作業系統、硬體處在一種最佳的工作狀態,即儘可能有效地利用系統資源。

 

結論

總之,執行緒池是一個非常棒的特性,它能促使NGINX效能上一個新臺階,移除了眾所周知的頑疾——阻塞,特別是涉及海量資料的時候。

當然遠非這些,正如前面所提到的,新的介面可能會允許我們分派任何長的阻塞操作,而且不會有效能損失。NGINX開闢了新天地,擁有一批新的模組和功能。而許多流行的庫依舊沒有提供某種非同步非阻塞介面,這樣很難和NGINX相容。或許我們需要花費很多時間和資源,開發一些我們自己的非阻塞原生庫,但這樣做值得麼?隨著執行緒池特性的上線,這些庫在不影響模組效能的前提下會更加相對簡單易用。

敬請期待!

打賞支援我翻譯更多好文章,謝謝!

打賞譯者

打賞支援我翻譯更多好文章,謝謝!

任選一種支付方式

Nginx 引入執行緒池,提升 9 倍效能 Nginx 引入執行緒池,提升 9 倍效能

相關文章