Download PDF version of this article PDF

大型多人在线中间件
MICHI HENNING,ZeroC

为超大规模在线游戏构建可扩展的中间件教会了我们一个通用的道理:大项目,简单设计。

Wish 是一款由 Mutable Realms1 开发的多人在线奇幻角色扮演游戏。它与类似在线游戏的不同之处在于,它允许数万名玩家参与到同一个游戏世界中(而不是其他游戏支持的数百名玩家)。允许如此大量的玩家需要将处理负载分布到多台机器上,并引出了选择合适的分发技术的问题。

分发需求

Mutable Realms 向 ZeroC 提出了 Wish 的分发需求。ZeroC 决定开发一个全新的中间件,而不是使用现有的技术,例如 CORBA(通用对象请求代理架构)2。为了理解这种选择的动机,我们需要检查一下 Wish 和其他大型分布式应用程序对中间件提出的一些要求。

多平台支持。 在线游戏市场的主导平台是 Microsoft Windows,因此中间件必须支持 Windows。对于服务器端,Mutable Realms 早期就决定使用 Linux 机器:该平台的低成本,加上其可靠性和丰富的工具支持,使其成为显而易见的选择。因此,中间件必须同时支持 Windows 和 Linux,并可能在以后支持 Mac OS X 和其他 Unix 变体。

多语言支持。 客户端和服务器软件是用 Java 以及 C++ 和汇编语言的组合编写的,用于对性能至关重要的功能。在 ZeroC,我们使用 Java 是因为我们的一些开发人员几乎没有 C++ 经验。Java 在缺陷计数和开发时间方面也具有优势;特别是,垃圾回收消除了经常困扰 C++ 开发的内存管理错误。为了通过 Web 管理游戏,我们希望使用 PHP 超文本处理器。因此,游戏中间件必须支持 C++、Java 和 PHP。

传输和协议支持。 当我们为游戏开发最初的分发架构时,我们清楚地意识到我们在底层传输和协议方面面临某些要求

玩家通过电话线以及宽带连接连接到 ISP。 虽然宽带正变得越来越普及,但我们早期就决定游戏必须可以通过普通调制解调器进行游戏。这意味着客户端和服务器之间的通信必须可以通过低带宽和高延迟的链路进行。

游戏的很大一部分是事件驱动的。 例如,当一个玩家移动时,同一区域的其他玩家需要被告知他们周围游戏世界的变化。这些变化可以作为简单的事件分发,例如“玩家 A 移动到新坐标 <x,y>”。

理想情况下,事件通过“数据报”分发。如果偶尔的状态更新丢失,则不会造成太大损害:丢失的事件会导致特定观察者对游戏世界的视图暂时落后,但是当另一个事件成功传递时,该视图会在很短的时间内再次更新。

• 游戏中事件通常有多个目的地。例如,如果一个玩家在五个其他玩家的视野范围内移动,则必须将相同的位置更新发送给所有五个观察玩家。我们希望能够使用广播或多播来支持这种情况。

• 客户端和游戏服务器之间的通信必须是安全的。对于基于在线订阅的游戏,这对于收入收取以及防止作弊都是必要的。(例如,玩家必须不可能通过操纵客户端软件来获得强大的神器。)

• 客户端从位于防火墙后并使用 NAT(网络地址转换)的 LAN 连接到游戏。游戏通信协议的设计方式必须能够适应 NAT,而无需应用程序特定信息的知识即可转换地址。

版本控制支持。 我们希望能够在游戏进行时更新游戏世界——例如,添加新物品或任务。这些更新必须是可能的,而无需立即升级每个已部署的客户端——也就是说,旧版本级别的客户端软件必须继续与更新后的游戏服务器一起工作(尽管无法访问新添加的功能)。这意味着类型系统必须足够灵活,以允许更新,例如向结构添加字段或更改方法的签名,而不会破坏已部署的客户端。

