Dans cet article, je parlerai d'une technologie peu connue qui a trouvé une application clé dans notre jeu en ligne pour les programmeurs. Afin de ne pas tirer sur le caoutchouc pendant longtemps, il y a un spoiler tout de suite: il semble que personne n'ait fait un tel chamanisme dans le code Node.js natif auquel nous sommes arrivés après plusieurs années de développement. Le moteur de machine virtuelle isolée (open source), qui fonctionne sous le capot du projet, a été écrit spécifiquement pour ses besoins et est actuellement utilisé en production par nous et une autre startup. Et les capacités d'isolement qu'il donne sont uniques et méritent d'être racontées à leur sujet.
Mais parlons de tout dans l'ordre.
Contexte
Aimez-vous la programmation? Pas l'entreprise de codage de routine que beaucoup d'entre nous sont obligés de faire 40 heures par semaine, aux prises avec la procrastination, verser des litres de café et brûler professionnellement; et la programmation est un processus magique incomparable de transformation des pensées en programme de travail, prenant plaisir à ce que le code que vous venez d'écrire s'incarne à l'écran et commence à vivre la vie que le créateur lui raconte. À de tels moments, je veux écrire le mot "Créateur" avec une lettre majuscule - un tel sentiment qui surgit dans le processus est parfois proche de la révérence.

Il est dommage que très peu de projets réels liés aux revenus quotidiens puissent offrir de tels sentiments à leurs développeurs. Le plus souvent, afin de ne pas perdre la passion de la programmation, les passionnés doivent commencer une affaire de côté: un passe-temps de programmation, un projet pour animaux de compagnie, un open-source à la mode, juste un script python pour automatiser leur maison intelligente ... ou le comportement d'un personnage dans certains sites populaires en ligne jeu.
Oui, ce sont les jeux en ligne qui constituent souvent une source d'inspiration inépuisable pour les programmeurs. Même les tout premiers jeux de ce genre (Ultima Online, Everquest, sans parler de toutes sortes de MUDs) ont attiré de nombreux artisans qui ne s'intéressent pas tant à jouer le rôle et à profiter de la fantaisie du monde, mais dans l'application de leurs talents pour automatiser tout et tout dans espace de jeu virtuel. Et à ce jour, cela reste une discipline spéciale de l'Olympiade MMO Games en ligne: affiner votre esprit en écrivant votre bot afin qu'il passe inaperçu par l'administration et obtienne le maximum de profit par rapport aux autres joueurs. Ou d'autres robots - comme, par exemple, dans EVE Online, où le commerce sur des marchés densément peuplés est légèrement moins que totalement contrôlé par les scripts de trading, tout comme sur les échanges réels.
L'idée d'un jeu en ligne, initialement et complètement orienté vers les programmeurs, planait dans l'air. Un tel jeu dans lequel l'écriture d'un bot n'est pas un acte punissable, mais l'essence du gameplay. Où la tâche ne serait pas d'exécuter les mêmes actions «Tuer X monstres et trouver des objets Y» de temps en temps, mais d'écrire un script qui peut effectuer correctement ces actions en votre nom. Et comme cela implique un jeu en ligne dans le genre MMO, la rivalité se déroule avec les scripts des autres joueurs en temps réel dans un seul monde de jeu commun.
Ainsi, en 2014, le jeu Screeps (à partir des mots "Scripts" et "creeps") est apparu - un sandbox stratégique MMO en temps réel avec un seul grand monde persistant dans lequel les joueurs n'ont aucune influence sur ce qui se passe, sauf en écrivant des scripts AI pour leurs unités de jeu. . Toutes les mécaniques d'un jeu stratégique ordinaire - extraction de ressources, construction d'unités, construction d'une base, saisie de territoires, fabrication et commerce - le joueur lui-même doit être programmé via l'API JavaScript fournie par le monde du jeu. La différence entre les différentes compétitions en matière d'écriture de l'IA est que le monde du jeu, comme il devrait l'être dans le monde du jeu en ligne, fonctionne constamment et vit sa vie en temps réel 24/7 au cours des 4 dernières années, lançant l'IA de chaque joueur à chaque cycle de jeu.
Donc, assez sur le jeu lui-même - cela devrait être suffisant pour mieux comprendre l'essence des problèmes techniques que nous avons rencontrés pendant le développement. Vous pouvez obtenir plus de vues de cette vidéo, mais cela est facultatif:
Problèmes techniques
L'essence de la mécanique du monde du jeu est la suivante: le monde entier est divisé en salles , qui sont reliées entre elles par des sorties sur quatre points cardinaux. Une pièce est une unité atomique du processus de traitement de l'état du monde du jeu. La salle peut avoir certains objets (par exemple, des unités) qui ont leur propre état, et à chaque étape du jeu, ils reçoivent des commandes des joueurs. Le gestionnaire de serveur prend une salle à la fois, exécute ces commandes, modifie l'état des objets et valide le nouvel état de la salle dans la base de données. Ce système évolue bien horizontalement: vous pouvez ajouter plus de gestionnaires au cluster, et comme les salles sont isolées architecturalement les unes des autres, autant de salles peuvent être traitées en parallèle qu'il y a de gestionnaires en cours d'exécution.

