Nginx基礎. 防止驚羣與子進程之間的負載均衡

作爲服務器子進程, 每個worker進程都需要處理大量網絡事件. 而網絡事件的處理來源於對監聽端口新連接的建立.
當有多個worker進程同時監聽同一個(或多個)端口時, 建立連接就沒那麼簡單了.
Nginx出於充分發揮多核CPU性能的考慮, 則使用了多個worker子進程的設計. 這樣多個子進程在accept建立連接時候就會有爭搶, 產生"驚羣"問題. 有的系統可能在內核就解決了這個問題, 但出於Nginx的跨平臺, Nginx還是自己解決了這個問題.
所以, 這裏我們將介紹accept鎖.
另外, 既然採用了多進程模型, 那麼多個進程之間就可能需要處理負載均衡的問題. 儘量使每個子進程處理連接的數量不會相差太大.
所以, 這裏我們將介紹Nginx的post事件處理機制.


解決驚羣問題
只有打開了accept_mutex鎖, 纔可解決驚羣問題.
什麼是驚羣?
假設下面的場景, 某時刻恰好所有的worker子進程都休眠且等待新連接的系統調用epoll_wait, 這時有個用戶向服務器發起連接, 內核收到TCP的SYN包後, 會激活所有的休眠的worker進程. 雖然只有最快的最先執行accept的worker子進程才能獲得這個連接的處理, 其他子進程會accept失敗, 但是其他子進程被內核喚醒是不必要的, 被喚醒後執行的內容也是不必要的. 那一刻他們引發了不必要的上下文切換資源, 增加了系統的開銷.
如何解決?
Nginx的解決方式是, 規定同一時刻只能有唯一一個worker子進程監聽Web端口, 這樣就不會發生驚羣了.此時新連接的到來只會喚醒一個進程.
如何實現?
在調用事件驅動模塊的process_events方法前, 每個子進程會事先執行下面這段代碼.
這段代碼就是worker子進程之間爭搶accept鎖的部分:
     //以下代碼截取自ngx_process_events_and_timers函數
    if (ngx_use_accept_mutex) {
          //如果當前worker進程的連接數過多, 那麼選擇不參與競爭
        if (ngx_accept_disabled > 0) {   
            ngx_accept_disabled--;

        } else {
               //爭搶鎖
            if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
                return;
            }
                //如果搶到了鎖, 那麼就需要採用Nginx的post事件處理機制(此機制下面會講)
            if (ngx_accept_mutex_held) {
                flags |= NGX_POST_EVENTS;

            } else {
                     //沒有搶到鎖的話, 會推遲一段時間在參與鎖的競爭
                if (timer == NGX_TIMER_INFINITE
                    || timer > ngx_accept_mutex_delay)
                {
                    timer = ngx_accept_mutex_delay;
                }
            }
        }