易用性。 尽管少数 Wish 游戏开发人员是分布式计算专家,但大多数人几乎没有或根本没有经验。这意味着中间件必须易于非专家使用,并具有简单、线程安全且异常安全的 API(应用程序编程接口)。

持久性。 游戏的很大一部分需要状态,例如每个玩家的库存,存储在数据库中。我们希望为开发人员提供一种存储和检索应用程序对象的持久状态的方法,而无需担心实际的数据库,也无需设计数据库架构。特别是在开发期间,随着游戏的演变,重复重新设计架构以适应更改会非常耗时。此外,随着我们在部署时改进游戏,我们必须向数据库添加新功能并从中删除旧功能。我们希望有一种自动方法可以将现有的、已填充的数据库迁移到新的数据库架构,而不会丢失旧数据库中仍然有效的任何信息。

线程。 大部分服务器端处理都是 I/O 绑定的:数据库和网络访问迫使服务器等待 I/O 完成。其他任务(例如寻路)是计算绑定的,最好使用并行算法来支持。这意味着中间件必须是固有线程化的,并为开发人员提供足够的线程策略控制,以实现并行算法,同时防止线程饥饿和死锁等问题。考虑到不同操作系统上线程的特殊性,我们还希望有一个具有可移植 API 的平台中立的线程模型。

可扩展性。 显然,中间件最严峻的挑战在于可扩展性领域:对于在线游戏,不可能预测现实的限制,例如订阅者总数或并发玩家数。这意味着我们需要一个可以通过联合服务器(即添加更多服务器)来扩展架构,以应对软件需求的增长。

我们还需要容错能力:例如,将服务器升级到较新版本的游戏软件必须是可能的,而不会将当前正在使用该服务器的每个玩家都踢出去。中间件必须能够在原始服务器正在升级时自动使用副本服务器。

其他可扩展性问题与资源管理有关。例如,我们不希望受到硬编码限制的约束,例如最大打开连接数或实例化对象数。这意味着,在可能的情况下,中间件必须提供不受任意限制且易于使用的自动化资源管理功能。同时,这些功能必须为开发人员提供足够的控制,以根据他们的需求调整资源管理。在可能的情况下,我们希望能够在无需重新编译的情况下更改资源管理策略。

分布式多人游戏的常见可扩展性问题与管理分布式对象集有关。游戏可能允许玩家组成公会,但须遵守某些规则:例如,一个玩家可能不能是多个公会的成员,或者一个公会最多只能有一个 5 级法师(魔法师)。在计算术语中,实现这种行为归结为对分布式对象集执行成员资格测试。高效实现此类集合操作需要一个对象模型,该模型不会为每个测试产生远程消息的成本。换句话说,对象的对象标识必须始终可见,并且必须具有总顺序。

在经典的 RPC(远程过程调用)系统中,对象实现在服务器中,客户端向对象发送远程消息:所有对象行为都在服务器上,客户端只调用行为,而不实现它。尽管这种方法很有吸引力,因为它自然地将本地过程调用的概念扩展到分布式场景,但它会导致重大问题

• 发送远程消息比发送本地消息慢几个数量级。减少网络流量的一种显而易见的方法是创建“胖” RPC:尽可能多的数据与每次调用一起发送,以便更好地分摊上线成本。胖 RPC 的缺点是性能考虑会干扰对象建模:虽然问题域可能需要具有许多操作的细粒度接口,这些操作仅交换少量状态,但良好的性能需要粗粒度接口。很难调和这种设计张力并找到合适的权衡。

• 许多对象都有行为,并且可以在玩家之间交易。然而,为了满足游戏的处理要求,我们有许多服务器(可能在不同的洲)来实现对象行为。如果行为保留在服务器中,但玩家可以交易对象,那么不久之后,玩家最终会得到一个药剂,其服务器在美国,一个卷轴,其服务器在欧洲,而药剂和卷轴都放在一个位于澳大利亚的包中。换句话说,纯粹的客户端-服务器模型不允许客户端行为和对象迁移,因此会破坏引用的局部性。

