深入浅出 Linux 惊群:现象、原因和解决方案
hello,?大家好,我是Alex,今天分享一篇關于socket編程里面經典問題:驚群問題,文章深入分析了驚群問題的現象和其根本原因,并給出一些很好的解決方案,值得我們參考和學習,通過文章,我們可以學習到
徹底理解驚群問題
epoll底層實現原理
epoll的ET和LT區別
socket的reuseaddress和reuseport場景
socket編程優化等
"驚群"簡單地來講,就是多個進程(線程)阻塞睡眠在某個系統調用上,在等待某個 fd(socket)的事件的到來。當這個 fd(socket)的事件發生的時候,這些睡眠的進程(線程)就會被同時喚醒,多個進程(線程)從阻塞的系統調用上返回,這就是"驚群"現象。"驚群"被人詬病的是效率低下,大量的 CPU 時間浪費在被喚醒發現無事可做,然后又繼續睡眠的反復切換上。本文談談 linux socket 中的一些"驚群"現象、原因以及解決方案。
1. Accept"驚群"現象
我們知道,在網絡分組通信中,網絡數據包的接收是異步進行的,因為你不知道什么時候會有數據包到來。因此,網絡收包大體分為兩個過程:
[1]?數據包到來后的事件通知 [2]?收到事件通知的Task執行流,響應事件并從隊列中取出數據包數據包到來的通知分為兩部分:
(1)網卡通知數據包到來,中斷協議棧收包;
(2)協議棧將數據包填充 socket 的接收隊列,通知應用程序有數據可讀,這里僅討論數據到達協議棧之后的事情。
應用程序是通過 socket 和協議棧交互的,socket 隔離了應用程序和協議棧,socket 是兩者之間的接口,對于應用程序,它代表協議棧;而對于協議棧,它又代表應用程序,當數據包到達協議棧的時候,發生下面兩個過程:
[1]?協議棧將數據包放入socket的接收緩沖區隊列,并通知持有該socket的應用程序; [2]?持有該socket的應用程序響應通知事件,將數據包從socket的接收緩沖區隊列中取出對于高性能的服務器而言,為了利用多 CPU 核的優勢,大多采用多個進程(線程)同時在一個 listen socket 上進行 accept 請求。多個進程阻塞在 Accept 調用上,那么在協議棧將 Client 的請求 socket 放入 listen socket 的 accept 隊列的時候,是要喚醒一個進程還是全部進程來處理呢?
linux 內核通過睡眠隊列來組織所有等待某個事件的 task,而 wakeup 機制則可以異步喚醒整個睡眠隊列上的 task,wakeup 邏輯在喚醒睡眠隊列時,會遍歷該隊列鏈表上的每一個節點,調用每一個節點的 callback,從而喚醒睡眠隊列上的每個 task。這樣,在一個 connect 到達這個 lisent socket 的時候,內核會喚醒所有睡眠在 accept 隊列上的 task。N 個 task 進程(線程)同時從 accept 返回,但是,只有一個 task 返回這個 connect 的 fd,其他 task 都返回-1(EAGAIN)。這是典型的 accept"驚群"現象。這個是 linux 上困擾了大家很長時間的一個經典問題,在 linux2.6(似乎在 2.4.1 以后就已經解決,有興趣的同學可以去驗證一下)以后的內核中得到徹底的解決,通過添加了一個 WQ_FLAG_EXCLUSIVE 標記告訴內核進行排他性的喚醒,即喚醒一個進程后即退出喚醒的過程,具體如下:
/**?The?core?wakeup?function.?Non-exclusive?wakeups?(nr_exclusive?==?0)?just*?wake?everything?up.?If?it's?an?exclusive?wakeup?(nr_exclusive?==?small?+ve*?number)?then?we?wake?all?the?non-exclusive?tasks?and?one?exclusive?task.**?There?are?circumstances?in?which?we?can?try?to?wake?a?task?which?has?already*?started?to?run?but?is?not?in?state?TASK_RUNNING.?try_to_wake_up()?returns*?zero?in?this?(rare)?case,?and?we?handle?it?by?continuing?to?scan?the?queue.*/ static?void?__wake_up_common(wait_queue_head_t?*q,?unsigned?int?mode,int?nr_exclusive,?int?wake_flags,?void?*key) {wait_queue_t?*curr,?*next;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 2.6 以后的內核,用戶進程 task 對 listen socket 進行 accept 操作,如果這個時候如果沒有新的 connect 請求過來,用戶進程 task 會阻塞睡眠在 listent fd 的睡眠隊列上。這個時候,用戶進程 Task 會被設置 WQ_FLAG_EXCLUSIVE 標志位,并加入到 listen socket 的睡眠隊列尾部(這里要確保所有不帶 WQ_FLAG_EXCLUSIVE 標志位的 non-exclusive waiters 排在帶 WQ_FLAG_EXCLUSIVE 標志位的 exclusive waiters 前面)。根據前面的喚醒邏輯,一個新的 connect 到來,內核只會喚醒一個用戶進程 task 就會退出喚醒過程,從而不存在了"驚群"現象。
2. select/poll/Epoll "驚群"現象
盡管 accept 系統調用已經不再存在"驚群"現象,但是我們的"驚群"場景還沒結束。通常一個 server 有很多其他網絡 IO 事件要處理,我們并不希望 server 阻塞在 accept 調用上,為提高服務器的并發處理能力,我們一般會使用 select/poll/epoll I/O 多路復用技術,同時為了充分利用多核 CPU,服務器上會起多個進程(線程)同時提供服務。于是,在某一時刻多個進程(線程)阻塞在 select/poll/epoll_wait 系統調用上,當一個請求上來的時候,多個進程都會被 select/poll/epoll_wait 喚醒去 accept,然而只有一個進程(線程 accept 成功,其他進程(線程 accept 失敗,然后重新阻塞在 select/poll/epoll_wait 系統調用上。可見,盡管 accept 不存在"驚群",但是我們還是沒能擺脫"驚群"的命運。難道真的沒辦法了么?我只讓一個進程去監聽 listen socket 的可讀事件,這樣不就可以避免"驚群"了么?
沒錯,就是這個思路,我們來看看 Nginx 是怎么避免由于 listen fd 可讀造成的 epoll_wait"驚群"。這里簡單說下具體流程,不進行具體的源碼分析。
2.1 Nginx 的 epoll"驚群"避免
Nginx 中有個標志 ngx_use_accept_mutex,當 ngx_use_accept_mutex 為 1 的時候(當 nginx worker 進程數>1 時且配置文件中打開 accept_mutex 時,這個標志置為 1),表示要進行 listen fdt"驚群"避免。
Nginx 的 worker 進程在進行 event 模塊的初始化的時候,在 core event 模塊的 process_init 函數中(ngx_event_process_init)將 listen fd 加入到 epoll 中并監聽其 READ 事件。Nginx 在進行相關初始化完成后,進入事件循環(ngx_process_events_and_timers 函數),在 ngx_process_events_and_timers 中判斷,如果 ngx_use_accept_mutex 為 0,那就直接進入 ngx_process_events(ngx_epoll_process_events),在 ngx_epoll_process_events 將調用 epoll_wait 等待相關事件到來或超時,epoll_wait 返回的時候該干嘛就干嘛。這里不講 ngx_use_accept_mutex 為 0 的流程,下面講下 ngx_use_accept_mutex 為 1 的流程。
[1]?進入ngx_trylock_accept_mutex,加鎖搶奪accept權限(ngx_shmtx_trylock(&ngx_accept_mutex)),加鎖成功,則調用ngx_enable_accept_events(cycle)?來將一個或多個listen fd加入epoll監聽READ事件(設置事件的回調函數ngx_event_accept),并設置ngx_accept_mutex_held = 1;標識自己持有鎖。 [2]?如果ngx_shmtx_trylock(&ngx_accept_mutex)失敗,則調用ngx_disable_accept_events(cycle, 0)來將listen fd從epoll中delete掉。 [3]?如果ngx_accept_mutex_held = 1(也就是搶到accept權),則設置延遲處理事件標志位flags |= NGX_POST_EVENTS;?如果ngx_accept_mutex_held =?0(沒搶到accept權),則調整一下自己的epoll_wait超時,讓自己下次能早點去搶奪accept權。 [4]?進入ngx_process_events(ngx_epoll_process_events),在ngx_epoll_process_events將調用epoll_wait等待相關事件到來或超時。 [5] epoll_wait返回,循環遍歷返回的事件,如果標志位flags被設置了NGX_POST_EVENTS,則將事件掛載到相應的隊列中(Nginx有兩個延遲處理隊列,(1)ngx_posted_accept_events:listen fd返回的事件被掛載到的隊列。(2)ngx_posted_events:其他socket fd返回的事件掛載到的隊列),延遲處理事件,否則直接調用事件的回調函數。 [6] ngx_epoll_process_events返回后,則開始處理ngx_posted_accept_events隊列上的事件,于是進入的回調函數是ngx_event_accept,在ngx_event_accept中accept客戶端的請求,進行一些初始化工作,將accept到的socket fd放入epoll中。 [7] ngx_epoll_process_events處理完成后,如果本進程持有accept鎖ngx_accept_mutex_held = 1,那么就將鎖釋放。 [8]?接著開始處理ngx_posted_events隊列上的事件。Nginx 通過一次僅允許一個進程將 listen fd 放入自己的 epoll 來監聽其 READ 事件的方式來達到 listen fd"驚群"避免。然而做好這一點并不容易,作為一個高性能 web 服務器,需要盡量避免阻塞,并且要很好平衡各個工作 worker 的請求,避免餓死情況,下面有幾個點需要大家留意:
[1]?避免新請求不能及時得到處理的餓死現象工作worker在搶奪到accept權限,加鎖成功的時候,要將事件的處理delay到釋放鎖后在處理(為什么ngx_posted_accept_events隊列上的事件處理不需要延遲呢??因為ngx_posted_accept_events上的事件就是listen fd的可讀事件,本來就是我搶到的accept權限,我還沒accept就釋放鎖,這個時候被別人搶走了怎么辦呢?)。否則,獲得鎖的工作worker由于在處理一個耗時事件,這個時候大量請求過來,其他工作worker空閑,然而沒有處理權限在干著急。 [2]?避免總是某個worker進程搶到鎖,大量請求被同一個進程搶到,而其他worker進程卻很清閑。Nginx有個簡單的負載均衡,ngx_accept_disabled表示此時滿負荷程度,沒必要再處理新連接了,我們在nginx.conf曾經配置了每一個nginx worker進程能夠處理的最大連接數,當達到最大數的7/8時,ngx_accept_disabled為正,說明本nginx worker進程非常繁忙,將不再去處理新連接。每次要進行搶奪accept權限的時候,如果ngx_accept_disabled大于0,則遞減1,不進行搶奪邏輯。Nginx 采用在同一時刻僅允許一個 worker 進程監聽 listen fd 的可讀事件的方式,來避免 listen fd 的"驚群"現象。然而這種方式編程實現起來比較難,難道不能像 accept 一樣解決 epoll 的"驚群"問題么?答案是可以的。要說明 epoll 的"驚群"問題以及解決方案,不能不從 epoll 的兩種觸發模式說起。
3 Epoll"驚群"之 LT(水平觸發模式)、ET(邊沿觸發模式)
我們先來看下 LT、ET 的語意:
[1]?LT?水平觸發模式 只要仍然有未處理的事件,epoll就會通知你,調用epoll_wait就會立即返回。 [2]?ET?邊沿觸發模式 只有事件列表發生變化了,epoll才會通知你。也就是,epoll_wait返回通知你去處理事件,如果沒處理完,epoll不會再通知你了,調用epoll_wait會睡眠等待,直到下一個事件到來或者超時。LT(水平觸發模式)、ET(邊沿觸發模式)在"驚群"問題上,有什么不一樣的表現么?要說明這個,就不能不來談談 Linux 內核的 sleep/wakeup 機制以及 epoll 的實現核心機制了。
3.1 epoll 的核心機制
在了解 epoll 的核心機制前,先了解一下內核 sleep/wakeup 機制的幾個核心概念:
[1]?等待隊列?waitqueue 隊列頭(wait_queue_head_t)往往是資源生產者 隊列成員(wait_queue_t)往往是資源消費者 當頭的資源ready后,?會逐個執行每個成員指定的回調函數,來通知它們資源已經ready了 [2]?內核的poll機制 被Poll的fd, 必須在實現上支持內核的Poll技術,比如fd是某個字符設備,或者是個socket, 它必須實現file_operations中的poll操作, 給自己分配有一個等待隊列頭wait_queue_head_t,主動poll fd的某個進程task必須分配一個等待隊列成員, 添加到fd的等待隊列里面去, 并指定資源ready時的回調函數,用socket做例子, 它必須有實現一個poll操作, 這個Poll是發起輪詢的代碼必須主動調用的, 該函數中必須調用poll_wait(),poll_wait會將發起者作為等待隊列成員加入到socket的等待隊列中去,這樣socket發生事件時可以通過隊列頭逐個通知所有關心它的進程。 [3]?epollfd本身也是個fd,?所以它本身也可以被epollepoll 作為中間層,為多個進程 task,監聽多個 fd 的多個事件提供了一個便利的高效機制,我們來看下 epoll 的機制圖:
從圖中,可以看到 epoll 可以監控多個 fd 的事件,它通過一顆紅黑樹來組織所有被 epoll_ctl 加入到 epoll 監聽列表中的 fd,每個被監聽的 fd 在 epoll 用一個 epoll item(epi)來標識。
根據內核的 poll 機制,epoll 需要為每個監聽的 fd 構造一個 epoll entry(設置關心的事件以及注冊回調函數)作為等待隊列成員睡眠在每個 fd 的等待隊列,以便 fd 上的事件 ready 了,可以通過 epoll 注冊的回調函數通知到 epoll。
epoll 作為進程 task 的中間層,它需要有一個等待隊列 wq 給 task 在沒事件來 epoll_wait 的時候來睡眠等待(epoll fd 本身也是一個 fd,它和其他 fd 一樣還有另外一個等待隊列 poll_wait,作為 poll 機制被 poll 的時候睡眠等待的地方)。
epoll 可能同時監聽成千上萬的 fd,這樣在少量 fd 有事件 ready 的時候,它需要一個 ready list 隊列來組織所有已經 ready 的就緒 fd,以便能夠高效通知給進程 task,而不需要遍歷所有監聽的 fd。圖中的一個 epoll 的 sleep/wakeup 流程如下:
無事件的時候,多個進程task調用epoll_wait睡眠在epoll的wq睡眠隊列上。 [1]?這個時候一個請求RQ_1上來,listen fd這個時候ready了,開始喚醒其睡眠隊列上的epoll entry,并執行之前epoll注冊的回調函數ep_poll_callback。 [2] ep_poll_callback主要做兩件事情,(1)發生的event事件是epoll entry關心的,則將epi掛載到epoll的就緒隊列ready list并進入(2),否則結束。(2)如果當前wq不為空,則喚醒睡眠在epoll等待隊列上睡眠的task(這里喚醒一個還是多個,是區分epoll的ET模式還是LT模式,下面在細講)。 [3] epoll_wait被喚醒繼續前行,在ep_poll中調用ep_send_events將fd相關的event事件和數據copy到用戶空間,這個時候就需要遍歷epoll的ready list以便收集task需要監控的多個fd的event事件和數據上報給用戶進程task,這個在ep_scan_ready_list中完成,這里會將ready list清空。通過上圖的 epoll 事件通知機制,epoll 的 LT 模式、ET 模式在事件通知行為上的差別,也只能是在[2]上 task 喚醒邏輯上的差別了。我們先來看下,在 epoll_wait 中調用的導致用戶進程 task 睡眠的 ep_poll 函數的核心邏輯:
static?int?ep_poll(struct?eventpoll?*ep,?struct?epoll_event?__user?*events,?int?maxevents,?long?timeout) { int?res?=?0,?eavail,?timed_out?=?0; bool?waiter?=?false; ... eavail?=?ep_events_available(ep);//是否有fd就緒 if?(eavail) goto?send_events;//有fd就緒,則直接跳過去上報事件給用戶if?(!waiter)?{waiter?=?true;init_waitqueue_entry(&wait,?current);//為當前進程task構造一個睡眠entryspin_lock_irq(&ep->wq.lock);//插入到epoll的wq后面,注意這里是排他插入的,就是帶WQ_FLAG_EXCLUSIVE?flag__add_wait_queue_exclusive(&ep->wq,?&wait);spin_unlock_irq(&ep->wq.lock);}for?(;;)?{//將當前進程設置位睡眠,?但是可以被信號喚醒的狀態,?注意這個設置是"將來時",?我們此刻還沒睡set_current_state(TASK_INTERRUPTIBLE);//?檢查是否真的要睡了if?(fatal_signal_pending(current))?{res?=?-EINTR;break;}eavail?=?ep_events_available(ep);if?(eavail)break;if?(signal_pending(current))?{res?=?-EINTR;break;}//?檢查是否真的要睡了?end//使得當前進程休眠指定的時間范圍,if?(!schedule_hrtimeout_range(to,?slack,?HRTIMER_MODE_ABS))?{timed_out?=?1;break;} }__set_current_state(TASK_RUNNING);send_events:/**?Try?to?transfer?events?to?user?space.?In?case?we?get?0?events?and*?there's?still?timeout?left?over,?we?go?trying?again?in?search?of*?more?luck.*///?ep_send_events往用戶態上報事件,即那些epoll_wait返回后能獲取的事件if?(!res?&&?eavail?&&!(res?=?ep_send_events(ep,?events,?maxevents))?&&?!timed_out)goto?fetch_events;if?(waiter)?{spin_lock_irq(&ep->wq.lock);__remove_wait_queue(&ep->wq,?&wait);spin_unlock_irq(&ep->wq.lock);}return?res; }接著,我們看下監控的 fd 有事件發生的回調函數 ep_poll_callback 的核心邏輯:
#define?wake_up(x)__wake_up(x,?TASK_NORMAL,?1,?NULL)static?int?ep_poll_callback(wait_queue_entry_t?*wait,?unsigned?mode,?int?sync,?void?*key) {int?pwake?=?0;struct?epitem?*epi?=?ep_item_from_wait(wait);struct?eventpoll?*ep?=?epi->ep;__poll_t?pollflags?=?key_to_poll(key);unsigned?long?flags;int?ewake?=?0;....//判斷是否有我們關心的eventif?(pollflags?&&?!(pollflags?&?epi->event.events))goto?out_unlock;//將當前的epitem放入epoll的ready?listif?(!ep_is_linked(epi)?&&list_add_tail_lockless(&epi->rdllink,?&ep->rdllist))?{ep_pm_stay_awake_rcu(epi);}//如果有task睡眠在epoll的等待隊列,喚醒它if?(waitqueue_active(&ep->wq))?{....wake_up(&ep->wq);//}.... }wake_up 函數最終會調用到 wake_up_common,通過前面的 wake_up_common 我們知道,喚醒過程在喚醒一個帶 WQ_FLAG_EXCLUSIVE 標記的 task 后,即退出喚醒過程。通過上面的 ep_poll,task 是排他(帶 WQ_FLAG_EXCLUSIVE 標記)加入到 epoll 的等待隊列 wq 的。也就是,在 ep_poll_callback 回調中,只會喚醒一個 task。這就有問題,根據 LT 的語義:只要仍然有未處理的事件,epoll 就會通知你。例如有兩個進程 A、B 睡眠在 epoll 的睡眠隊列,fd 的可讀事件到來喚醒進程 A,但是 A 可能很久才會去處理 fd 的事件,或者它根本就不去處理。根據 LT 的語義,應該要喚醒進程 B 的。
我們來看下 epoll 怎么在 ep_send_events 中實現滿足 LT 語義的:
??static?int?ep_send_events(struct?eventpoll?*ep,struct?epoll_event?__user?*events,?int?maxevents){struct?ep_send_events_data?esed;esed.maxevents?=?maxevents;esed.events?=?events;ep_scan_ready_list(ep,?ep_send_events_proc,?&esed,?0,?false);return?esed.res;}static?__poll_t?ep_scan_ready_list(struct?eventpoll?*ep,__poll_t?(*sproc)(struct?eventpoll?*,struct?list_head?*,?void?*),void?*priv,?int?depth,?bool?ep_locked){...//?所有的epitem都轉移到了txlist上,?而rdllist被清空了list_splice_init(&ep->rdllist,?&txlist);...//sproc?就是?ep_send_events_procres?=?(*sproc)(ep,?&txlist,?priv);...//沒有處理完的epitem,?重新插入到ready?listlist_splice(&txlist,?&ep->rdllist);/*?ready?list不為空,?直接喚醒...?*/?//?保證(2)if?(!list_empty(&ep->rdllist))?{if?(waitqueue_active(&ep->wq))wake_up(&ep->wq);...}}static?__poll_t?ep_send_events_proc(struct?eventpoll?*ep,?struct?list_head?*head,void?*priv){...//遍歷就緒fd列表list_for_each_entry_safe(epi,?tmp,?head,?rdllink)?{...//然后從鏈表里面移除當前就緒的epilist_del_init(&epi->rdllink);//讀取當前epi的事件revents?=?ep_item_poll(epi,?&pt,?1);if?(!revents)continue;//將當前的事件和用戶傳入的數據都copy給用戶空間if?(__put_user(revents,?&uevent->events)?||__put_user(epi->event.data,?&uevent->data))?{//如果發生錯誤了,?則終止遍歷過程,將當前epi重新返回就緒隊列,剩下的也會在ep_scan_ready_list中重新放回就緒隊列list_add(&epi->rdllink,?head);ep_pm_stay_awake(epi);if?(!esed->res)esed->res?=?-EFAULT;return?0;}}if?(epi->event.events?&?EPOLLONESHOT)epi->event.events?&=?EP_PRIVATE_BITS;else?if?(!(epi->event.events?&?EPOLLET))?{?//?保證(1)//如果是非ET模式(即LT模式),當前epi會被重新放到epoll的ready list。list_add_tail(&epi->rdllink,?&ep->rdllist);ep_pm_stay_awake(epi);}}上面處理邏輯的核心流程就 2 點:
[1]?遍歷并清空epoll的ready?list,遍歷過程中,對于每個epi收集其返回的events,如果沒收集到event,則continue去處理其他epi,否則將當前epi的事件和用戶傳入的數據都copy給用戶空間,并判斷,如果是在LT模式下,則將當前epi重新放回epoll的ready?list [2]?遍歷epoll的ready list完成后,如果ready list不為空,則繼續喚醒epoll睡眠隊列wq上的其他task B。task B從epoll_wait醒來繼續前行,重復上面的流程,繼續喚醒wq上的其他task C,這樣鏈式喚醒下去。通過上面的流程,在一個 epoll 上睡眠的多個 task,如果在一個 LT 模式下的 fd 的事件上來,會喚醒 epoll 睡眠隊列上的所有 task,而 ET 模式下,僅僅喚醒一個 task,這是 epoll"驚群"的根源。等等,這樣在 LT 模式下就必然"驚群",epoll 在 LT 模式下的"驚群"沒辦法解決么?
3.2 epoll_create& fork
相信大家在多進程服務中使用 epoll 的時候,都會有這樣一個疑問,是先 epoll_create 得到 epoll fd 后在 fork 子進程,還是先 fork 子進程,然后每個子進程在 epoll_create 自己獨立的 epoll fd 呢?有什么異同?
3.2.1 先 epoll_create 后 fork
這樣,多個進程公用一個 epoll 實例(父子進程的 epoll fd 指向同一個內核 epoll 對象),上面介紹的 epoll 核心機制流程,都是在同一個 epoll 對象上的,這種情況下,epoll 有以下這些特性:
[1] epoll在ET模式下不存在“驚群”現象,LT模式是epoll“驚群”的根源,并且LT模式下的“驚群”沒辦法避免。 [2] LT的“驚群”是鏈式喚醒的,喚醒過程直到當前epi的事件被處理了,無法獲得到新的事件才會終止喚醒過程。 例如有A、B、C、D...等多個進程task睡眠在epoll的睡眠隊列上,并且都監控同一個listen fd的可讀事件。一個請求上來,會首先喚醒A進程,A在epoll_wait的處理過程中會喚醒進程B,這樣進程B在epoll_wait的處理過程中會喚醒C,這個時候A的epoll_wait處理完成返回,進程A調用accept讀取了當前這個請求,進程C在自己的epoll_wait處理過程中,從epi中獲取不到事件了,于是終止了整個鏈式喚醒過程。 [3]?多個進程的epoll fd由于指向同一個epoll內核對象,他們對epoll fd的相關epoll_ctl操作會相互影響。一不小心可能會出現一些比較詭異的行為。 想象這樣一個場景(實際上應該不是這樣用),有一個服務在1234,1235,1236這3個端口上提供服務,于是它epoll_create得到epoll fd后,fork出3個工作的子進程A、B、C,它們分別在這3個端口創建listen fd,然后加入到epoll中監聽其可讀事件。這個時候端口1234上來一個請求,A、B、C同時被喚醒,A在epoll_wait返回后,在進行accept前由于種種原因卡住了,沒能及時accept。B、C在epoll_wait返回后去accept又不能accept到請求,這樣B、C重新回到epoll_wait,這個時候又被喚醒,這樣只要A沒有去處理這個請求之前,B、C就一直被喚醒,然而B、C又無法處理該請求。 [4] ET模式下,一個fd上的同事多個事件上來,只會喚醒一個睡眠在epoll上的task,如果該task沒有處理完這些事件,在沒有新的事件上來前,epoll不會在通知task去處理。由于 ET 的事件通知模式,通常在 ET 模式下的 epoll_wait 返回,我們會循環 accept 來處理所有未處理的請求,直到 accept 返回 EAGAIN 才退出 accept 流程。否則,沒處理遺留下來的請求,這個時候如果沒有新的請求過來觸發 epoll_wait 返回,這樣遺留下來的請求就得不到及時處理。這種處理模式,會帶來一種類"驚群"現象??紤],下面的一個處理過程:
A、B、C三個進程在監聽listen fd的EPOLLIN事件,都睡眠在epoll_wait上,都是ET模式。 [1] listen fd上一個請求C_1上來,該請求喚醒了A進程,A進程從epoll_wait返回準備去accept該請求來處理。 [2]?這個時候,第二個請求C_2上來,由于睡眠隊列上是B、C,于是epoll喚醒B進程,B進程從epoll_wait返回準備去accept該請求來處理。 [3] A進程在自己的accept循環中,首選accept得到C_1,接著A進程在第二個循環繼續accept,繼續得到C_2。 [4] B進程在自己的accept循環中,調用accept,由于C_2已經被A拿走了,于是B進程accept返回EAGAIN錯誤,于是B進程退出accept流程重新睡眠在epoll_wait上。 [5] A進程繼續第三個循環,這個時候已經沒有請求了, accept返回EAGAIN錯誤,于是A進程也退出accept處理流程,進入請求的處理流程。可以看到,B 進程被喚醒了,但是并沒有事情可以做,同時,epoll 的 ET 這樣的處理模式,負載容易出現不均衡。
3.2.2 先 fork 后 epoll_create
用法上,通常是在父進程創建了 listen fd 后,fork 多個 worker 子進程來共同處理同一個 listen fd 上的請求。這個時候,A、B、C...等多個子進程分別創建自己獨立的 epoll fd,然后將同一個 listen fd 加入到 epoll 中,監聽其可讀事件。這種情況下,epoll 有以下這些特性:
[1]?由于相對同一個listen fd而言,?多個進程之間的epoll是平等的,于是,listen fd上的一個請求上來,會喚醒所有睡眠在listen fd睡眠隊列上的epoll,epoll又喚醒對應的進程task,從而喚醒所有的進程(這里不管listen fd是以LT還是ET模式加入到epoll)。 [2]?多個進程間的epoll是獨立的,對epoll fd的相關epoll_ctl操作相互獨立不影響。可以看出,在使用友好度方面,多進程獨立 epoll 實例要比共用 epoll 實例的模式要好很多。獨立 epoll 模式要解決 fd 的排他喚醒 epoll 即可。
4.EPOLLEXCLUSIVE 排他喚醒 Epoll
linux4.5 以后的內核版本中,增加了 EPOLLEXCLUSIVE, 該選項只能通過 EPOLL_CTL_ADD 對需要監控的 fd(例如 listen fd)設置 EPOLLEXCLUSIVE 標記。這樣 epoll entry 是通過排他方式掛載到 listen fd 等待隊列的尾部的,睡眠在 listen fd 的等待隊列上的 epoll entry 會加上 WQ_FLAG_EXCLUSIVE 標記。根據前面介紹的內核 wake up 機制,listen fd 上的事件上來,在遍歷并喚醒等待隊列上的 entry 的時候,遇到并喚醒第一個帶 WQ_FLAG_EXCLUSIVE 標記的 entry 后,就結束遍歷喚醒過程。于是,多進程獨立 epoll 的"驚群"問題得到解決。
5."驚群"之 SO_REUSEPORT
"驚群"浪費資源的本質在于很多處理進程在別驚醒后,發現根本無事可做,造成白白被喚醒,做了無用功。但是,簡單的避免"驚群"會造成同時并發上來的請求得不到及時處理(降低了效率),為了避免這種情況,NGINX 允許配置成獲得 Accept 權限的進程一次性循環 Accept 所有同時到達的全部請求,但是,這會造成短時間 worker 進程的負載不均衡。為此,我們希望的是均衡喚醒,也就是,假設有 4 個 worker 進程睡眠在 epoll_wait 上,那么此時同時并發過來 3 個請求,我們希望 3 個 worker 進程被喚醒去處理,而不是僅僅喚醒一個進程或全部喚醒。
然而要實現這樣不是件容易的事情,其根本原因在于,對于大多采用 MPM 機制(multi processing module)TCP 服務而言,基本上都是多個進程或者線程同時在一個 Listen socket 上進行監聽請求。根據前面介紹的 Linux 睡眠隊列的喚醒方式,基本睡眠在這個 listen socket 上的 Task 只能要么全部被喚醒,要么被喚醒一個。
于是,基本的解決方案是起多個 listen socket,好在我們有 SO_REUSEPORT(linux 3.9 以上內核支持),它支持多個進程或線程 bind 相同的 ip 和端口,支持以下特性:
[1]?允許多個socket?bind/listen在相同的IP,相同的TCP/UDP端口 [2]?目的是同一個IP、PORT的請求在多個listen?socket間負載均衡 [3]?安全上,監聽相同IP、PORT的socket只能位于同一個用戶下于是,在一個多核 CPU 的服務器上,我們通過 SO_REUSEPORT 來創建多個監聽相同 IP、PORT 的 listen socket,每個進程監聽不同的 listen socket。這樣,在只有 1 個新請求到達監聽的端口的時候,內核只會喚醒一個進程去 accept,而在同時并發多個請求來到的時候,內核會喚醒多個進程去 accept,并且在一定程度上保證喚醒的均衡性。SO_REUSEPORT 在一定程度上解決了"驚群"問題,但是,由于 SO_REUSEPORT 根據數據包的四元組和當前服務器上綁定同一個 IP、PORT 的 listen socket 數量,根據固定的 hash 算法來路由數據包的,其存在如下問題:
[1] Listen Socket數量發生變化的時候,會造成握手數據包的前一個數據包路由到A listen socket,而后一個握手數據包路由到B listen socket,這樣會造成client的連接請求失敗。 [2]?短時間內各個listen?socket間的負載不均衡6.驚不"驚群"其實是個問題
很多時候,我們并不是害怕"驚群",我們怕的"驚群"之后,做了很多無用功。相反在一個異常繁忙,并發請求很多的服務器上,為了能夠及時處理到來的請求,我們希望能有多"驚群"就多"驚群",因為根本做不了無用功,請求多到都來不及處理。于是出現下面的情形:
從上可以看到各個 CPU 都很忙,但是實際有用的 CPU 時間卻很少,大部分的 CPU 消耗在_spin_lock 自旋鎖上了,并且服務器并發吞吐量并沒有隨著 CPU 核數增加呈現線性增長,相反出現下降的情況。這是為什么呢?怎么解決?
6.1 問題原因
我們知道,一般一個 TCP 服務只有一個 listen socket、一個 accept 隊列,而一個 TCP 服務一般有多個服務進程(一個核一個)來處理請求。于是并發請求到達 listen socket 處,那么多個服務進程勢必存在競爭,競爭一存在,那么就需要用排隊來解決競態問題,于是似乎鎖就無法避免了。在這里,有兩類競爭主體,一類是內核協議棧(不可睡眠類)、一類是用戶進程(可睡眠類),這兩類主體對 listen socket 發生三種類型的競爭:
[1]?協議棧內部之間的競爭 [2]?用戶進程內部之間的競爭 [3]?協議棧和用戶之間的競爭由于內核協議棧是不可睡眠的,為此 linux 中采用兩層鎖定的 lock 結構,一把 listen_socket.lock 自旋鎖,一把 listen_socket.own 排他標記鎖。其中,listen_socket.lock 用于協議棧內部之間的競爭、協議棧和用戶之間的競爭,而 listen_socket.own 用于用戶進程內部之間的競爭,listen_socket.lock 作為 listen_socket.own 的排他保護(要獲取 listen_socket.own 首先要獲取到 listen_socket.lock)。對于處理 TCP 請求而言,一個 SYN 包 syn_skb 到來,這個時候內核 Lock(RCU 鎖)住全局的 listeners Table,查找 syn_skb 對應的 listen_socket,沒找到則返回錯誤。否則,就需要進入三次握手處理,首先內核協議棧需要自旋獲得 listen_socket.lock 鎖,初始化一些數據結構,回復 syn_ack,然后釋放 listen_socket.lock 鎖。
接著,client 端的 ack 包到來,協議棧這個時候,需要自旋獲得 listen_socket.lock 鎖,構造 client 端的 socket 等數據結構,如果 accept 隊列沒有被用戶進程占用,那么就將連接排入 accept 隊列等待用戶進程來 accept,否則就排入 backlog 隊列(職責轉移,連接排入 accept 隊列的事情交給占有 accept 隊列的用戶進程)??梢?#xff0c;處理一個請求,協議棧需要競爭兩次 listen_socket 的自旋鎖。由于內核協議棧不能睡眠,于是它只能自旋不斷地去嘗試獲取 listen_socket.lock 自旋鎖,直到獲取到自旋鎖成功為止,中間不能停下來。自旋鎖這種暴力、打架的搶鎖方式,在一個高并發請求到來的服務器上,就有可能出現上面這種 80%多的 CPU 時間被內核占用,應用程序只能夠分配到較少的 CPU 時鐘周期的資源的情況。
6.2 問題的解決
解決這個問題無非兩個方向:(1) 多隊列化,減少競爭者 (2) listen_socket 無鎖化 。
6.2.1 多隊列化 - SO_REUSEPORT
通過上面的介紹,在 Linux kernel 3.9 以上,可以通過 SO_REUSEPORT 來創建多個 bind 相同 IP、PORT 的 listen_socket。我們可以每一個 CPU 核創建一個 listen_socket 來監聽處理請求,這樣就是每個 CPU 一個處理進程、一個 listen_socket、一個 accept 隊列,多個進程同時并發處理請求,進程之間不再相互競爭 listen_socket。SO_REUSEPORT 可以做到多個 listen_socket 間的負載均衡,然而其負載均衡效果是取決于 hash 算法,可能會出現短時間內的負載極端不均衡。
SO_REUSEPORT 是在將一對多的問題變成多對多的問題,將 Listen Socket 無序暴力爭搶 CPU 的現狀變成更為有序的爭搶。多隊列化的優化必須要面對和解決的四個問題是:隊列比 CPU 多,隊列與 CPU 相等,隊列比 CPU 少,根本就沒有隊列,于是,他們要解決隊列發生變化的情況。
如果僅僅把 TCP 的 Listener 看作一個被協議棧處理的 Socket,它和 Client Socket 一起都在相互拼命搶 CPU 資源,那么就可能出現上面的,短時間大量并發請求過來的時候,大量的 CPU 時間被消耗在自旋鎖的爭搶上了。我們可以換個角度,如果把 TCP Listener 看作一個基礎設施服務呢?Listener 為新來的連接請求提供連接服務,并產生 Client Socket 給用戶進程,它可以通過一個或多個兩類 Accept 隊列提供一個服務窗口給用戶進程來 accept Client Socket 來處理。僅僅在 Client Socket 需要排入 Accept 隊列的是,細粒度鎖住隊列即可,多個有多個 Accept 隊列(每 CPU 一個,那么連鎖隊列的操作都可以省了)。這樣 Listener 就與用戶進程無關了,用戶進程的產生、退出、CPU 間跳躍、綁定,解除綁定等等都不會影響 TCP Listener 基礎設施服務,受影響的是僅僅他們自己該從那個 Accept 隊列獲取 Client Socket 來處理。于是一個解決思路是連接處理無鎖化。
6.2.2 listen socket 無鎖化- 旁門左道之 SYN Cookie
SYN Cookie 原理由 D.J. Bernstain 和 Eric Schenk 提出,專門用來防范 SYN Flood 攻擊的一種手段。它的原理是,在 TCP 服務器接收到 SYN 包并返回 SYN ACK 包時,不分配一個專門的數據結構(避免浪費服務器資源),而是根據這個 SYN 包計算出一個 cookie 值。這個 cookie 作為 SYN ACK 包的初始序列號。當客戶端返回一個 ACK 包時,根據包頭信息計算 cookie,與返回的確認序列號(初始序列號 + 1)進行對比,如果相同,則是一個正常連接,然后,分配資源,創建 Client Socket 排入 Accept 隊列等等用戶進程取出處理。于是,整個 TCP 連接處理過程實現了無狀態的三次握手。SYN Cookie 機制實現了一定程度上的 listen socket 無鎖化,但是它有以下幾個缺點。
(1)丟失 TCP 選項信息在建立連接的過程中,不在服務器端保存任何信息,它會丟失很多選項協商信息,這些信息對 TCP 的性能至關重要,比如超時重傳等。但是,如果使用時間戳選項,則會把 TCP 選項信息保存在 SYN ACK 段中 tsval 的低 6 位。
(2)cookie 不能隨地開啟Linux 采用動態資源分配機制,當分配了一定的資源后再采用 cookie 技術。同時為了避免另一種拒絕服務攻擊方式,攻擊者發送大量的 ACK 報文,服務器忙于計算驗證 SYN Cookie。服務器對收到的 ACK 進行 Cookie 合法性驗證前,需要確定最近確實發生了半連接隊列溢出,不然攻擊者只要隨便發送一些 ACK,服務器便要忙于計算了。
6.2.3 listen socket 無鎖化- Linux 4.4 內核給出的 Lockless TCP listener
SYN cookie 給出了 Lockless TCP listener 的一些思路,但是我們不想是無狀態的三次握手,又不想請求的處理和 Listener 強相關,避免每次進行握手處理都需要 lock 住 listen socket,帶來性能瓶頸。4.4 內核前的握手處理是以 listen socket 為主體,listen socket 管理著所有屬于它的請求,于是進行三次握手的每個數據包的處理都需要操作這個 listener 本身,而一般情況下,一個 TCP 服務器只有一個 listener,于是在多核環境下,就需要加鎖 listen socket 來安全處理握手過程了。我們可以換個角度,握手的處理不再以 listen socket 為主體,而是以連接本身為主體,需要記住的是該連接所屬的 listen socket 即可。4.4 內核握手處理流程如下:
[1] TCP 數據包 skb 到達本機,內核協議棧從全局 socket 表中查找 skb 的目的 socket(sk),如果是 SYN 包,當然查找到的是 listen_socket 了,于是,協議棧根據 skb 構造出一個新的 socket(tmp_sk),并將 tmp_sk 的 listener 標記為 listen_socket,并將 tmp_sk 的狀態設置為 SYNRECV,同時將構造好的 tmp_sk 排入全局 socket 表中,并回復 syn_ack 給 client。
[2] 如果到達本機的 skb 是 syn_ack 的 ack 數據包,那么查找到的將是 tmp_sk,并且 tmp_sk 的 state 是 SYNRECV,于是內核知道該數據包 skb 是 syn_ack 的 ack 包了,于是在 new_sk 中拿出連接所屬的 listen_socket,并且根據 tmp_sk 和到來的 skb 構造出 client_socket,然后將 tmp_sk 從全局 socket 表中刪除(它的使命結束了),最后根據所屬的 listen_socket 將 client_socket 排如 listen_socket 的 accept 隊列中,整個握手過程結束。
4.4 內核一改之前的以 listener 為主體,listener 管理所有 request 的方式,在 SYN 包到來的時候,進行控制反轉,以 Request 為主體,構造出一個臨時的 tmp_sk 并標記好其所屬的 listener,然后平行插入到所有 socket 公共的 socket 哈希表中,從而解放掉 listener,實現 Lockless TCP listener。
7.參考文獻
https://blog.csdn.net/dog250/article/details/50528426?
https://zhuanlan.zhihu.com/p/51251700?
https://blog.csdn.net/dog250/article/details/80837278
- END -
看完一鍵三連在看,轉發,點贊
是對文章最大的贊賞,極客重生感謝你
推薦閱讀
圖解Linux 內核TCP/IP 協議棧實現|Linux網絡硬核系列
深入理解編程藝術之策略與機制相分離
一個奇葩的網絡問題
總結
以上是生活随笔為你收集整理的深入浅出 Linux 惊群:现象、原因和解决方案的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Spectre CPU漏洞借着BPF春风
- 下一篇: 你大学遗憾过吗