動手打造Nginx多進程架構

最近對Nginx源碼比較感興趣,藉助於強大的VS Code,我一步一步,似魔鬼的步伐,開始了Nginx的探索之旅。關於 VS Code 如何調試 Nginx 可參考上篇文章《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 操作 Ready 時,操作系統會主動通知進程。

Nginx 正是使用了這樣的思想,雖然同時有很多請求需要處理,但是沒必要爲每個請求都分配一個線程啊。哪個請求的網絡 IO Ready 了,我就去處理哪個,這樣不就可以了嗎?何必創建一個線程在那傻傻的等着。

舉個不恰當的例子,服務器就好比是學校,客戶端好比是學生,學生有不會的問題就會問老師。

  • 對於傳統的 Web 服務器,每個學生,學校都會派一個老師去服務,一個學校可能有幾千個學生,那豈不是要僱幾千個老師,校領導怕是連工資都發不出來了吧。仔細想想,每個學生不可能隨時都在提問吧,總得休息下吧!那學生休息時,老師幹嘛呢?白拿工資還不幹活。
  • 對於Nginx,它就不給老師閒的機會啦,學校有幾間辦公室,就僱幾個老師,有學生提問時,就派一個老師解答,所以一個老師會負責很多學生,哪個學生舉手了,他就去幫助哪個學生解決問題。

這裏有讀者怕是會疑惑,如果哪個學生一直霸佔着老師不放怎麼辦?這樣老師不就沒有機會去解答其他同學的問題了嗎?如果作爲一個負責業務處理的 Web 服務器,Nginx這種架構確實可能出現這樣的問題,但是要記住,Nginx主要是用來做負載均衡的,他的主要任務是接收請求、轉發請求,所以它的業務處理其實就是將請求再轉發給其他的服務器,那麼接收用IO多路複用,轉發也用 IO 多路複用不就行了。

四. 源碼分析

基於最新 1.15.5 版本

4.1 整體運行機制

一切都從 main()開始。

nginx 的 main()方法中有不少邏輯,不過對於今天我要講的事情來說,最重要的就是兩件事:

  1. 創建套接字,監聽端口;
  2. Fork 出 N 個 Worker 進程。

監聽端口沒什麼太多邏輯,我們先來看看 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);
        ......
    }
}

這裏主要是根據配置的 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)
{
    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:

// 創建一個內核消息隊列,返回隊列描述符
int  kqueue(void); 

// 用途:註冊\反註冊 監聽事件,等待事件通知
// kq,上面創建的消息隊列描述符
// changelist,需要註冊的事件
// changelist,changelist數組大小
// eventlist,內核會把返回的事件放在該數組中
// nevents,eventlist數組大小
// timeout,等待內核返回事件的超時事件,NULL 即爲無限等待
int  kevent(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​github.com圖標

六. 總結

Nginx 之所以有強大的高併發能力,得益於它與衆不同的架構設計,無論是多進程還是 IO 多路複用,都是 Nginx 不可或缺的一部分。研究 Nginx 源碼十分有趣,但是看源碼和動手寫又是兩回事,看源碼只能大概瞭解脈絡,只有自己操刀,才能真正理解和運用!

 

原文地址:https://zhuanlan.zhihu.com/p/47831334

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