Linux 驚群效應之 Nginx 解決方案

前言

因為項目涉及到 Nginx 一些公共模塊的使用,而且也想對驚群效應有個深入的了解,在整理了網上資料以及實踐後,記錄成文章以便大家複習鞏固。

結論

  • 不管還是多進程還是多線程,都存在驚群效應,本篇文章使用多進程分析。
  • 在 Linux2.6 版本之後,已經解決了系統調用 accept 的驚群效應(前提是沒有使用 select、poll、epoll 等事件機制)。
  • 目前 Linux 已經部分解決了 epoll 的驚群效應(epoll 在 fork 之前),Linux2.6 是沒有解決的。
  • Epoll 在 fork 之後創建仍然存在驚群效應,Nginx 使用自己實現的互斥鎖解決驚群效應。

驚群效應是什麼

驚群效應(thundering herd)是指多進程(多線程)在同時阻塞等待同一個事件的時候(休眠狀態),如果等待的這個事件發生,那麼他就會喚醒等待的所有進程(或者線程),但是最終卻只能有一個進程(線程)獲得這個時間的「控制權」,對該事件進行處理,而其他進程(線程)獲取「控制權」失敗,只能重新進入休眠狀態,這種現象和性能浪費就叫做驚群效應。

驚群效應消耗了什麼

  • Linux 內核對用戶進程(線程)頻繁地做無效的調度、上下文切換等使系統性能大打折扣。上下文切換(context switch)過高會導致 CPU 像個搬運工,頻繁地在寄存器和運行隊列之間奔波,更多的時間花在了進程(線程)切換,而不是在真正工作的進程(線程)上面。直接的消耗包括 CPU 寄存器要保存和載入(例如程序計數器)、系統調度器的代碼需要執行。間接的消耗在於多核 cache 之間的共享數據。
  • 為了確保只有一個進程(線程)得到資源,需要對資源操作進行加鎖保護,加大了系統的開銷。目前一些常見的伺服器軟體有的是通過鎖機制解決的,比如 Nginx(它的鎖機制是默認開啟的,可以關閉);還有些認為驚群對系統性能影響不大,沒有去處理,比如 Lighttpd。

Linux 解決方案之 Accept

Linux 2.6 版本之前,監聽同一個 socket 的進程會掛在同一個等待隊列上,當請求到來時,會喚醒所有等待的進程。

Linux 2.6 版本之後,通過引入一個標記位 WQ_FLAG_EXCLUSIVE,解決掉了 accept 驚群效應。

具體分析會在代碼注釋裡面,accept代碼實現片段如下:

// 當accept的時候,如果沒有連接則會一直阻塞(沒有設置非阻塞)
// 其阻塞函數就是:inet_csk_accept(accept的原型函數)
struct sock *inet_csk_accept(struct sock *sk, int flags, int *err)
{
...
// 等待連接
error = inet_csk_wait_for_connect(sk, timeo);
...
}

static int inet_csk_wait_for_connect(struct sock *sk, long timeo)
{
...
for (;;) {
// 只有一個進程會被喚醒。
// 非exclusive的元素會加在等待隊列前頭,exclusive的元素會加在所有非exclusive元素的後頭。
prepare_to_wait_exclusive(sk_sleep(sk), &wait,TASK_INTERRUPTIBLE);
}
...
}

void prepare_to_wait_exclusive(wait_queue_head_t *q, wait_queue_t *wait, int state)
{
unsigned long flags;
// 設置等待隊列的flag為EXCLUSIVE,設置這個就是表示一次只會有一個進程被喚醒,我們等會就會看到這個標記的作用。
// 注意這個標誌,喚醒的階段會使用這個標誌。
wait->flags |= WQ_FLAG_EXCLUSIVE;
spin_lock_irqsave(&q->lock, flags);
if (list_empty(&wait->task_list))
// 加入等待隊列
__add_wait_queue_tail(q, wait);
set_current_state(state);
spin_unlock_irqrestore(&q->lock, flags);
}

喚醒阻塞的 accept 代碼片段如下:

// 當有tcp連接完成,就會從半連接隊列拷貝socket到連接隊列,這個時候我們就可以喚醒阻塞的accept了。
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
...
// 關注此函數
if (tcp_child_process(sk, nsk, skb)) {
rsk = nsk;
goto reset;
}
...
}

int tcp_child_process(struct sock *parent, struct sock *child, struct sk_buff *skb)
{
...
// Wakeup parent, send SIGIO 喚醒父進程
if (state == TCP_SYN_RECV && child->sk_state != state)
// 調用sk_data_ready通知父進程
// 查閱資料我們知道tcp中這個函數對應是sock_def_readable
// 而sock_def_readable會調用wake_up_interruptible_sync_poll來喚醒隊列
parent->sk_data_ready(parent, 0);
}
...
}

void __wake_up_sync_key(wait_queue_head_t *q, unsigned int mode, int nr_exclusive, void *key)
{
...
// 關注此函數
__wake_up_common(q, mode, nr_exclusive, wake_flags, key);
spin_unlock_irqrestore(&q->lock, flags);
...
}