En ce moment, nous avons 42 060 chambres dans le jeu. Un cluster de serveurs de 36 machines physiques quad-core contient 144 processeurs. Nous utilisons Redis pour créer des files d'attente, l'ensemble du backend est écrit dans Node.js.
Ce fut une étape du jeu tactique. Mais d'où viennent les équipes de joueurs? Les spécificités du jeu est qu'il n'y a pas d'interface où vous pouvez cliquer sur une unité et lui dire d'aller à un certain point ou de construire une structure spécifique. Le maximum qui peut être fait dans l'interface est de mettre un drapeau intangible au bon endroit dans la pièce. Pour que l'unité vienne à cet endroit et prenne les mesures nécessaires, il est nécessaire que votre script fasse quelque chose comme ceci pour plusieurs ticks de jeu:
module.exports.loop = function() { let creep = Game.creeps['Creep1']; let flag = Game.flags['Flag1']; if(!creep.pos.isEqualTo(flag.pos)) { creep.moveTo(flag.pos); } }
Il s'avère qu'à chaque étape du jeu, vous devez prendre la fonction de loop
du joueur, l'exécuter dans un environnement JavaScript à part entière de ce joueur particulier (dans lequel l'objet de Game
formé pour lui existe), obtenir un ensemble d'ordres pour les unités et les donner à la prochaine étape de traitement. Tout semble assez simple.

Les problèmes commencent quand il s'agit des nuances de mise en œuvre. À l'heure actuelle, nous avons 1600 joueurs actifs dans le monde. Les scripts de joueurs individuels ne peuvent déjà pas être appelés «scripts» - certains d'entre eux contiennent jusqu'à 25 000 lignes de code , sont compilés à partir de TypeScript ou même de C / C ++ / Rust via WebAssembly (oui, nous prenons en charge le wasm!), Et implémentent le concept de véritables systèmes d'exploitation miniatures, dans lequel les joueurs ont développé leur propre pool de processus de tâches de jeu et leur gestion à travers le noyau, qui prend autant de tâches qu'il s'avère effectuer sur un tact donné d'un jeu, les exécute et les remet en file d'attente jusqu'à la prochaine mesure. Étant donné que le processeur et la mémoire du lecteur sont limités à chaque cycle d'horloge, ce modèle fonctionne bien. Bien que ce ne soit pas obligatoire - pour démarrer le jeu, il suffit qu'un débutant prenne un script de 15 lignes, qui est également déjà écrit dans le cadre du tutoriel.
Mais rappelons-nous maintenant que le script du lecteur devrait fonctionner dans une vraie machine JavaScript. Et que le jeu fonctionne en temps réel - c'est-à-dire que la machine JavaScript de chaque joueur doit constamment exister, travailler à un certain rythme, afin de ne pas ralentir le jeu dans son ensemble. L'étape d'exécution des scripts de jeu et de formation des ordres pour les unités fonctionne approximativement sur le même principe que les salles de traitement - le script de chaque joueur est une tâche assumée par un gestionnaire de la piscine, de nombreux gestionnaires parallèles travaillent dans le cluster. Mais contrairement au stade des salles de traitement, il y a déjà beaucoup de difficultés.
Premièrement, vous ne pouvez pas simplement répartir les tâches par les gestionnaires au hasard à chaque cycle d'horloge, comme c'est possible dans le cas des salles. La machine JavaScript du lecteur devrait fonctionner sans interruption, chaque mesure suivante n'est qu'un nouvel appel de fonction de loop
, mais le contexte global devrait continuer d'exister de la même manière. En gros, le jeu vous permet de faire quelque chose comme ceci:
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; } }

