1. 程式人生 > 其它 >Nginx 實現高併發原理

Nginx 實現高併發原理

Nginx 實現高併發原理

1. 概述

Nginx由核心和模組組成。
Nginx本身做的工作實際很少,當它接到一個HTTP請求時,它僅僅是通過查詢配置檔案將此次請求對映到一個location block,而此location中所配置的各個指令則會啟動不同的模組去完成工作,因此模組可以看做Nginx真正的勞動工作者。通常一個location中的指令會涉及一個handler模組和多個filter模組(當然,多個location可以複用同一個模組)。handler模組負責處理請求,完成響應內容的生成,而filter模組對響應內容進行處理。

Nginx程序模型
Nginx預設採用多程序工作方式,Nginx啟動後,會執行一個master程序和多個worker程序。其中master充當整個程序組與使用者的互動介面,同時對程序進行監護,管理worker程序來實現重啟服務、平滑升級、更換日誌檔案、配置檔案實時生效等功能。worker用來處理基本的網路事件,worker之間是平等的,他們共同競爭來處理來自客戶端的請求。

Nginx 採用的是多程序(單執行緒) & 多路IO複用模型。使用了 I/O 多路複用技術的 Nginx,就成了”併發事件驅動“的伺服器

2. 驚群現象

主程序(master 程序)首先通過 socket() 來建立一個 sock 檔案描述符用來監聽,然後fork生成子程序(workers 程序),子程序將繼承父程序的 sockfd(socket 檔案描述符),之後子程序 accept() 後將建立已連線描述符(connected descriptor)),然後通過已連線描述符來與客戶端通訊。

那麼,由於所有子程序都繼承了父程序的 sockfd,那麼當連線進來時,所有子程序都將收到通知並“爭著”與它建立連線,這就叫驚群現象

。大量的程序被啟用又掛起,只有一個程序可以accept() 到這個連線,這當然會消耗系統資源。

3. Nginx對驚群現象的處理

Nginx 提供了一個 accept_mutex 這個東西,這是一個加在accept上的一把共享鎖。即每個 worker 程序在執行 accept 之前都需要先獲取鎖,獲取不到就放棄執行 accept()。有了這把鎖之後,同一時刻,就只會有一個程序去 accpet(),這樣就不會有驚群問題了。accept_mutex 是一個可控選項,我們可以顯示地關掉,預設是開啟的。

4. Nginx程序詳解

nginx的程序模型如圖所示:

使用多程序模式,不僅能提高併發率,而且程序之間相互獨立,一個 worker 程序掛了不會影響到其他 worker 程序。

注意: worker 程序數,一般會設定成機器 cpu 核數。因為更多的worker 數,只會導致程序相互競爭 cpu,從而帶來不必要的上下文切換

4.1 master程序

主要用來管理worker程序,包含:

  • 接收來自外界的訊號
  • 向各worker程序傳送訊號
  • 監控worker程序的執行狀態
  • 當worker程序退出後(異常情況下),會自動重新啟動新的worker程序。

master程序充當整個程序組與使用者的互動介面,同時對程序進行監護。它不需要處理網路事件,不負責業務的執行,只會通過管理worker程序來實現重啟服務、平滑升級、更換日誌檔案、配置檔案實時生效等功能。

我們要控制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程序傳送訊號一樣了。

4.2 worker程序

而基本的網路事件,則是放在worker程序中來處理了。多個worker程序之間是對等的,他們同等競爭來自客戶端的請求,各程序互相之間是獨立的。一個請求,只可能在一個worker程序中處理,一個worker程序,不可能處理其它程序的請求。worker程序的個數是可以設定的,一般我們會設定與機器cpu核數一致,這裡面的原因與nginx的程序模型以及事件處理模型是分不開的。

worker程序之間是平等的,每個程序,處理請求的機會也是一樣的。當我們提供80埠的http服務時,一個連線請求過來,每個程序都有可能處理這個連線,怎麼做到的呢?

首先,每個worker程序都是從master程序fork過來,在master程序裡面,先建立好需要listen的socket(listenfd)之後,然後再fork出多個worker程序。所有worker程序的listenfd會在新連線到來時變得可讀,為保證只有一個程序處理該連線,所有worker程序在註冊listenfd讀事件前搶accept_mutex,搶到互斥鎖的那個程序註冊listenfd讀事件,在讀事件裡呼叫accept接受該連線。

當一個worker程序在accept這個連線之後,就開始讀取請求,解析請求,處理請求,產生資料後,再返回給客戶端,最後才斷開連線,這樣一個完整的請求就是這樣的了。

我們可以看到,一個請求,完全由worker程序來處理,而且只在一個worker程序中處理。

程序連線數
每個worker程序都有一個獨立的連線池,連線池的大小是worker_connections。這裡的連線池裡面儲存的其實不是真實的連線,它只是一個worker_connections大小的一個ngx_connection_t結構的陣列。並且,nginx會通過一個連結串列free_connections來儲存所有的空閒ngx_connection_t,每次獲取一個連線時,就從空閒連線連結串列中獲取一個,用完後,再放回空閒連線連結串列裡面。一個nginx能建立的最大連線數,應該是worker_connections * worker_processes。當然,這裡說的是最大連線數,對於HTTP請求本地資源來說,能夠支援的最大併發數量是worker_connections * worker_processes,而如果是HTTP作為反向代理來說,最大併發數量應該是worker_connections * worker_processes/2。因為作為反向代理伺服器,每個併發會建立與客戶端的連線和與後端服務的連線,會佔用兩個連線。

4.3 worker程序工作流程

當一個 worker 程序在 accept() 這個連線之後,就開始讀取請求,解析請求,處理請求,產生資料後,再返回給客戶端,最後才斷開連線,一個完整的請求。一個請求,完全由 worker 程序來處理,而且只能在一個 worker 程序中處理。

