Unter der Haube Screeps - Virtualisierung in der MMO-Sandbox für Programmierer

In diesem Artikel werde ich über eine wenig bekannte Technologie sprechen, die in unserem Online-Spiel für Programmierer eine Schlüsselanwendung gefunden hat. Um nicht lange am Gummi zu ziehen, gibt es sofort einen Spoiler: Es scheint, dass niemand im nativen Node.js-Code, zu dem wir nach mehreren Jahren der Entwicklung gekommen sind, einen solchen Schamanismus betrieben hat. Die isolierte Virtual Machine Engine (Open Source), die unter der Haube des Projekts ausgeführt wird, wurde speziell für ihre Anforderungen geschrieben und wird derzeit von uns und einem anderen Startup in der Produktion verwendet. Und die Isolationsfähigkeiten, die er gibt, sind einzigartig und verdienen es, darüber informiert zu werden.


Aber reden wir über alles in Ordnung.


Hintergrund


Programmierst du gerne Nicht die routinemäßige Unternehmenscodierung, zu der viele von uns gezwungen sind, 40 Stunden pro Woche zu arbeiten, mit Aufschub zu kämpfen, Liter Kaffee einzuschenken und professionell auszubrennen; und Programmieren ist ein unvergleichlicher magischer Prozess, bei dem Gedanken in ein Arbeitsprogramm umgewandelt werden. Dabei wird Freude daran, dass der Code, den Sie gerade geschrieben haben, auf dem Bildschirm angezeigt wird und das Leben beginnt, das der Schöpfer ihm sagt. In solchen Momenten möchte ich das Wort "Schöpfer" mit einem Großbuchstaben schreiben - ein solches Gefühl, das dabei entsteht, ist manchmal der Ehrfurcht nahe.



Es ist nur schade, dass nur sehr wenige echte Projekte, die sich auf das tägliche Einkommen beziehen, ihren Entwicklern solche Gefühle vermitteln können. Um die Leidenschaft für das Programmieren nicht zu verlieren, müssen Enthusiasten nebenbei eine Affäre beginnen: ein Programmierhobby, ein Haustierprojekt, ein modisches Open-Source-Programm, nur ein Python-Skript zur Automatisierung ihres Smart Homes ... oder das Verhalten eines Charakters in einigen beliebten Online-Versionen Spiel.


Ja, es sind Online-Spiele, die Programmierern oft eine unerschöpfliche Inspirationsquelle bieten. Schon die ersten Spiele in diesem Genre (Ultima Online, Everquest, ganz zu schweigen von allen Arten von MUDs) zogen viele Handwerker an, die nicht so sehr daran interessiert sind, die Rolle zu spielen und die Fantasie der Welt zu genießen, sondern ihre Talente einzusetzen, um alles und jedes zu automatisieren virtueller Spielraum. Und bis heute bleibt dies eine besondere Disziplin der Online-MMO-Spiele-Olympiade: Verfeinern Sie Ihren Verstand beim Schreiben Ihres Bots, damit er von der Verwaltung unbemerkt bleibt und im Vergleich zu anderen Spielern den maximalen Gewinn erzielt. Oder andere Bots - wie zum Beispiel in EVE Online, wo der Handel in dicht besiedelten Märkten etwas weniger als vollständig durch Handelsskripte kontrolliert wird, genau wie an echten Börsen.


Die Idee eines Online-Spiels, das ursprünglich und vollständig auf Programmierer ausgerichtet war, schwebte in der Luft. Ein solches Spiel, bei dem das Schreiben eines Bots keine strafbare Handlung ist, sondern die Essenz des Gameplays. Wobei die Aufgabe nicht darin besteht, von Zeit zu Zeit dieselben Aktionen wie "Töte X Monster und finde Y Gegenstände" auszuführen, sondern ein Skript zu schreiben, das diese Aktionen in deinem Namen korrekt ausführen kann. Und da es sich um ein Online-Spiel im MMO-Genre handelt, findet die Rivalität mit den Skripten anderer Spieler in Echtzeit in einer einzigen gemeinsamen Spielwelt statt.


