skynet任務調度分析

    雲風同學開源的skynet,當前規模是8K+ C代碼和2K+ lua代碼,實現了一個多線程高併發的在線遊戲後臺服務框架,提供定時器、併發調度、服務擴展框架、異步消息隊列、命名服務等基礎能力,支持lua腳本。單服務器支持10K+客戶端接入和處理。

 

    我個人比較關注高性能和併發調度這塊,這兩天分析了一下skynet的代碼,簡單總結一下。

 

1. 總體架構

 

    一圖勝千言,去掉監控、服務擴展、定時器等功能,skynet服務處理的簡化框架如下圖所示:



 
    每個在線客戶的客戶端,在skynet server上都對應有一個socket與其連接。一個socket在skynet內部對應一個lua虛擬機和一個”客戶特定消息隊列“(per client mq)。當客戶特定消息隊列中有消息時,該隊列就會掛載到全局隊列(global message queue)上,供工作線程(worker threads)進行調度處理。

 

     skynet的服務處理主流程比較簡單:一個socket線程輪詢所有的socket,收到客戶端請求後將請求打包成一個消息,發送到該socket對應的客戶特定消息隊列中,然後將該消息隊列掛到全局隊列隊尾;N個工作線程從全局隊列頭部獲取client特定的消息隊列,從客戶特定消息隊列中取出一個消息進行處理,處理完後將該消息隊列重新掛到全局隊列隊尾。

 

    實際代碼要更復雜一些:定時器線程會週期性檢查一下設置的定時器,將到期的定時器消息發送到client消息隊列中;每個lua vm在運行過程中也會向其他lua vm(或自己)的客戶特定消息隊列發送消息;monitor線程監控各個客戶端狀態,看是否有死循環的消息等等。本文重點關注消息的調度,因爲消息調度的任何細微調整都可能對服務端性能產生很大影響。

 

    另外可以看出,每個客戶處理消息時,都是按照消息到達的順序進行處理。同一時刻,一個客戶的消息只會被一個工作線程調度,因此客戶處理邏輯無需考慮多線程併發,基本不需要加鎖。

 

2. 併發任務調度方式

 

    lua支持non-preemptive的coroutine,一個lua虛擬機中可以支持海量併發的協作任務,coroutine主要的問題是不支持多核,無法充分利用當前服務器普遍提供的多核能力。所以目前有很多項目爲lua添加OS thread支持,比如Lua LanesLuaProc等,這些項目都要解決的一個問題就是併發任務的組成以及調度問題。

 

    併發任務可以使用coroutine表示:每個OS線程上創建一個lua虛擬機(lua_State),虛擬機上可以創建海量的coroutine,這種調度如下圖所示:




 
 

這種OS線程與lua vm 1:1的調度方式有很多優點:

  • 每個OS線程都有私有的消息隊列,該隊列有多個寫入者,但只有一個讀取者,可以實現讀端免鎖設計。
  • OS線程可以與lua vm綁定,也可以不綁定。由於現代OS都會盡量將CPU core和OS線程綁定,所以如果OS線程與lua vm綁定的話,可以大大減少cpu cache的刷新,提高cache命中率
  • lua vm與OS線程個數相當,與任務數無關。大量任務可以共用同一個lua vm,共享其lua bytecode,字符串常量等信息,極大減少每個任務的內存佔用。