5. 這樣做帶來的好處:

  1. 節省鎖帶來的開銷。每個 worker 程序都是獨立的程序,不共享資源,不需要加鎖。同時在程式設計以及問題查上時,也會方便很多。

  2. 獨立程序,減少風險。採用獨立的程序,可以讓互相之間不會影響,一個程序退出後,其它程序還在工作,服務不會中斷,master 程序則很快重新啟動新的 worker 程序。當然,worker 程序的也能發生意外退出。

6. IO 多路複用

多程序模型每個程序/執行緒只能處理一路IO,那麼 Nginx是如何處理多路IO呢?

如果不使用 IO 多路複用,那麼在一個程序中,同時只能處理一個請求,比如執行 accept(),如果沒有連線過來,那麼程式會阻塞在這裡,直到有一個連線過來,才能繼續向下執行。

而多路複用,允許我們只在事件發生時才將控制返回給程式,而其他時候核心都掛起程序,隨時待命。

核心:Nginx採用的 IO多路複用模型epoll

epoll通過在Linux核心中申請一個簡易的檔案系統(檔案系統一般用什麼資料結構實現?B+樹),其工作流程分為三部分:

  • 呼叫 int epoll_create(int size)建立一個epoll物件,核心會建立一個eventpoll結構體,用於存放通過epoll_ctl()向epoll物件中新增進來的事件,這些事件都會掛載在紅黑樹中。
  • 呼叫 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) 在 epoll 物件中為 fd 註冊事件,所有新增到epoll中的件都會與裝置驅動程式建立回撥關係,也就是說,當相應的事件發生時會呼叫這個sockfd的回撥方法,將sockfd新增到eventpoll 中的雙鏈表
  • 呼叫 int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout) 來等待事件的發生,timeout 為 -1 時,該呼叫會阻塞知道有事件發生

這樣,註冊好事件之後,只要有 fd 上事件發生,epoll_wait() 就能檢測到並返回給使用者,使用者就能”非阻塞“地進行 I/O 了。

epoll() 中核心則維護一個連結串列,epoll_wait 直接檢查連結串列是不是空就知道是否有檔案描述符準備好了。(epoll 與 select 相比最大的優點是不會隨著 sockfd 數目增長而降低效率,使用 select() 時,核心採用輪訓的方法來檢視是否有fd 準備好,其中的儲存 sockfd 的是類似陣列的資料結構 fd_set,key 為 fd,value 為 0 或者 1。)

能達到這種效果,是因為在核心實現中 epoll 是根據每個 sockfd 上面的與裝置驅動程式建立起來的回撥函式實現的。那麼,某個 sockfd 上的事件發生時,與它對應的回撥函式就會被呼叫,來把這個 sockfd 加入連結串列,其他處於“空閒的”狀態的則不會。在這點上,epoll 實現了一個”偽”AIO。但是如果絕大部分的 I/O 都是“活躍的”,每個 socket 使用率很高的話,epoll效率不一定比 select 高(可能是要維護佇列複雜)。

可以看出,因為一個程序裡只有一個執行緒,所以一個程序同時只能做一件事,但是可以通過不斷地切換來“同時”處理多個請求。

例子:

  • Nginx 會註冊一個事件:“如果來自一個新客戶端的連線請求到來了,再通知我”,此後只有連線請求到來,伺服器才會執行 accept() 來接收請求。
  • 又比如向上遊伺服器(比如 PHP-FPM)轉發請求,並等待請求返回時,這個處理的 worker 不會在這阻塞,它會在傳送完請求後,註冊一個事件:“如果緩衝區接收到資料了,告訴我一聲,我再將它讀進來”,於是程序就空閒下來等待事件發生。

這樣,基於 多程序+epoll, Nginx 便能實現高併發。

Nginx 與 多程序模式 Apache 的比較:

  1. 對於Apache,每個請求都會獨佔一個工作執行緒,當併發數到達幾千時,就同時有幾千的執行緒在處理請求了。這對於作業系統來說,佔用的記憶體非常大,執行緒的上下文切換帶來的cpu開銷也很大,效能就難以上去,同時這些開銷是完全沒有意義的。

    • web伺服器程序(web server process)在監聽套接字上,監聽新的連線(客戶端發起的新比賽)。
    • 一局新的比賽發起後,程序就開始工作,每一步棋下完後都進入阻塞狀態,等待客戶端走下一步棋。
    • 一旦比賽結束,web伺服器程序會看看客戶是否想開始新的比賽(這相當於一個存活的連線)。如果連線被關閉(客戶端離開或者超時),web伺服器程序會回到監聽狀態,等待全新的比賽
  2. 對於Nginx來講,一個程序只有一個主執行緒,通過非同步非阻塞的事件處理機制,實現了迴圈處理多個準備好的事件,從而實現輕量級和高併發。

    • 工作程序在監聽套接字和連線套接字上等待事件。
    • 事件發生在套接字上,工作程序會處理這些事件。
      • 監聽套接字上的事件意味著:客戶端開始了一局新的遊戲。工作程序建立了一個新的連線套接字。
      • 連線套接字上的事件意味著:客戶端移動了棋子。工作程序會迅速響應。

NGINX的規模可以很好地支援每個工作程序上數以萬計的連線。每個新連線都會建立另一個檔案描述符,並消耗工作程序中少量的額外記憶體。每一個連線的額外消耗都很少。NGINX程序可以保持固定的CPU佔用率。當沒有工作時,上下文切換也較少。
APACHE 的阻塞式的、一個連線/一個程序的模式中,每個連線需要大量的額外資源和開銷,並且上下文切換(從一個程序到另一個程序)非常頻繁。