So erschien 2014 das Spiel Screeps (aus den Wörtern "Scripts" und "Creeps") - eine strategische Echtzeit-MMO-Sandbox mit einer einzigen großen, beständigen Welt, in der die Spieler keinen Einfluss darauf haben, was passiert, außer indem sie KI-Skripte für ihre Spieleinheiten schreiben . Alle Mechanismen eines gewöhnlichen strategischen Spiels - Ressourcenextraktion, Aufbau von Einheiten, Aufbau einer Basis, Eroberung von Gebieten, Herstellung und Handel - müssen vom Spieler selbst über die von der Spielwelt bereitgestellte JavaScript-API programmiert werden. Der Unterschied zu verschiedenen Wettbewerben beim Schreiben von KI besteht darin, dass die Spielwelt, wie sie in der Online-Spielewelt sein sollte, in den letzten 4 Jahren rund um die Uhr in Echtzeit arbeitet und ihr Leben lebt und die KI jedes Spielers in jedem Spielzyklus startet.


Genug vom Spiel selbst - dies sollte ausreichen, um die Essenz der technischen Probleme, auf die wir während der Entwicklung gestoßen sind, besser zu verstehen. Sie können mehr Ansichten von diesem Video erhalten, dies ist jedoch optional:


Video-Trailer

Technische Probleme


Das Wesen der Mechanik der Spielwelt ist wie folgt: Die ganze Welt ist in Räume unterteilt , die durch Ausgänge an vier Kardinalpunkten miteinander verbunden sind. Ein Raum ist eine atomare Einheit des Prozesses zur Verarbeitung des Zustands der Spielwelt. Der Raum kann bestimmte Objekte (z. B. Einheiten) haben, die ihren eigenen Status haben, und bei jedem Spielschritt erhalten sie Befehle von den Spielern. Der Server-Handler nimmt jeweils einen Raum ein, führt diese Befehle aus, ändert den Status der Objekte und schreibt den neuen Status des Raums in die Datenbank. Dieses System lässt sich horizontal gut skalieren: Sie können dem Cluster weitere Handler hinzufügen. Da die Räume architektonisch voneinander isoliert sind, können so viele Räume parallel verarbeitet werden, wie Handler ausgeführt werden.



Im Moment haben wir 42.060 Räume im Spiel. Ein Servercluster von 36 physischen Quad-Core-Maschinen enthält 144 Prozessoren. Wir verwenden Redis, um Warteschlangen zu erstellen. Das gesamte Backend ist in Node.js geschrieben.


Dies war eine Phase des Spieltakts. Aber woher kommen die Spielerteams? Die Besonderheit des Spiels ist, dass es keine Schnittstelle gibt, über die Sie auf eine Einheit klicken und sie anweisen können, zu einem bestimmten Punkt zu gehen oder eine bestimmte Struktur aufzubauen. Das Maximum, das in der Benutzeroberfläche erreicht werden kann, besteht darin, eine immaterielle Flagge an der richtigen Stelle im Raum zu platzieren. Damit die Einheit an diesen Ort kommt und die erforderlichen Maßnahmen ergreift, muss Ihr Skript für mehrere Spiel-Ticks Folgendes ausführen:


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

Es stellt sich heraus, dass Sie bei jedem Spielschritt die loop Funktion des Spielers ausführen, sie in einer vollwertigen JavaScript-Umgebung des jeweiligen Spielers (in der das für ihn gebildete Spielobjekt vorhanden ist) ausführen, eine Reihe von Befehlen für die Einheiten abrufen und sie der nächsten Verarbeitungsstufe übergeben müssen. Alles scheint ziemlich einfach zu sein.



