遊戲中的定時器

轉載自:http://www.cnblogs.com/fingerpass/p/linux-kernel-timer-in-game.html


寫在前面

遊戲中處處都有定時器,基本上每個邏輯部分我們都能看到定時器的影子。如果翻看一下以前網上流傳的一些MMO的代碼,比如mangos的,比如大唐的,比如天龍的,我們都可以看到形形色色的定時器實現。

在以前,很多程序員用起來C++還都是在用C with Object,以前的C++寫callback也好異步也好總是感覺哪裏不對勁的樣子,所以網上流傳的那種線上服務器的代碼,一般都是往主循環裏硬塞定時器邏輯。

定時器在很多能參考到的代碼裏都是邏輯和底層不做區分的,這樣就會導致一些問題。
一方面,底層的需求是通用性。要通用性的話就必須得在主循環中輪詢timeout,而不是藉助一些更高層級的抽象;
另一方面,上層的需求是易用性。要易用性的話就必須得用起來方便,而且最好是能原生嵌入在一些常規的異步編程模型中的。最不濟的,需要我很方便的掛callback。再高級點,我需要能yield。最上層的,能讓我在描述一次lasy evaluation的計算中描述WaitForTime語義,做future什麼的當然更好了。

但是,由於之前說到的,很多現成的都是底層上層不區分的,所以最常見的可能就是利用一種比較挫的觀察者模式,比如繼承一個Observable之類的東西,掛在主循環中。主循環輪詢timeout,timeout了就callback之前註冊進來的Observable。寫起來真是要多蛋疼有多蛋疼。雖然說既照顧了上層,讓上層能用callback了,算是溫飽,也照顧了底層,底層寫起來也是主循環來做timeout的,但是這樣一來就只是一個擴展性非常差的Timer模塊了。

當然,這篇文章不打算繼續糾纏這種形而上的設計問題,上層的一些更高層次的抽象也不是這篇文章的重點,這裏重點care下底層定時器機制的實現。

定時器實現

一般比較常見的定時器實現,其實就那麼幾種。
一種是比較容易能想到的,一個簡單的最小堆,每次tick都查一下top的expire有沒有timeout,timeout了就取出來,取出來再重複。

這種模型好處就是簡單,找個學過數據結構的畢業生就能寫出來,不容易有bug。但是有個比較致命的問題就是,如果短期內註冊了大量timer,我add的時候需要nlgn,timeout的時候還需要nlgn。

所以網上後來就出現了鋪天蓋地的另一種定時器實現,linux內核中的timer實現,當然這內核裏一坨坨的代碼我估計是沒人想看的,不重要的細枝末節把我們需要學習的精華地方完全遮住了,看看原理就可以了。或者看下skynet_timer的實現,這裏的還是比較淺顯易懂的,可讀性也很強。

這篇文章就重點來對比下這兩種定時器的實現。下面代碼都上C#了。

第一種。基於最小堆實現的,首先你要有一個最小堆,動手實現一下

複製代碼
1 public class PriorityQueue<T> : IEnumerable<T>
2 {
3     public PriorityQueue(IComparer<T> comparer);
4     public void Push(T v);
5     public T Pop();
6     public T Top();
7 }
複製代碼

 

1 public interface ITimeManager
2 {
3     ITimer AddTimer(uint afterTick, OnTimerTimeout callback, params object[] userData);
4     FixedTick();
5 }

ps.增加這個Callback主要是爲了方便跑測試用例。

1 public class TrivialTimeManager : ITimeManager
2 {
3     // ...
4 }

具體的實現就不用多說了。

然後是第二種。第二種思考方式需要有這樣一個前提:
通過tick來定義整個系統的時間精度下限。比如遊戲中其實都不是特別care 10ms以下的精度的,我們可以定義一個tick的長度爲10ms。也就是說我先掛上去的WaitFor(8ms)和後上去的WaitFor(5ms),有可能是前者先timeout的。一個tick爲10ms,那麼一個32bit的tick能表達的時間粒度就有將近500天,遠超過一個服務器組不重啓的時間了。
如果有了這樣的前提,就可以針對之前提到的、方法一面對大量臨近tick的timer插入鎖遇到的問題,做一些特殊的優化。
也就是根據tick直接拿到timeout鏈表,直接dispatch,拿到這個鏈表的時間是一個常數,而最小堆方法拿到這個鏈表需要的時間是m*lgn。