但該方式也有比較嚴重的缺陷:
  • 不支持任務跨lua vm遷移。每個任務是一個coroutine,而coroutine是lua vm內部的數據結構,執行中其stack引用了lua vm內部的大量共享數據,無法遷移到另一個lua vm上執行。當一個lua vm上的多個任務都比較繁忙時,只能由一個OS線程串行執行,無法通過work stealing等方式交給其他OS線程並行處理。我以前參與的一個電信項目中就是這種業務和線程綁定的處理方式,對於業務邏輯比較固定的電信業務,各個客戶請求的處理工作量類似,因爲綁定後CPU使用比較均衡。由於cache locality比較好的原因,這種處理方式性能極高。但對於一些工作量不固定甚至經常變動的客戶請求,這種方式很容易造成某些線程很忙,另外一些線程很閒,無法有效利用多核能力。
  • 在同一個lua vm內的多個任務,共享lua vm的內存空間。一個任務出現問題時,很容易影響到其他任務。簡單說就是任務間的隔離性不好。
    另一種處理方式是每個lua vm表示一個任務,系統中的海量併發任務由海量lua vm處理。skynet就是採用的這種方式。這種方式有效解決了上一種處理方式的兩個缺陷,每個任務完全獨立,可以交給任意一個OS線程處理;同時任務不會共享lua vm內存空間,隔離性非常好,一個任務的問題不會影響其他任務的執行。這種方式的主要問題就是存在大量內存浪費。每個lua vm都要加載大量相同的lua字節碼和常量,對內存需求量非常高。這也造成每個任務執行時無法重用cpu cache,導致cache命中率降低很多。雲風對這一問題的解決方案是修改lua vm的代碼加載機制,同一進程內部的多個lua vm共享字節碼。具體實現上,有一個獨立的lua vm專門負責加載字節碼,並負責字節碼的垃圾回收。同一進程中的其他lua vm共享該獨立lua vm加載的字節碼。這種方式無法解決字符串常量共享的問題,僅僅解決了字節碼共享的問題。不過即使這樣,每個在線用戶也節省了1M的內存。
 
3.  全局消息隊列
 
   skynet中有一個”全局消息隊列“和在線用戶特定的”任務特定消息隊列“。與go語言1.1版本之前runtime對goroutine的調度類似,所有工作線程使用了一個公共的全局隊列。skynet全局隊列是一個循環隊列,使用數組實現。使用全局隊列不是一種很高效的消息調度方式,每個消息的出隊入隊一般都需要加全局鎖。go1.1中,優化了調度器算法,每個線程使用局部隊列,性能提高很多(據稱有些應用性能提升近40%)。
    skynet全局隊列有多個併發生產者和併發消費者,通常情況下訪問該全局隊列時是需要加鎖的。但skynet居然採用了一種wait-free的隊列實現方式,看如下代碼:
消息入隊列:
C代碼  收藏代碼
  1. <span style="font-size: 14px;">static void  
  2. skynet_globalmq_push(struct message_queue * queue) {  
  3.     struct global_queue *q= Q;  
  4.   
  5.     uint32_t tail = GP(__sync_fetch_and_add(&q->tail,1));  
  6.     q->queue[tail] = queue;  
  7.     __sync_synchronize();  
  8.     q->flag[tail] = true;  
  9. }  
  10. </span>  
 
 消息出隊列:
C代碼  收藏代碼
  1. <span style="font-size: 14px;">struct message_queue *  
  2. skynet_globalmq_pop() {  
  3.     struct global_queue *q = Q;  
  4.     uint32_t head =  q->head;  
  5.     uint32_t head_ptr = GP(head);  
  6.     if (head_ptr == GP(q->tail)) {  
  7.         return NULL;  
  8.     }  
  9.   
  10.     if(!q->flag[head_ptr]) {  
  11.         return NULL;  
  12.     }  
  13.   
  14.     __sync_synchronize();  
  15.   
  16.     struct message_queue * mq = q->queue[head_ptr];  
  17.     if (!__sync_bool_compare_and_swap(&q->head, head, head+1)) {  
  18.         return NULL;  
  19.     }  
  20.     q->flag[head_ptr] = false;  
  21.   
  22.     return mq;  
  23. }  
  24. </span>  
 
    可以看到,不管是入隊列還是出隊列,skynet僅使用了gcc提供的幾個原子操作,沒有任何加鎖解鎖處理。
 
    在入隊列時,直接將任務特定隊列添加到全局隊列隊尾,沒有任何判斷隊列滿的處理,這裏假定全局隊列永遠都不會滿。全局隊列在初始化時直接爲其分配了65536個slot的隊列空間,後續不會增長。一臺服務器併發客戶端一般不會超過10000,每個客戶端對應一個任務特定消息隊列,每個任務特定消息隊列最多添加到全局隊列中一次。根據這些信息,同時在線客戶小於65535時隊列不會滿。正是基於這種假定,skynet讓多個線程併發入隊列實現了wait-free。這裏對任務特定消息隊列的訪問代碼提出了比較嚴格的要求:這些消息隊列的訪問必須加鎖,以防止一個消息隊列多次添加到全局隊列中。
 
    實際上,雖然全局隊列入隊時實現了wait-free,但q->tail在通過原子操作加1後,原先tail指向的位置q->queue[tail]中的內容並沒有同步設置。正因如此,全局隊列中除了隊列數組外,還有一個flag數組,用以標記q->queue[tail]中的值是否設置。同時爲了防止cpu亂序執行導致flag先設置,必須通過__sync_synchronize()在設置flag爲true前添加內存barrier,保證flag的設置在q->queue[tail]賦值之後執行。
 
    在出隊列時,有對全局隊列進行判空處理(if (head_ptr == GP(q->tail))),但當發生多個線程同時取同一個消息時,通過原子操作雖然保證了只有一個線程取到消息,但沒有取到消息的線程不會繼續取後續消息,而是直接假裝隊列已空,回家idle去了。參考如下代碼:
 
