深入浅出-面向程序员的MMO沙箱中的虚拟化

在本文中,我将讨论一种鲜为人知的技术,该技术已在程序员的在线游戏中找到了关键应用。 为了避免长时间拖拉橡胶,马上要有一个破坏者:似乎没有人对我们经过几年的开发而来的原生Node.js代码进行过这种萨满主义。 隔离的虚拟机引擎(开源)在项目的后台运行,是专门为满足其需求而编写的,目前由我们和另一家初创公司在生产中使用。 他提供的隔离功能是独一无二的,值得一提。


但是,让我们依次讨论所有内容。


背景知识


你喜欢编程吗? 不是我们许多人被迫每周进行40个小时的常规企业编码,他们陷入了拖延,倒入几升咖啡,职业倦怠的困境; 编程是将思想转化为工作程序的无与伦比的神奇过程,它的乐趣在于您刚编写的代码已体现在屏幕上,并开始过着创作者讲述的生活。 在这种情况下,我想用大写字母写“创造者”一词-在此过程中出现的这种感觉有时接近崇敬。



遗憾的是,很少有与日常收入相关的真实项目能够给开发人员带来这种感觉。 大多数情况下,为了不失去对编程的热情,发烧友必须从侧面开始:编程爱好,宠物项目,时尚的开源软件,只是使自己的智能家居自动化的python脚本……或某些流行的在线角色的行为游戏。


是的,在线游戏通常为程序员提供了不竭的灵感来源。 即使是这类游戏中的第一款游戏(《创世纪》,《无尽的任务》,更不用说各种MUD),也吸引了许多手工艺人,他们对扮演角色和享受世界的幻想并不感兴趣,而是将自己的才华运用到使一切自动化的过程中。虚拟游戏空间。 直到今天,它仍然是在线MMO游戏奥林匹克竞赛的一门特殊学科:精打细算,编写自己的机器人,以免引起管理部门的注意并与其他玩家相比获得最大的利润。 或其他机器人程序-例如在EVE Online中,在人口稠密的市场中进行交易的程度要比完全由交易脚本完全控制的程度要小,就像在真实交易所中一样。


最初完全面向程序员的在线游戏的思想in绕在空中。 编写机器人的游戏不是应受惩罚的行为,而是游戏玩法的本质。 任务不是时常执行相同的动作“杀死X个怪物并找到Y个物品”,而是编写一个脚本来代表您正确执行这些动作。 而且,由于它暗示着MMO游戏类型中的在线游戏,因此在单个普通游戏世界中与其他玩家的脚本实时进行竞争。


因此,在2014年,游戏Screeps(来自“脚本”和“爬行”两个词)出现了-实时战略性MMO沙盒,具有一个大型持久世界 ,玩家在其中不会对所发生的事情产生任何影响,除非为其游戏单元编写AI脚本。 普通战略游戏的所有机制-资源提取,建造单位,建造基地,夺取领土,制造和交易-您需要通过游戏世界提供的JavaScript API对玩家自己进行编程。 在编写AI方面,不同比赛的不同之处在于,在过去的4年中,游戏世界(应该在在线游戏世界中)不断地以24/7的速度实时工作并过着自己的生活,并在每个游戏周期内启动每个玩家的AI。


因此,关于游戏本身的知识就足够了-这足以进一步了解我们在开发过程中遇到的技术问题的实质。 您可以从该视频中获得更多观看次数,但这是可选的:


视频预告片

技术问题


游戏世界机制的实质如下:整个世界分为多个房间 ,这些房间通过四个主要点上的出口相互连接。 一个房间是处理游戏世界状态过程的原子单元。 房间中可能有某些具有各自状态的对象(例如,单位),并且在每个游戏步骤中,它们都从玩家那里接收命令。 服务器处理程序一次只占用一个房间,执行这些命令,更改对象的状态,然后将房间的新状态提交给数据库。 该系统在水平方向上可以很好地伸缩:您可以向集群添加更多的处理程序,并且由于各个房间在架构上是彼此隔离的,因此可以并行运行多个处理程序,从而可以并行处理多个房间。



目前,我们在游戏中有42,060个房间 。 一个由36个四核物理机组成的服务器集群包含144个处理器。 我们使用Redis创建队列,整个后端都是用Node.js编写的。


这是比赛策略的一个阶段。 但是球员队伍来自哪里? 游戏的特点是没有界面,您可以单击某个单位并告诉他去某个点或建立一个特定的结构。 界面中最多可以在房间的正确位置放置一个无形标记。 为了使单位到达这个地方并采取必要的行动,您的脚本有必要对多个游戏滴答做出类似以下的操作:


