轉載自: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,附帶了測試用例。可以直接把代碼摳出來拿來用到項目裏。