nginx機制詳解

nginx架構

衆所周知,nginx性能高,而nginx的高性能與其架構是分不開的。那麼nginx究竟是怎麼樣的呢?這一節我們先來初識一下nginx框架吧。

1、nginx在啓動後,在unix系統中會以daemon的方式在後臺運行,後臺進程包含一個master進程和多個worker進程。我們也可以手動地關掉後臺模式,讓nginx在前臺運行,並且通過配置讓nginx取消master進程,從而可以使nginx以單進程方式運行。很顯然,生產環境下我們肯定不會這麼做,所以關閉後臺模式,一般是用來調試用的,在後面的章節裏面,我們會詳細地講解如何調試nginx。所以,我們可以看到,nginx是以多進程的方式來工作的,當然nginx也是支持多線程的方式的,只是我們主流的方式還是多進程的方式,也是nginx的默認方式。nginx採用多進程的方式有諸多好處,所以我就主要講解nginx的多進程模式吧。

剛纔講到,nginx在啓動後,會有一個master進程和多個worker進程。master進程主要用來管理worker進程,包含:接收來自外界的信號,向各worker進程發送信號,監控worker進程的運行狀態,當worker進程退出後(異常情況下),會自動重新啓動新的worker進程。而基本的網絡事件,則是放在worker進程中來處理了。多個worker進程之間是對等的,他們同等競爭來自客戶端的請求,各進程互相之間是獨立的。一個請求,只可能在一個worker進程中處理,一個worker進程,不可能處理其它進程的請求。worker進程的個數是可以設置的,一般我們會設置與機器cpu核數一致,這裏面的原因與nginx的進程模型以及事件處理模型是分不開的。nginx的進程模型,可以由下圖來表示:
這裏寫圖片描述

2、在nginx啓動後,如果我們要操作nginx,要怎麼做呢?
從上文中我們可以看到,master來管理worker進程,所以我們只需要與master進程通信就行了。
master進程會接收來自外界發來的信號,再根據信號做不同的事情。所以我們要控制nginx,只需要通過kill向master進程發送信號就行了。比如kill -HUP pid,則是告訴nginx,從容地重啓nginx,我們一般用這個信號來重啓nginx,或重新加載配置,因爲是從容地重啓,因此服務是不中斷的。master進程在接收到HUP信號後是怎麼做的呢?首先master進程在接到信號後,會先重新加載配置文件,然後再啓動新的worker進程,並向所有老的worker進程發送信號,告訴他們可以光榮退休了。新的worker在啓動後,就開始接收新的請求,而老的worker在收到來自master的信號後,就不再接收新的請求,並且在當前進程中的所有未處理完的請求處理完成後,再退出。當然,直接給master進程發送信號,這是比較老的操作方式,nginx在0.8版本之後,引入了一系列命令行參數,來方便我們管理。比如,./nginx -s reload,就是來重啓nginx,./nginx -s stop,就是來停止nginx的運行。如何做到的呢?我們還是拿reload來說,我們看到,執行命令時,我們是啓動一個新的nginx進程,而新的nginx進程在解析到reload參數後,就知道我們的目的是控制nginx來重新加載配置文件了,它會向master進程發送信號,然後接下來的動作,就和我們直接向master進程發送信號一樣了。

3、現在,我們知道了當我們在操作nginx的時候,nginx內部做了些什麼事情,那麼,worker進程又是如何處理請求的呢?
我們前面有提到,worker進程之間是平等的,每個進程,處理請求的機會也是一樣的。當我們提供80端口的http服務時,一個連接請求過來,每個進程都有可能處理這個連接,怎麼做到的呢?
首先,每個worker進程都是從master進程fork過來,在master進程裏面,先建立好需要listen的socket(listenfd)之後,然後再fork出多個worker進程。所有worker進程的listenfd會在新連接到來時變得可讀,爲保證只有一個進程處理該連接,所有worker進程在註冊listenfd**讀事件前搶accept_mutex**,搶到互斥鎖的那個進程註冊listenfd讀事件,在讀事件裏調用accept接受該連接。當一個worker進程在accept這個連接之後,就開始讀取請求,解析請求,處理請求,產生數據後,再返回給客戶端,最後才斷開連接,這樣一個完整的請求就是這樣的了。我們可以看到,一個請求,完全由worker進程來處理,而且只在一個worker進程中處理。