Probleme beginnen, wenn es um die Nuancen der Implementierung geht. Im Moment haben wir 1600 aktive Spieler auf der Welt. Die Skripte einzelner Spieler können bereits nicht als "Skripte" bezeichnet werden - einige von ihnen enthalten bis zu 25.000 Codezeilen , werden aus TypeScript oder sogar aus C / C ++ / Rust über WebAssembly kompiliert (ja, wir unterstützen wasm!) Und implementieren das Konzept von echten Miniatur-Betriebssystemen. in dem die Spieler ihren eigenen Pool von Spielaufgaben-Prozessen und deren Verwaltung durch den Kern entwickelt haben, der so viele Aufgaben übernimmt, wie sich herausstellt, um einen bestimmten Takt eines Spiels auszuführen, sie ausführt und sie bis zur nächsten Maßnahme wieder in die Warteschlange stellt. Da die CPU und der Speicher des Players bei jedem Taktzyklus begrenzt sind, funktioniert dieses Modell gut. Obwohl es nicht obligatorisch ist - um das Spiel zu starten, reicht es für Anfänger aus, ein Skript mit 15 Zeilen zu erstellen, das auch bereits als Teil des Tutorials geschrieben wurde.


Aber jetzt denken wir daran, dass das Player-Skript in einer echten JavaScript-Maschine funktionieren sollte. Und dass das Spiel in Echtzeit funktioniert - das heißt, die JavaScript-Maschine jedes Spielers muss ständig vorhanden sein und in einem bestimmten Tempo arbeiten, um das Spiel insgesamt nicht zu verlangsamen. Das Ausführen von Spielskripten und das Bilden von Aufträgen für Einheiten erfolgt ungefähr nach dem gleichen Prinzip wie das Verarbeiten von Räumen. Das Skript jedes Spielers ist eine Aufgabe, die ein Handler aus dem Pool übernimmt. Viele parallele Handler arbeiten im Cluster. Im Gegensatz zur Phase der Bearbeitung von Räumen gibt es jedoch bereits viele Schwierigkeiten.


Erstens können Sie Aufgaben nicht einfach nach dem Zufallsprinzip in jedem Taktzyklus auf Handler verteilen, wie dies bei Räumen möglich ist. Die JavaScript-Maschine des Players sollte ohne Unterbrechung funktionieren. Jede nachfolgende Kennzahl ist nur ein neuer loop , aber der globale Kontext sollte weiterhin unverändert bleiben. Grob gesagt können Sie mit dem Spiel Folgendes tun:


 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; } } 


Solch ein Grusel wird bei jedem Spielschlag in einer Zeile des Songs singen. Die Zeilennummer des counter wird in einem globalen Kontext gespeichert, der zwischen den Takten gespeichert wird. Wenn das Skript dieses Players jedes Mal in einem neuen Handlerprozess ausgeführt wird, geht der Kontext verloren. Dies bedeutet, dass alle Spieler bestimmten Handlern zugeordnet und so wenig wie möglich geändert werden sollten. Aber was ist dann mit dem Lastausgleich? Ein Spieler kann 500 ms Ausführung auf diesem Knoten verbringen, und der andere Spieler kann 10 ms verbringen, und es ist sehr schwierig, dies im Voraus vorherzusagen. Wenn 20 Spieler mit jeweils 500 ms auf einen Knoten fallen, dauert der Betrieb eines solchen Knotens 10 Sekunden. Während dieser Zeit warten alle anderen auf seine Fertigstellung und stehen im Leerlauf. Und um diese Spieler wieder ins Gleichgewicht zu bringen und sie auf andere Knoten zu werfen, müssen Sie ihren Kontext verlieren.


Zweitens muss die Spielerumgebung gut von anderen Spielern und von der Serverumgebung isoliert sein. Dies betrifft nicht nur die Sicherheit, sondern auch den Komfort für die Benutzer. Wenn ein benachbarter Spieler, der auf demselben Knoten im Cluster läuft wie ich, es schrecklich macht, viel Müll erzeugt und sich im Allgemeinen nicht richtig verhält, sollte ich es nicht fühlen. Da die CPU-Ressource im Spiel die Skriptausführungszeit ist (sie wird vom Anfang bis zum Ende der loop berechnet), kann die Verschwendung von Ressourcen für externe Aufgaben während der Ausführung meines Skripts sehr empfindlich sein, da sie aus meinem CPU-Ressourcenbudget ausgegeben wird.