Un tel fluage chantera sur une ligne de la chanson à chaque battement de jeu. Le numéro de ligne du counter
morceaux est stocké dans un contexte global qui est stocké entre les mesures. Si chaque fois que le script de ce joueur est exécuté dans un nouveau processus de gestionnaire, le contexte sera perdu. Cela signifie que tous les joueurs doivent être attribués à des gestionnaires spécifiques et qu'ils doivent être modifiés le moins possible. Mais qu'en est-il de l'équilibrage de charge? Un joueur peut passer 500 ms d'exécution sur ce nœud, et l'autre joueur peut passer 10 ms, et il est très difficile de le prévoir à l'avance. Si 20 joueurs de 500 ms tombent chacun sur un nœud, le fonctionnement d'un tel nœud prendra 10 secondes, pendant lesquelles tous les autres attendront son achèvement et resteront inactifs. Et pour rééquilibrer ces joueurs et les lancer vers d'autres nœuds, vous devez perdre leur contexte.
Deuxièmement, l'environnement du joueur doit être bien isolé des autres joueurs et de l'environnement du serveur. Et cela concerne non seulement la sécurité, mais aussi le confort des utilisateurs eux-mêmes. Si un joueur voisin fonctionnant sur le même nœud du cluster que moi le fait horriblement, génère beaucoup de déchets et se comporte généralement de manière incorrecte, alors je ne devrais pas le sentir. Étant donné que la ressource CPU dans le jeu est le temps d'exécution du script (il est calculé du début à la fin de la méthode de loop
), le gaspillage de ressources sur les tâches étrangères lors de l'exécution de mon script peut être très sensible, car il est dépensé à partir de mon budget de ressources CPU.
En essayant de résoudre ces problèmes, nous avons trouvé plusieurs solutions.
Première version
La première version du moteur de jeu était basée sur deux éléments de base:
- module
vm
temps plein dans la livraison de Node.js, - fork des processus d'exécution.
Cela ressemblait à ça. Sur chaque machine du cluster, il y avait 4 (selon le nombre de cœurs) processus de gestionnaires de script de jeu. Lorsqu'une nouvelle tâche a été reçue de la file d'attente de scripts de jeu, le gestionnaire a demandé les données nécessaires à la base de données et les a transférées au processus enfant, qui a été maintenu dans un état en cours d'exécution, redémarré en cas d'échec et réutilisé par différents joueurs. Le processus enfant, isolé du parent (qui contenait la logique métier du cluster), ne pouvait que faire une chose: créer un objet Game
partir des données reçues et démarrer la machine virtuelle du joueur. Pour commencer, nous avons utilisé le module vm
dans Node.js.
Pourquoi cette décision était-elle imparfaite? À strictement parler, les deux problèmes ci-dessus n'ont pas été résolus ici.
vm
fonctionne dans le même mode monothread que Node.js. Par conséquent, pour disposer de quatre processeurs parallèles sur chaque cœur d'une machine à 4 cœurs, vous devez disposer de 4 processus. Déplacer un acteur «vivant» dans un processus vers un autre processus conduit à une recréation complète du contexte global, même si cela se produit au sein de la même machine.

