摘要:纵观过去 10 年的游戏领域,单机向网络发展已成为一个非常大的趋势。然而,为游戏添加网络支持的过程中往往存在着大量挑战,这里将为大家揭示游戏引擎网络开发者的 64 个做与不做。

【编者按】时下,游戏网络化已势不可逆,因此,对于游戏开发者来说,掌握网络引擎的打造技巧同样不可避免。近日,Research Industrial Systems Engineering GmbH 安全研究员 Sergey Ignatchenko「拥有 20 年以上的工程经验」在 IT Hare 上撰文,深入分享了游戏引擎网络开发的相关经验,由 OneAPM 工程师翻译。

游戏引擎网络开发者的 64 做与不做(一):客户端方面 技术分享

以下为译文:

纵观过去 10 年的游戏领域,单机向网络发展已成为一个非常大的趋势。然而,为游戏添加网络支持的过程中往往存在着大量挑战,而据近几年的工作经验「不仅参与了这一衍变,同样也为大量开发者提供资讯支持」来看,许多游戏开发者甚至都违反了「打造一个优秀网络应用程序」应该坚守的一些基本原则。因此,应用程序往往会面临着「frozen」UIs、莫名的断线「在其他程序互联网访问正常时」、不定期崩溃,以及峰值期间的服务器过载等问题。毫无疑问,这些问题将直接影响到玩家的游戏体验,同时其直接程度也远超管理员和图形开发者的想象。庆幸的是,这些问题处理起来并不复杂,有些甚至是一点就明。

因此,这里将通过一系列博文来布道网络开发的某些理念,其中大部分是游戏引擎开发者不曾留意的。当然对于某些朋友来说,有些观点可能你已经接触到了,但毫无疑问的是,它对大量游戏开发者都是有价值的。因此,对于期望打造出类似游戏或证券交易这类高交互应用程序的开发者,这些建议值得一读。

作为系列的第一篇文章,这里将着重讨论不涉及协议的客户端应用程序网络开发。本系列文章将包括:

  • Protocols and APIs
  • Protocols and APIs 「continued」
  • Server-Side 「Store-Process-and-Forward Architecture」
  • Server-Side 「deployment, optimizations, and testing」
  • Great TCP-vs-UDP Debate
  • UDP
  • TCP
  • Security 「TLS/SSL」
  • ……

0. 范围确定

总的来说,游戏引擎网络支持是个非常大的主题,因此本系列博文将圈定一个范围——聚焦拥有客户端应用程序的游戏,而不是那些基于 browser-/AJAX 的游戏,虽然这两种游戏在设计上有着很多共同点,但是其中的区别也足够让讨论分开。本系列博文将尝试覆盖游戏网络层开发的常见理念:

首先,不会只聚焦某种类型的游戏,比如 MMORPGs;毫无疑问, MMORPGs 确实在讨论的范畴中,但是也不乏社交游戏、多玩家战略「包括实时和回合制」、赌博类游戏、证券交易型等等。而出人意料的是,在做网络支持时,这些游戏存在着大量的共同点。「尽管许多取决于时间控制问题,这点将在 Great TCP-vs-UDP Debate 一节详述」。

其次,同样不会限制到某个特定的平台:事实上,这里更推荐开发者写跨平台引擎,其中就包含了网络引擎。在实践中,笔者也曾写过一个网络引擎,它可以在 5 个以上完全不同的平台上运行,这点将在第六条中进行详述。

再次,因为基于游戏引擎开发者的视角,所以这里有个背景是游戏开发者经常需要为他们的游戏开发游戏引擎。在这个情况下,大多数建议都是适用的。

最后,虽然类似「哪个引擎或者网络引擎是最好的?」这样的问题已经超出了讨论的范畴,但是本系列博文同样对回答这个问题有所帮助;毫无疑问,答案取决于游戏的具体需求,因此请详细阅读。换句话说:如果你的游戏引擎或者框架提供了一个支撑网络的方式,这些博文可以作为一个工具对其进行考量,从而弄清其网络实现是否对特定的游戏有益。