C代碼  收藏代碼
  1. <span style="font-size: 14px;">static void *  
  2. _worker(void *p) {  
  3.     struct worker_parm *wp = p;  
  4.     int id = wp->id;  
  5.     struct monitor *m = wp->m;  
  6.     struct skynet_monitor *sm = m->m[id];  
  7.     for (;;) {  
  8.         if (skynet_context_message_dispatch(sm)) {    
  9.             //沒有取到消息時,會進入這裏進行wait,線程掛起  
  10.             CHECK_ABORT  
  11.             if (pthread_mutex_lock(&m->mutex) == 0) {  
  12.                 ++ m->sleep;  
  13.                 pthread_cond_wait(&m->cond, &m->mutex);  
  14.                 -- m->sleep;  
  15.                 if (pthread_mutex_unlock(&m->mutex)) {  
  16.                     fprintf(stderr, "unlock mutex error");  
  17.                     exit(1);  
  18.                 }  
  19.             }  
  20.         }  
  21.     }  
  22.     return NULL;  
  23. }  
  24. </span>  
 
當大量客戶端併發請求時,出隊列的這種碰撞應該是比較頻繁的,這種處理方式會導致線程頻繁掛起。而爲了減少線程的頻繁喚醒,skynet只有在所有worker都idle後,再收到新的socket請求時纔會喚醒一個掛起的線程:
 
C代碼  收藏代碼
  1. <span style="font-size: 14px;">static void  
  2. wakeup(struct monitor *m, int busy) {  
  3.     if (m->sleep >= m->count - busy) {  
  4.         //busy=0,意味着只有掛起線程數sleep=工作線程數count時纔會喚醒線程  
  5.         pthread_cond_signal(&m->cond);  
  6.     }  
  7. }  
  8.   
  9. static void *  
  10. _socket(void *p) {  
  11.     struct monitor * m = p;  
  12.     for (;;) {  
  13.         int r = skynet_socket_poll();  
  14.         if (r==0)  
  15.             break;  
  16.         if (r<0) {  
  17.             CHECK_ABORT  
  18.             continue;  
  19.         }  
  20.         wakeup(m,0); // 參數busy爲0  
  21.     }  
  22.     return NULL;  
  23. }  
  24. </span>  
 
    可以看出,當由於從全局隊列取消息發生碰撞,部分線程被掛起後,只要有一個線程仍然在工作,這些掛起的線程就不會被喚醒(即使全局隊列中仍然有大量需要處理的消息)。這樣並不能非常有效地使用多核能力。
 
    解決方案可以參考go1.1運行時goroutine調度器的實現方案,將全局隊列劃分爲每個線程一個的線程特定隊列,同一線程特定隊列中的任務特定隊列都由該線程處理。這樣線程特定隊列只有一個消費者線程,可以很容易實現無鎖出隊操作,同時不會出現上述問題。另外爲了防止出現各個線程間不均衡的情況,一個線程在處理完一個消息後,根據當前自身線程特定隊列的負載情況(比如隊列長度和消息滯留時間),可以決定將任務特定隊列push到其他線程特定隊列的隊尾,還是自己線程特定隊列的隊尾。在自己線程特定隊列爲空時,可以直接從任務特定隊列中取出下一個消息繼續處理,減少一次入隊和出隊操作。這種方案在不使用複雜的work stealing方案的情況下,仍能保持線程間的負載均衡,不會出現線程餓死的情況。
 