4、那麼,nginx採用這種進程模型有什麼好處呢?當然,好處肯定會很多了。
首先,對於每個worker進程來說,獨立的進程,不需要加鎖,所以省掉了鎖帶來的開銷,同時在編程以及問題查找時,也會方便很多。其次,採用獨立的進程,可以讓互相之間不會影響,一個進程退出後,其它進程還在工作,服務不會中斷,master進程則很快啓動新的worker進程。當然,worker進程的異常退出,肯定是程序有bug了,異常退出,會導致當前worker上的所有請求失敗,不過不會影響到所有請求,所以降低了風險。當然,好處還有很多,大家可以慢慢體會。

5、上面講了很多關於nginx的進程模型,接下來,我們來看看nginx是如何處理事件的。
有人可能要問了,nginx採用多worker的方式來處理請求,每個worker裏面只有一個主線程,那能夠處理的併發數很有限啊,多少個worker就能處理多少個併發,何來高併發呢?非也,這就是nginx的高明之處,nginx採用了異步非阻塞的方式來處理請求,也就是說,nginx是可以同時處理成千上萬個請求的。想想apache的常用工作方式(apache也有異步非阻塞版本,但因其與自帶某些模塊衝突,所以不常用),每個請求會獨佔一個工作線程,當併發數上到幾千時,就同時有幾千的線程在處理請求了。這對操作系統來說,是個不小的挑戰,線程帶來的內存佔用非常大,線程的上下文切換帶來的cpu開銷很大,自然性能就上不去了,而這些開銷完全是沒有意義的。

6、爲什麼nginx可以採用異步非阻塞的方式來處理呢,或者異步非阻塞到底是怎麼回事呢?
我們先回到原點,看看一個請求的完整過程。首先,請求過來,要建立連接,然後再接收數據,接收數據後,再發送數據。具體到系統底層,就是讀寫事件,而當讀寫事件沒有準備好時,必然不可操作,如果不用非阻塞的方式來調用
【阻塞調用】那就得阻塞調用了,事件沒有準備好,那就只能等了,等事件準備好了,你再繼續吧。阻塞調用會進入內核等待,cpu就會讓出去給別人用了,對單線程的worker來說,顯然不合適,當網絡事件越多時,大家都在等待呢,cpu空閒下來沒人用,cpu利用率自然上不去了,更別談高併發了。好吧,你說加進程數,這跟apache的線程模型有什麼區別,注意,別增加無謂的上下文切換。所以,在nginx裏面,最忌諱阻塞的系統調用了。
【非阻塞調用】不要阻塞,那就非阻塞嘍。非阻塞就是,事件沒有準備好,馬上返回EAGAIN,告訴你,事件還沒準備好呢,你慌什麼,過會再來吧。好吧,你過一會,再來檢查一下事件,直到事件準備好了爲止,在這期間,你就可以先去做其它事情,然後再來看看事件好了沒
【異步非阻塞調用】雖然不阻塞了,但你得不時地過來檢查一下事件的狀態,你可以做更多的事情了,但帶來的開銷也是不小的。所以,纔會有了異步非阻塞的事件處理機制,具體到系統調用就是像select/poll/epoll/kqueue這樣的系統調用。它們提供了一種機制,讓你可以同時監控多個事件,調用他們是阻塞的,但可以設置超時時間,在超時時間之內,如果有事件準備好了,就返回。這種機制正好解決了我們上面的兩個問題。
拿epoll爲例(在後面的例子中,我們多以epoll爲例子,以代表這一類函數),當事件沒準備好時,放到epoll裏面,事件準備好了,我們就去讀寫,當讀寫返回EAGAIN時,我們將它再次加入到epoll裏面。這樣,只要有事件準備好了,我們就去處理它,只有當所有事件都沒準備好時,纔在epoll裏面等着。這樣,我們就可以併發處理大量的併發了,當然,這裏的併發請求,是指未處理完的請求,線程只有一個,所以同時能處理的請求當然只有一個了,只是在請求間進行不斷地切換而已,切換也是因爲異步事件未準備好,而主動讓出的。這裏的切換是沒有任何代價,你可以理解爲循環處理多個準備好的事件,事實上就是這樣的。
與多線程相比,這種事件處理方式是有很大的優勢的,不需要創建線程,每個請求佔用的內存也很少,沒有上下文切換,事件處理非常的輕量級。併發數再多也不會導致無謂的資源浪費(上下文切換)。更多的併發數,只是會佔用更多的內存而已。 我之前有對連接數進行過測試,在24G內存的機器上,處理的併發請求數達到過200萬。現在的網絡服務器基本都採用這種方式,這也是nginx性能高效的主要原因。