我们希望对象模型支持客户端和服务器端行为,以便我们可以迁移对象并提高引用的局部性。

设计新的中间件

查看我们的需求,我们很快意识到现有的中间件是不合适的。跨平台和多语言要求建议使用 CORBA;但是,我们中的一些人之前构建过商业对象请求代理,并从这次经验中了解到 CORBA 无法满足我们的功能和可扩展性要求。因此,我们决定开发我们自己的中间件,称为 Ice(Internet Communications Engine 的缩写)3

Ice 设计的首要重点是简单性:我们从痛苦的经验中了解到,每个功能都会付出代价,即增加代码和内存大小、更复杂的 API、更陡峭的学习曲线以及降低的性能。我们尽一切努力找到最简单的抽象(而不会将“复杂性负担”转嫁给开发人员),并且我们只有在我们确定我们绝对必须拥有这些功能后才承认这些功能。

对象模型。 Ice 将其对象模型限制为最低限度:内置数据类型仅限于有符号整数、浮点数、布尔值、Unicode 字符串和 8 位未解释(二进制)字节。用户定义的类型包括常量、枚举、结构、序列、字典和具有继承的异常。远程对象被建模为具有多重继承的接口,其中包含带有输入和输出参数以及返回值的操作。接口按引用传递——也就是说,传递接口会传递一个调用句柄,通过该句柄可以远程调用对象。

为了支持客户端行为和对象迁移,我们添加了类:类上的操作调用在客户端的地址空间中执行(而不是服务器的地址空间,接口的情况就是如此)。此外,类可以具有状态(而接口在对象建模级别始终是无状态的)。类按值传递——也就是说,传递类实例会传递类的状态,而不是远程对象的句柄。

我们没有尝试传递行为:这将需要对象的虚拟执行环境,但这将与我们的性能和多语言要求相冲突。相反,我们在所有可能的宿主位置(客户端和服务器)为类实现了相同的行为:我们没有到处运送代码,而是在需要代码的任何地方提供代码,并且只运送状态。要迁移对象,进程将类实例传递给另一个进程,然后销毁其自身的实例副本;从语义上讲,效果与迁移状态和行为相同。

从架构上讲,以这种方式实现对象迁移是一把双刃剑,因为它要求所有宿主位置实现相同(而不仅仅是相似)的行为。这会影响版本控制:如果我们在一个宿主位置更改类的行为,我们必须在所有其他位置更改该类的行为(否则会遭受不一致的行为)。多种语言也需要注意。例如,如果类实例从 C++ 服务器传递到 Java 客户端,我们必须提供具有相同行为的 C++ 和 Java 实现。(显然,这比仅用一种语言和单个服务器实现一次行为需要付出更多努力。)

对于像 Wish 这样的环境,我们控制客户端和服务器部署,这是可以接受的;对于仅提供服务器并依赖其他方提供客户端的应用程序,这可能会有问题,因为很难确保第三方类实现的相同行为。

协议设计。 为了满足我们的性能目标,我们在两个方面打破了 RPC 协议的既定智慧

• 数据在线路上未标记其类型,并尽可能紧凑地编码:编码不使用填充(所有内容都按字节对齐),并应用多种简单技术来节省带宽。例如,小于 255 的正整数需要单个字节而不是四个字节,并且字符串不是以 NUL 结尾的。与 CORBA 的 CDR(通用数据表示)编码相比,这种编码更紧凑(有时甚至紧凑两倍或更多,具体取决于数据类型)。

• 数据始终以小端字节顺序编组。我们拒绝了接收者纠正方法(如 CORBA 所使用的),因为实验表明没有可衡量的性能提升。

该协议支持压缩,以便在低速链路上获得更好的性能。(有趣的是,对于高速链路,最好禁用压缩:压缩数据比发送未压缩数据花费更多时间。)

