動手打造Nginx多程序架構
最近對Nginx原始碼比較感興趣,藉助於強大的VS Code,我一步一步,似魔鬼的步伐,開始了Nginx的探索之旅。關於 VS Code 如何除錯 Nginx 可參考上篇文章 ofollow,noindex" target="_blank">《VS CODE 輕鬆除錯 Nginx》 。
一. 引言
Nginx 其實無需做太多介紹,作為業界知名的高效能伺服器,被廣大網際網路公司應用,阿里的Tegine 就是基於 Nginx 開發的。
Nginx 基本上都是用來做負載均衡、反向代理和動靜分離。目前大部分公司都採用 Nginx 作為負載均衡器。作為 LBS,最基本的要求就是要支援高併發,畢竟所有的請求都要經過它來進行轉發。
那麼為什麼 Nginx 擁有如此強大的併發能力呢?這便是我感興趣的事情,也是這篇文章所要講的事情。但是標題是《動手打造Nginx多程序架構》,難道這篇文章卻只是簡單的原始碼分析?
這幾天研究 Nginx 過程中,我常常陷於Nginx 複雜的原始碼之中,不得其解,雖然也翻了一些資料和書籍,但是總覺得沒有 get 到精髓,就是好像已經理解了,但是對於具體流程和細節,總是模模糊糊。於是趁著週末,花了小半天,再次梳理了下Nginx 多程序事件的原始碼,仿照著寫了一個普通的 Server,雖然程式碼和功能都非常簡單,不過剛好適合於讀者瞭解Nginx,而不至於陷於叢林之中,不知方向。
二. 傳統 Web Server 架構
讓我們來思考下,如果讓你動手打造一個 web 伺服器,你會怎麼做?
第一步,監聽埠
第二步,處理請求
監聽埠倒是很簡單,處理請求該怎麼做呢?不知道大家上大學剛開始學c語言的時候,老師有沒有佈置過聊天室之類的作業?那時候我其實完全靠百度來完成的:開啟埠監聽,死迴圈接收請求,每接收一個請求就直接開個新執行緒去處理。

這樣做當然可以,也很簡單,完全滿足了我當時的作業要求,其實目前很多web伺服器,諸如tomcat之類,也都是這樣做的,為每個請求單獨分配一個執行緒。那麼這樣做,有什麼弊端呢?
最直接的弊端就是執行緒數量開的太多,會導致 CPU 在不同執行緒之間不斷的進行上下文切換。CPU 的每次任務切換,都需要為上一次任務儲存一些上下文資訊(如暫存器的值),再裝載新任務的上下文資訊,這些都是不小的開銷。
第二個弊端就是CPU利用率的下降,考慮當前只有一個執行緒的情況,當執行緒在等待網路 IO 的時候其實是處於阻塞狀態,這個時候 CPU 便處於空閒狀態,這直接導致了 CPU 沒有被充分利用,簡直是暴殄天物!
這種架構,使 Web 伺服器從骨子裡,就對高併發沒有很好的承載能力!
三. Nginx 多程序架構
Nginx 之所以可以支援高併發,正是因為它摒棄了傳統 Web 伺服器的多執行緒架構,並充分利用了 CPU。
Nginx採用的是 單Master、多Worker 架構,顧名思義,Master 是老闆,而 Worker 才是真正幹活的工人階層。
我們先來看下 Nginx 接收請求的大概架構。