7、爲何要推薦設置worker的個數爲cpu的核數?
我們之前說過,推薦設置worker的個數爲cpu的核數,在這裏就很容易理解了,更多的worker數,只會導致進程來競爭cpu資源了,從而帶來不必要的上下文切換。而且,nginx爲了更好的利用多核特性,提供了cpu親緣性的綁定選項,我們可以將某一個進程綁定在某一個核上,這樣就不會因爲進程的切換帶來cache的失效。像這種小的優化在nginx中非常常見,同時也說明了nginx作者的苦心孤詣。比如,nginx在做4個字節的字符串比較時,會將4個字符轉換成一個int型,再作比較,以減少cpu的指令數等等。

現在,知道了nginx爲什麼會選擇這樣的進程模型與事件模型了。對於一個基本的web服務器來說,事件通常有三種類型,網絡事件、信號、定時器。從上面的講解中知道,網絡事件通過異步非阻塞可以很好的解決掉。

8、如何處理信號與定時器?
首先,信號的處理。對nginx來說,有一些特定的信號,代表着特定的意義。信號會中斷掉程序當前的運行,在改變狀態後,繼續執行。如果是系統調用,則可能會導致系統調用的失敗,需要重入。關於信號的處理,大家可以學習一些專業書籍,這裏不多說。對於nginx來說,如果nginx正在等待事件(epoll_wait時),如果程序收到信號,在信號處理函數處理完後,epoll_wait會返回錯誤,然後程序可再次進入epoll_wait調用。

另外,再來看看定時器。由於epoll_ wait等函數在調用的時候是可以設置一個超時時間的,所以nginx藉助這個超時時間來實現定時器。nginx裏面的定時器事件是放在一顆維護定時器的紅黑樹裏面,每次在進入epoll_wait前,先從該紅黑樹裏面拿到所有定時器事件的最小時間,在計算出epoll_wait的超時時間後進入epoll_wait。所以,當沒有事件產生,也沒有中斷信號時,epoll_wait會超時,也就是說,定時器事件到了。這時,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);
}

nginx基礎概念

-connection
在nginx中connection就是對tcp連接的封裝,其中包括連接的socket,讀事件,寫事件。利用nginx封裝的connection,我們可以很方便的使用nginx來處理與連接相關的事情,比如,建立連接,發送與接受數據等。而nginx中的http請求的處理就是建立在connection之上的,所以nginx不僅可以作爲一個web服務器,也可以作爲郵件服務器。當然,利用nginx提供的connection,我們可以與任何後端服務打交道。