OK,在交代完大致的讨论方向后,下面言归正传。

1. 在客户端请使用事件驱动编程模型

当下,大多数客户端 UI 框架都包含一个所谓的「main thread」,或者叫「main loop」,运行于「main thread」之中,而这个「main thread」本质上会处理一些特定的事件「最原始的是 UI 事件」。这种模型存在所有客户端框架之中,从 Windows GUI、Direct X 和 Cocoa,到 Unity 3D、Android 和 iOS。同时,也确实有一个很好的理由来驱动大家这么做:因为其他的编程模型只能给你带来噩梦。事实上,在实际工作中,笔者也只碰到了一个「出格」的框架,即最初 Java 的 AWT,而在 AWT 中编写 APP 的痛苦也众所周知,有鉴于此,AWT 自始至终也没有流行起来;实际上,谷歌也确实需要为 Android 开发新的 GUI 框架。

那么,在给应用程序添加网络支持后,事件驱动模型究竟应该如何转变?其实,这里并不需要任何改变。实际生产中,所有游戏网络通信逻辑都由消息发送和接收构成;而每一个接收到的网络消息都应该被作为游戏事件驱动逻辑「除去传统的 UI 事件,比如鼠标和键盘输入」的另一个事件。

通常情况下,这个操作可以通过给 main thread 的「message queue」注入一条 message 轻松实现。举个例子,在 Win32 中,这个操作通常由 PostMessage()或者 PostThreadMessage()方法完成。如果你选择的图形框架不支持这个理念,你可能需要通过建立你的队列并进行轮询进行模拟「举个例子,Unity3D2012」。对比在单线程中强制处理所有事件「同时包含 UI 事件和网络消息」,将事件作为数据(win32)还是回调这样的问题并不重要。NB:如果使用 Unity,这个技巧很少会用到,因为 Unity 内置的网络「已经使用了 Unity 的事件处理线程」非常适用于「实时世界模拟real-time world simulator」游戏;然而根据具体游戏特征,使用 Unity 网络做 UDP 传输也并不一定就是最好的途径——特别是那些与实时世界模拟无关的游戏。

在有些用例中,事件处理线程可能与选择框架的「main thread」相去甚远,但是这里需要谨记的是,将所有与逻辑相关事件处理都放到同一个单线程中。然而,纯通信相关「与游戏逻辑完全无关),比如 marshalling、en/decryption 和 (de)compres,尽可能在「main thread」外部处理,在下面的第 3 条中会详细讨论线程隔离问题。

2. 别在事件处理线程之外调用应用程序级别回调

犹记那年,笔者还「很傻很天真」,那时候负责给一个证券交易业务开发网络框架「PS:别问我为什么这么重要的一个任务会交给一个没经验的工程师,笔者同样无解」。开始的时候,新网络库编写的确实比较顺利,但是在这里,笔者同样犯了一个原则错误——在应用程序层面调用了一个回调「它本应该是 1 个回调来响应 sendMessageOverTheNetworkAndCallbackOnReply()-style 函数」。这个蹩脚的错误曾一度给后续使用这个框架的同仁带去了大量麻烦。首先,交互「以及潜在的 races」让使用它的同事难以理解。其次,给 bugs 和 races 追踪带来了大量麻烦。最后,虽然并没有太坏的影响,而且框架总体运行良好,但是如果没有这个回调,开发将变得更加平顺。

数年后,笔者一直为大型多玩家游戏开发网络引擎——同时在线玩家 50 万,日消息数 5 亿条。而在吸取了之前的经验后,避免了类似线程回调,所有的工作都井井有条,同时在多平台切换上也异常平顺。