static void __wake_up_common(wait_queue_head_t *q, unsigned int mode, int nr_exclusive, int wake_flags, void *key)
{
...
// 傳進來的nr_exclusive是1
// 所以flags & WQ_FLAG_EXCLUSIVE為真的時候,執行一次,就會跳出循環
// 我們記得accept的時候,加到等待隊列的元素就是WQ_FLAG_EXCLUSIVE的
list_for_each_entry_safe(curr, next, &q->task_list, task_list) {
unsigned flags = curr->flags;
if (curr->func(curr, mode, wake_flags, key)
&& (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
break;
}
...
}

Linux 解決方案之 Epoll

在使用 select、poll、epoll、kqueue 等 IO 復用時,多進程(線程)處理鏈接更加複雜。

在討論 epoll 的驚群效應時候,需要分為兩種情況:

  • epoll_create 在 fork 之前創建
  • epoll_create 在 fork 之後創建

epoll_create 在 fork 之前創建

與 accept 驚群的原因類似,當有事件發生時,等待同一個文件描述符的所有進程(線程)都將被喚醒,而且解決思路和 accept 一致。

為什麼需要全部喚醒?因為內核不知道,你是否在等待文件描述符來調用 accept() 函數,還是做其他事情(信號處理,定時事件)。

此種情況驚群效應已經被解決。

epoll_create 在 fork 之後創建

epoll_create 在 fork 之前創建的話,所有進程共享一個 epoll 紅黑數。

如果我們只需要處理 accept 事件的話,貌似世界一片美好了。但是 epoll 並不是只處理 accept 事件,accept 後續的讀寫事件都需要處理,還有定時或者信號事件。

當連接到來時,我們需要選擇一個進程來 accept,這個時候,任何一個 accept 都是可以的。當連接建立以後,後續的讀寫事件,卻與進程有了關聯。一個請求與 a 進程建立連接後,後續的讀寫也應該由 a 進程來做。

當讀寫事件發生時,應該通知哪個進程呢?Epoll 並不知道,因此,事件有可能錯誤通知另一個進程,這是不對的。所以一般在每個進程(線程)裡面會再次創建一個 epoll 事件循環機制,每個進程的讀寫事件只註冊在自己進程的 epoll 種。

我們知道 epoll 對驚群效應的修復,是建立在共享在同一個 epoll 結構上的。epoll_create 在 fork 之後執行,每個進程有單獨的 epoll 紅黑樹,等待隊列,ready 事件列表。因此,驚群效應再次出現了。有時候喚醒所有進程,有時候喚醒部分進程,可能是因為事件已經被某些進程處理掉了,因此不用在通知另外還未通知到的進程了。

Nginx 解決方案之鎖的設計

首先我們要知道在用戶空間進程間鎖實現的原理,起始原理很簡單,就是能弄一個讓所有進程共享的東西,比如 mmap 的內存,比如文件,然後通過這個東西來控制進程的互斥。

Nginx 中使用的鎖是自己來實現的,這裡鎖的實現分為兩種情況,一種是支持原子操作的情況,也就是由 NGX_HAVE_ATOMIC_OPS 這個宏來進行控制的,一種是不支持原子操作,這是是使用文件鎖來實現。

鎖結構體

如果支持原子操作,則我們可以直接使用 mmap,然後 lock 就保存 mmap 的內存區域的地址

如果不支持原子操作,則我們使用文件鎖來實現,這裡 fd 表示進程間共享的文件句柄,name 表示文件名

typedef struct {
#if (NGX_HAVE_ATOMIC_OPS)
ngx_atomic_t *lock;
#else
ngx_fd_t fd;
u_char *name;
#endif
} ngx_shmtx_t;

原子鎖創建

// 如果支持原子操作的話,非常簡單,就是將共享內存的地址付給loc這個域
ngx_int_t ngx_shmtx_create(ngx_shmtx_t *mtx, void *addr, u_char *name)
{
mtx->lock = addr;

return NGX_OK;
}

原子鎖獲取

TryLock,它是非阻塞的,也就是說它會嘗試的獲得鎖,如果沒有獲得的話,它會直接返回錯誤。

Lock,它也會嘗試獲得鎖,而當沒有獲得他不會立即返回,而是開始進入循環然後不停的去獲得鎖,知道獲得。不過 Nginx 這裡還有用到一個技巧,就是每次都會讓當前的進程放到 CPU 的運行隊列的最後一位,也就是自動放棄 CPU。

原子鎖實現

如果系統庫支持的情況,此時直接調用OSAtomicCompareAndSwap32Barrier,即 CAS。

#define ngx_atomic_cmp_set(lock, old, new)
OSAtomicCompareAndSwap32Barrier(old, new, (int32_t *) lock)

如果系統庫不支持這個指令的話,Nginx 自己還用彙編實現了一個。

static ngx_inline ngx_atomic_uint_t ngx_atomic_cmp_set(ngx_atomic_t *lock, ngx_atomic_uint_t old,
ngx_atomic_uint_t set)
{
u_char res;

__asm__ volatile (

NGX_SMP_LOCK
" cmpxchgl %3, %1; "
" sete %0; "

: "=a" (res) : "m" (*lock), "a" (old), "r" (set) : "cc", "memory");

return res;
}

原子鎖釋放

Unlock 比較簡單,和當前進程 id 比較,如果相等,就把 lock 改為 0,說明放棄這個鎖。

#define ngx_shmtx_unlock(mtx) (void) ngx_atomic_cmp_set((mtx)->lock, ngx_pid, 0)

Nginx 解決方案之驚群效應

變數分析

// 如果使用了 master worker,並且 worker 個數大於 1,並且配置文件裡面有設置使用 accept_mutex. 的話,設置
ngx_use_accept_mutex
if (ccf->master && ccf->worker_processes > 1 && ecf->accept_mutex)
{
ngx_use_accept_mutex = 1;
// 下面這兩個變數後面會解釋。
ngx_accept_mutex_held = 0;
ngx_accept_mutex_delay = ecf->accept_mutex_delay;
} else {
ngx_use_accept_mutex = 0;
}

ngx_use_accept_mutex 這個變數,如果有這個變數,說明 Nginx 有必要使用 accept 互斥體,這個變數的初始化在 ngx_event_process_init 中。

ngx_accept_mutex_held 表示當前是否已經持有鎖。

ngx_accept_mutex_delay 表示當獲得鎖失敗後,再次去請求鎖的間隔時間,這個時間可以在配置文件中設置的。

ngx_accept_disabled = ngx_cycle->connection_n / 8 - ngx_cycle->free_connection_n;

ngx_accept_disabled,這個變數是一個閾值,如果大於 0,說明當前的進程處理的連接過多。

是否使用鎖

// 如果有使用mutex,則才會進行處理。
if (ngx_use_accept_mutex)
{
// 如果大於0,則跳過下面的鎖的處理,並減一。
if (ngx_accept_disabled > 0) {
ngx_accept_disabled--;
} else {
// 試著獲得鎖,如果出錯則返回。
if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
return;
}
// 如果ngx_accept_mutex_held為1,則說明已經獲得鎖,此時設置flag,這個flag後面會解釋。
if (ngx_accept_mutex_held) {
flags |= NGX_POST_EVENTS;
} else {
// 否則,設置timer,也就是定時器。接下來會解釋這段。
if (timer == NGX_TIMER_INFINITE
|| timer > ngx_accept_mutex_delay) {
timer = ngx_accept_mutex_delay;
}
}
}
}

NGX_POST_EVENTS 標記,設置了這個標記就說明當 socket 有數據被喚醒時,我們並不會馬上 accept 或者說讀取,而是將這個事件保存起來,然後當我們釋放鎖之後,才會進行 accept 或者讀取這個句柄。

// 如果ngx_posted_accept_events不為NULL,則說明有accept event需要nginx處理。
if (ngx_posted_accept_events) {
ngx_event_process_posted(cycle, &ngx_posted_accept_events);
}

如果沒有設置 NGX_POST_EVENTS 標記的話,Nginx 會立即 Accept 或者讀取句柄

定時器,這裡如果 Nginx 沒有獲得鎖,並不會馬上再去獲得鎖,而是設置定時器,然後在 epoll 休眠(如果沒有其他的東西喚醒)。此時如果有連接到達,當前休眠進程會被提前喚醒,然後立即 accept。否則,休眠 ngx_accept_mutex_delay時間,然後繼續 tryLock。

獲取鎖來解決驚群

ngx_int_t ngx_trylock_accept_mutex(ngx_cycle_t *cycle)
{
// 嘗試獲得鎖
if (ngx_shmtx_trylock(&ngx_accept_mutex)) {
// 如果本來已經獲得鎖,則直接返回Ok
if (ngx_accept_mutex_held
&& ngx_accept_events == 0
&& !(ngx_event_flags & NGX_USE_RTSIG_EVENT))
{
return NGX_OK;
}

// 到達這裡,說明重新獲得鎖成功,因此需要打開被關閉的listening句柄。
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;
}

// 如果我們前面已經獲得了鎖,然後這次獲得鎖失敗
// 則說明當前的listen句柄已經被其他的進程鎖監聽
// 因此此時需要從epoll中移出調已經註冊的listen句柄
// 這樣就很好的控制了子進程的負載均衡
if (ngx_accept_mutex_held) {
if (ngx_disable_accept_events(cycle) == NGX_ERROR) {
return NGX_ERROR;
}
// 設置鎖的持有為0.
ngx_accept_mutex_held = 0;
}

return NGX_OK;
}

如上代碼,當一個連接來的時候,此時每個進程的 epoll 事件列表裡面都是有該 fd 的。搶到該連接的進程先釋放鎖,在 accept。沒有搶到的進程把該 fd 從事件列表裡面移除,不必再調用 accept,造成資源浪費。

同時由於鎖的控制(以及獲得鎖的定時器),每個進程都能相對公平的 accept 句柄,也就是比較好的解決了子進程負載均衡。

本文作者:史明亞

滴滴雲官網:didiyun.com/?

滴滴雲瘋狂雙十一,伺服器最低35折起

推薦閱讀:

TAG:Linux | Nginx | 雲計算 |