1、結合一個tcp連接的生命週期,我們看看nginx是如何處理一個連接的。
首先,nginx在啓動時,會解析配置文件,得到需要監聽的端口與ip地址,然後在nginx的master進程裏面,先初始化好這個監控的socket(創建socket,設置addrreuse等選項,綁定到指定的ip地址端口,再listen),然後再fork出多個子進程出來,然後子進程會競爭accept新的連接。
此時,客戶端就可以向nginx發起連接了。當客戶端與服務端通過三次握手建立好一個連接後,nginx的某一個子進程會accept成功,得到這個建立好的連接的socket,然後創建nginx對連接的封裝,即ngx_connection_t結構體。
接着,設置讀寫事件處理函數並添加讀寫事件來與客戶端進行數據的交換。
最後,nginx或客戶端來主動關掉連接,到此,一個連接就壽終正寢了。

當然,nginx也是可以作爲客戶端來請求其它server的數據的(如upstream模塊),此時,與其它server創建的連接,也封裝在ngx_connection_t中。作爲客戶端,nginx先獲取一個ngx_connection_t結構體,然後創建socket,並設置socket的屬性( 比如非阻塞)。然後再通過添加讀寫事件,調用connect/read/write來調用連接,最後關掉連接,並釋放ngx_connection_t。

2、在nginx中,每個進程會有一個連接數的最大上限,這個上限與系統對fd的限制不一樣。
在操作系統中,通過ulimit -n,我們可以得到一個進程所能夠打開的fd的最大數,即nofile,因爲每個socket連接會佔用掉一個fd,所以這也會限制我們進程的最大連接數,當然也會直接影響到我們程序所能支持的最大併發數,當fd用完後,再創建socket時,就會失敗
nginx通過設置worker_connectons來設置每個進程支持的最大連接數。如果該值大於nofile,那麼實際的最大連接數是nofile,nginx會有警告。
nginx在實現時,是通過一個連接池來管理的,每個worker進程都有一個獨立的連接池,連接池的大小是worker_ connections。這裏的連接池裏面保存的其實不是真實的連接,它只是一個worker_ connections大小的一個ngx_connection_t結構的數組。並且,nginx會通過一個鏈表free_ connections來保存所有的空閒ngx_connection_t,每次獲取一個連接時,就從空閒連接鏈表中獲取一個,用完後,再放回空閒連接鏈表裏面。
在這裏,很多人會誤解worker_ connections這個參數的意思,認爲這個值就是nginx所能建立連接的最大值。其實不然,這個值是表示每個worker進程所能建立連接的最大值,所以,一個nginx能建立的最大連接數,應該是worker_ connections * worker_ processes。當然,這裏說的是最大連接數,對於HTTP請求本地資源來說,能夠支持的最大併發數量是worker_ connections * worker_ processes,而如果是HTTP作爲反向代理來說,最大併發數量應該是worker_ connections * worker_processes/2。因爲作爲反向代理服務器,每個併發會建立與客戶端的連接和與後端服務的連接,會佔用兩個連接。

3、那麼,我們前面有說過一個客戶端連接過來後,多個空閒的進程,會競爭這個連接,很容易看到,這種競爭會導致不公平,如果某個進程得到accept的機會比較多,它的空閒連接很快就用完了,如果不提前做一些控制,當accept到一個新的tcp連接後,因爲無法得到空閒連接,而且無法將此連接轉交給其它進程,最終會導致此tcp連接得不到處理,就中止掉了。很顯然,這是不公平的,有的進程有空餘連接,卻沒有處理機會,有的進程因爲沒有空餘連接,卻人爲地丟棄連接。那麼,如何解決這個問題呢?
首先,nginx的處理得先打開accept_ mutex選項,此時,只有獲得了accept_ mutex的進程纔會去添加accept事件,也就是說,nginx會控制進程是否添加accept事件。
nginx使用一個叫ngx_accept_disabled的變量來控制是否去競爭accept_mutex鎖
在第一段代碼中,計算ngx_accept_disabled的值,這個值是nginx單進程的所有連接總數的八分之一,減去剩下的空閒連接數量,得到的這個ngx_accept_disabled有一個規律,當剩餘連接數小於總連接數的八分之一時,其值才大於0,而且剩餘的連接數越小,這個值越大。再看第二段代碼,當ngx_accept_disabled大於0時,不會去嘗試獲取accept_ mutex鎖,並且將ngx_accept_ disabled減1,於是,每次執行到此處時,都會去減1,直到小於0。不去獲取accept_ mutex鎖,就是等於讓出獲取連接的機會,很顯然可以看出,當空餘連接越少時,ngx_accept_disable越大,於是讓出的機會就越多,這樣其它進程獲取鎖的機會也就越大。不去accept,自己的連接就控制下來了,其它進程的連接池就會得到利用,這樣,nginx就控制了多進程間連接的平衡了。

