上面講了很多關於nginx的進程模型,接下來,我們來看看nginx是如何處理事件的。
有人可能要問了,nginx采用多worker的方式來處理請求,每個worker里面只有一個主線程,那能夠處理的並發數很有限啊,多少個worker就能處理多少個並發,何來高並發呢?非也,這就是nginx的高明之處,nginx采用了異步非阻塞的方式來處理請求,也就是說,nginx是可以同時處理成千上萬個請求的。想想apache的常用工作方式(apache也有異步非阻塞版本,但因其與自帶某些模塊沖突,所以不常用),每個請求會獨占一個工作線程,當並發數上到幾千時,就同時有幾千的線程在處理請求了。這對操作系統來說,是個不小的挑戰,線程帶來的內存占用非常大,線程的上下文切換帶來的cpu開銷很大,自然性能就上不去了,而這些開銷完全是沒有意義的。
為什么nginx可以采用異步非阻塞的方式來處理呢,或者異步非阻塞到底是怎么回事呢?我們先回到原點,看看一個請求的完整過程。首先,請求過來,要建立連接,然后再接收數據,接收數據后,再發送數據。具體到系統底層,就是讀寫事件,而當讀寫事件沒有准備好時,必然不可操作,如果不用非阻塞的方式來調用,那就得阻塞調用了,事件沒有准備好,那就只能等了,等事件准備好了,你再繼續吧。阻塞調用會進入內核等待,cpu就會讓出去給別人用了,對單線程的worker來說,顯然不合適,當網絡事件越多時,大家都在等待呢,cpu空閑下來沒人用,cpu利用率自然上不去了,更別談高並發了。好吧,你說加進程數,這跟apache的線程模型有什么區別,注意,別增加無謂的上下文切換。所以,在nginx里面,最忌諱阻塞的系統調用了。不要阻塞,那就非阻塞嘍。非阻塞就是,事件沒有准備好,馬上返回EAGAIN,告訴你,事件還沒准備好呢,你慌什么,過會再來吧。好吧,你過一會,再來檢查一下事件,直到事件准備好了為止,在這期間,你就可以先去做其它事情,然后再來看看事件好了沒。雖然不阻塞了,但你得不時地過來檢查一下事件的狀態,你可以做更多的事情了,但帶來的開銷也是不小的。所以,才會有了異步非阻塞的事件處理機制,具體到系統調用就是像select/poll/epoll/kqueue這樣的系統調用。它們提供了一種機制,讓你可以同時監控多個事件,調用他們是阻塞的,但可以設置超時時間,在超時時間之內,如果有事件准備好了,就返回。這種機制正好解決了我們上面的兩個問題,拿epoll為例(在后面的例子中,我們多以epoll為例子,以代表這一類函數),當事件沒准備好時,放到epoll里面,事件准備好了,我們就去讀寫,當讀寫返回EAGAIN時,我們將它再次加入到epoll里面。這樣,只要有事件准備好了,我們就去處理它,只有當所有事件都沒准備好時,才在epoll里面等着。這樣,我們就可以並發處理大量的並發了,當然,這里的並發請求,是指未處理完的請求,線程只有一個,所以同時能處理的請求當然只有一個了,只是在請求間進行不斷地切換而已,切換也是因為異步事件未准備好,而主動讓出的。這里的切換是沒有任何代價,你可以理解為循環處理多個准備好的事件,事實上就是這樣的。與多線程相比,這種事件處理方式是有很大的優勢的,不需要創建線程,每個請求占用的內存也很少,沒有上下文切換,事件處理非常的輕量級。並發數再多也不會導致無謂的資源浪費(上下文切換)。更多的並發數,只是會占用更多的內存而已。 我之前有對連接數進行過測試,在24G內存的機器上,處理的並發請求數達到過200萬。現在的網絡服務器基本都采用這種方式,這也是nginx性能高效的主要原因。
我們之前說過,推薦設置worker的個數為cpu的核數,在這里就很容易理解了,更多的worker數,只會導致進程來競爭cpu資源了,從而帶來不必要的上下文切換。而且,nginx為了更好的利用多核特性,提供了cpu親緣性的綁定選項,我們可以將某一個進程綁定在某一個核上,這樣就不會因為進程的切換帶來cache的失效。像這種小的優化在nginx中非常常見,同時也說明了nginx作者的苦心孤詣。比如,nginx在做4個字節的字符串比較時,會將4個字符轉換成一個int型,再作比較,以減少cpu的指令數等等。
現在,知道了nginx為什么會選擇這樣的進程模型與事件模型了。對於一個基本的web服務器來說,事件通常有三種類型,網絡事件、信號、定時器。從上面的講解中知道,網絡事件通過異步非阻塞可以很好的解決掉。如何處理信號與定時器?
首先,信號的處理。對nginx來說,有一些特定的信號,代表着特定的意義。信號會中斷掉程序當前的運行,在改變狀態后,繼續執行。如果是系統調用,則可能會導致系統調用的失敗,需要重入。關於信號的處理,大家可以學習一些專業書籍,這里不多說。對於nginx來說,如果nginx正在等待事件(epoll_wait時),如果程序收到信號,在信號處理函數處理完后,epoll_wait會返回錯誤,然后程序可再次進入epoll_wait調用。
另外,再來看看定時器。由於epoll_wait等函數在調用的時候是可以設置一個超時時間的,所以nginx借助這個超時時間來實現定時器。nginx里面的定時器事件是放在一顆維護定時器的紅黑樹里面,每次在進入epoll_wait前,先從該紅黑樹里面拿到所有定時器事件的最小時間,在計算出epoll_wait的超時時間后進入epoll_wait。所以,當沒有事件產生,也沒有中斷信號時,epoll_wait會超時,也就是說,定時器事件到了。這時,nginx會檢查所有的超時事件,將他們的狀態設置為超時,然后再去處理網絡事件。由此可以看出,當我們寫nginx代碼時,在處理網絡事件的回調函數時,通常做的第一個事情就是判斷超時,然后再去處理網絡事件。
我們可以用一段偽代碼來總結一下nginx的事件處理模型:
- while (true) {
- for t in run_tasks:
- t.handler();
- update_time(&now);
- timeout = ETERNITY;
- for t in wait_tasks: /* sorted already */
- if (t.time <= now) {
- t.timeout_handler();
- } else {
- timeout = t.time - now;
- break;
- }
- nevents = poll_function(events, timeout);
- for i in nevents:
- task t;
- if (events[i].type == READ) {
- t.handler = read_handler;
- } else { /* events[i].type == WRITE */
- t.handler = write_handler;
- }
- run_tasks_add(t);
- }