总结:如果你需要从网络层实现一个回调到应用程序层,首先你需要将事件传递给事件处理线程「通常情况下就是 main thread」,随后通过网络层库调用「发源于事件处理线程」来处理事件,并在必要时调用应用程序级回调。换句话说,下面才是一个完善的途径:

network thread –inter-thread-communication –event-processing thread –network-library-call –application-callback –no-thread-sync-needed

而下面虽然可行,但是不利于他人长期使用:

network thread –network-library-call –application-callback –thread-sync-required

在完善的途径中,回调只存在事件处理线程环境中,这将显著简化应用程序开发。所有应用程序级处理都被严格确定,从而最大程度地减少 races 出现的可能,同时也减少了应用程序级所必要的同步。上面的过程听起来可能比较笨重,操作起来也有些繁琐,但是它可以切实地减少游戏开发者的后续麻烦。

3. 别从事件处理线程调用可能阻塞网络的函数

这是网络开发者所有可以提交中影响最大的错误之一。如上文所述,你需要在一个单独的线程中处理所有事件。这种操作得当且方便,但麻烦也因此产生,比如:在一个事件处理器中做一个简单如 gethostbyname() 的调用,这个操作在小范围中不会存在任何问题,但是在有些情况中现实世界的玩家却会因此阻塞数分钟之久!如果你在 GUI 线程中调用这样一个函数,对于用户来说,在函数阻塞时,GUI 一直都处于 frozen 或者 hanged 状态,这从用户体验的角度是绝对不允许的。

因此,通过 GUI 来做网络交互时所有函数都应该是非阻塞的,或者位于不同的线程中。在这种情况下,你需要让事件状态机更加复杂,你可以效率地取得类似「waiting for DNS resolution」这样的状态,同时它还需要可以避免「frozen」GUI ,并且可以让你处理网络延时,包括:

  • 在需要时通知用户。举个例子,在等待了 1 秒或者 5 秒后,你知道这里出现了问题,用户同样需要知道这个事情。因此,你最好让用户了解到你已经发现了这个问题,并着手处理。
  • 在需要时终止操作并重试。
  • 允许用户优雅地终止请求或应用程序,而不是逼迫他们去使用任务管理器。

需要注意的是,这点看起来似乎与第一条和第二条相违背,但事实上并不是这样。对于「hey, so should I do it single-threaded or multi-threaded?」这样的问题,答案是:系统级别网络调用要么是非阻塞的,要么是来自非事件处理线程;同时,所有事件处理必须在事件处理线程中完成。这就意味着,如果使用多线程,你需要在一个非事件处理的网络处理线程中调用类似阻塞 recv() 的函数,随后将调用的结果转换为一个事件,并通过队列的形式「如上文 1 中介绍」将这个事件传递给事件处理线程。严格来讲,decryption/decompression 就要进行这样的处理,虽然需要去做避免事件处理线程成为一个瓶颈的流程,但它通常比只将 encryption/compression 扔到网络处理线程中来得更有性价比。

网络线程的另一个替代是 non-blocking IO,这里同样存在一些需要注意的地方,包括 gethostbyname() 和 getaddrinfo() 在主流平台中并不存在 non-blocking IO 版本,同时笔者也不认为在客户端使用 non-blocking 带来的麻烦会更少。服务器端将是另一种情景,详情会在系列博文的第三部分服务器端讨论。

4. 不要将用户作为免费的错误处理程序

在游戏引擎开发中,很多开发者使用了一个异常简单的网络错误处理途径。也就是,他们简单的将错误抛到用户面前,只留下一句「服务器存在一点问题,请重试」。这个做法是非常讨厌的,并且不会带来任何效果「当然轻松了开发者,但是损害了用户」。除下开发人员太懒,不存在任何理由不将问题在内部解决。在问题产生并给用户提示后,没理由不自动重试而要求用户再次操作。为了通知这个问题,你可以在屏幕的显著位置进行显示,或者是弹出一个对话框「没有ok这个按钮,只有关闭」,同时将在问题解决后自动消失。这样一来,在问题产生用户离开后,如果你能短时间解决问题,你不会对用户体验产生任何影响。

