一、nginx 高並發原理
簡單介紹:nginx 采用的是多進程(單線程) + io多路復用(epoll)模型 實現高並發
二、nginx 多進程
- 啟動nginx
解析初始化配置文件后會 創建(fork)一個master進程 之后 這個進程會退出
master 進程會 變為孤兒進程 由init進程托管。(可以通過python 或php 啟動后創建子進程,然后殺死父進程得見子進程會由init進程托管)
如下圖可以看到nginx master 進程由init(ppid 為1 )進程管理。
- master進程和worker進程
1、master
首先nginx 創建一個master 進程,通過socket() 創建一個sock文件描述符用來監聽(sockfd)
綁定端口(bind) 開啟監聽(listen)。
nginx 一般監聽80(http) 或 443 (https)端口
(fork 多個子進程后,master 會監聽worker進程,和等待信號)
2、worker
然后 創建(fork)多個 worker子進程(復制master 進程的數據),
此時所有的worker進程 繼承了sockfd(socket文件描述符),
當有連接進來之后 worker進程就可以accpet()創建已連接描述符,
然后通過已連接描述符與客戶端通訊
- 驚群現象
由於worker進程 繼承了master進程的sockfd,當連接進來是,所有的子進程都將收到通知並“爭着”與
它建立連接,這就叫驚群現象。大量的進程被激活又掛起,最后只有一個進程accpet() 到這個連接,這會消耗系統資源
(等待通知,進程被內核全部喚醒,只有一個進程accept成功,其他進程又休眠。這種浪費現象叫驚群)
- nginx 對驚群現象的處理
原因:
多個進程監聽同一個端口引發的。
解決:
如果可以同一時刻只能有一個進程監聽端口,這樣就不會發生“驚群”了,此時新連接事件只能喚醒正在監聽的唯一進程。
如何保持一個時刻只能有一個worker進程監聽端口呢?nginx設置了一個accept_mutex鎖,在使用accept_mutex鎖是,
只有進程成功調用了ngx_trylock_accept_mutex方法獲取鎖后才可以監聽端口
(linux 內核2.6 之后 不會出現驚群現象,只會有一個進程被喚醒)
- 代碼簡單理解
三、worker進程
- worker進程做了什么事
從上圖中,我們可以看到worker進程做了
1、accept() 與客戶端建立連接
2、recv()接收客戶端發過來的數據
3、send() 向客戶端發送數據
4、close() 關閉客戶端連接
- 如果不使用io多路復用 會是什么樣的
首先 等待客戶端有連接進來
accpet() 與客戶端建立連接后
recv() 一直等待客戶的發送過來數據(此時處於io阻塞狀態)
如果此時又有客戶端過來建立連接,那么只能等待,需要一直等待close() 之后才可以建立連接
也就是說這個worker進程會因為recv() 而處於阻塞狀態,而不能處理與其他客戶端建立連接,
這段時間不能做任何事,這是對性能了浪費。
(進程 和線程的切換也是需要消耗 時間的。)
- 能不能利用io堵塞的時間 accept,recv
nginx 采用了io多路復用技術實現了
四、io多路復用
- 什么是io復用
IO復用解決的就是並發行的問題,比如多個用戶並發訪問一個WEB網站,對於服務端后台而言就會產生多個請求,處理多個請求對於中間件就會產生多個IO流對於系統的讀寫。那么對於IO流請求操作系統內核有並行處理和串行處理的概念,串行處理的方式是一個個處理,前面的發生阻塞,就沒辦法完成后面的請求。這個時候我們必須考慮並行的方式完成整個IO流的請求來實現最大的並發和吞吐,這時候就是用到IO復用技術。IO復用就是讓一個Socket來作為復用完成整個IO流的請求。
當然實現整個IO流的請求多線程的方式就是其中一種。
(一個socket作為復用來完成整個io流的請求連接建立(accept),而處理請求(recv,send,close)則采用多線程)
- io復用之多線程處理
# -*- coding:utf-8 -*-
import socket
from threading import Thread
def comm(conn):
data = conn.recv(1024)
conn.send(data)
conn.close()
obj = socket.socket(socket.AF_INET,socket.SOCK_STREAM) # sockfd (socket 文件描述符,這個描述符是用來監聽的--監聽socket)
obj.bind(('127.0.0.1',8082)) # 綁定地址
obj.listen(5) # 開啟監聽
while True:
conn,addr = obj.accept() // 每建立一個 連接 就交由線程處理
t = Thread(target=comm,args=(conn,)) // 創建線程對象
t.start() // 啟動
// 這樣就可以處理多個io請求,不會因為一個沒處理完而導致的堵塞
- io 多路復用
多個描述符(監聽描述符,已連接描述符) 的io 操作都能在一個線程內並發交替順序完成,這就叫io多路復用,
這里的復用指的是復用同一個線程
-
io 多路復用的三種機制 select poll epoll
- select
#include <sys/select.h> int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); //參數nfds是需要監聽的最大的文件描述符值+1 //rdset,wrset,exset分別對應需要檢測的可讀文件描述符的集合, //可寫文件描述符的集合集異常文件描述符的集合 //參數timoout為結構timeval,用來設置select()的等待時間
1、用戶將自己所關心的文件描述符添加進描述符集中,並且明確關心的是讀,寫,還是異常事件 2、select 通過輪詢的方式不斷掃碼所有被關心的文件描述符,具體時間由參數timeout決定的 3、執行成功則返回文件描述符已改變的個數 4、具體哪一個或哪幾個文件描述符就緒,則需要文件描述符集傳出,它既是輸入型參數,又是輸出型參數 5、fd_set 是用位圖存儲文件描述符的,因為文件描述符是唯一且遞增的整數
特點: 1、可關心的文件描述符數量是有上限的,取決於fd_set(文件描述符集)的大小 2、每次的調用select 前,都要把文件描述符重新添加進fd_set(文件描述符集)中,因為fd_set也是輸出型參數 在函數返回后,fd_set中只有就緒的文件描述符 3、通常我們要關心的文件描述符不止一個,所有首先用數組保存文件描述符,每次調用select前再通過遍歷數逐個添加進去
缺點: 1、每次調用select都需要手動設置fd_Set 2、每次調用select 需要遍歷fd_set 集合,而且要將fd_set 集合從用戶態拷貝到內核態,如何fd很多時,開銷會很大 3、select 支持的文件描述符數量太少 32- 1024 64 -2048
- poll
#include <sys/poll.h> int poll(struct pollfd *fds, nfds_t nfds, int timeout); // 第一個參數是指向一個結構數組的第一個元素的指針 // 第二個參數是要監聽的文件描述符的個數 // 第三個參數意義與select相同 //pollfd結構 struct pollfd{ int fd; short events; short revents; }; //events 是我們要關心的事件,revents是調用后操作系統設置的參數, //也就是表明該文件描述符是否就緒
首先創建一個pollfd結構體變量數組fd_list,然后將我們然后將我們關心的fd(文件描述符)放置在數組中的結構變量中, 並添加我們所關系的事件,調用poll函數,函數返回后我們再通過遍歷的方式去查看數組中那些文件描述符上的事件就緒了。
特點(相對於select) 1、每次調用poll之前不需要手動設置文件描述符集 2、poll將用戶關系的實際和發生的實際進程分離 3、支持的文件描述符數量理論上是無上限的,其實也有, 因為一個進程能打開的文件數量是有上限的 ulimit -n 查看進程可打開的最大文件數
1、poll 返回后,也需要輪詢pollfd 來獲取就緒的描述符 2、同時連接的大量客戶端,可能只有很少的處於就緒狀態,因此隨着監事的描述符數量的增長,其效率也會線性下降
- select poll 的共同點
都做了很多 無效的 輪詢檢測描述符是否就緒的操作
- epoll
- 代碼
#include <sys/epoll.h> int epoll_create(int size); // 在內核里,一切皆文件。所以,epoll向內核注冊了一個文件系統, //epoll_create的作用是創建一個epoll模型,該模型在底層建立了-> //**紅黑樹,就緒隊列,回調機制** //size可以被忽略,不做解釋 int epoll_ctl(int epfd, int op, int fd, struct epoll_events *event); //epfd:epoll_create()的返回值(epoll的句柄,本質上也是一個文件描述符) //op:表示動作,用三個宏來表示 // EPOLL_CTL_ADD:注冊新的fd到epfd中 // EPOLL_CTL_MOD:修改已經注冊的fd的監聽事件 // EPOLL_CTL_DEL:從epfd中刪除一個事件 //fd:需要監聽的文件描述符 //event:具體需要在該文件描述符上監聽的事件 int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); //函數調用成功,返回文件描述符就緒的個數,也就是就緒隊列中文件描述符的個數, //返回0表示超時,小於0表示出錯 //epoll_event結構體 struct epoll_event{ uint32_t events; /* Epoll events */ epoll_data_t data;/* User data variable */ }__EPOLL_PACKED; //events可以是一堆宏的集合,這里介紹幾個常用的 // EPOLLIN:表示對應的文件描述符可以讀(包括對端socket正常關閉) // EPOLLOUT:表示對應的文件描述符可以寫 // EPOLLET:將EPOLL設為邊緣觸發(Edge Triggered)模式, // 默認情況下epoll為水平觸發(Level Triggered)模式 typedef union epoll_data{ void *ptr; int fd; uint32_t u32; uint64_t u64; }epoll_data_t; //聯合體里通常只需要填充fd就OK了,其他參數暫時可以不予理會
- 工作原理
1、創建一個epoll 對象,向epoll 對象中添加文件描述符以及我們所關心的在在該文件描述符上發生的事件 2、通過epoll_ctl 向我們需要關心的文件描述符中注冊事件(讀,寫,異常等), 操作系統將該事件和對象的文件描述符作為一個節點插入到底層建立的紅黑樹中 3、添加到文件描述符上的實際都會與網卡建立回調機制,也就是實際發生時會自主調用一個回調方法, 將事件所在的文件描述符插入到就緒隊列中 4、引用程序調用epoll_wait 就可以直接從就緒隊列中將所有就緒的文件描述符拿到,可以說時間復雜度O(1)
- 水平觸發工作方式(LT)
處理socket時,即使一次沒將數據讀完,下次調用epoll_wait時該文件描述符也會就緒,可以繼續讀取數據
- 邊沿觸發工作方式(ET)
處理socket時沒有一次將數據讀完,那么下次再調用epoll_wait該文件描述符將不再顯示就緒,除非有新數據寫入 在該工作方式,當一個文件描述符就緒是,我們要一次性的將數據讀完
- 隱患問題
當我們調用read讀取緩沖去數據時,如果已經讀取完了,對端沒有關系蟹段,read就會堵塞,影響后續邏輯 解決方式就是講文件描述符,設置成非堵塞的,當沒有數據的時候,read也不會被堵塞, 可以處理后續邏輯(讀取其他的fd或者繼續wait) ET 的性能要好與LT,因為epoll_wait返回的次數比較少,ninx中默認采用ET模式使用epoll
- 特點
1、采用了回調機制,與輪詢區別看待 2、底層采用紅黑樹結構管理已經注冊的文件描述符 3、采用就緒隊列保存已經就緒的文件描述符
- 優點
1、文件描述符數目無上限:通過epoll_ctl 注冊一個文件描述符后,底層采用紅黑樹結構管理所有需要監控的文件描述符 2、基於實際的就緒通知方式:每當有文件描述符就緒時,該響應事件會調用回調方法將該文件描述符插入到就緒隊列中, 不需要內核每次去輪詢式的查看每個被關心的文件描述符 3、維護就緒隊列:當文件描述符就緒的時候,就會被放到內核中的一個就緒隊列中, 調用epoll_wait可以直接從就緒隊列中獲取就緒的文件描述符,時間復雜度是O(1)
四、總結
nginx 通過 多進程 + io多路復用(epoll) 實現了高並發
采用多個worker 進程實現對 多cpu 的利用
通過eopll 對 多個文件描述符 事件回調機制和就緒描述符的處理 實現單線程io復用
從而實現高並發