module.exports.loop = function() { let creep = Game.creeps['Creep1']; let flag = Game.flags['Flag1']; if(!creep.pos.isEqualTo(flag.pos)) { creep.moveTo(flag.pos); } } 

事实证明,在每个游戏步骤中,您都需要采取玩家的loop功能,在该特定玩家的完整JavaScript环境(存在为他形成的Game对象)中执行该功能,获得一组单位的订单,并将其交给下一个处理阶段。 一切似乎都非常简单。



问题从实现的细微差别开始。 目前,我们在全球拥有1600名活跃玩家 。 单个播放器的脚本已经不能被称为“脚本”-其中一些脚本包含多达25k行代码 ,是通过WebAssembly从TypeScript甚至从C / C ++ / Rust通过WebAssembly编译的(是的,我们支持wasm!),并实现了真正的微型操作系统的概念,在其中,玩家通过核心开发了自己的游戏任务库-流程及其管理,该库承担了要在给定的游戏节拍上执行的尽可能多的任务,执行这些任务并将它们放回到队列中,直到下一个措施为止。 由于播放器的CPU和内存在每个时钟周期都受到限制,因此该模型运行良好。 尽管不是强制性的,但要开始游戏,对于初学者来说,要使用15行的脚本就足够了,该脚本也已作为教程的一部分编写。


但是,现在让我们记住播放器脚本应该可以在真正的JavaScript机器上运行。 游戏可以实时运行-也就是说,每个玩家的JavaScript机器都必须以一定的速度持续存在,以免降低游戏的整体速度。 执行游戏脚本和为部队定单的阶段与处理室大致相同,每个玩家的脚本都是池中的一个处理程序承担的任务,集群中有许多并行处理程序。 但是与处理室的阶段不同,已经存在很多困难。


首先,您不能只是在每个时钟周期随机分配处理程序的任务,就房间而言,这是可以做到的。 播放器的JavaScript机器应正常运行,每个后续措施只是一个新的loop函数调用,但全局上下文应继续存在。 大致来说,该游戏允许您执行以下操作:


 let counter = 0; let song = ['EX-', 'TER-', 'MI-', 'NATE!']; module.exports.loop = function () { Game.creeps['DalekSinger'].say(song[counter]); counter++; if(counter == song.length) { counter = 0; } } 


每个游戏节拍,这种蠕变都会在歌曲的一行中唱歌。 歌曲counter的行号存储在小节之间存储的全局上下文中。 如果每次在新的处理程序进程中执行此播放器的脚本,则上下文将丢失。 这意味着应将所有玩家分配给特定的处理程序,并应尽可能少地对其进行更改。 但是负载平衡又如何呢? 一个玩家可以在该节点上花费500ms的执行时间,而另一个玩家可以花10ms的执行时间,因此很难预先预测这一点。 如果每个节点上有20个500ms的播放器,则此节点的操作将花费10秒,在此期间,所有其他节点将等待其完成并处于空闲状态。 为了重新平衡这些玩家并将它们放置到其他节点,您必须失去他们的上下文。


其次,播放器的环境必须与其他播放器以及服务器环境完全隔离。 这不仅涉及安全性,还涉及用户自身的舒适性。 如果与我一样在群集中同一节点上运行的相邻播放器非常糟糕,产生大量垃圾,并且通常行为不当,那么我应该不会感觉到。 由于游戏中的CPU资源是脚本执行时间(它是从loop方法的开始到结束进行计算的),因此在执行脚本期间对无关紧要任务的资源浪费可能非常敏感,因为这是我的CPU资源预算所花费的时间。


为了解决这些问题,我们提出了几种解决方案。


第一版


游戏引擎的第一个版本基于两个基本方面:


  • Node.js交付中的全职vm模块,
  • 运行时进程的分支。

看起来像这样。 在群集中的每台计算机上,游戏脚本处理程序有4个(根据内核数)进程。 当从游戏脚本队列中接收到新任务时,处理程序从数据库中请求必要的数据,并将其传输到子进程,该子进程保持在不断运行的状态,如果发生故障则重新启动,并由其他玩家重用。 与父进程(包含集群业务逻辑)隔离的子进程只能做一件事:从接收到的数据创建Game对象,然后启动玩家的虚拟机。 首先,我们使用Node.js中的vm模块。


为什么这个决定不完善? 严格来说,以上两个问题在这里没有解决。