Bei dem Versuch, diese Probleme zu lösen, haben wir verschiedene Lösungen gefunden.


Erste Version


Die erste Version der Spiel-Engine basierte auf zwei grundlegenden Dingen:


  • Vollzeit- vm Modul in der Lieferung von Node.js,
  • Gabel der Laufzeitprozesse.

Es sah so aus. Auf jedem Computer im Cluster gab es 4 (entsprechend der Anzahl der Kerne) Prozesse von Spieleskript-Handlern. Wenn eine neue Aufgabe aus der Warteschlange der Spielskripte empfangen wurde, forderte der Handler die erforderlichen Daten aus der Datenbank an und übertrug sie an einen untergeordneten Prozess, der in einem ständig laufenden Zustand gehalten, im Fehlerfall neu gestartet und von verschiedenen Spielern wiederverwendet wurde. Der untergeordnete Prozess, der vom übergeordneten Prozess isoliert war (der die Cluster-Geschäftslogik enthielt), konnte nur eines tun: Aus den empfangenen Daten ein Spielobjekt erstellen und die virtuelle Maschine des Spielers starten. Zu Beginn haben wir das vm Modul in Node.js verwendet.


Warum war diese Entscheidung unvollkommen? Genau genommen wurden die beiden oben genannten Probleme hier nicht gelöst.


vm arbeitet im selben Single-Thread-Modus wie Node.js. Um vier parallele Prozessoren auf jedem Kern einer 4-Kern-Maschine zu haben, benötigen Sie daher 4 Prozesse. Das Verschieben eines Spielers, der in einem Prozess „lebt“, in einen anderen Prozess führt zu einer vollständigen Neuerstellung des globalen Kontexts, selbst wenn dies innerhalb derselben Maschine geschieht.



Darüber hinaus erstellt vm keine vollständig isolierte virtuelle Maschine. Sie erstellen lediglich einen isolierten Kontext oder Bereich, führen den Code jedoch in derselben Instanz der virtuellen JavaScript-Maschine aus, von der der Aufruf vm.runInContext . Und das bedeutet - in demselben Fall, in dem andere Spieler gestartet werden. Obwohl die Player durch isolierte globale Kontexte getrennt sind, haben sie als Teil derselben virtuellen Maschine einen gemeinsamen Heap-Speicher, einen gemeinsamen Garbage Collector und generieren gemeinsam Müll. Wenn Spieler "A" während der Ausführung seines Spielskripts viel Müll erzeugt, die Arbeit beendet und die Kontrolle an Spieler "B" übergeben hat, kann in diesem Moment der gesamte Müll des Prozesses gesammelt werden, und Spieler "B" zahlt CPU-Zeit für das Sammeln der Müll eines anderen. Ganz zu schweigen von der Tatsache, dass alle Kontexte in derselben Ereignisschleife arbeiten und es theoretisch jederzeit möglich ist, das Versprechen eines anderen auszuführen, obwohl wir versucht haben, dies zu verhindern. Außerdem können Sie mit vm nicht steuern, wie viel vm für die Skriptausführung zugewiesen wird. Der gesamte Prozessspeicher ist verfügbar.


isoliert-vm


Dort lebt eine so wundervolle Person namens Marcel Laverde. Für einige war er einmal bemerkenswert, weil er eine Node-Fiber- Bibliothek geschrieben hatte, für andere, weil er Facebook gehackt hatte, und wurde angeheuert, um dort zu arbeiten . Und für uns ist er wunderbar, weil er großzügig an unserer allerersten Crowdfunding-Kampagne teilgenommen hat und bis heute ein großer Fan von Screeps ist.


Unser Projekt ist seit einigen Jahren in Open Source - der Spieleserver wird auf GitHub veröffentlicht. Obwohl der offizielle Client gegen eine Gebühr über Steam verkauft wird, gibt es alternative Versionen davon, und der Server selbst kann in jeder Größenordnung studiert und geändert werden, was wir nachdrücklich empfehlen.