该协议将请求数据编码为字节计数,后跟作为 blob 的有效负载。这允许消息接收者将消息转发给多个下游接收者,而无需解组和重新编组消息。避免这种成本非常重要,这样我们才能构建用于事件分发的有效消息交换机。

该协议支持 TCP/IP 和 UDP(用户数据报协议)。对于安全通信,我们使用 SSL(安全套接字层):它是免费提供的,并且安全社区已经对其缺陷进行了广泛审查。

该协议是双向的,因此服务器可以通过先前由客户端建立的连接进行回调。这对于通过防火墙进行通信非常重要,防火墙通常允许传出连接,但不允许传入连接。该协议也可以跨 NAT 边界工作。

类使协议更加复杂,因为它们是多态的:如果进程将派生实例发送给仅理解该实例基类型的接收者,则 Ice 运行时会将实例切片到接收者已知的最派生基类型。切片要求接收者解组类型未知的数据。此外,类可以是自引用的,并形成任意的节点图:给定一个起始节点,Ice 运行时会编组所有可到达的节点,因此图需要发送者执行循环检测。

切片和类图的实现非常复杂。为了支持解组,协议将类作为单独封装的切片发送,每个切片都标记有其类型。平均而言(与结构相比),这需要额外 10% 到 15% 的带宽。为了保留节点的标识关系并检测循环,编组代码创建了额外的数据结构。平均而言,这会导致 5% 到 10% 的性能损失。最后,对于 C++,我们必须编写一个垃圾回收器,以避免在存在循环类图时发生内存泄漏,这并非易事。如果没有切片和类图,协议实现会更简单,并且(对于类)速度会稍快一些。

版本控制。 对象模型支持多个接口:对象可以提供任意数量的接口,而不是只有一个最派生的接口。给定对象的句柄,客户端可以在运行时使用安全向下转型请求特定的接口。多个接口允许对对象进行版本控制,而不会破坏在线兼容性:为了创建更新的版本,我们向现有对象添加新接口。已部署的客户端继续使用旧接口工作,而新客户端可以使用新接口。

天真地使用多个接口可能会导致版本控制混乱,迫使客户端不断选择正确的版本。为了避免这些问题,我们设计了游戏,使客户端通过少量引导对象访问它,客户端为其选择接口版本。此后,客户端通过其在引导对象上选择的接口获取其他对象的句柄,因此引导对象隐式地知道所需的版本。Ice 协议提供了一种隐式传播上下文信息(例如版本控制)的机制,因此我们无需通过添加额外的版本参数来污染所有对象接口。

多个接口减少了游戏的开发时间,因为除了版本控制之外,它们还允许我们在客户端和服务器之间在类型级别使用松耦合。我们没有修改现有接口的定义,而是可以通过添加新接口来添加新功能。这减少了整个系统的依赖关系数量,并使开发人员免受彼此的更改以及经常随之而来的相关编译雪崩的影响。

不利的一面是,多个接口会导致静态类型安全性的损失,因为接口仅在运行时选择,这使得系统更容易受到可能逃避测试的潜在错误的影响。但是,如果明智地使用,多个接口在对抗传统 RPC 方法通常过分紧密的耦合方面非常有用。

易用性。 易用性是首要的设计目标。一方面,这意味着我们使运行时 API 尽可能简单和小巧。例如,29 行规范足以定义 Ice 对象适配器的 API。尽管如此,对象适配器功能齐全,并支持灵活的对象实现,例如每个对象的单独 servant、servant 到对象的一对多映射、默认 servant、servant 定位器和驱逐器。通过在设计上花费大量时间,我们不仅保持了 API 的小巧,而且还因更小的代码和工作集大小而获得了性能提升。

另一方面,我们希望语言映射简单直观。将自己限制在小型对象模型上在这里得到了回报——更少的类型意味着更少的生成代码和更小的 API。