從這部分可以看出, 如果在爭搶到accept鎖後, 該worker進程就會向process_events方法傳入NGX_POST_EVENTS標誌
這個標誌用於各子進程之間的負載均衡, 放在下面會講到
這裏先看一下爭搶鎖的方法具體實現:
ngx_int_t
ngx_trylock_accept_mutex(ngx_cycle_t *cycle)
{
     //調用此方法試圖獲取進程間共享的accept鎖.
     //此函數返回1表示成功獲取, 0相反.
     //調用的是trylock, 所以從字面上就能看出是非阻塞的, 如果鎖此時正被其他子進程佔用, 那麼立即返回失敗
    if (ngx_shmtx_trylock(&ngx_accept_mutex)) {
          //以下就是成功搶到鎖後的操作
          //搶到鎖時發現自身本就持有着accept鎖, 那麼立即返回
        if (ngx_accept_mutex_held
            && ngx_accept_events == 0
            && !(ngx_event_flags & NGX_USE_RTSIG_EVENT))
        {
            return NGX_OK;
        }
          //將所有監聽連接的讀事件添加到當前的epoll中, 等待被觸發
          //在 ngx_enable_accept_events中調用的方法是ngx_add_event
        if (ngx_enable_accept_events(cycle) == NGX_ERROR) {
            ngx_shmtx_unlock(&ngx_accept_mutex);
            return NGX_ERROR;
        }

        ngx_accept_events = 0;
        ngx_accept_mutex_held = 1;

        return NGX_OK;
    }

     //如果沒有獲取到鎖, 但是當前卻持有鎖的狀態, 需要把ngx_accept_mutex_held關掉
    if (ngx_accept_mutex_held) {
        if (ngx_disable_accept_events(cycle) == NGX_ERROR) {
            return NGX_ERROR;
        }

        ngx_accept_mutex_held = 0;
    }

    return NGX_OK;
}
所以, 根據以上代碼的含義, 就是說只有在獲取到鎖的情況下, 建立新連接的方法纔會被註冊到epoll中去. 即要麼是唯一獲取到accept_mutex鎖且其epoll等事件驅動模塊開始監控端口上的新連接, 要麼是沒有獲取到鎖, 當前進程不會收到新連接事件.
如果沒有獲取到鎖, 接下來調用事件驅動模塊的process_events方法時只能處理已有的連接上的事件;
如果獲取到了鎖, 調用process_events方法時就會既處理已有連接上的事件, 也處理新連接的事件.
何時釋放持有的accept鎖也是需要考慮的問題, 如果等到處理爲完這批事件, 那麼可能因爲這個worker上本身就有的許多活躍連接而導致很長時間沒有釋放鎖, 使得其他worker進程很難獲得處理新連接的機會.
這時候我們考慮的就是各個worker子進程之間的負載均衡問題了.


何時釋放accept鎖?
這裏就需要利用Nginx的post機制, 即將事件區分的ngx_posted_accept_events和ngx_posted_events隊列
上面的截取自ngx_process_events_and_timers函數的部分代碼可以看出, 如果某worker子進程獲取了接收新連接的"權力", 就會有NGX_POST_EVENTS標誌被設置.
再根據之前關於epoll事件驅動模塊文章中的ngx_epoll_process_events函數代碼解析部分代碼來看:
        if (flags & NGX_POST_EVENTS) {
                    //對於EPOLLIN事件, 其有可能是普通的讀事件, 也有可能是有新連接到來 
                queue = rev->accept ? &ngx_posted_accept_events
                                    : &ngx_posted_events;

                ngx_post_event(rev, queue);

            }
如果沒有這個標誌的話, 此事件會被立即處理. (即立刻調用該事件的回調函數)
可以看出, Nginx用ngx_posted_accept_events和ngx_posted_events隊列將所有事件歸類了
那依舊沒有談到何時釋放了鎖啊?看下面這段代碼:
     //這一段代碼也是截取自ngx_process_events_and_timers函數部分
     //上面提到, 會先獲取鎖, 無論是否獲取成功, 之後就是調用事件驅動模塊的process_events函數了
    (void) ngx_process_events(cycle, timer, flags);
     ...
    ngx_event_process_posted(cycle, &ngx_posted_accept_events);

    if (ngx_accept_mutex_held) {
        ngx_shmtx_unlock(&ngx_accept_mutex);
    }

    if (delta) {
        ngx_event_expire_timers();
    }

    ngx_event_process_posted(cycle, &ngx_posted_events);
關於上面這段代碼, 我們不明白的應該就是ngx_event_process_posted方法了. 此方法的用途就是執行某個post隊列中的事件回調函數.
這裏的關注點在於該段代碼表明, 在執行完接收連接的post隊列後, 立即釋放accept鎖, 之後才處理已有連接的事件.

如何實現負載均衡?
根據上面的內容, 我們已經有所瞭解, 想要實現負載均衡, accept鎖必不可少.
當監聽端口有新連接到來時, 連接事件會被放到ngx_posted_accept_events隊列中, Nginx會調用ngx_event_accept來試圖建立新的連接
在ngx_event_accept這個建立連接的方法中, 控制負載均衡的關鍵部分如下:
ngx_accept_disabled = ngx_cycle->connection_n / 8
                              - ngx_cycle->free_connection_n;
