最近對Nginx源碼比較感興趣,借助于強大的VS Code,我一步一步,似魔鬼的步伐,開始了Nginx的探索之旅。關(guān)于 VS Code 如何調(diào)試 Nginx 可參考上篇文章《VS CODE 輕松調(diào)試 Nginx》。
一. 引言
Nginx 其實無需做太多介紹,作為業(yè)界知名的高性能服務(wù)器,被廣大互聯(lián)網(wǎng)公司應用,阿里的 Tegine 就是基于 Nginx 開發(fā)的。
Nginx 基本上都是用來做負載均衡、反向代理和動靜分離。目前大部分公司都采用 Nginx 作為負載均衡器。作為 LBS,最基本的要求就是要支持高并發(fā),畢竟所有的請求都要經(jīng)過它來進行轉(zhuǎn)發(fā)。
那么為什么 Nginx 擁有如此強大的并發(fā)能力呢?這便是我感興趣的事情,也是這篇文章所要講的事情。但是標題是《動手打造Nginx多進程架構(gòu)》,難道這篇文章卻只是簡單的源碼分析?
這幾天研究 Nginx 過程中,我常常陷于Nginx 復雜的源碼之中,不得其解,雖然也翻了一些資料和書籍,但是總覺得沒有 get 到精髓,就是好像已經(jīng)理解了,但是對于具體流程和細節(jié),總是模模糊糊。于是趁著周末,花了小半天,再次梳理了下Nginx 多進程事件的源碼,仿照著寫了一個普通的 Server,雖然代碼和功能都非常簡單,不過剛好適合于讀者了解Nginx,而不至于陷于叢林之中,不知方向。
二. 傳統(tǒng) Web Server 架構(gòu)
讓我們來思考下,如果讓你動手打造一個 web 服務(wù)器,你會怎么做?
第一步,監(jiān)聽端口
第二步,處理請求
監(jiān)聽端口倒是很簡單,處理請求該怎么做呢?不知道大家上大學剛開始學c語言的時候,老師有沒有布置過聊天室之類的作業(yè)?那時候我其實完全靠百度來完成的:開啟端口監(jiān)聽,死循環(huán)接收請求,每接收一個請求就直接開個新線程去處理。

這樣做當然可以,也很簡單,完全滿足了我當時的作業(yè)要求,其實目前很多web服務(wù)器,諸如tomcat之類,也都是這樣做的,為每個請求單獨分配一個線程。那么這樣做,有什么弊端呢?
最直接的弊端就是線程數(shù)量開的太多,會導致 CPU 在不同線程之間不斷的進行上下文切換。CPU 的每次任務(wù)切換,都需要為上一次任務(wù)保存一些上下文信息(如寄存器的值),再裝載新任務(wù)的上下文信息,這些都是不小的開銷。
第二個弊端就是CPU利用率的下降,考慮當前只有一個線程的情況,當線程在等待網(wǎng)絡(luò) IO 的時候其實是處于阻塞狀態(tài),這個時候 CPU 便處于空閑狀態(tài),這直接導致了 CPU 沒有被充分利用,簡直是暴殄天物!
這種架構(gòu),使 Web 服務(wù)器從骨子里,就對高并發(fā)沒有很好的承載能力!
三. Nginx 多進程架構(gòu)
Nginx 之所以可以支持高并發(fā),正是因為它摒棄了傳統(tǒng) Web 服務(wù)器的多線程架構(gòu),并充分利用了 CPU。
Nginx采用的是 單Master、多Worker 架構(gòu),顧名思義,Master 是老板,而 Worker 才是真正干活的工人階層。
我們先來看下 Nginx 接收請求的大概架構(gòu)。

