當有多個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不空, 處理其中的讀寫事件