在Nginx啓動時, ngx_accept_disable的值就是一個負數, 其值爲連接總數的 7/8. 雖然是一個整型數據, 但它是負載均衡的實現的關鍵閥值
當此值爲負數時, 不會觸發負載均衡操作; 而當此值爲正時, 就會觸發負載均衡的操作了, 即當前進程不再處理新連接事件, 而是簡單的ngx_accept_disable-1操作.
這時候我們再次回到文章開頭的截取自ngx_process_events_and_timers函數的部分代碼:
        if (ngx_accept_disabled > 0) {   
            ngx_accept_disabled--;

        } else {
             //爭搶鎖
             if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
                  return;
                  ...
              }
所以, 我們可以看出, 在當前使用的連接到達總連接數的7/8時, 就不會再處理新的連接了, 同時, 在每次調用process_evnets_and_timers函數時都會將ngx_accept_disabled減一, 知道ngx_accept_disabled降到總連接數的7/8以下, 纔會再次參與accept鎖的競爭嘗試接收新連接.
因此, Nginx各worker子進程間的負載均衡僅在某個worker進程處理的連接數到達最大處理數的7/8時纔會觸發. 這時該worker子進程將減少處理新連接的機會. 這樣其他空閒的worker進程就有機會去處理更多的連接. 在Nginx中, accept鎖默認是打開的.


流程

上面討論了Nginx使用accept鎖解決驚羣問題, 以及多個worker子進程之間是如何解決負載均衡問題的.
分析過程中, 多次涉及一個方法ngx_process_events_and_timers, 事實上, 循環調用此方法正是事件驅動機制的核心.此方法不僅處理網絡事件, 也處理定時器事件.
代碼這裏就不再分析了, 因爲上面已經斷斷續續的分析過了. 下面簡述一下其執行流程(摘自<深入理解Nginx>):
第一步:
        如果配置文件中聲明使用timer_resolution配置項, 即令全局變量ngx_timer_resolution大於0, 則說明用戶希望服務器的時間精度爲timer_resolution秒. 這時候會將傳給epoll_wait的timer參數置爲-1, 即令epoll_wait一直等待直到有事件發生. 可以這樣做是因爲timer_resolution的存在. 是否還記得之前在分析事件模塊ngx_event_core_module時, 其模塊接口中聲明的函數ngx_event_process_init, 此函數中就因爲配置項timer_resolution的存在而設置了這樣一段代碼:
    if (ngx_timer_resolution && !(ngx_event_flags & NGX_USE_TIMER_EVENT)) {
        struct sigaction  sa;
        struct itimerval  itv;

        ngx_memzero(&sa, sizeof(struct sigaction));
        sa.sa_handler = ngx_timer_signal_handler;
        sigemptyset(&sa.sa_mask);
          //可以發現, 這裏設置了信號處理函數, 針對的信號是SIGALRM, 處理函數是ngx_timer_signal_handler
          //跳轉到ngx_timer_signal_handler函數, 發現其作用就是使ngx_event_timer_alarm置1, 置1有什麼用呢?
          //該變量置1表示需要更新時間. 在事件驅動機制實現的事件模塊接口ngx_event_module_t中的ngx_event_actions_t成員中的process_events中,
          //當ngx_event_timer_alarm爲1時, 都會調用ngx_time_update方法更新系統時間. 下面對定時器還會有更詳細的分析
        if (sigaction(SIGALRM, &sa, NULL) == -1) {
            ngx_log_error(NGX_LOG_ALERT, cycle->log, ngx_errno,
                          "sigaction(SIGALRM) failed");
            return NGX_ERROR;
        }

        itv.it_interval.tv_sec = ngx_timer_resolution / 1000;
        itv.it_interval.tv_usec = (ngx_timer_resolution % 1000) * 1000;
        itv.it_value.tv_sec = ngx_timer_resolution / 1000;
        itv.it_value.tv_usec = (ngx_timer_resolution % 1000 ) * 1000;
          //引起SIGALRM的函數是setitimer函數. 此函數用於間隔性的引起SIGALRM信號
        if (setitimer(ITIMER_REAL, &itv, NULL) == -1) {
            ngx_log_error(NGX_LOG_ALERT, cycle->log, ngx_errno,
                          "setitimer() failed");
        }
    }