有人可能争论不停重试会造成网络阻塞,但是作为一名开发者,你有责任让用户体验变得简单。当然,你也可以设置一个临界值,比如 5 分钟来关闭重试,并提示「对不起,我们已经尽力了,但问题在短时间内无法得到修复」。

综合上面的 1-3 条,你通常需要在网络处理线程中检测问题,并将它转换成 1 个事件,并在事件处理线程中处理事件,比如显示一个对话框。

5. 为用户提供有价值的错误消息

从终端用户的角度来看,「网络不可用」、「连接拒绝」以及「连接终止」没有任何区别;如果可能的话,你可能是想告诉他们网线未插入或者是服务器故障或者是两者之间的一些问题,但是仅仅因为一些专业用语让用户无法确定问题真相是完全不可取的。更糟糕的做法是,试图将技术细节隐藏于一些模棱两可的话语之间,比如「服务器有一点问题」和「你丢失了连接」。

总之,切记将错误消息从你能理解的语句转换到用户能理解的提示,而不是让用户无法辨别各种提示间的区别——让所有消息看起来完全相同。

6. 支持多平台

纵观当下游戏领域,单平台游戏已经不再有吸引力。即使引擎只为一款游戏打造,但是你又真的能确定游戏未来不会过渡到其他平台?实践中,让网络代码跨平台并不是一件难事,因此你没理由不做多平台的准备。笔者个人的网络库就覆盖了 Windows、Linux、Mac OS X、FreeBSD、iOS 等引擎。

6a. 在客户端使用 Berkeley Sockets

如果你的游戏引擎是基于 C 或者 C++,并且将应用程序定义为只 Windows 平台,那么就可能尝试一些 Windows 特有的函数「那些以 WSA*()为前缀的」来通信。请不要这么做,转而使用 Berkeley sockets「那些 socket()/connect()/send()/recv()函数」进行取代;关于使用细节,请自行 Google。对于其他提供了跨平台 APIs 的编程语言,选择一个合适的网络库通常不会有太多问题。

7. 提供一个自动升级 APP 的途径

通常情况下,自动升级并不会考虑为游戏引擎的一部分。然而,个人觉得将自动升级纳入网络层会有一些相应的好处,其原因是:

  • 用户可能期望多一些选择「从主题到 DLCs」。
  • 如果可以边玩边下载,那么他们会很开心。
  • 如果下载干涉到娱乐,那他们肯定会不开心。
  • 在你的网络层提供可选下载,你可以优先考虑流量,将下载对游戏的影响降到最低「在本系列博文的 Part IIb 一节将讨论更多技巧」。因为 QoS 并不适用于互联网,两个并行的连接很可能产生相互影响。
  • 如果你支持可选下载,他们同样需要自动化更新,因此结合自动下载和自动更新是件不错的事情。
  • 因此,在网络引擎实现整个自动更新功能是个不错的事情。
  • 此外,用户可以边玩边下载,从而最大化了娱乐时间。
  • 上面的推理并不是在任何条件下都成立的,但是我看到了类似系统的实现,同时也取得了非常好的效果。

注意:尽管与网络库集成,你同样需要在 HTTP「而不是基于你的协议」上实现初始自动更新「会在游戏应用启动前启动」;这么操作并不会带来太多的复杂性,但是很可能会彻底地修改你的协议。

其他在启动更新上的操作是非常复杂的,因此会单独开一篇文章来表述、

To Be Continued……

原文链接:64 Network DO’s and DON’Ts for Game Engine Developers. Part I: Client Side

本文系 OneAPM 工程师编译整理。OneAPM应用性能管理领域的新兴领军企业,能帮助企业用户和开发者轻松实现:缓慢的程序代码和 SQL 语句的实时抓取。想阅读更多技术文章,请访问 OneAPM 官方博客