當然,由於空間有限,我們不可能做到每個將要timeout的tick都有對應的鏈表。考慮到其實80%以上的timer的時間都不會超過2.55s,我們只針對前256個tick做這種優化措施即可。

那註冊進來一個256tick之後timeout的timer怎麼辦呢?我們可以把時間還比較長的timer放在更粗粒度的鏈表中,等到還剩下的tick數小於256之後再把他們取出來重新整理一下鏈表就能搞定。

如果我們保證每一次tick都嚴格的做到:

  • 未來256tick內的鏈表都能常數時間取到
  • 新加入的256tick以及更遲的timer纔會加入到粗粒度鏈表

保證這兩點,就需要每個tick都對所有鏈表做一次整理。這樣就得不償失了,所以這裏有個trade-off,就是我通過一個指針(index),來標記我當前處理的position,每過256tick是一個cycle,才進行一次整理。而整理的成本就通過均攤在256tick中,降低了實際上的單位時間成本。

概念比較抽象,先來看下數據結構。

常量的定義

複製代碼
1 public const int TimeNearShift = 8;
2 public const int TimeNearNum = 1 << TimeNearShift;      // 256
3 public const int TimeNearMask = TimeNearNum - 1;        // 0x000000ff
4 
5 public const int TimeLevelShift = 6;
6 public const int TimeLevelNum = 1 << TimeLevelShift;    // 64
7 public const int TimeLevelMask = TimeLevelNum - 1;      // 00 00 00 (0011 1111)
複製代碼

        
基礎數據結構

1 using TimerNodes = LinkedList<TimerNode>;
2 private readonly TimerNodes[TimeNearNum] nearTimerNodes;
3 private readonly TimerNodes[4][TimeLevelNum] levelTimerNodes;

tick有32位,每一個tick只會timeout掉expire與index相同的timer。

循環不變式保證near表具有這樣幾個性質:

  •     第i個鏈表中的所有timer的expire,(expire >> 8) == (index >> 8) 且(expire & TimeNearMask) == i
  •     i小於(index & TimeNearMask)的鏈表,都已經AllTimeout


level表有4個,分別對應9到14bit,15到20bit,21到26bit,27到32bit。
由於原理都類似,我這裏拿9到14bit的表來說下循環不變式:

  •     表中的所有64個鏈表,所有timer的expire的高18個bit一定是與index的高18個bit相等的
  •     第i個鏈表的元素的expire的9到14bit單獨抽出來就是i
  •     i小於(index的9到14bit單獨抽出來)的鏈表,都已經Shift


有了數據結構和循環不變式,後面的代碼也就容易理解了。主要列一下AddTimer的邏輯和Shift邏輯。

複製代碼
 1 private void AddTimerNode(TimerNode node)
 2 {
 3     var expire = node.ExpireTick;
 4 
 5     if (expire < index)
 6     {
 7         throw new Exception();
 8     }
 9 
10     // expire 與 index 的高24bit相同
11     if ((expire | TimeNearMask) == (index | TimeNearMask))
12     {
13         nearTimerNodes[expire & TimeNearMask].AddLast(node);
14     }
15     else
16     {
17         var shift = TimeNearShift;
18 
19         for (int i = 0; i < 4; i++)
20         {
21             var lowerMask = (1 << (shift+TimeLevelShift))-1;
22             
23             // expire 與 index 的高bit相同
24             // (24-6*(i+1))
25             if ((expire | lowerMask) == (index | lowerMask))
26             {
27                 // 取出[(8+i*6), (14+i*6))這段bits
28                 levelTimerNodes[i][(expire >> shift)&TimeLevelMask].AddLast(node);
29                 break;
30             }
31 
32             shift += TimeLevelShift;
33         }
34     }
35 }
複製代碼

 