Und sobald Marcel uns schreibt: „Leute, ich habe gute Erfahrungen in der nativen C / C ++ - Entwicklung für Node.js und ich mag dein Spiel, aber nicht jedem gefällt, wie es funktioniert - lass uns ein brandneues schreiben Starttechnologie für virtuelle Maschinen für Node.js speziell für Screeps? “


Da Marcel nicht um Geld bat, konnten wir nicht ablehnen. Nach einigen Monaten unserer Zusammenarbeit wurde die isolierte VM- Bibliothek geboren. Und das hat absolut alles verändert.


isolated-vm unterscheidet sich von vm darin, dass es nicht den Kontext isoliert, sondern in Bezug auf V8 isoliert . Ohne auf Details einzugehen, bedeutet dies, dass eine vollwertige separate Instanz der JavaScript-Maschine erstellt wird, die nicht nur über einen eigenen globalen Kontext verfügt, sondern auch über einen eigenen Heap-Speicher, einen Garbage Collector und als Teil einer separaten Ereignisschleife arbeitet. Von den Minuspunkten: Für jede laufende Maschine ist ein kleiner RAM-Overhead erforderlich (ca. 20 MB), und es ist auch unmöglich, Objekte zu übertragen oder Funktionen direkt in die Maschine aufzurufen. Der gesamte Austausch muss serialisiert werden. Dies beendet die Nachteile, der Rest - es ist nur ein Allheilmittel!



Jetzt ist es wirklich möglich, das Skript jedes Spielers in einem völlig isolierten Bereich auszuführen. Der Spieler hat seine eigenen 500 MB Hüfte. Wenn es endet, bedeutet dies, dass Ihre eigene Hüfte geendet hat und nicht die Hüfte des gesamten Prozesses. Wenn Sie Müll erzeugt haben - dann ist dies Ihr eigener Müll, den Sie sammeln müssen. Dangling-Versprechen werden nur ausgeführt, wenn Ihr Isolat das nächste Mal übernimmt, und nicht früher. Gut und sicher - unter keinen Umständen ist ein Zugriff außerhalb des Isolats möglich, nur wenn Sie auf V8-Ebene eine Sicherheitslücke finden.


Aber was ist mit dem Balancieren? Ein weiteres Plus von isoliertem VM ist, dass die Maschinen vom selben Prozess aus gestartet werden, jedoch in separaten Threads (die Erfahrung von Marcel mit Knotenfasern hat sich hier als nützlich erwiesen). Wenn wir eine 4-Kern-Maschine haben, können wir einen Pool von 4 Threads erstellen und 4 parallele Maschinen gleichzeitig starten. Gleichzeitig können wir innerhalb desselben Prozesses, dh mit einem gemeinsamen Speicher, jeden Spieler innerhalb dieses Pools von einem Thread auf einen anderen übertragen. Obwohl jeder Spieler an einen bestimmten Prozess auf einem bestimmten Computer gebunden bleibt (um den globalen Kontext nicht zu verlieren), reicht das Ausbalancieren zwischen 4 Threads aus, um die Probleme der Verteilung von "schweren" und "leichten" Spielern zwischen Knoten zu lösen, sodass alle Prozessoren fertig sind gleichzeitig und pünktlich arbeiten.


Nachdem wir diese Funktion im experimentellen Modus ausgeführt hatten, erhielten wir eine Menge positiver Rückmeldungen von Spielern, deren Skripte viel besser, stabiler und vorhersehbarer zu funktionieren begannen. Und jetzt ist dies unsere Standard-Engine, obwohl die Spieler die Legacy-Laufzeit nur aus Gründen der Abwärtskompatibilität mit alten Skripten auswählen können (einige Spieler haben sich bewusst auf die Besonderheiten der gemeinsam genutzten Umgebung im Spiel konzentriert).


Natürlich gibt es noch Raum für weitere Optimierungen, und es gibt auch andere interessante Bereiche des Projekts, in denen wir verschiedene technische Probleme gelöst haben. Aber dazu ein anderes Mal mehr.

Source: https://habr.com/ru/post/de437836/


All Articles