利用setitimer設置了一個定時器, 此定時器總是會在一定間隔時間後引發SIGALRM信號.
如果我們的epoll_wait因爲沒有事件被觸發而陷入睡眠, 在一定間隔時間後到來的SIGALRM信號必然會將其打斷.
在平時處理可能休眠的系統調用時, 我們可能會判斷該調用是否因爲突然到來的信號而返回EINTR錯誤, 從而忽視信號繼續調用該函數.
然而在Nginx中, 可能因爲需要信號來控制時間精度, 所以在epoll_wait陷入睡眠期間如果被打斷,會立即根據返回的錯誤判斷錯誤是否是SIGALRM信號引發的, 即要求我們更新時間.
在epoll事件驅動模塊中, 可以看到下面這段代碼:
    events = epoll_wait(ep, event_list, (int) nevents, timer);

    err = (events == -1) ? ngx_errno : 0;

    if (flags & NGX_UPDATE_TIME || ngx_event_timer_alarm) {
        ngx_time_update();
    }

    if (err) {
        if (err == NGX_EINTR) {

            if (ngx_event_timer_alarm) {
                ngx_event_timer_alarm = 0;
                return NGX_OK;
            }

        ...
    }
SIGALRM的信號處理函數很簡單, 就是將ngx_event_timer_alarm全局變量置1. 根據該變量判斷是否是SIGALRM導致.
如果是別的信號, 則另做處理
第二步:
        如果沒有在配置文件中聲明timer_resolution配置項, 那麼將調用ngx_event_find_timer方法, 獲取最近一個將要觸發的事件距離現在有多少毫秒. 然後把這個值賦予timer參數. 令epoll_wait方法如果沒有任何事件發生, 最多等待timer事件就需要返回, 且將flags參數設置NGX_UPDATE_TIME參數. 讓process_events方法更新時間.
第三步:
        如果在配置文件中關閉了accept鎖, 那麼直接執行process_events(跳到第七步). 否則檢查負載均衡閥值變量ngx_accept_disabled. 若此變量爲正, 那麼將其值減一, 直接執行process_events(跳到第七步)
第四步:
        如果負載均衡閥值變量ngx_accept_disabled爲負數, 則調用trylock嘗試獲取鎖
    events = epoll_wait(ep, event_list, (int) nevents, timer);

    err = (events == -1) ? ngx_errno : 0;

    if (flags & NGX_UPDATE_TIME || ngx_event_timer_alarm) {
        ngx_time_update();
    }

    if (err) {
        if (err == NGX_EINTR) {

            if (ngx_event_timer_alarm) {
                ngx_event_timer_alarm = 0;
                return NGX_OK;
            }

           ...
    }
第五步:
        如果獲取到了鎖, flags將設置NGX_POST_EVENTS標誌. 讓之後收集到的事件不要立即執行而是放在post隊列等待執行
第六步:
        沒有獲取到鎖的話, 意味着有進程已經持有鎖了. 此進程不能頻繁的去嘗試獲取鎖, 需要延後一段時間纔再嘗試獲取鎖. 但也不能讓其等太長時間, 所以有了
ngx_accept_mutex_delay變量:
       if (timer == NGX_TIMER_INFINITE
                    || timer > ngx_accept_mutex_delay)
                {
                    timer = ngx_accept_mutex_delay;
                }
可以看出來的是, 如果我們設置了timer_resolution來控制時間精度, 即使它大於ngx_accept_mutex_delay的值, 進程也會在ngx_accept_mutex_delay之後嘗試獲取鎖
第七步:
        調用ngx_process_evnets方法, 並記錄其消耗的時間, 如果消耗時間爲0, 那麼接下來將不會處理定時器中的事件.(沒有事件流逝, 自然也沒有定時器超時的可能)
第八步:
       如果ngx_posted_accept_events隊列不空, 那麼將處理ngx_posted_accept_events隊列中需要建立新連接的事件
第九步:
       如果當前持有鎖, 那麼在這個地方將會釋放鎖. 文章前面內容有涉及了.
第十步:
       如果ngx_process_evnets耗時了, 那麼可能有定時器事件超時了, 需要處理
第十一步:
       如果ngx_posted_events不空, 處理其中的讀寫事件

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