Linux驚羣效應之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時間,然後繼續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句柄,也就是比較好的解決了子進程負載均衡。

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