4. 任務特定消息隊列
 
     每個任務特定消息隊列都是針對一個在線客戶,對應有一個socket和一個lua vm。這個隊列有多個併發生產者,但只有一個併發消費者。對於這種隊列,一般我們可以在入隊列中使用spin lock,在出隊列時免鎖(但需要使用內存屏障保證cpu不會亂序執行內存訪問指令)。不過skynet中,在入隊列和出隊列時都加了spin lock:
 
C代碼  收藏代碼
  1. <span style="font-size: 14px;">#define LOCK(q) while (__sync_lock_test_and_set(&(q)->lock,1)) {}  
  2. #define UNLOCK(q) __sync_lock_release(&(q)->lock);  
  3.   
  4. int  
  5. skynet_mq_pop(struct message_queue *q, struct skynet_message *message) {  
  6.     int ret = 1;  
  7.     LOCK(q)  
  8.   
  9.     if (q->head != q->tail) {  
  10.         *message = q->queue[q->head];  
  11.         ret = 0;  
  12.         if ( ++ q->head >= q->cap) {  
  13.             q->head = 0;  
  14.         }  
  15.     }  
  16.   
  17.     if (ret) {  
  18.         q->in_global = 0;  
  19.     }  
  20.   
  21.     UNLOCK(q)  
  22.   
  23.     return ret;  
  24. }  
  25. </span>  
 
 入隊列:
 
C代碼  收藏代碼
  1. <span style="font-size: 14px;">void  
  2. skynet_mq_push(struct message_queue *q, struct skynet_message *message) {  
  3.     assert(message);  
  4.     LOCK(q)  
  5.   
  6.     if (q->lock_session !=0 && message->session == q->lock_session) {  
  7.         _pushhead(q,message);  
  8.     } else {  
  9.         q->queue[q->tail] = *message;  
  10.         if (++ q->tail >= q->cap) {  
  11.             q->tail = 0;  
  12.         }  
  13.   
  14.         if (q->head == q->tail) {  
  15.             expand_queue(q);  
  16.         }  
  17.   
  18.         if (q->lock_session == 0) {  
  19.             if (q->in_global == 0) {  
  20.                 q->in_global = MQ_IN_GLOBAL;  
  21.                 skynet_globalmq_push(q);  
  22.             }  
  23.         }  
  24.     }  
  25.   
  26.     UNLOCK(q)  
  27. }  
  28. </span>  
 
    很奇怪的設計。仔細分析入隊列的代碼,原來skynet並不是把任務特定隊列當做一個FIFO隊列來使用,而是當做同時支持FIFO和LIFO的deque來使用。究其原因,是爲了支持lua中的skynet.blockcall才這樣做的。爲了支持skynet.blockcall,任務特定隊列數據結構中添加了一個lock_session成員:
 
C代碼  收藏代碼
  1. <span style="font-size: 14px;">struct message_queue {  
  2.     uint32_t handle;  
  3.     int cap;  
  4.     int head;  
  5.     int tail;  
  6.     int lock;  
  7.     int release;  
  8.     int lock_session;  
  9.     int in_global;  
  10.     struct skynet_message *queue;  
  11. };  
  12. </span>  
 
skynet.blockcall實現代碼如下:
 