C++ 映射尤为重要:从 CORBA,我们知道设计糟糕的映射会增加开发时间和缺陷计数,我们想要更安全的东西。我们确定了一个小的映射(记录在 40 页中),它提供了很高的便利性和安全性。特别是,该映射与 C++ 标准模板库集成,是完全线程安全的,并且不需要内存管理。开发人员永远不需要释放任何东西,并且异常不会导致内存泄漏。

我们反复遇到的语言映射问题是命名空间冲突。每种语言都有自己的一组关键字、库命名空间等等。如果(与语言无关的)对象模型使用在特定目标语言中保留的名称,我们必须绕过由此产生的冲突进行映射。此类冲突可能出人意料地微妙且得到证实,但再次证实,API 设计(尤其是通用 API 设计,例如语言映射)是困难且耗时的。易用性和功能性之间的权衡选择也可能存在争议(例如我们选择不允许在对象模型标识符中使用下划线来创建无冲突的命名空间)。

持久性。 为了提供对象持久性,我们扩展了对象模型以允许定义对象的持久性属性。对于开发人员而言,使对象持久化包括定义应存储在数据库中的那些属性。编译器处理这些定义并生成运行时库,该库为每种类型的对象实现关联容器。

开发人员通过按键在容器中查找持久对象来访问它们——如果对象尚未在内存中,则会从数据库中透明地加载它。要更新对象,开发人员只需分配给其状态属性。对象会自动写入数据库,由 Ice 运行时完成。(可以使用各种策略来控制在何种情况下执行物理数据库更新。)

此模型使数据库访问完全透明。对于需要更大控制的情况,一个小的 API 允许开发人员建立事务边界并保持数据库完整性。

为了使我们能够在不不断将数据库迁移到新架构的情况下更改游戏,我们开发了一个数据库转换工具。对于简单的功能添加,我们向该工具提供旧的和新的对象定义——该工具会自动生成新的数据库架构并将旧数据库的内容迁移以符合新的架构。对于更复杂的更改,例如更改结构字段的名称或更改字典的键类型,该工具会在 XML 中创建一个默认转换脚本,开发人员可以修改该脚本以实现所需的迁移操作。

该工具非常有用,尽管我们一直在考虑可以合并的新功能。与往常一样,困难在于知道何时停止:构建更好的工具的诱惑很容易分散对总体项目目标的注意力。(“每个大型程序内部都有一个小程序在努力挣脱。”)

线程。 我们构建了一个可移植的线程 API,为开发人员提供平台无关的线程和锁定原语。对于远程调用分发,我们决定仅支持领导者/追随者线程模型4。在某些情况下,阻塞或反应式模型会更适合,但此决定使我们的性能略有下降,但我们获得了更简单的运行时和 API,并减少了嵌套 RPC 中死锁的可能性。

可扩展性。 Ice 允许在不同的服务器中冗余实现对象。运行时自动绑定到对象的副本之一,并且如果副本变得不可用,则故障转移到另一个副本。副本的绑定信息保存在配置中,并在运行时动态获取,因此添加冗余服务器只需要更新配置,而无需更改源代码。这使我们能够在不将所有使用该服务器的游戏玩家踢出游戏的情况下关闭游戏服务器进行软件升级。相同的机制也提供了硬件故障时的容错能力。

为了支持跨多个服务器联合逻辑功能并分担负载,我们构建了一个实现存储库,该存储库在运行时向客户端传递绑定信息。随机化算法在构成逻辑服务的任意数量的服务器之间分配负载。

我们为复制和负载共享做出了许多权衡。例如,并非所有游戏组件都可以在不关闭服务器的情况下进行升级,并且负载反馈机制将提供比简单随机化更好的负载共享。鉴于我们的要求,这些限制是可以接受的,但是,对于具有更严格要求的应用程序,情况可能并非如此。技巧在于决定何时不构建某些东西,以及何时构建某些东西——如果开发基础设施的成本超过其使用期间的节省,那么基础设施就没有意义。

