驚群問題(thundering herd)
想像桌上只放一塊餅乾,二十個飢餓的孩子全被交代「看到食物就衝」。餅乾一出現,二十個全跳起來、衝過去,十九個撲了空——白費力氣,路上還互相踐踏。驚群問題在軟體裡正是如此:許多執行緒或行程被喚醒去處理「單一」事件,但實際上只有一個能拿到它,於是其餘的醒來、競爭、再睡回去,浪費 CPU 並造成排程器的折騰。
經典情形是許多工作執行緒全阻塞著、等在「同一個」監聽通訊端上 accept()。當一條連線抵達時,一個天真的核心會喚醒「所有」等待者;它們全衝進 accept();一個成功、其餘的拿到 EAGAIN 或被放回去睡。同樣的形態在許多執行緒都等在「同一個」epoll 實例上、盯著同一個監聽通訊端時也會咬人——一條進來的連線可能把它們全喚醒。修正之道都瞄準「只喚醒一個」:在 Linux 上,對一筆 epoll 登記加 EPOLLEXCLUSIVE 請核心每個事件只喚醒一個等待者;像舊版 Apache 這類經典伺服器用一個明確的 accept-mutex,使同一時間只允許一個工作執行緒坐在 accept() 裡;而 SO_REUSEPORT 給每個工作執行緒「自己的」監聽通訊端,讓核心把每條新連線恰好導向其中一個,徹底繞開驚群。
為何重要:在高連線速率下,驚群把本該安靜的一次喚醒變成上下文切換與鎖競爭的踩踏,恰恰在負載最高時封頂了吞吐量並增添延遲。在任何「多個等待者共用一個喚醒來源」的設計中,它都是反覆出現的風險——accept 迴圈、條件變數與 futex 各有自己的版本與各自的緩解手段。
struct epoll_event ev = { .events = EPOLLIN | EPOLLEXCLUSIVE, .data.fd = listen_fd }; epoll_ctl(ep, EPOLL_CTL_ADD, listen_fd, &ev); /* 每條進來的連線只喚醒一個等待者 */
EPOLLEXCLUSIVE 告訴核心每個事件只喚醒眾多等待者中的一個,馴服驚群。
現代核心已避開最單純的 accept() 驚群,但它在共享 epoll 集合與多個等待者時仍會出現,這正是 EPOLLEXCLUSIVE 與 SO_REUSEPORT 存在的原因。EPOLLEXCLUSIVE 只喚醒一個等待者,但不對等待者之間的公平性或負載均衡提供任何保證。