Lua代碼  收藏代碼
  1. <span style="font-size: 14px;">function skynet.blockcall(addr, typename , ...)  
  2.     local p = proto[typename]  
  3.     c.command("LOCK")  
  4.     local session = c.send(addr, p.id , nil , p.pack(...))  
  5.     if session == nil then  
  6.         c.command("UNLOCK")  
  7.         error("call to invalid address " .. skynet.address(addr))  
  8.     end  
  9.     return p.unpack(yield_call(addr, session))  
  10. end  
  11. </span>  
 
    c.command("LOCK")命令執行時會設置消息隊列的lock_session爲context的下一個session值,c.send發送請求時返回的session就是這個session值。響應消息回來時,skynet_mq_push發現響應消息中的session值與隊列中的lock_session一致,就會通過_pushhead函數將響應消息放到隊列頭部,從而讓skynet儘快處理。_pushhead實現如下:
 
C代碼  收藏代碼
  1. <span style="font-size: 14px;">static void  
  2. _pushhead(struct message_queue *q, struct skynet_message *message) {  
  3.     int head = q->head - 1;  
  4.     if (head < 0) {  
  5.         head = q->cap - 1;  
  6.     }  
  7.     if (head == q->tail) {  
  8.         expand_queue(q);  
  9.         --q->tail;  
  10.         head = q->cap - 1;  
  11.     }  
  12.   
  13.     q->queue[head] = *message;  
  14.     q->head = head;  
  15.   
  16.     _unlock(q);  
  17. }  
  18.   
  19. static void  
  20. _unlock(struct message_queue *q) {  
  21.     // this api use in push a unlock message, so the in_global flags must not be 0 ,  
  22.     // but the q is not exist in global queue.  
  23.     if (q->in_global == MQ_LOCKED) {  
  24.         skynet_globalmq_push(q);  
  25.         q->in_global = MQ_IN_GLOBAL;  
  26.     } else {  
  27.         assert(q->in_global == MQ_DISPATCHING);  
  28.     }  
  29.     q->lock_session = 0;  
  30. }  
  31. </span>  
 
    實際上在我看來,deque的使用完全沒有必要。skynet.blockcall使用了新的session,因此可以對應使用新的coroutine。此時即使將響應消息放到隊列尾部,該響應也會正常處理。使用lock_session的唯一好處就是提高了響應消息的優先級,當響應消息過來時,優先處理響應消息。但這種做法完全可以通過另外一個高優先級隊列來實現,就像erlang調度器中做的那樣。爲每個任務增加一個高優先級隊列後,可以達到與當前deque相同的效果,同時又可以免鎖從隊列中取消息,應該會提高一點性能。
 
5. 總結
 
    沒有做過遊戲開發,憑着自己多年做電信服務器軟件的經驗和一些個人興趣愛好瞎扯了一些。電信服務器軟件和遊戲後臺服務器軟件有很多相似之處,但兩個領域也有很多重大差異,可能很多在我看來很重要的問題在遊戲開發中實際上無所謂。skynet已經實際應用了一段時間,具體設計和實現應該都有特定的需求。還是那句話,軟件開發中可以爭論對錯,不過最終是好是壞還是由實際運行效果決定。正因爲沒看到具體的應用場景,這裏的總結只是拿一些服務器軟件通用的原理姑且推之。
 
    skynet代碼中,handle的存取使用了讀寫鎖,任務特定隊列的訪問使用了自旋鎖,全局隊列的訪問使用了wait-free的免鎖設計。除了handle存取時多讀少寫,使用讀寫鎖比較合適之外,任務特定隊列和全局隊列的調度設計都有待改進。系統中每處理一個消息時會涉及全局隊列的一次出隊列和一次入隊列,全局隊列的使用非常頻繁,因此看起來wait-free的設計沒什麼問題。但仔細分析後會發現,在系統比較繁忙時,wait-free設計會導致部分衝突線程出現沒必要的等待。將全局隊列改爲線程特定隊列,入隊列使用自旋鎖而出隊列不加鎖,會提高多核使用效率。任務特定隊列只有一個併發消費者,使用FIFO的隊列後,出隊列不需要加鎖。
發佈了30 篇原創文章 · 獲贊 67 · 訪問量 21萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章