De plus, vm
ne crée pas réellement une machine virtuelle entièrement isolée. Ce qu'il fait est simplement de créer un contexte ou une portée isolé, mais d'exécuter le code dans la même instance de la machine virtuelle JavaScript, d'où vm.runInContext
appel vm.runInContext
. Et cela signifie - dans le même cas où d'autres joueurs sont lancés. Bien que les joueurs soient séparés par des contextes globaux isolés, mais, faisant partie de la même machine virtuelle, ils ont une mémoire de tas commune, un garbage collector commun et génèrent des déchets ensemble. Si le joueur «A» a généré beaucoup d'ordures pendant l'exécution de son script de jeu, le travail terminé et le contrôle passé au joueur «B», alors à ce moment-là toutes les ordures du processus peuvent être collectées, et le joueur «B» paiera du temps CPU pour la collecte la poubelle de quelqu'un d'autre. Sans parler du fait que tous les contextes fonctionnent dans la même boucle d'événements, et théoriquement il est possible d'exécuter la promesse de quelqu'un d'autre à tout moment, bien que nous ayons essayé d'empêcher cela. De plus, vm
ne vous permet pas de contrôler la quantité de mémoire de tas allouée pour l'exécution de script, toute la mémoire de processus est disponible.
vm-isolé
Il vit une personne si merveilleuse nommée Marcel Laverde. Pour certains, il est devenu remarquable pour avoir écrit une bibliothèque de fibres nodales, pour d'autres, pour avoir piraté Facebook et a été embauché pour y travailler . Et pour nous, il est merveilleux car il a généreusement participé à notre toute première campagne de crowdfunding et à ce jour est un grand fan de Screeps.
Notre projet est en open source depuis plusieurs années maintenant - le serveur de jeu est publié sur GitHub. Bien que le client officiel soit vendu moyennant des frais via Steam, il existe des versions alternatives de celui-ci, et le serveur lui-même est disponible pour étude et modification à n'importe quelle échelle, ce que nous encourageons vivement.
Et une fois que Marcel nous écrit: "Les gars, j'ai une bonne expérience en développement natif C / C ++ pour Node.js, et j'aime votre jeu, mais je n'aime pas comment ça fonctionne dans tout - écrivons-en un tout nouveau technologie de lancement de machine virtuelle pour Node.js spécifiquement pour Screeps? "
Comme Marcel n'a pas demandé d'argent, nous n'avons pas pu refuser. Après plusieurs mois de coopération, la bibliothèque isolated-vm est née. Et cela a absolument tout changé.
isolated-vm
diffère de vm
en ce qu'il n'isole pas le contexte , mais isole en termes de V8 . Sans entrer dans les détails, cela signifie qu'une instance distincte à part entière de la machine JavaScript est créée, qui a non seulement son propre contexte global, mais aussi sa propre mémoire de tas, le garbage collector et fonctionne dans le cadre d'une boucle d'événements distincte. Parmi les inconvénients: pour chaque machine en marche, une petite surcharge de RAM est requise (environ 20 Mo), et il est également impossible de transférer des objets ou d'appeler des fonctions directement dans la machine, l'ensemble du central doit être sérialisé. Cela met fin aux inconvénients, le reste - c'est juste une panacée!

Maintenant, il est vraiment possible d'exécuter le script de chaque joueur dans son propre espace complètement isolé. Le joueur a ses propres 500 Mo de hanche, si cela se termine, cela signifie que c'est votre propre hanche qui a pris fin, et non la hanche du processus global. Si vous avez généré des déchets - alors ce sont vos propres déchets, vous devez les collecter. Les promesses pendantes ne seront exécutées que lorsque votre isolat prendra le relais la prochaine fois, et pas plus tôt. Eh bien et la sécurité - en aucun cas il n'est possible d'accéder quelque part en dehors de l'isolat, uniquement si vous trouvez quelque part une vulnérabilité au niveau V8.
Mais qu'en est-il de l'équilibre? Un autre avantage de isolated-vm est qu'il démarre les machines à partir du même processus, mais dans des threads séparés (l'expérience de Marcel avec les fibres nodales est ici utile). Si nous avons une machine à 4 cœurs, nous pouvons créer un pool de 4 threads et démarrer 4 machines parallèles en même temps. Dans le même temps, étant dans le même processus, ce qui signifie avoir une mémoire commune, nous pouvons transférer n'importe quel joueur d'un thread à un autre à l'intérieur de ce pool. Bien que chaque joueur reste lié à un processus spécifique sur une machine spécifique (afin de ne pas perdre le contexte global), l'équilibre entre 4 threads est suffisant pour résoudre les problèmes de distribution des joueurs "lourds" et "légers" entre les nœuds afin que tous les processeurs finissent travailler en même temps et à l'heure.
Après avoir exécuté cette fonction en mode expérimental, nous avons reçu une énorme quantité de commentaires positifs de joueurs dont les scripts ont commencé à fonctionner beaucoup mieux, plus stables et plus prévisibles. Et maintenant, c'est notre moteur par défaut, bien que les joueurs puissent toujours choisir le runtime hérité uniquement pour une compatibilité descendante avec les anciens scripts (certains joueurs se sont consciemment concentrés sur les spécificités de l'environnement partagé dans le jeu).
Bien sûr, il y a encore de la place pour l'optimisation, et il y a aussi d'autres domaines intéressants du projet dans lesquels nous avons résolu divers problèmes techniques. Mais plus à ce sujet une autre fois.