vm以与Node.js相同的单线程模式工作。 因此,为了在4核计算机上的每个核上具有四个并行处理器,您需要具有4个进程。 将玩家“生活”在一个进程中的移动到另一个进程会导致全局上下文的完全重新创建,即使这是在同一台机器上发生的。



此外, vm实际上不会创建完全隔离的虚拟机。 它所做的只是创建一个隔离的上下文或作用域,但在JavaScript虚拟机的同一实例(其中vm.runInContext调用vm.runInContext执行代码。 这意味着-在启动其他玩家的情况下。 尽管播放器被隔离的全局上下文分隔开,但是它们属于同一虚拟机,因此它们具有公共的堆内存,公共的垃圾收集器,并一起生成垃圾。 如果玩家“ A”在执行游戏脚本,完成工作并将控制权传递给玩家“ B”的过程中产生了大量垃圾,那么此时可能会收集该进程的所有垃圾,并且玩家“ B”将花费CPU时间进行收集别人的垃圾。 更不用说所有上下文都在同一事件循环中工作的事实,并且尽管我们试图防止这种情况,但理论上可以随时执行其他人的诺言。 另外, vm不允许您控制为脚本执行分配多少堆内存,所有进程内存都可用。


隔离虚拟机


那里住着一个叫Marcel Laverde的好人。 对于某些人来说,他曾经因编写节点纤维库而闻名,对于其他人而言,由于入侵了Facebook而受聘,并被雇用在那里工作 。 对于我们来说,他很棒,因为他慷慨地参加了我们的第一个众筹活动,并且至今仍是Screeps的忠实粉丝。


我们的项目已经开源多​​年了-游戏服务器发布在GitHub上。 尽管官方客户通过Steam付费出售,但它还有其他版本,并且服务器本身可用于任何规模的研究和修改,我们强烈建议您这样做。


当Marcel写信给我们时:“伙计们,我在Node.js的本机C / C ++开发方面拥有丰富的经验,我喜欢您的游戏,但并不是每个人都喜欢它的工作方式-让我们来编写一个全新的游戏专门用于Screeps的Node.js虚拟机启动技术?”


由于马塞尔(Marcel)没有要钱,我们不能拒绝。 经过几个月的合作, isolated-vm库诞生了。 这绝对改变了一切。


isolated-vmisolated-vm不同之处在于,它不隔离 上下文 ,而是根据V8进行 隔离 。 无需赘述,这意味着将创建一个完整的JavaScript机器实例,该实例不仅具有自己的全局上下文,而且具有自己的堆内存,垃圾收集器,并作为单独的事件循环的一部分工作。 缺点:每台运行的计算机都需要很小的RAM开销(约20 MB),并且不可能将对象或调用函数直接转移到计算机中,整个交换必须序列化。 这就结束了弊端,剩下的就是万灵药!



现在,真正有可能在每个人完全独立的空间中运行每个玩家的脚本。 播放器具有自己的500 MB髋部,如果髋部结束,则意味着结束时是您自己的髋部,而不是整个过程中的髋部。 如果生成了垃圾-这是您自己的垃圾,则需要对其进行收集。 悬空的承诺仅在您的隔离将在下一次而不是更早执行时才会执行。 安全可靠-在任何情况下都不能访问隔离区之外的某个地方,除非您发现V8级别的某个地方的漏洞。


但是平衡呢? isolated-vm的另一个优点是,它从相同的进程启动计算机,但使用单独的线程(Marcel在使用节点纤维方面的经验非常有用)。 如果我们有一台四核计算机,则可以创建一个包含四个线程的池,并一次启动四台并行计算机。 同时,在同一个进程中,这意味着拥有共同的内存,我们可以将任何播放器从该线程中的一个线程转移到另一个线程。 尽管每个播放器都与一台特定计算机上的一个特定进程保持联系(以免丢失全局上下文),但是在4个线程之间进行平衡就足以解决在节点之间分配“重”和“轻”播放器的问题,以便所有处理器都能完成同时并准时工作。


在实验模式下运行此功能后,我们收到了来自其脚本开始更好,更稳定和更可预测的播放器的大量正面反馈。 现在,这是我们的默认引擎,尽管玩家仍然可以纯粹为了与旧脚本向后兼容而选择旧版运行时(某些玩家自觉地专注于游戏中共享环境的细节)。


当然,仍然存在进一步优化的空间,并且在项目的其他有趣领域中,我们解决了各种技术问题。 但是,还有另一回事。

Source: https://habr.com/ru/post/zh-CN437836/


All Articles