複製代碼
 1 private void TimerShift()
 2 {
 3     // TODO index迴繞到0的情況暫時不考慮
 4     index++;
 5 
 6     var ct = index;
 7 
 8     // mask0 : 8bit
 9     // mask1 : 14bit
10     // mask2 : 20bit
11     // mask3 : 26bit
12     // mask4 : 32bit
13 
14     var partialIndex = ct & TimeNearMask;
15 
16     if (partialIndex != 0)
17     {
18         return;
19     }
20 
21     ct >>= TimeNearShift;
22 
23     for (int i = 0; i < 4; i++)
24     {
25         partialIndex = ct & TimeLevelMask;
26 
27         if (partialIndex == 0)
28         {
29             ct >>= TimeLevelShift;
30             continue;
31         }
32 
33         ReAddAll(levelTimerNodes[i], partialIndex);
34         break;
35     }
36 }
複製代碼

以上代碼用c/c++重寫後品嚐風味更佳。

下面我們來測一下到底linux內核風格的定時器比最小堆的快了多少。

先是構造測試用例。我這裏只考慮突然的來一大批timer,然後看所有都timeout需要消耗多久。

複製代碼
 1 static IEnumerable<TestCase> BuildTestCases(uint first, uint second)
 2 {
 3     var rand = new Random();
 4 
 5     for (int i = 0; i < first; i++)
 6     {
 7         yield return new TestCase()
 8         {
 9             Tick = (uint)rand.Next(256),
10         };
11     }
12 
13     for (int i = 0; i < 4; i++)
14     {
15         var begin = 1U << (8 + 6*i);
16         var end = 1U << (14 + 6*i);
17 
18         for (int j = 0; j < rand.Next((int)second * (4 - i)); j++)
19         {
20             yield return new TestCase()
21             {
22                 Tick = (uint)rand.Next((int)(begin+end)/2),
23             };
24         }
25     }
26 }
複製代碼

構造測試用例

複製代碼
 1 static IEnumerable<TestCase> BuildTestCases(uint first, uint second)
 2 {
 3     var rand = new Random();
 4 
 5     for (int i = 0; i < first; i++)
 6     {
 7         yield return new TestCase()
 8         {
 9             Tick = (uint)rand.Next(256),
10         };
11     }
12 
13     for (int i = 0; i < 4; i++)
14     {
15         var begin = 1U << (8 + 6*i);
16         var end = 1U << (14 + 6*i);
17 
18         for (int j = 0; j < rand.Next((int)second * (4 - i)); j++)
19         {
20             yield return new TestCase()
21             {
22                 Tick = (uint)rand.Next((int)(begin+end)/2),
23             };
24         }
25     }
26 }
複製代碼

測試函數

複製代碼
 1 {
 2     var maxTick = cases.Max(c => c.Tick);
 3     var results = new HashSet<uint>();
 4 
 5     foreach (var c in cases)
 6     {
 7         TestCase c1 = c;
 8         mgr.AddTimer(c.Tick, (timer, data) =>
 9         {
10             if (mgr.FixedTicks == c1.Tick)
11                 results.Add((uint) data[0]);
12         }, c.Id);
13     }
14 
15     var begin = DateTime.Now;
16     for (int i = 0; i < maxTick+1; i++)
17     {
18         mgr.FixedTick();
19     }
20     var end = DateTime.Now;
21 }
複製代碼

看圖得結論

first固定爲一千萬,這個也是比較符合實際的情況,大量的timer都是2.5s以內的。可以看出隨着遠timer數量的增加,linux內核定時器對比最小堆定時器的優勢是越來越小的。

這個是固定遠timer的數量,係數固定爲1000。跟上圖得到的結論差不多,近timer佔比越高,相比最小堆定時器的優勢越大。

 

總之,linux內核定時器比起最小堆定時器的優勢還是很明顯的,隨便就能有2倍以上的性能表現,強烈建議採用。

去年剛來工作室的時候做了個skynet的源碼閱讀分享,當時也提到了裏面定時器的實現,但是隻看代碼那肯定是記不住的,總得寫一遍,後來也一直沒抽出時間。直到前幾天看到一個答案,正好業餘做的一個小東西開始需要時間模塊了,就實現了下,順便產出此小品文。

 

最新的代碼放在了github上:CoroutineSharp

這個項目是基於本文提到的定時器做了一個unity風格的coroutine,附帶了測試用例。可以直接把代碼摳出來拿來用到項目裏。


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