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 die Reifen lange Zeit nicht schleifen zu lassen, der Spoiler sofort: Es scheint, dass ein solcher Schamanismus im nativen Node.js-Code, zu dem wir nach mehrjähriger Entwicklung gekommen sind, niemand vor uns getan hat. Die Engine isolierter virtueller Maschinen (Open Source), die unter der Haube des Projekts arbeitet, wurde speziell für seine Bedürfnisse geschrieben und wird derzeit von uns und einem weiteren Startup in der Produktion eingesetzt. Und die Möglichkeiten der Isolation, die er gibt, sind einzigartig und verdienen es, über sie erzählt zu werden.


    Aber lasst uns alles in Ordnung bringen.


    Vorgeschichte


    Magst du das Programmieren? Nicht die gleiche Routinekodierung, die viele von uns 40 Stunden in der Woche durchführen müssen, indem sie Zaudern bekämpft, Liter Kaffee einfüllt und professionell ausbrennt. und Programmierung - mit nichts vergleichbar mit dem magischen Prozess der Ideen in ein Arbeitsprogramm verwandeln, immer Freude an der Tatsache , dass nur der Code von Ihnen geschrieben wird auf dem Bildschirm dargestellt und beginnt das Leben zu leben , die ihm den Schöpfer erzählt. In solchen Momenten, dem Wort "Schöpfer", möchte ich mit einem Großbuchstaben schreiben - so viel Gefühl entsteht dabei, manchmal ist es fast Ehrfurcht.



    Es ist schade, dass nur wenige echte Projekte, die mit dem täglichen Einkommen zu tun haben, ihren Entwicklern solche Gefühle bieten können. Um die Leidenschaft für das Programmieren nicht zu verlieren, müssen Enthusiasten häufig eine Intrige an der Seite beginnen: das Hobby eines Programmierers, ein Projekt für Haustiere, ein trendiges Open-Source-Programm, nur ein Python-Skript, um sein intelligentes Zuhause zu automatisieren ... oder das Verhalten eines Charakters in beliebten Online-Umgebungen das Spiel


    Ja, Online-Spiele bieten Programmierern oft eine unerschöpfliche Inspirationsquelle. Schon die ersten Spiele in diesem Genre (Ultima Online, Everquest, ganz zu schweigen von MUDs aller Art) zogen etliche Handwerker an, die nicht so sehr daran interessiert waren, die Rolle zu spielen und die Fantasie der Welt zu genießen, als sie mit ihren Talenten alles und jeden automatisierten virtueller Spielraum. Bis zum heutigen Tag ist es eine spezielle Disziplin der Online-MMO-Spiele-Olympiade: Um zu übertreffen, schreiben Sie Ihren Bot, um von der Administration unbemerkt zu bleiben und den maximalen Gewinn im Vergleich zu anderen Spielern zu erzielen. Oder andere Bots - wie zum Beispiel in EVE Online, wo der Handel in dicht besiedelten Märkten etwas weniger als vollständig durch Handelsskripte gesteuert wird, genau wie an echten Börsen.


    Die Idee eines Online-Spiels, ursprünglich an Programmierer orientiert , lag in der Luft . Ein solches Spiel, in dem das Schreiben eines Bots keine strafbare Handlung ist, sondern die Essenz des Gameplays. Wenn die Aufgabe darin bestehen würde, nicht die gleichen Aktionen auszuführen, "töte X-Monster und finde Y-Objekte" von Zeit zu Zeit, sondern ein Skript zu schreiben, das in der Lage ist, diese Aktionen in deinem Namen kompetent auszuführen. Und da es sich um ein Online-Spiel im MMO-Genre handelt, erfolgt die Rivalität mit den Skripten anderer Spieler in Echtzeit in einer einzigen gemeinsamen Spielwelt.


    So erschien 2014 das Spiel Screeps (aus den Wörtern "Scripts" und "Creeps") - eine strategische Echtzeit-MMO-Sandbox mit einer einzigen großen persistenten Welt , in der die Spieler keinen Einfluss auf das haben, außer durch das Schreiben von AI-Scripts für ihre Spieleinheiten. . Alle Mechanismen eines gewöhnlichen Strategiespiels - Ressourcengewinnung, Erstellung von Einheiten, Aufbau einer Basis, Besetzung von Territorien, Produktion und Handel - müssen vom Spieler selbst über die JavaScript-API der Spielwelt programmiert werden. Der Unterschied zu den verschiedenen KI-Schreibwettbewerben besteht darin, dass die Welt des Spiels, wie es in der Online-Spielewelt sein sollte, in den letzten 4 Jahren rund um die Uhr in Echtzeit arbeitet und sein eigenes Leben führt, wobei die KI jedes Spielers bei jedem Spieltakt gestartet wird.


    Genug über das Spiel selbst - dies sollte ausreichend sein, damit Sie das Wesen der technischen Probleme, die während der Entwicklung aufgetreten sind, besser verstehen können. Weitere Präsentationen erhalten Sie in diesem Video. Dies ist jedoch optional:


    Video-Trailer

    Technische Fragen


    Die Mechanik der Spielwelt ist wie folgt: Die ganze Welt ist in Räume unterteilt , die auf vier Seiten der Welt durch Ausgänge verbunden sind. Ein Raum ist eine atomare Einheit der Verarbeitung des Zustands der Spielwelt. Es gibt möglicherweise Objekte im Raum (z. B. Einheiten), die ihren eigenen Status haben, und bei jedem Spieltakt erhalten sie Befehle von den Spielern. Der Server-Handler nimmt jeweils einen Raum in Anspruch, führt diese Befehle aus, ändert den Status der Objekte und überträgt 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 viele Räume parallel verarbeitet werden, da viele Handler ausgeführt werden.



    Im Moment haben wir 42 060 Räume im Spiel . Der Server-Cluster von 36 physischen Quad-Core-Computern enthält 144 Prozessoren. Für das Queuing verwenden wir Redis, das gesamte Backend ist auf Node.js geschrieben.


    Es war eine Phase des Spieltakts. Aber woher kommen die Teams? Die Besonderheit des Spiels besteht darin, dass es keine Benutzeroberfläche gibt, über die Sie auf das Gerät klicken und es anweisen können, zu einem bestimmten Punkt zu gehen oder eine bestimmte Struktur aufzubauen. Das Maximum, das in der Benutzeroberfläche ausgeführt werden kann - Setzen Sie eine immaterielle Flagge an die richtige Stelle im Raum. Damit eine Einheit an diesen Ort kommt und die erforderliche Aktion ausführt, muss Ihr Skript für mehrere Spielzyklen Folgendes tun:


    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 Spieltakt loopdie Funktion des Spielers übernehmen müssen , diese in der vollständigen JavaScript-Umgebung dieses bestimmten Spielers ausführen müssen (in der ein Objekt für ihn erstellt wurde Game), eine Reihe von Aufträgen für die Einheiten erhalten und sie zur nächsten Verarbeitungsstufe weiterleiten. Es scheint ziemlich einfach zu sein.



    Die Probleme beginnen mit Implementierungsnuancen. Im Moment haben wir 1600 aktive Spieler auf der Welt. Skripte einzelner Spieler können bereits nicht als "Skripts" der Sprache bezeichnet werden - einige enthalten bis zu 25.000 Zeilen Code., kompiliert aus TypeScript oder sogar aus C / C ++ / Rust über WebAssembly (ja, wir unterstützen wasm!) und implementieren das Konzept echter Miniaturbetriebssysteme, bei denen die Spieler einen eigenen Pool von Spielaufgaben-Prozessen und deren Verwaltung durch den Kern entwickelt haben, was so viele Aufgaben erfordert Wie sehr es sich bei diesem Spieltakt herausstellt, führt sie aus, und Standardwerte setzen sie in eine Warteschlange bis zum nächsten Uhrschritt. Da die CPU-Ressourcen und die Speicherressourcen des Players bei jedem Taktzyklus begrenzt sind, funktioniert dieses Modell gut. Es ist zwar nicht zwingend erforderlich - um das Spiel zu starten, reicht es für einen Anfänger aus, ein Skript mit 15 Zeilen zu erstellen, das ebenfalls im Rahmen des Tutorials geschrieben wird.


    Aber denken wir jetzt daran, dass das Playerskript in einer echten JavaScript-Maschine funktionieren sollte. Und dass das Spiel in Echtzeit funktioniert - das heißt, die JavaScript-Maschine eines jeden Spielers muss ständig existieren und mit einem bestimmten Tempo arbeiten, um das Spiel insgesamt nicht zu verlangsamen. Die Phase der Ausführung von Spielskripten und der Reihenfolge von Einheiten für Einheiten funktioniert ungefähr nach dem gleichen Prinzip wie die Verarbeitung von Räumen - jedes Playerskript ist eine Aufgabe, die ein Prozessor vom Pool aus erledigt, viele parallele Prozessoren arbeiten in einem Cluster. Aber im Gegensatz zu den Bearbeitungsräumen gibt es bereits viele Schwierigkeiten.


    Erstens ist es nicht möglich, Aufgaben einfach nach dem Zufallsprinzip für jeden Taktzyklus an die Bearbeiter zu verteilen, wie dies bei Räumen der Fall ist. Die JavaScript-Maschine des Players sollte ohne Unterbrechung funktionieren. Jeder nächste Taktzyklus ist nur ein neuer Funktionsaufruf loop, der globale Kontext sollte jedoch weiterhin derselbe sein. Grob gesagt, das Spiel darf so etwas 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;
      }
    }


    Ein solcher Schleicher singt bei jedem Beat eine Zeile des Songs. Die Zeilennummer des Songs counterwird in einem globalen Kontext gespeichert, der zwischen Takten gespeichert wird. Wenn Sie jedes Mal, wenn Sie das Skript dieses Players im neuen Handler-Prozess ausführen, der Kontext verloren gehen. Dies bedeutet, dass alle Spieler an bestimmte Handler verteilt werden sollten und sie so wenig wie möglich geändert werden sollten. Aber wie geht man mit dem Lastausgleich um? Ein Spieler kann 500ms für die Ausführung dieses Knotens aufbringen, ein anderer Spieler 10ms. Dies ist sehr schwer vorherzusagen. Wenn 20 Spieler von jeweils 500 ms plötzlich auf einen Knoten fallen, dauert die Arbeit eines solchen Knotens 10 Sekunden, während alle anderen warten, bis der Knoten beendet ist und sich im Leerlauf befindet. Und um diese Spieler wieder ins Gleichgewicht zu bringen und auf andere Knoten zu übertragen, müssen Sie ihren Kontext verlieren.


    Zweitens muss die Umgebung des Players gut von anderen Playern und der Serverumgebung isoliert sein. Und dies betrifft nicht nur die Sicherheit, sondern auch den Komfort für die Benutzer. Wenn ein Spieler in der Nähe, der auf demselben Knoten im Cluster wie ich läuft, irgendetwas erstellt, viel Müll erzeugt und sich unangemessen verhält, sollte ich es nicht fühlen. Da die CPU-Ressource im Spiel die Ausführungszeit des Skripts ist (wird vom Start bis zum Ende der Methode berechnet loop), kann die Verschwendung von Ressourcen für andere Aufgaben während der Ausführung meines Skripts sehr empfindlich sein, da sie aus meinem Budget an CPU-Ressourcen verbraucht werden.


    Um diese Probleme zu bewältigen, haben wir verschiedene Lösungen gefunden.


    Erste Version


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


    • Mitarbeitermodul vmbei der Lieferung von Node.js,
    • Gabeln Laufzeitprozesse.

    Es sah so aus. Auf jedem Rechner im Cluster gab es 4 (nach Anzahl der Kerne) Prozesshandler für Spielskripts. Beim Empfang einer neuen Aufgabe aus der Warteschlange der Spielskripte 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 Element (das die Geschäftslogik des Clusters enthielt) isoliert war, wusste nur eines: Erzeuge Gameaus den erhaltenen Daten ein Objekt und starte die virtuelle Maschine des Players. Zum Starten wurde ein Modul vmin Node.js verwendet .


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


    vmarbeitet im selben Single-Threading-Modus wie Node.js. Um auf jedem 4-Core-Rechner vier parallele Prozessoren zu haben, benötigen Sie vier Prozesse. Wenn ein Spieler in einem Prozess "lebend" in einen anderen Prozess verschoben wird, wird der globale Kontext vollständig neu erstellt, auch wenn er innerhalb derselben Maschine auftritt.



    Außerdem wird vmkeine vollständig isolierte virtuelle Maschine erstellt. Dabei wird nur ein isolierter Kontext oder ein isolierter Bereich erstellt, es wird jedoch Code in derselben Instanz der virtuellen JavaScript-Maschine ausgeführt, von der der Aufruf stammt vm.runInContext. Also - in dem Fall, in dem andere Spieler gestartet werden. Obwohl die Player in isolierte globale Kontexte unterteilt sind, aber Teil derselben virtuellen Maschine sind, teilen sie sich einen gemeinsamen Heap-Speicher, einen gemeinsamen Garbage-Collector, und generieren gemeinsam Müll. Wenn Spieler „A“ während der Ausführung seines Spielskripts eine Menge Trümmer erzeugt, die Arbeit beendet und die Kontrolle an Spieler „B“ übergeben hat, dann in diesem Moment das GanzeMüllprozess, und Spieler "B" bezahlt seine CPU-Zeit für das Sammeln von Müll anderer Benutzer. Ganz zu schweigen davon, dass alle Kontexte in derselben Ereignisschleife funktionieren und es theoretisch möglich ist, das Versprechen eines anderen jederzeit umzusetzen, obwohl wir versucht haben, dies zu verhindern. Außerdem kann vmnicht gesteuert werden, wie viel Heapspeicher für die Skriptausführung zugewiesen ist. Der gesamte Prozessspeicher ist verfügbar.


    isoliert-vm


    In der Welt lebt so ein wunderbarer Mann namens Marcel Laverde. Für einige wurde er einmal bemerkenswert, indem er die Node-Fibre- Library schrieb , für andere - dass er Facebook hackte und dazu verpflichtet wurde, dort zu arbeiten . Und für uns ist er bemerkenswert, weil er großzügig an unserer ersten Crowdfunding-Kampagne teilgenommen hat und bis heute ein großer Fan von Screeps ist.


    Unser Projekt ist seit einigen Jahren als Open Source veröffentlicht - der Gameserver ist auf GitHub veröffentlicht. Obwohl der offizielle Client kostenlos über Steam verkauft wird, gibt es alternative Versionen, und der Server selbst steht in jeder Größenordnung zum Lernen und Modifizieren zur Verfügung, was wir dringend empfehlen.


    Und einmal schrieb uns Marcel: „Leute, ich habe eine gute Erfahrung in der nativen Entwicklung von C / C ++ unter Node.js, und ich mag dein Spiel, aber nicht bei allem, was mir gefällt, wie es funktioniert - lass uns mit dir ein völlig neues schreiben Technologie, um virtuelle Maschinen unter Node.js speziell für Screeps auszuführen? ".


    Da Marcel kein Geld verlangt hat, konnten wir das nicht ablehnen. Nach einigen Monaten unserer Zusammenarbeit war die Isolated-vm- Bibliothek geboren . Und es hat alles verändert.


    isolated-vmunterscheidet sich von dem, vmwas nicht den Kontext , sondern das V8 isoliert . Ohne auf Details einzugehen, bedeutet dies, dass eine vollwertige separate Instanz der JavaScript-Maschine erstellt wird, die nicht nur einen eigenen globalen Kontext hat, sondern auch einen eigenen Heap-Speicher, einen Garbage-Collector und die Funktion in einer separaten Ereignisschleife. Minuspunkte: Ein kleiner RAM-Aufwand (ca. 20 MB) ist für jede laufende Maschine erforderlich, und es ist unmöglich, Objekte oder Aufruffunktionen direkt in das Innere der Maschine zu übertragen, der gesamte Austausch muss serialisiert werden. Damit sind die Nachteile beendet, sonst ist es nur ein Allheilmittel!



    Jetzt ist es wirklich möglich, das Skript jedes Spielers in einem vollständig isolierten Raum auszuführen. Der Spieler hat seine 500 MB Hüfte, wenn er beendet ist - das bedeutet, dass die eigene Hüfte beendet wurde und nicht die allgemeine Hüfte. Wenn Sie Müll erzeugt haben, dann ist dies Ihr eigener Müll, und Sie sammeln ihn. Dangling-Versprechen werden nur ausgeführt, wenn Ihr Isolat beim nächsten Mal die Kontrolle übergibt und nicht früher. Nun, Sicherheit - unter keinen Umständen ist es möglich, irgendwo außerhalb des Isolats zuzugreifen, nur wenn Sie auf der Ebene von V8 eine Schwachstelle finden.


    Aber was ist mit dem Balancieren? Ein weiterer Pluspunkt von isolated-vm ist, dass die Maschinen vom selben Prozess aus gestartet werden, jedoch in separaten Threads (die Erfahrung von Marcel mit Knotenfasern war hier hilfreich). Wenn wir eine 4-Kern-Maschine haben, können wir einen Pool von 4 Threads erstellen und 4 parallele Maschinen gleichzeitig ausführen. Da wir uns im selben Prozess befinden und einen gemeinsamen Speicher haben, können wir jeden Player innerhalb dieses Pools von einem Thread auf einen anderen übertragen. Obwohl jeder Spieler auf einem bestimmten Rechner an einen bestimmten Prozess gebunden ist (um den globalen Kontext nicht zu verlieren), stellt sich das Ausbalancieren zwischen 4 Threads als ausreichend heraus, um die Probleme der Verteilung von "schweren" und "leichten" Spielern zwischen den Knoten zu lösen, so dass alle Prozessoren fertig sind zur gleichen Zeit und pünktlich arbeiten.


    Nachdem wir in dieser Funktion experimentell gearbeitet hatten, erhielten wir eine Menge positives Feedback von Spielern, deren Skripte viel besser, stabiler und vorhersehbarer waren. Und jetzt ist dies unsere Standard-Engine, obwohl die Spieler weiterhin die ältere Laufzeitumgebung wählen können, um die Abwärtskompatibilität mit alten Skripts zu gewährleisten (einige Spieler haben sich bewusst auf die Besonderheiten der gemeinsam genutzten Umgebung im Spiel konzentriert).


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


    Jetzt auch beliebt: