結論
- 不管還是多進程還是多線程,都存在驚羣效應,本篇文章使用多進程分析。
- 在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時間,然後繼續try lock。
獲取鎖來解決驚羣
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句柄,也就是比較好的解決了子進程負載均衡。