【原創】 [ 探索epoll的內置Leader-Follower支持以及線程安全問題, epoll可以更高效! ]


  最近在探索借助epoll做為reactor, 設計高效的服務端的方法. 

  常見的基於epoll的編程方式主要為單線程的事件循環, 用於一些非阻塞的業務邏輯開發是比較高效並且簡單易懂的.
  
  但實際開發業務的時候, 往往面臨着查數據庫, 訪問磁盤, 通過網絡訪問其他主機的需求, 耗時往往較長, 所以單線程的epoll並不能輕松的適用, 往往需要做一些額外的設計與構思才能得到解決.
  
  解決此類慢處理的服務端架構主要以leader-follower架構以及half-sync-half-async為主, 通過多線程的並發能力來滿足同時執行多個慢處理業務邏輯.  其中, leader-follower因為較half-sync-half-async而言減少了從異步層 與 同步層間必要的內存拷貝, 並且half-sync-half-async的異步層是單線程實現, 是很容易達到單核CPU瓶頸的, 所以基本完敗於leader-follower.

  公司的UB框架使用的應該是簡化版的leader-follower模型, 即線程池共享同一個epoll set, 而epoll set中只注冊了監聽socket, 從而保證同一個時刻只由leader線程accept得到connected socket並使用帶超時的阻塞read接口先后讀取nshead header與nshead body, 所以UB的設計是不適合暴露給外網的, 很容易受到攻擊而影響服務.
    
   正統的leader-follower應當由線程池共享同一個epoll set, 並將所有的socket注冊到該Epoll set中, 然后由leader負責epoll_wait獲取一個event socket並將其從epoll set中暫時移除, 之后Leader便可轉換為follower並處理event socket, 並在處理完成后將event socket重新注冊回epoll set以便繼續監監聽.  

  可以看到, leader-follower按照程序設計方法, 需要使用mutex+cond實現leader-follower職責間的轉換, 在恢復event socket的時候, 因為epoll set被多線程共享的原因, 涉及到epoll_ctl的線程安全問題, 簡單的例子: 假設A線程希望恢復socket1的事件注冊, 但此時B線程正在epoll_wait, 那么A線程是否可以線程安全的使用epoll_ctl恢復socket1的事件.


  幸運的是, epoll內置了leader-follower的選項支持, 可以避免mutex+cond的使用, 並且保障了epoll_ctl的線程安全性, 同時保證了epoll_wait是線程安全的並且會負載均衡事件到多個調用者, 不會引發驚群問題. 

  為了做到這一點, 只需要為每一個event socket設置選項: EPOLLONESHOT即可.
        EPOLLONESHOT
              Sets  the  One-Shot  behaviour  for  the  associated file descriptor. It means that after an event is pulled out with
              epoll_wait(2) the associated file descriptor is internally disabled and no other events will be reported by the epoll
              interface. The user must call epoll_ctl(2) with EPOLL_CTL_MOD to re-enable the file descriptor with a new event mask.
  
   更關鍵的說明需要man epoll, 其中:
       On the contrary, when used as a Level Triggered interface, epoll is by all means a faster poll(2), and can be used  wherever
       the  latter is used since it shares the same semantics. Since even with the Edge Triggered epoll multiple events can be gen-
       erated up on receival of multiple chunks of data, the caller has the option to specify the EPOLLONESHOT flag, to tell  epoll
       to  disable the associated file descriptor after the receival of an event with epoll_wait(2).  When the EPOLLONESHOT flag is
       specified, it is caller responsibility to rearm the file descriptor using epoll_ctl(2) with EPOLL_CTL_MOD.  

  有了這個機制的保障, 在實現leader-follower的時候, 只要創建多個線程, 所有線程epoll_wait同一個epoll set, 並且保證epoll set中每個注冊的event socket都攜帶了EPOLLONESHOT選項即可.
  最初, epoll set里只有監聽socket. 當多個線程調用epoll_wait時, 每個線程均會得到一批不同的event socekt. 這是因為在epoll_wait返回之前會為你設置發生event的socket的注冊事件為0, 即其他線程不可能再次檢測到該socket的事件, 相當於epoll_wait將poll與epoll_ctl(DEL)做了原子化, 這是渾然天成的leader-follower, 代碼與單線程相比幾乎一模一樣.  在讀寫並處理完event socket后, 需要使用epoll_ctl(MOD)恢復event socket到epoll set中, 而epoll_ctl保證這是線程安全的, 並且如果socket有事件會立即通知某epoll_wait返回.

    有了上面的理論基礎, 我試着開發了一個多線程基於epoll的server, 測試了長連接的qps, 只跑echo服務, 發現可以達到15萬, 但出現了大面積線程D狀態, 繼續提高壓力qps也沒有提升, 程序的系統調用也僅僅是read/write/accept/close/epoll_ctl/epoll_wait, 程序是無內存分配的, 並且每個核心的idle還剩余60多.

    在猜測嘗試了一些方法后, 包括單線程跑epoll, 竟然發現qps也是15萬, 但單核CPU可以跑滿, 懷疑了一頓網卡之后, 發現測試是單機的, 根本沒走網卡, 所以不是網卡軟中斷過高的問題. 
   根據epoll_ctl/epoll_wait線程安全這個設計, 我猜測多個線程訪問同一個epoll set是有鎖的, 從而導致了程序瓶頸, 於是我創建了多個epoll set, 每個epoll set作為一個組, 組內有若干worker線程共享該epoll set, 從而減小了鎖的面積.
   但這樣設計, 需要考慮到監聽socket怎么處理, 因為每個組都有自己的epoll set, 不能將監聽socket注冊給多個epoll set, 那樣會有驚群問題. 於是, 我獨立創建了一個監聽線程, 並創建了一個獨立的epoll set跑事件循環來專門做accept, 而因為epoll_ctl的線程安全特性, 所以監聽線程采用了round robin輪轉的向每個組的epoll set進行conntected socket的事件注冊.
   
   通過以上的優化, 驚喜的發現服務端的qps達到了近40萬, 而cpu idle也終於從60榨到了不到10%.
   

   唯一的缺點就是round robin的分發策略可能因為客戶端主動斷開行為導致各個組內的負載不均, 希望找到一種無鎖並且代價低的解決方案, (PS: 多線程server, 對業務數據並發訪問的同步問題是個老問題)暫時就到這里了.

   考慮到推廣百度雲盤, 代碼放在這里了:http://pan.baidu.com/share/link?shareid=332303&uk=2686094642


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM