Node.js 打造實時多人遊戲框架

收藏一下。原文:Node.js 打造實時多人遊戲框架html

在 Node.js 如火如荼發展的今天,咱們已經能夠用它來作各類各樣的事情。前段時間UP主參加了極客鬆活動,在此次活動中咱們意在作出一款讓「低頭族」可以更多交流的遊戲,核心功能即是 Lan Party 概念的實時多人互動。極客鬆比賽只有短得可憐的36個小時,要求一切都敏捷迅速。在這樣的前提下初期的準備顯得有些「水到渠成」。跨平臺應用的 solution 咱們選擇了 node-webkit,它足夠簡單且符合咱們的要求。前端

按照需求,咱們的開發能夠按照模塊分開進行。本文具體講述了開發 Spaceroom(咱們的實時多人遊戲框架)的過程,包括一系列的探索與嘗試,以及對 Node.js、WebKit 平臺自己的一些限制的解決,和解決方案的提出。node

Spaceroom 一瞥

在最開始,Spaceroom 的設計確定是需求驅動的。咱們但願這個框架能夠提供如下的基礎功能:git

  • 可以以 房間(或者說頻道) 爲單位,區分一組用戶
  • 可以接收收集組內用戶發來的指令
  • 在各個客戶端之間對時,可以按照規定的 interval 精確廣播遊戲數據
  • 可以儘可能消除由網絡延遲帶來的影響

固然,在 coding 的後期,咱們爲 Spaceroom 提供了更多的功能,包括暫停遊戲、在各個客戶端之間生成一致的隨機數等(固然根據需求這些均可以在遊戲邏輯框架裏本身實現,並不是必定須要用到 Spaceroom 這個更多在通訊層面上工做的框架)。github

APIs

Spaceroom 分爲先後端兩個部分。服務器端所須要作的工做包括維護房間列表,提供建立房間、加入房間的功能。咱們的客戶端 APIs 看起來像這樣:web

  • spaceroom.connect(address, callback) – 鏈接服務器
  • spaceroom.createRoom(callback) – 建立一個房間
  • spaceroom.joinRoom(roomId) – 加入一個房間
  • spaceroom.on(event, callback) – 監聽事件
  • ……

客戶端鏈接到服務器後,會收到各類各樣的事件。例如一個在一間房間中的用戶,可能收到新玩家加入的事件,或者遊戲開始的事件。咱們給客戶端賦予了「生命週期」,他在任什麼時候候都會處於如下狀態的一種:算法

clipboard.png

你能夠經過 spaceroom.state 獲取客戶端的當前狀態。後端

使用服務器端的框架相對來講簡單不少,若是使用默認的配置文件,那麼直接運行服務器端框架就能夠了。咱們有一個基本的需求:服務器代碼 能夠直接運行在客戶端中,而不須要一個單獨的服務器。玩過 PS 或者 PSP 的玩家應該清楚我在說什麼。固然,能夠跑在專門的服務器裏,天然也是極好的。瀏覽器

邏輯代碼的實現這裏簡略了。初代的 Spaceroom 完成了一個 Socket 服務器的功能,它維護房間列表,包括房間的狀態,以及每個房間對應的遊戲時通訊(指令收集,bucket 廣播等)。具體實現能夠參看源碼。服務器

同步算法

那麼,要怎麼才能使得各個客戶端之間顯示的東西都是實時一致的呢?

這個東西聽起來頗有意思。仔細想一想,咱們須要服務器幫咱們傳遞什麼東西?天然就會想到是什麼可能形成各個客戶端之間邏輯的不一致:用戶指令。既然處理遊戲邏輯的代碼都是相同的,那麼給定一樣的條件,代碼的運行結果也是相同的。惟一不一樣的就是在遊戲過程中,接收到的各類玩家指令。理所固然的,咱們須要一種方式來同步這些指令。若是全部的客戶端都能拿到一樣的指令,那麼全部的客戶端從理論上講就能有同樣的運行結果了。

網絡遊戲的同步算法千奇百怪,適用的場景也各不相同。Spaceroom 採用的同步算法相似於幀鎖定的概念。咱們把時間軸分紅了一個一個的區間,每個區間稱爲一個 bucket。Bucket 是用來裝載指令的,由服務器端維護。在每個 bucket 時間段的末尾,服務器把 bucket 廣播給全部客戶端,客戶端拿到 bucket 以後從中取出指令,驗證以後執行。

爲了下降網絡延遲形成的影響,服務器接到的來自客戶端的指令每個都會按照必定的算法投遞到對應的 bucket 中,具體按照如下步驟:

  1. 設 order_start 爲指令攜帶的指令發生時間, t 爲 order_start 所在 bucket 的起始時間
  2. 若是 t + delay_time <= 當前正在收集指令的 bucket 的起始時間,將指令投遞到 當前正在收集指令的 bucket 中,不然繼續 step 3
  3. 將指令投遞到 t + delay_time 對應的 bucket 中

其中 delay_time 爲約定的服務器延遲時間,能夠取爲客戶端之間的平均延遲,Spaceroom 裏默認取值80,以及 bucket 長度默認取值48. 在每一個 bucket 時間段的末尾,服務器將此 bucket 廣播給全部客戶端,並開始接收下一個 bucket 的指令。客戶端根據收到的 bucket 間隔,在邏輯中自動進行對時,將時間偏差控制在一個能夠接受的範圍內。

這個意思是,正常狀況下,客戶端每隔 48ms 會收到從服務器端發來的一個 bucket,當到達須要處理這個 bucket 的時間時,客戶端會進行相應處理。假設客戶端 FPS=60,每隔 3幀 左右的時間,會收到一次 bucket,根據這個 bucket 來更新邏輯。若是由於網絡波動,超出時間後尚未收到 bucket,客戶端暫停遊戲邏輯並等待。在一個 bucket 以內的時間,邏輯的更新可使用 lerp 的方法。

clipboard.png

在 delay_time = 80, bucket_size = 48 的狀況下,任一指令最少會被延遲 96ms 執行。更改這兩個參數,例如在 delay_time = 60, bucket_size = 32 的狀況下,任一指令最少會被延遲 64ms 執行。

計時器引起的血案

整個看下來,咱們的框架在運行的時候須要有一個精確的計時器。在固定的 interval 下執行 bucket 的廣播。理所固然地,咱們首先想到了使用setInterval(),然而下一秒咱們就意識到這個想法有多麼的不靠譜:調皮的setInterval() 彷佛有很是嚴重的偏差。並且要命的是,每一次的偏差都會累計起來,形成愈來愈嚴重的後果。

因而咱們立刻又想到了使用 setTimeout(),經過動態地修正下一次到時的時間來讓咱們的邏輯大體穩定在規定的 interval 左右。例如這次setTimeout()比預期少了5ms, 那麼咱們下一次就讓他提早5ms. 不過測試結果不盡人意,並且這怎麼看都不夠優雅。

因此咱們又要換一個思路。是否可讓 setTimeout() 儘量快地到期,而後咱們檢查當前的時間是否到達目標時間。例如在咱們的循環中,使用setTimeout(callback, 1) 來不停地檢查時間,這看起來像是一個不錯的主意。

使人失望的計時器

咱們當即寫了一段代碼來測試咱們的想法,結果使人失望。在目前最新的 node.js 穩定版(v0.10.32)以及 Windows 平臺下,運行這樣一段代碼:

var sum = 0, count = 0; 
function test() { 
  var now = Date.now(); 
  setTimeout(function () { 
    var diff = Date.now() - now; 
    sum += diff; 
    count++; 
    test(); 
  }); 
} 

test();

一段時間以後在控制檯裏輸入 sum/count,能夠看到一個結果,相似於:

> sum / count 
15.624555160142348

什麼?!!我要 1ms 的間隔時間,你卻告訴我實際的平均間隔爲 15.625ms!這個畫面簡直是太美。咱們在 mac 上作一樣的測試,獲得的結果是 1.4ms。因而咱們心生疑惑:這究竟是什麼鬼?若是我是一個果粉,我可能就要得出 Windows 太垃圾而後放棄 Windows 的結論了,不過好在我是一名嚴謹的前端工程師,因而我開始繼續思索起這個數字來。

等等,這個數字爲何那麼眼熟?15.625ms 這個數字會不會太像 Windows 下的最大計時器間隔了?當即下載了一個 ClockRes 進行測試,控制檯一跑果真獲得了以下結果:

Maximum timer interval: 15.625 ms 
Minimum timer interval: 0.500 ms 
Current timer interval: 1.001 ms

果不其然!查閱 node.js 的手冊咱們能看到這樣一段對 setTimeout 的描述:

The actual delay depends on external factors like OS timer granularity and system load.

然而測試結果顯示,這個實際延遲是最大計時器間隔(注意此時系統的當前計時器間隔只有 1.001ms),不管如何讓人沒法接受,強大的好奇心驅使咱們翻翻看 node.js 的源碼來一窺究竟。

Node.js 中的 BUG

相信大部分你我都對 Node.js 的 even loop 機制有必定的瞭解,查看 timer 實現的源碼咱們能夠大體瞭解到 timer 的實現原理,讓咱們從 event loop 的主循環講起:

while (r != 0 && loop->stop_flag == 0) { 
    /* 更新全局時間 */ 
    uv_update_time(loop); 
    /* 檢查計時器是否到期,並執行對應計時器回調 */ 
    uv_process_timers(loop); 

    /* Call idle callbacks if nothing to do. */ 
    if (loop->pending_reqs_tail == NULL && 
        loop->endgame_handles == NULL) { 
      /* 防止event loop退出 */ 
      uv_idle_invoke(loop); 
    } 

    uv_process_reqs(loop); 
    uv_process_endgames(loop); 

    uv_prepare_invoke(loop); 

    /* 收集 IO 事件 */ 
    (*poll)(loop, loop->idle_handles == NULL && 
                  loop->pending_reqs_tail == NULL && 
                  loop->endgame_handles == NULL && 
                  !loop->stop_flag && 
                  (loop->active_handles > 0 || 
                   !ngx_queue_empty(&loop->active_reqs)) && 
                  !(mode & UV_RUN_NOWAIT)); 
    /* setImmediate() 等 */ 
    uv_check_invoke(loop); 
    r = uv__loop_alive(loop); 
    if (mode & (UV_RUN_ONCE | UV_RUN_NOWAIT)) 
      break; 
  }

其中 uv_update_time 函數的源碼以下:(https://github.com/joyent/libuv/blob/v0.10/src/win/timer.c

void uv_update_time(uv_loop_t* loop) { 
  /* 獲取當前系統時間 */ 
  DWORD ticks = GetTickCount(); 

  /* The assumption is made that LARGE_INTEGER.QuadPart has the same type */ 
  /* loop->time, which happens to be. Is there any way to assert this? */ 
  LARGE_INTEGER* time = (LARGE_INTEGER*) &loop->time; 

  /* If the timer has wrapped, add 1 to it's high-order dword. */ 
  /* uv_poll must make sure that the timer can never overflow more than */ 
  /* once between two subsequent uv_update_time calls. */ 
  if (ticks < time->LowPart) { 
    time->HighPart += 1; 
  } 
  time->LowPart = ticks; 
}

該函數的內部實現,使用了 Windows 的 GetTickCount() 函數來設置當前時間。簡單地來講,在調用setTimeout 函數以後,通過一系列的掙扎,內部的 timer->due 會被設置爲當前 loop 的時間 + timeout。在 event loop 中,先經過 uv_update_time 更新當前 loop 的時間,而後在uv_process_timers 中檢查是否有計時器到期,若是有就進入 JavaScript 的世界。通篇讀下來,event loop大概是這樣一個流程:

  1. 更新全局時間
  2. 檢查定時器,若是有定時器過時,執行回調
  3. 檢查 reqs 隊列,執行正在等待的請求
  4. 進入 poll 函數,收集 IO 事件,若是有 IO 事件到來,將相應的處理函數添加到 reqs 隊列中,以便在下一次 event loop 中執行。在 poll 函數內部,調用了一個系統方法來收集 IO 事件。這個方法會使得進程阻塞,直到有 IO 事件到來或者到達設定好的超時時間。調用這個方法時,超時時間設定爲最近的一個 timer 到期的時間。意思就是阻塞收集 IO 事件,最大阻塞時間爲 下一個 timer 的到底時間。

Windows下 poll 函數之一的源碼:

static void uv_poll(uv_loop_t* loop, int block) { 
  DWORD bytes, timeout; 
  ULONG_PTR key; 
  OVERLAPPED* overlapped; 
  uv_req_t* req; 

  if (block) { 
    /* 取出最近的一個計時器的過時時間 */ 
    timeout = uv_get_poll_timeout(loop); 
  } else { 
    timeout = 0; 
  } 

  GetQueuedCompletionStatus(loop->iocp, 
                            &bytes, 
                            &key, 
                            &overlapped, 
                            /* 最多阻塞到下個計時器到期 */ 
                            timeout); 

  if (overlapped) { 
    /* Package was dequeued */ 
    req = uv_overlapped_to_req(overlapped); 
    /* 把 IO 事件插入隊列裏 */ 
    uv_insert_pending_req(loop, req); 
  } else if (GetLastError() != WAIT_TIMEOUT) { 
    /* Serious error */ 
    uv_fatal_error(GetLastError(), "GetQueuedCompletionStatus"); 
  } 
}

按照上述步驟,假設咱們設置了一個 timeout = 1ms 的計時器,poll 函數會最多阻塞 1ms 以後恢復(若是期間沒有任何 IO 事件)。在繼續進入 event loop 循環的時候, uv_update_time 就會更新時間,而後uv_process_timers 發現咱們的計時器到期,執行回調。因此初步的分析是,要麼是uv_update_time 出了問題(沒有正確地更新當前時間),要麼是 poll 函數等待 1ms 以後恢復,這個 1ms 的等待出了問題。

查閱 MSDN,咱們驚人地發現對 GetTickCount 函數的描述:

The resolution of the GetTickCount function is limited to the resolution of the system timer, which is typically in the range of 10 milliseconds to 16 milliseconds.

GetTickCount 的精度是如此的粗糙!假設 poll 函數正確地阻塞了 1ms 的時間,然而下一次執行uv_update_time 的時候並無正確地更新當前 loop 的時間!因此咱們的定時器沒有被斷定爲過時,因而 poll 又等待了 1ms,又進入了下一次 event loop。直到終於 GetTickCount 正確地更新了(所謂15.625ms更新一次),loop 的當前時間被更新,咱們的計時器纔在 uv_process_timers 裏被斷定過時。

向 WebKit 求助

Node.js 的這段源碼看得人很無助:他使用了一個精度低下的時間函數,並且沒有作任何處理。不過咱們馬上想到了既然咱們使用 Node-WebKit,那麼除了 Node.js 的 setTimeout,咱們還有 Chromium 的 setTimeout。寫一段測試代碼,用咱們的瀏覽器或者 Node-WebKit 跑一下:http://marks.lrednight.com/test.html#1 (#後面跟的數字表示須要測定的間隔),結果以下圖:

clipboard.png

按照 HTML5 的規範,理論結果應該是前5次結果是1ms,之後的結果是4ms。測試用例中顯示的結果是從第3次開始的,也就是說表上的數據理論上應該是前3次都是1ms,以後的結果都是4ms。結果有必定的偏差,並且根據規定,咱們能拿到的最小的理論結果是4ms。雖然咱們不知足,但顯然這比 node.js 的結果讓咱們滿意多了。強大的好奇心趨勢咱們看看 Chromium 的源碼,看看他是如何實現的。(https://chromium.googlesource.com/chromium/src.git/+/38.0.2125.101/base/time/time_win.cc)

首先,在肯定 loop 的當前時間方面,Chromium 使用了 timeGetTime() 函數。查閱 MSDN 能夠發現這個函數的精度受系統當前 timer interval 影響。在咱們的測試機上,理論上也就是上文中提到過的 1.001ms。然而 Windows 系統默認狀況下,timer interval 是其最大值(測試機上也就是 15.625ms),除非應用程序修改了全局 timer interval。

若是你關注 IT界的新聞,你必定看過這樣的一條新聞。看起來咱們的 Chromium 把計時器間隔設定得很小了嘛!看來咱們不用擔憂系統計時器間隔的問題了?不要開心得太早,這樣的一條修復給了咱們當頭一棒。事實上,這個問題在 Chrome 38 中已經獲得了修復。難道咱們要使用修復之前的 Node-WebKit?這顯然不夠優雅,並且阻止了咱們使用性能更高的 Chromium 版本。

進一步查看 Chromium 源碼咱們能夠發現,在有計時器,且計時器的 timeout < 32ms 時,Chromium 會更改系統的全局定時器間隔以實現小於 15.625ms 精度的計時器。(查看源碼) 啓動計時器時,一個叫HighResolutionTimerManager 的東西會被啓用,這個類會根據當前設備的電源類型,調用EnableHighResolutionTimer 函數。具體來講,當前設備用電池時,他會調用EnableHighResolutionTimer(false),而使用電源時會傳入 true。EnableHighResolutionTimer 函數的實現以下:

void Time::EnableHighResolutionTimer(bool enable) { 
  base::AutoLock lock(g_high_res_lock.Get()); 
  if (g_high_res_timer_enabled == enable) 
    return; 
  g_high_res_timer_enabled = enable; 
  if (!g_high_res_timer_count) 
    return; 
  // Since g_high_res_timer_count != 0, an ActivateHighResolutionTimer(true) 
  // was called which called timeBeginPeriod with g_high_res_timer_enabled 
  // with a value which is the opposite of |enable|. With that information we 
  // call timeEndPeriod with the same value used in timeBeginPeriod and 
  // therefore undo the period effect. 
  if (enable) { 
    timeEndPeriod(kMinTimerIntervalLowResMs); 
    timeBeginPeriod(kMinTimerIntervalHighResMs); 
  } else { 
    timeEndPeriod(kMinTimerIntervalHighResMs); 
    timeBeginPeriod(kMinTimerIntervalLowResMs); 
  } 
}

其中,kMinTimerIntervalLowResMs = 4,kMinTimerIntervalHighResMs = 1。timeBeginPeriod 以及timeEndPeriod 是 Windows 提供的用來修改系統 timer interval 的函數。也就是說在接電源時,咱們能拿到的最小的 timer interval 是1ms,而使用電池時,是4ms。因爲咱們的循環不斷地調用了 setTimeout,根據 W3C 規範,最小的間隔也是 4ms,因此鬆口氣,這個對咱們的影響不大。

又一個精度問題

回到開頭,咱們發現測試結果顯示,setTimeout 的間隔並非穩定在 4ms 的,而是在不斷地波動。而http://marks.lrednight.com/test.html#48 測試結果也顯示,間隔在 48ms 和 49ms 之間跳動。緣由是,在 Chromium 和 Node.js 的 event loop 中,等待 IO 事件的那個 Windows 函數調用的精度,受當前系統的計時器影響。遊戲邏輯的實現須要用到 requestAnimationFrame 函數(不停更新畫布),這個函數能夠幫咱們將計時器間隔至少設置爲 kMinTimerIntervalLowResMs(由於他須要一個16ms的計時器,觸發了高精度計時器的要求)。測試機使用電源的時候,系統的 timer interval 是 1ms,因此測試結果有 ±1ms 的偏差。若是你的電腦沒有被更改系統計時器間隔,運行上面那個#48的測試,max可能會到達48+16=64ms。

使用 Chromium 的 setTimeout 實現,咱們能夠將 setTimeout(fn, 1) 的偏差控制在 4ms 左右,而 setTimeout(fn, 48) 的偏差能夠控制在 1ms 左右。因而,咱們的心中有了一幅新的藍圖,它讓咱們的代碼看起來像是這樣:

/* Get the max interval deviation */ 
var deviation = getMaxIntervalDeviation(bucketSize); // bucketSsize = 48, deviation = 2; 
function gameLoop() { 
  var now = Date.now(); 
  if (previousBucket + bucketSize <= now) { 
    previousBucket = now; 

    doLogic(); 
  } 

  if (previousBucket + bucketSize - Date.now() > deviation) { 
    // Wait 46ms. The actual delay is less than 48ms. 
    setTimeout(gameLoop, bucketSize - deviation); 
  } else { 
    // Busy waiting. Use setImmediate instead of process.nextTick because the former does not block IO events. 
    setImmediate(gameLoop); 
  } 
}

上面的代碼讓咱們等待一個偏差小於 bucket_size( bucket_size – deviation) 的時間而不是直接等於一個 bucket_size,46ms 的 delay 即使發生了最大的偏差,根據上文的理論,實際間隔也是小於48ms的。剩下的時間咱們使用忙等待的方法,確保咱們的 gameLoop 在足夠精確的 interval 下執行。

雖然咱們利用 Chromium 在必定程度上解決了問題,但這顯然不夠優雅。

還記得咱們最初的要求嗎?咱們的服務器端代碼是應該能夠脫離 Node-Webkit 客戶端的,直接在一臺有 Node.js 環境的電腦中運行。若是直接跑上面的代碼,deviation 的值至少是16ms,也就是說在每個48ms中,咱們要忙等待16ms的時間。CPU使用率蹭蹭蹭就上去了。

意想不到的驚喜

真是氣人啊,Node.js 裏這麼大的一個BUG,沒有人注意到嗎?答案真是讓咱們喜出望外。這個BUG在 v.0.11.3 版本里已經獲得了修復。直接查看 libuv 代碼的 master 分支也能看到修改後的結果。具體的作法是,在 poll 函數等待完成以後,把 loop 的當前時間,加上一個 timeout。這樣即使 GetTickCount 沒有反應過來,在通過poll的等待以後,咱們仍是加上了這段等待的時間。因而計時器就可以順利地到期了。

也就是說,辛苦半天的問題,在 v.0.11.3 裏已經獲得瞭解決。不過,咱們的努力不是白費的。由於即使消除了 GetTickCount 函數的影響,poll 函數自己也受到系統定時器的影響。解決方案之一,即是編寫 Node.js 插件,更改系統定時器的間隔。

不過咱們此次的遊戲,初步設定是沒有服務器的。客戶端創建房間以後,就成爲了一個服務器。服務器代碼能夠跑在 Node-WebKit 的環境中,因此 Windows 系統下計時器的問題的優先級並非最高的。按照上文中咱們給出的解決方案,結果已經足夠讓咱們滿意。

收尾

解決了計時器的問題,咱們的框架實現也就基本上再沒什麼阻礙了。咱們提供了 WebSocket 的支持(在純 HTML5 環境下),也自定義了通訊協議實現了性能更高的 Socket 支持(Node-WebKit 環境下)。固然,Spaceroom 的功能在最初是比較簡陋的,但隨着需求的提出和時間的增長,咱們也在逐漸地完善這個框架。

例如咱們發如今咱們的遊戲裏須要生成一致的隨機數的時候,咱們就爲 Spaceroom 加上了這樣的功能。在遊戲開始的時候 Spaceroom 會分發隨機數種子,客戶端的 Spaceroom 提供了利用 md5 的隨機性,藉助隨機數種子生成隨機數的方法。

So far so good. 看起來仍是蠻欣慰的。在編寫這樣一個框架的過程中,也學到了不少的東西。若是你對 Spaceroom 有點興趣,也能夠參與到它當中來。相信,Spaceroom 會在更多的地方施展它的拳腳。

相關文章
相關標籤/搜索