乍一看,好像和傳統的 Web Server 也沒啥區別啊,不過是右邊的 Thread 變成了 Worker 罷了。這其實正是 Nginx 的精妙之處。
Master 程序啟動後,會 fork 出 N 個 Worker 程序,N 是 可配置的,一般來說,可以設定為伺服器核心數,設定更大值也沒有太多意義,無非是會增加 CPU 程序切換的開銷。
每個Worker 程序都會監聽來自客戶端的請求,並進行處理,與傳統 Web Server 不同的是,Worker 程序不會對於每個請求都分配一個單獨執行緒去處理,而是充分利用了非同步 IO 的特性。
如果讀者之前沒有了解或者使用過非同步IO,那確實該好好充充電了。Android 中的 Looper、Java 著名的開源庫 Netty,都是基於非同步IO,所謂非同步IO,與同步IO最大的區別就是,程序不會在等待 IO 操作時被阻塞,而是可以去幹其他的任務,當 IO 操作 Ready 時,作業系統會主動通知程序。
Nginx 正是使用了這樣的思想,雖然同時有很多請求需要處理,但是沒必要為每個請求都分配一個執行緒啊。哪個請求的網路 IO Ready 了,我就去處理哪個,這樣不就可以了嗎?何必建立一個執行緒在那傻傻的等著。
舉個不恰當的例子,伺服器就好比是學校,客戶端好比是學生,學生有不會的問題就會問老師。
- 對於傳統的 Web 伺服器,每個學生,學校都會派一個老師去服務,一個學校可能有幾千個學生,那豈不是要僱幾千個老師,校領導怕是連工資都發不出來了吧。仔細想想,每個學生不可能隨時都在提問吧,總得休息下吧!那學生休息時,老師幹嘛呢?白拿工資還不幹活。
- 對於Nginx,它就不給老師閒的機會啦,學校有幾間辦公室,就僱幾個老師,有學生提問時,就派一個老師解答,所以一個老師會負責很多學生,哪個學生舉手了,他就去幫助哪個學生解決問題。
這裡有讀者怕是會疑惑,如果哪個學生一直霸佔著老師不放怎麼辦?這樣老師不就沒有機會去解答其他同學的問題了嗎?如果作為一個負責業務處理的 Web 伺服器,Nginx這種架構確實可能出現這樣的問題,但是要記住,Nginx主要是用來做負載均衡的,他的主要任務是接收請求、轉發請求,所以它的業務處理其實就是將請求再轉發給其他的伺服器,那麼接收用非同步IO,轉發也用非同步 IO 不就行了。
四. 原始碼分析
基於最新 1.15.5 版本
4.1 整體執行機制
一切都從 main()開始。
nginx 的 main()方法中有不少邏輯,不過對於今天我要講的事情來說,最重要的就是兩件事:
- 建立套接字,監聽埠;
- Fork 出 N 個 Worker 程序。
監聽埠沒什麼太多邏輯,我們先來看看 Worker 程序的誕生:
static void ngx_start_worker_processes(ngx_cycle_t *cycle, ngx_int_t n, ngx_int_t type) { ngx_int_ti; ngx_channel_tch; .... for (i = 0; i < n; i++) { ngx_spawn_process(cycle, ngx_worker_process_cycle, (void *) (intptr_t) i, "worker process", type); ...... } }
這裡主要是根據配置的 Worker 數量,創建出對應數量的 Worker 程序,建立 Woker 程序呼叫的是 ngx_spawn_process(),第二個引數 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 ,正如其名,在其內部開啟了一個死迴圈,不斷呼叫 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); ...... }
這裡最後呼叫了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) { intevents, n; ngx_int_ti, instance; ngx_uint_tlevel; ngx_err_terr; ngx_event_t*ev; ngx_queue_t*queue; struct timespects, *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:
// 建立一個核心訊息佇列,返回佇列描述符 intkqueue(void); // 用途:註冊\反註冊 監聽事件,等待事件通知 // kq,上面建立的訊息佇列描述符 // changelist,需要註冊的事件 // changelist,changelist陣列大小 // eventlist,核心會把返回的事件放在該陣列中 // nevents,eventlist陣列大小 // timeout,等待核心返回事件的超時事件,NULL 即為無限等待 intkevent(int kq, const struct kevent *changelist, int nchanges, struct kevent *eventlist, int nevents, const struct timespec *timeout);
我們回過頭再來看看上面 ngx_kqueue_process_events()中程式碼,其實也就是在呼叫kevent()等待核心返回訊息,收到訊息後再進行處理。這裡訊息處理主要是進行ACCEPT、READ、WRITE等。
所以從整體來看,Nginx事件模組的執行就是 Worker 程序在死迴圈中,不斷等待核心訊息佇列返回事件訊息,並加以處理的一個過程。
4.2 驚群問題
到這裡我們一直在討論一個單獨的 Worker 程序執行機制,那麼每個 Worker 程序之間有沒有什麼互動呢?
回到上面的 ngx_process_events_and_timers()中,在每次呼叫 ngx_process_events()等待訊息之前,Worker 程序都會進行一個 ngx_trylock_accept_mutex()操作,這其實就是多個 Worker 程序之間在爭奪監聽資格的過程,是 Nginx 為了解決驚群問題而設計出的方案。
所謂驚群,其實就是如果有多個Worker程序同時在監聽核心訊息事件,當有請求到來時,每個Worker程序都會被喚醒,去accept同一個請求,但是隻能有一個程序會accept成功,其他程序會accept失敗,被白白的喚醒了,就像你再睡覺時被突然叫醒,卻發現壓根沒你啥事,你說氣不氣人。
為了解決這個問題,Nginx 讓每個Worker 程序在監聽核心訊息事件前去競爭一把鎖,只有成功獲得鎖的程序才能去監聽核心事件,其他程序就乖乖的睡眠在鎖的等待佇列上。當獲得鎖的程序處理完accept事件,就會回來釋放掉這把鎖,這時所有程序又會同時去競爭鎖了。
為了不讓每次都是同一個程序搶到鎖,Nginx 設計了一個小演算法,用一個因子ngx_accept_disabled 去 平均每個程序獲得鎖的概率,感興趣的同學可以自己看下這塊原始碼。
五. 動手打造 Nginx 多程序架構
終於到DIY的環節了,這裡我基於 MacOS 平臺來開發,非同步IO庫也是選用上面所講的 kqueue。
5.1 建立程序鎖,用於搶到監聽事件資格
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 建立套接字,監聽埠
// 建立套接字 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); } //啟動監聽 if(listen(serverSock, 20) == -1) { printf("listen failed\n"); exit(0); }
5.3 建立多個 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 程序,非同步監聽 IO 事件
void startWorker(int workerId,int serverSock) { // 建立核心事件佇列 int kqueuefd=kqueue(); struct kevent change_list[1];//想要監控的事件的陣列 struct kevent event_list[1];//用來接受事件的陣列 //初始化所需註冊事件 EV_SET(&change_list[0], serverSock, EVFILT_READ, EV_ADD | EV_ENABLE, 0, 0, 0); // 迴圈接受事件 while (true) { // 競爭鎖,獲取監聽資格 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){ //讀取客戶端傳來的資料 char * msg = handleReadFromClient(workerId,event); handleWriteToClient(workerId,event,msg); } } } }
5.5 開啟多個 Client 程序測試
void startClientId(int clientId) { //建立套接字 int sock = socket(AF_INET, SOCK_STREAM, 0); //向Server發起請求 struct sockaddr_in serv_addr; serv_addr.sin_family = AF_INET;//使用IPv4地址 serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");//具體的IP地址 serv_addr.sin_port = htons(9999);//埠 connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)); while (true) { //向伺服器傳送資料 string s = "I am Client "; s.append(to_string(clientId)); char str[60]; strcpy(str,s.c_str()); write(sock, str, strlen(str)); //讀取伺服器傳回的資料 char buffer[60]; if(read(sock, buffer, sizeof(buffer)-1)>0){ printf("Client %d receive : %s\n",clientId,buffer); } sleep(9); } }
執行結果:

哈哈,基本實現了我的要求。
Demo 原始碼見:
HalfStackDeveloper/LearnNginx六. 總結
Nginx 之所以有強大的高併發能力,得益於它與眾不同的架構設計,無論是多程序還是非同步IO,都是 Nginx 不可或缺的一部分。研究 Nginx 原始碼十分有趣,但是看原始碼和動手寫又是兩回事,看原始碼只能大概瞭解脈絡,只有自己操刀,才能真正理解和運用!