ngx_accept_disabled = ngx_cycle->connection_n / 8
    - ngx_cycle->free_connection_n;

if (ngx_accept_disabled > 0) {
    ngx_accept_disabled--;

} else {
    if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
        return;
    }

    if (ngx_accept_mutex_held) {
        flags |= NGX_POST_EVENTS;

    } else {
        if (timer == NGX_TIMER_INFINITE
                || timer > ngx_accept_mutex_delay)
        {
            timer = ngx_accept_mutex_delay;
        }
    }
}

-request
1、這節我們講request,在nginx中我們指的是http請求,具體到nginx中的數據結構是ngx_http_request_t。ngx_http_request_t是對一個http請求的封裝。 我們知道,一個http請求,包含請求行、請求頭、請求體、響應行、響應頭、響應體。
http請求是典型的請求-響應類型的的網絡協議,而http是文件協議,所以我們在分析請求行與請求頭,以及輸出響應行與響應頭,往往是一行一行的進行處理。
如果我們自己來寫一個http服務器,通常在一個連接建立好後,客戶端會發送請求過來。然後我們讀取一行數據,分析出請求行中包含的method、uri、http_ version信息。然後再一行一行處理請求頭,並根據請求method與請求頭的信息來決定是否有請求體以及請求體的長度,然後再去讀取請求體。得到請求後,我們處理請求產生需要輸出的數據,然後再生成響應行,響應頭以及響應體。在將響應發送給客戶端之後,一個完整的請求就處理完了。
當然這是最簡單的webserver的處理方式,其實nginx也是這樣做的,只是有一些小小的區別,比如,當請求頭讀取完成後,就開始進行請求的處理了。nginx**通過ngx_http_request_t來保存解析請求與輸出響應相關的數據**。

2、那接下來,簡要講講nginx是如何處理一個完整的請求的。
對於nginx來說,一個請求是從ngx_http_init_request開始的,在這個函數中,會設置讀事件爲ngx_http_process_request_line,也就是說,接下來的網絡事件,會由ngx_http_process_request_line來執行。
從ngx_http_process_request_line的函數名,我們可以看到,這就是來處理請求行的,正好與之前講的,處理請求的第一件事就是處理請求行是一致的。通過ngx_http_read_request_header來讀取請求數據。然後調用ngx_http_parse_request_line函數來解析請求行。
nginx爲提高效率,採用狀態機來解析請求行,而且在進行method的比較時,沒有直接使用字符串比較,而是將四個字符轉換成一個整型,然後一次比較以減少cpu的指令數,這個前面有說過。很多人可能很清楚一個請求行包含請求的方法,uri,版本,卻不知道其實在請求行中,也是可以包含有host的。比如一個請求GET http://www.taobao.com/uri HTTP/1.0這樣一個請求行也是合法的,而且host是www.taobao.com,這個時候,nginx會忽略請求頭中的host域,而以請求行中的這個爲準來查找虛擬主機。另外,對於對於http0.9版來說,是不支持請求頭的,所以這裏也是要特別的處理。所以,在後面解析請求頭時,協議版本都是1.0或1.1。
整個請求行解析到的參數,會保存到ngx_http_request_t結構當中。
在解析完請求行後,nginx會設置讀事件的handler爲ngx_http_process_request_headers,然後後續的請求就在ngx_http_process_request_headers中進行讀取與解析。ngx_http_process_request_headers函數用來讀取請求頭,跟請求行一樣,還是調用ngx_http_read_request_header來讀取請求頭,調用ngx_http_parse_header_line來解析一行請求頭,解析到的請求頭會保存到ngx_http_request_t的域headers_in中,headers_in是一個鏈表結構,保存所有的請求頭。而HTTP中有些請求是需要特別處理的,這些請求頭與請求處理函數存放在一個映射表裏面,即ngx_http_headers_in,在初始化時,會生成一個hash表,當每解析到一個請求頭後,就會先在這個hash表中查找,如果有找到,則調用相應的處理函數來處理這個請求頭。比如:Host頭的處理函數是ngx_http_process_host。