简单更好

我们在游戏开发中使用 Ice 的经验非常积极。尽管运行的分布式系统涉及数十台服务器和数千个客户端,但中间件并没有成为性能瓶颈。

我们在设计期间对简单性的关注在开发过程中多次得到了回报。对于中间件而言,越简单越好:精心选择且小的功能集有助于及时开发以及实现性能目标。

最后,设计和实现中间件是困难且昂贵的,即使拥有多年的经验也是如此。如果您正在寻找中间件,那么您很可能最好购买它而不是构建它。

参考文献

1. Mutable Realms (Wish 主页): 参见 http://www.mutablerealms.com

2. Henning, M., 和 S. Vinoski。使用 C++ 进行高级 CORBA 编程。Addison-Wesley,雷丁:MA,1999 年。

3. ZeroC。使用 Ice 进行分布式编程:参见 http://www.zeroc.com/Ice-Manual.pdf

4. Schmidt, D. C., O’Ryan, C., Pyarali, I., Kircher, M., 和 Buschmann, F. 领导者/追随者:用于高效多线程事件多路分解和分派的设计模式。第 7 届程序模式语言会议 (PLoP 2000) 的论文集;http://deuce.doc.wustl.edu/doc/pspdfs/lf.pdf

MICHI HENNING ([email protected]) 是 ZeroC 的首席科学家。从 1995 年到 2002 年,他作为对象管理组架构委员会成员和 ORB 实现者、顾问和培训师从事 CORBA 工作。他与 Steve Vinoski 合著了《使用 C++ 进行高级 CORBA 编程》(Addison-Wesley,1999 年),这是该领域的权威文本。自加入 ZeroC 以来,他一直致力于 Ice 的设计和实现,并在 2003 年与人合著了 ZeroC 的“使用 Ice 进行分布式编程”。他拥有澳大利亚昆士兰大学计算机科学荣誉学位。

 

acmqueue

最初发表于 Queue 第 1 卷,第 10 期
数字图书馆 中评论本文





更多相关文章

Walker White, Christoph Koch, Johannes Gehrke, Alan Demers - 更好的脚本,更好的游戏
2007 年,视频游戏行业收入达到 88.5 亿美元,几乎与电影票房收入相当。这部分收入的大部分是由大型团队制作的热门游戏创造的。虽然大型开发团队在软件行业中并不罕见,但游戏工作室往往拥有独特的开发人员组合。软件工程师在游戏开发团队中占比较小,而团队的大部分由内容创作者组成,例如艺术家、音乐家和设计师。


Jim Waldo - 游戏和虚拟世界的扩展
我曾经是一名系统程序员,从事银行、电信公司和其他工程师使用的基础设施工作。我从事操作系统工作。我从事分布式中间件工作。我从事编程语言工作。我编写工具。我做了硬核系统程序员所做的一切。


Mark Callow, Paul Beardow, David Brittain - 大型游戏,小型屏幕
在创建和分发移动 3D 游戏时,首先显而易见的一件事是,手机市场与更传统的游戏市场(例如游戏机和掌上游戏设备)之间存在根本差异。其中最引人注目的是交付平台的数量;设备的严重限制,包括可以更改方向的小屏幕;有限的输入控制;需要处理其他任务;非物理交付机制;以及手机性能和输入能力的差异。


Nick Porcino - 游戏图形:革命之路
从瓷砖块背景上的彩色精灵到现代游戏中沉浸式 3D 环境,这是一个漫长的旅程。曾经是单个游戏创作者的工作,现在已成为一个多方面的制作,涉及来自各个创意学科的员工。下一代游戏机和家用电脑硬件将带来可用计算能力的革命性飞跃;商品硬件将提供万亿次浮点运算(teraflop)或更高的运算能力。





© 保留所有权利。

© . All rights reserved.