乍一看,好像和傳統(tǒng)的 Web Server 也沒啥區(qū)別啊,不過是右邊的 Thread 變成了 Worker 罷了。這其實正是 Nginx 的精妙之處。
Master 進程啟動后,會 fork 出 N 個 Worker 進程,N 是 可配置的,一般來說,可以設(shè)置為服務(wù)器核心數(shù),設(shè)置更大值也沒有太多意義,無非是會增加 CPU 進程切換的開銷。
每個Worker 進程都會監(jiān)聽來自客戶端的請求,并進行處理,與傳統(tǒng) Web Server 不同的是,Worker 進程不會對于每個請求都分配一個單獨線程去處理,而是充分利用了IO多路復用 的特性。
如果讀者之前沒有了解或者使用過IO多路復用,那確實該好好充充電了。Android 中的 Looper、Java 著名的開源庫 Netty,都是基于多路復用,所謂多路復用,與同步阻塞IO最大的區(qū)別就是,一個進程可以同時處理多個IO操作,當 某個IO 操作 Ready 時,操作系統(tǒng)會主動通知進程。
Nginx 正是使用了這樣的思想,雖然同時有很多請求需要處理,但是沒必要為每個請求都分配一個線程啊。哪個請求的網(wǎng)絡(luò) IO Ready 了,我就去處理哪個,這樣不就可以了嗎?何必創(chuàng)建一個線程在那傻傻的等著。
舉個不恰當?shù)睦?,服?wù)器就好比是學校,客戶端好比是學生,學生有不會的問題就會問老師。
- 對于傳統(tǒng)的 Web 服務(wù)器,每個學生,學校都會派一個老師去服務(wù),一個學校可能有幾千個學生,那豈不是要雇幾千個老師,校領(lǐng)導怕是連工資都發(fā)不出來了吧。仔細想想,每個學生不可能隨時都在提問吧,總得休息下吧!那學生休息時,老師干嘛呢?白拿工資還不干活。
- 對于Nginx,它就不給老師閑的機會啦,學校有幾間辦公室,就雇幾個老師,有學生提問時,就派一個老師解答,所以一個老師會負責很多學生,哪個學生舉手了,他就去幫助哪個學生解決問題。
這里有讀者怕是會疑惑,如果哪個學生一直霸占著老師不放怎么辦?這樣老師不就沒有機會去解答其他同學的問題了嗎?如果作為一個負責業(yè)務(wù)處理的 Web 服務(wù)器,Nginx這種架構(gòu)確實可能出現(xiàn)這樣的問題,但是要記住,Nginx主要是用來做負載均衡的,他的主要任務(wù)是接收請求、轉(zhuǎn)發(fā)請求,所以它的業(yè)務(wù)處理其實就是將請求再轉(zhuǎn)發(fā)給其他的服務(wù)器,那么接收用IO多路復用,轉(zhuǎn)發(fā)也用 IO 多路復用不就行了。
四. 源碼分析
基于最新 1.15.5 版本
4.1 整體運行機制
一切都從 main()開始。
nginx 的 main()方法中有不少邏輯,不過對于今天我要講的事情來說,最重要的就是兩件事:
- 創(chuàng)建套接字,監(jiān)聽端口;
- Fork 出 N 個 Worker 進程。
監(jiān)聽端口沒什么太多邏輯,我們先來看看 Worker 進程的誕生:
static void
ngx_start_worker_processes(ngx_cycle_t *cycle, ngx_int_t n, ngx_int_t type)
{
ngx_int_t i;
ngx_channel_t ch;
....
for (i = 0; i < n; i++) {
ngx_spawn_process(cycle, ngx_worker_process_cycle,
(void *) (intptr_t) i, "worker process", type);
......
}
}
這里主要是根據(jù)配置的 Worker 數(shù)量,創(chuàng)建出對應數(shù)量的 Worker 進程,創(chuàng)建 Woker 進程調(diào)用的是 ngx_spawn_process(),第二個參數(shù) ngx_worker_process_cycle 就是子進程的新起點。
static void
ngx_worker_process_cycle(ngx_cycle_t *cycle, void *data)
{
......
for ( ;; ) {
......
ngx_process_events_and_timers(cycle);
......
}
}
上面的代碼省略了一些邏輯,只保留了最核心的部分。ngx_worker_process_cycle ,正如其名,在其內(nèi)部開啟了一個死循環(huán),不斷調(diào)用 ngx_process_events_and_timers()。
void
ngx_process_events_and_timers(ngx_cycle_t *cycle)
{
......
if (ngx_use_accept_mutex) {
if (ngx_accept_disabled > 0) {
ngx_accept_disabled--;
} else {
if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
return;
}
......
}
}
......
(void) ngx_process_events(cycle, timer, flags);
......
}
這里最后調(diào)用了ngx_process_events()來接收并處理事件。
ngx_process_events()在不同平臺指向不同的 IO 處理模塊,比如Linux上為epoll,而在Mac OS上指向的其實是kqueue模塊中的ngx_kqueue_process_events()。
static ngx_int_t
ngx_kqueue_process_events(ngx_cycle_t *cycle, ngx_msec_t timer,
ngx_uint_t flags)
{
int events, n;
ngx_int_t i, instance;
ngx_uint_t level;
ngx_err_t err;
ngx_event_t *ev;
ngx_queue_t *queue;
struct timespec ts, *tp;
n = (int) nchanges;
nchanges = 0;
......
events = kevent(ngx_kqueue, change_list, n, event_list, (int) nevents, tp);
......
for (i = 0; i < events; i++) {
......
ev = (ngx_event_t *) event_list[i].udata;
switch (event_list[i].filter) {
case EVFILT_READ:
case EVFILT_WRITE:
......
break;
case EVFILT_VNODE:
ev->kq_vnode = 1;
break;
case EVFILT_AIO:
ev->complete = 1;
ev->ready = 1;
break;
......
}
......
ev->handler(ev);
}
return NGX_OK;
}
上面其實就是一個比較基本的 kqueue 使用方式了。說到這里,我們就不得不說下 kqueue 的使用方式了。
kqueue 主要依托于兩個 API:
// 創(chuàng)建一個內(nèi)核消息隊列,返回隊列描述符
int kqueue(void);
// 用途:注冊\反注冊 監(jiān)聽事件,等待事件通知
// kq,上面創(chuàng)建的消息隊列描述符
// changelist,需要注冊的事件
// changelist,changelist數(shù)組大小
// eventlist,內(nèi)核會把返回的事件放在該數(shù)組中
// nevents,eventlist數(shù)組大小
// timeout,等待內(nèi)核返回事件的超時事件,NULL 即為無限等待
int kevent(int kq,
const struct kevent *changelist, int nchanges,
struct kevent *eventlist, int nevents,
const struct timespec *timeout);
我們回過頭再來看看上面 ngx_kqueue_process_events()中代碼,其實也就是在調(diào)用kevent()等待內(nèi)核返回消息,收到消息后再進行處理。這里消息處理主要是進行ACCEPT、READ、WRITE等。
所以從整體來看,Nginx事件模塊的運行就是 Worker 進程在死循環(huán)中,不斷等待內(nèi)核消息隊列返回事件消息,并加以處理的一個過程。
4.2 驚群問題
到這里我們一直在討論一個單獨的 Worker 進程運行機制,那么每個 Worker 進程之間有沒有什么交互呢?
回到上面的 ngx_process_events_and_timers()中,在每次調(diào)用 ngx_process_events()等待消息之前,Worker 進程都會進行一個 ngx_trylock_accept_mutex()操作,這其實就是多個 Worker 進程之間在爭奪監(jiān)聽資格的過程,是 Nginx 為了解決驚群問題而設(shè)計出的方案。
所謂驚群,其實就是如果有多個Worker進程同時在監(jiān)聽內(nèi)核消息事件,當有請求到來時,每個Worker進程都會被喚醒,去accept同一個請求,但是只能有一個進程會accept成功,其他進程會accept失敗,被白白的喚醒了,就像你再睡覺時被突然叫醒,卻發(fā)現(xiàn)壓根沒你啥事,你說氣不氣人。
為了解決這個問題,Nginx 讓每個Worker 進程在監(jiān)聽內(nèi)核消息事件前去競爭一把鎖,只有成功獲得鎖的進程才能去監(jiān)聽內(nèi)核事件,其他進程就乖乖的睡眠在鎖的等待隊列上。當獲得鎖的進程處理完accept事件,就會回來釋放掉這把鎖,這時所有進程又會同時去競爭鎖了。
為了不讓每次都是同一個進程搶到鎖,Nginx 設(shè)計了一個小算法,用一個因子ngx_accept_disabled 去 平均每個進程獲得鎖的概率,感興趣的同學可以自己看下這塊源碼。
五. 動手打造 Nginx 多進程架構(gòu)
終于到DIY的環(huán)節(jié)了,這里我基于 MacOS 平臺來開發(fā),IO多路復用也是選用上面所講的 kqueue。
5.1 創(chuàng)建進程鎖,用于搶到監(jiān)聽事件資格
mm = (mt*)mmap(NULL,sizeof(*mm),PROT_READ|PROT_WRITE,MAP_SHARED|MAP_ANON,-1,0);
memset(mm,0x00,sizeof(*mm));
pthread_mutexattr_init(&mm->mutexattr);
pthread_mutexattr_setpshared(&mm->mutexattr, PTHREAD_PROCESS_SHARED);
pthread_mutex_init(&mm->mutex,&mm->mutexattr);
5.2 創(chuàng)建套接字,監(jiān)聽端口
// 創(chuàng)建套接字
int serverSock =socket(AF_INET, SOCK_STREAM, 0);
if (serverSock == -1)
{
printf("socket failed\n");
exit(0);
}
//綁定ip和端口
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(9999);
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
if(::bind(serverSock, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1)
{
printf("bind failed\n");
exit(0);
}
//啟動監(jiān)聽
if(listen(serverSock, 20) == -1)
{
printf("listen failed\n");
exit(0);
}
5.3 創(chuàng)建多個 Worker 進程
// fork 出 3 個 Worker 進程
int result;
for(int i = 1; i< 3; i++){
result = fork();
if(result == 0){
startWorker(i,serverSock);
printf("start worker %d\n",i);
break;
}
}
5.4 啟動Worker 進程,監(jiān)聽 IO 事件
void startWorker(int workerId,int serverSock)
{
// 創(chuàng)建內(nèi)核事件隊列
int kqueuefd=kqueue();
struct kevent change_list[1]; //想要監(jiān)控的事件的數(shù)組
struct kevent event_list[1]; //用來接受事件的數(shù)組
//初始化所需注冊事件
EV_SET(&change_list[0], serverSock, EVFILT_READ, EV_ADD | EV_ENABLE, 0, 0, 0);
// 循環(huán)接受事件
while (true) {
// 競爭鎖,獲取監(jiān)聽資格
pthread_mutex_lock(&mm->mutex);
printf("Worker %d get the lock\n",workerId);
// 注冊事件,等待通知
int nevents = kevent(kqueuefd, change_list, 1, event_list, 1, NULL);
// 釋放鎖
pthread_mutex_unlock(&mm->mutex);
//遍歷返回的所有就緒事件
for(int i = 0; i< nevents;i++){
struct kevent event =event_list[i];
if(event.ident == serverSock){
// ACCEPT 事件
handleNewConnection(kqueuefd,serverSock);
}else if(event.filter == EVFILT_READ){
//讀取客戶端傳來的數(shù)據(jù)
char * msg = handleReadFromClient(workerId,event);
handleWriteToClient(workerId,event,msg);
}
}
}
}
5.5 開啟多個 Client 進程測試
運行結(jié)果:

哈哈,基本實現(xiàn)了我的要求。
Demo 源碼見:https://github.com/HalfStackDeveloper/LearnNginx
六. 總結(jié)
Nginx 之所以有強大的高并發(fā)能力,得益于它與眾不同的架構(gòu)設(shè)計,無論是多進程還是 IO 多路復用,都是 Nginx 不可或缺的一部分。研究 Nginx 源碼十分有趣,但是看源碼和動手寫又是兩回事,看源碼只能大概了解脈絡(luò),只有自己操刀,才能真正理解和運用!