當nginx解析到兩個回車換行符時,就表示請求頭的結束,此時就會調用ngx_http_process_request來處理請求了。ngx_http_process_request會設置當前的連接的讀寫事件處理函數爲ngx_http_request_handler,然後再調用ngx_http_handler來真正開始處理一個完整的http請求。這裏可能比較奇怪,讀寫事件處理函數都是ngx_http_request_handler,其實在這個函數中,會根據當前事件是讀事件還是寫事件,分別調用ngx_http_request_t中的read_event_handler或者是write_event_handler。由於此時,我們的請求頭已經讀取完成了,之前有說過,nginx的做法是先不讀取請求body,所以這裏面我們設置read_event_handler爲ngx_http_block_reading,即不讀取數據了。剛纔說到,真正開始處理數據,是在ngx_http_handler這個函數裏面,這個函數會設置write_event_handler爲ngx_http_core_run_phases,並執行ngx_http_core_run_phases函數。ngx_http_core_run_phases這個函數將執行多階段請求處理,nginx將一個http請求的處理分爲多個階段,那麼這個函數就是執行這些階段來產生數據。因爲ngx_http_core_run_phases最後會產生數據,所以我們就很容易理解,爲什麼設置寫事件的處理函數爲ngx_http_core_run_phases了。在這裏,我簡要說明了一下函數的調用邏輯,我們需要明白最終是調用ngx_http_core_run_phases來處理請求,產生的響應頭會放在ngx_http_request_t的headers_out中,這一部分內容,我會放在請求處理流程裏面去講。nginx的各種階段會對請求進行處理,最後會調用filter來過濾數據,對數據進行加工,如truncked傳輸、gzip壓縮等。這裏的filter包括header filter與body filter,即對響應頭或響應體進行處理。filter是一個鏈表結構,分別有header filter與body filter,先執行header filter中的所有filter,然後再執行body filter中的所有filter。在header filter中的最後一個filter,即ngx_http_header_filter,這個filter將會遍歷所有的響應頭,最後需要輸出的響應頭在一個連續的內存,然後調用ngx_http_write_filter進行輸出。ngx_http_write_filter是body filter中的最後一個,所以nginx首先的body信息,在經過一系列的body filter之後,最後也會調用ngx_http_write_filter來進行輸出(有圖來說明)。

這裏要注意的是,nginx會將整個請求頭都放在一個buffer裏面,這個buffer的大小通過配置項client_header_buffer_size來設置,如果用戶的請求頭太大,這個buffer裝不下,那nginx就會重新分配一個新的更大的buffer來裝請求頭,這個大buffer可以通過large_client_header_buffers來設置,這個large_buffer這一組buffer,比如配置4 8k,就是表示有四個8k大小的buffer可以用。注意,爲了保存請求行或請求頭的完整性,一個完整的請求行或請求頭,需要放在一個連續的內存裏面,所以,一個完整的請求行或請求頭,只會保存在一個buffer裏面。這樣,如果請求行大於一個buffer的大小,就會返回414錯誤,如果一個請求頭大小大於一個buffer大小,就會返回400錯誤。在瞭解了這些參數的值,以及nginx實際的做法之後,在應用場景,我們就需要根據實際的需求來調整這些參數,來優化我們的程序了。

處理流程圖:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章