En este artículo hablaré sobre una tecnología poco conocida que ha encontrado una aplicación clave en nuestro juego en línea para programadores. Para no tirar de la goma durante mucho tiempo, hay un spoiler de inmediato: parece que nadie ha hecho tal chamanismo en el código nativo de Node.js al que llegamos después de varios años de desarrollo. El motor de máquina virtual aislado (código abierto), que se ejecuta bajo el capó del proyecto, fue escrito específicamente para sus necesidades, y actualmente lo utilizamos en la producción nosotros y otra startup. Y las capacidades de aislamiento que ofrece son únicas y merecen ser informadas sobre ellas.
Pero hablemos de todo en orden.
Antecedentes
¿Te gusta la programación? No es la codificación empresarial de rutina que muchos de nosotros estamos obligados a hacer 40 horas a la semana, luchando con la dilación, vertiendo litros de café y quemando profesionalmente; y la programación es un proceso mágico incomparable de transformar los pensamientos en un programa de trabajo, disfrutando del hecho de que el código que acaba de escribir está incorporado en la pantalla y comienza a vivir la vida que el creador le dice. En esos momentos, quiero escribir la palabra "Creador" con una letra mayúscula; tal sentimiento que surge en el proceso a veces es cercano a la reverencia.

Es una pena que muy pocos proyectos reales relacionados con las ganancias diarias puedan ofrecer a sus desarrolladores tales sentimientos. La mayoría de las veces, para no perder la pasión por la programación, los entusiastas deben comenzar una aventura al margen: un pasatiempo de programación, un proyecto favorito, un código abierto de moda, solo un script de Python para automatizar su hogar inteligente ... o el comportamiento de un personaje en algún popular en línea juego
Sí, son los juegos en línea que a menudo proporcionan una fuente inagotable de inspiración para los programadores. Incluso los primeros juegos de este género (Ultima Online, Everquest, sin mencionar todo tipo de MUD) atrajeron a muchos artesanos que no están interesados tanto en interpretar el papel y disfrutar la fantasía del mundo, sino en la aplicación de sus talentos para automatizar todo y todo en espacio de juego virtual Y hasta el día de hoy, sigue siendo una disciplina especial de la Olimpiada de juegos MMO en línea: refina tu mente para escribir tu bot para pasar desapercibido por la administración y obtener el máximo beneficio en comparación con otros jugadores. U otros bots, como, por ejemplo, en EVE Online, donde el comercio en mercados densamente poblados está ligeramente menos que completamente controlado por scripts de comercio, al igual que en los intercambios reales.
La idea de un juego en línea, inicialmente y completamente orientada a los programadores, flotaba en el aire. Tal juego en el que escribir un bot no es un acto punible, sino la esencia del juego. Donde la tarea no sería realizar las mismas acciones "matar monstruos X y encontrar elementos Y" de vez en cuando, sino escribir un script que pueda realizar correctamente estas acciones en su nombre. Y dado que implica un juego en línea en el género MMO, la rivalidad tiene lugar con los guiones de otros jugadores en tiempo real en un solo mundo de juego común.
Entonces, en 2014, apareció el juego Screeps (de las palabras "Scripts" y "creeps"): una caja de arena MMO estratégica en tiempo real con un solo mundo grande y persistente en el que los jugadores no tienen influencia sobre lo que está sucediendo, excepto escribiendo scripts de IA para sus unidades de juego . Todas las mecánicas de un juego estratégico ordinario: extracción de recursos, construcción de unidades, construcción de una base, toma de territorios, fabricación y comercio: el propio jugador debe ser programado a través de la API de JavaScript proporcionada por el mundo del juego. La diferencia de las diferentes competencias en la escritura de IA es que el mundo del juego, como debería ser en el mundo de los juegos en línea, trabaja constantemente y vive su vida en tiempo real 24/7 durante los últimos 4 años, lanzando la IA de cada jugador en cada ciclo de juego.
Entonces, lo suficiente sobre el juego en sí, esto debería ser suficiente para comprender mejor la esencia de los problemas técnicos que encontramos durante el desarrollo. Puede obtener más vistas de este video, pero esto es opcional:
Problemas técnicos
La esencia de la mecánica del mundo del juego es la siguiente: el mundo entero está dividido en salas , que están interconectadas por salidas en cuatro puntos cardinales. Una habitación es una unidad atómica del proceso de procesamiento del estado del mundo del juego. La sala puede tener ciertos objetos (por ejemplo, unidades) que tienen su propio estado, y en cada paso del juego reciben comandos de los jugadores. El controlador del servidor ocupa una sala a la vez, ejecuta estos comandos, cambia el estado de los objetos y confirma el nuevo estado de la sala en la base de datos. Este sistema se escala bien horizontalmente: puede agregar más controladores al clúster, y dado que las salas están aisladas arquitectónicamente entre sí, se pueden procesar tantas salas en paralelo como hay controladores en ejecución.

Por el momento tenemos 42,060 habitaciones en el juego. Un grupo de servidores de 36 máquinas físicas de cuatro núcleos contiene 144 procesadores. Usamos Redis para crear colas, todo el backend está escrito en Node.js.
Esta fue una etapa del tacto del juego. ¿Pero de dónde vienen los equipos de jugadores? Los detalles del juego son que no hay una interfaz en la que puedas hacer clic en una unidad y decirle que vaya a cierto punto o que construya una estructura específica. Lo máximo que se puede hacer en la interfaz es colocar una bandera intangible en el lugar correcto de la habitación. Para que la unidad venga a este lugar y tome las medidas necesarias, es necesario que su script haga algo como lo siguiente para varios ticks del juego:
module.exports.loop = function() { let creep = Game.creeps['Creep1']; let flag = Game.flags['Flag1']; if(!creep.pos.isEqualTo(flag.pos)) { creep.moveTo(flag.pos); } }
Resulta que en cada paso del juego debes tomar la función de loop
del jugador, ejecutarla en un entorno JavaScript completo de ese jugador en particular (en el que existe el objeto del Game
creado para él), obtener un conjunto de órdenes para las unidades y darles la siguiente etapa de procesamiento. Todo parece ser bastante simple.

Los problemas comienzan cuando se trata de los matices de la implementación. En este momento tenemos 1600 jugadores activos en el mundo. Los scripts de reproductores individuales ya no se pueden llamar "scripts": algunos de ellos contienen hasta 25k líneas de código , se compilan a partir de TypeScript o incluso de C / C ++ / Rust a través de WebAssembly (sí, admitimos wasm!), E implementamos el concepto de sistemas operativos en miniatura reales, en el que los jugadores han desarrollado su propio grupo de tareas-procesos de juego y su gestión a través del núcleo, que toma tantas tareas como resulta realizar en un determinado toque de un juego, las ejecuta y las vuelve a poner en la cola hasta la próxima medida. Dado que la CPU y la memoria del reproductor están limitadas en cada ciclo de reloj, este modelo funciona bien. Aunque no es obligatorio, para comenzar el juego es suficiente que un principiante tome un guión de 15 líneas, que también está escrito como parte del tutorial.
Pero ahora recordemos que el script del reproductor debería funcionar en una máquina JavaScript real. Y que el juego funciona en tiempo real, es decir, la máquina JavaScript de cada jugador debe existir constantemente, trabajando a un cierto ritmo, para no ralentizar el juego en su conjunto. La etapa de ejecutar guiones de juego y formar órdenes para unidades funciona aproximadamente con el mismo principio que las salas de procesamiento: el guión de cada jugador es una tarea que un manejador del grupo asume, muchos manejadores paralelos trabajan en el grupo. Pero a diferencia de la etapa de salas de procesamiento, ya hay muchas dificultades.
En primer lugar, no puede simplemente distribuir tareas de forma aleatoria en cada ciclo de reloj, como es posible en el caso de las salas. La máquina JavaScript del reproductor debería funcionar sin interrupción, cada medida posterior es solo una nueva llamada de función de loop
, pero el contexto global debe seguir existiendo igual. En términos generales, el juego te permite hacer algo como esto:
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; } }

Tal asqueroso cantará en una línea de la canción cada latido del juego. El número de línea del counter
canciones se almacena en un contexto global que se almacena entre compases. Si cada vez que se ejecuta el script de este reproductor en un nuevo proceso de controlador, se perderá el contexto. Esto significa que todos los jugadores deberían asignarse a controladores específicos, y deberían cambiarse lo menos posible. Pero entonces, ¿qué pasa con el equilibrio de carga? Un jugador puede gastar 500 ms de ejecución en este nodo, y el otro jugador puede gastar 10 ms, y es muy difícil predecir esto por adelantado. Si 20 jugadores 500ms caen cada uno en un nodo, entonces la operación de dicho nodo tomará 10 segundos, durante los cuales todos los demás esperarán su finalización y permanecerán inactivos. Y para reequilibrar estos jugadores y lanzarlos a otros nodos, debes perder su contexto.
En segundo lugar, el entorno del jugador debe estar bien aislado de otros jugadores y del entorno del servidor. Y esto no solo concierne a la seguridad, sino también a la comodidad de los propios usuarios. Si un jugador vecino que se ejecuta en el mismo nodo del clúster que yo lo hace horriblemente, genera mucha basura y generalmente se comporta de manera incorrecta, entonces no debería sentirlo. Dado que el recurso de CPU en el juego es el tiempo de ejecución del script (se calcula desde el principio hasta el final del método de loop
), el desperdicio de recursos en tareas extrañas durante la ejecución de mi script puede ser muy sensible, ya que lo gasto del presupuesto de recursos de mi CPU.
Al tratar de resolver estos problemas, encontramos varias soluciones.
Primera versión
La primera versión del motor del juego se basó en dos cosas básicas:
- módulo
vm
tiempo completo en la entrega de Node.js, - bifurcación de procesos de tiempo de ejecución.
Se veía así. En cada máquina en el clúster había 4 (según el número de núcleos) procesos de controladores de guiones de juego. Cuando se recibió una nueva tarea de la cola de scripts del juego, el controlador solicitó los datos necesarios de la base de datos y los transfirió a un proceso secundario, que se mantuvo en un estado de ejecución constante, se reinició en caso de falla y fue reutilizado por diferentes jugadores. El proceso hijo, al estar aislado del padre (que contenía la lógica empresarial del clúster), solo podía hacer una cosa: crear un objeto Game
partir de los datos recibidos e iniciar la máquina virtual del jugador. Para comenzar, utilizamos el módulo vm
en Node.js.
¿Por qué esta decisión fue imperfecta? Estrictamente hablando, los dos problemas anteriores no se resolvieron aquí.
vm
funciona en el mismo modo de subproceso único que Node.js. Por lo tanto, para tener cuatro procesadores paralelos en cada núcleo en una máquina de 4 núcleos, debe tener 4 procesos. Mover a un jugador "vivo" en un proceso a otro proceso lleva a una recreación completa del contexto global, incluso si esto sucede dentro de la misma máquina.

Además, vm
realidad no crea una máquina virtual completamente aislada. Lo que hace es crear un contexto o ámbito aislado, pero ejecutar el código en la misma instancia de la máquina virtual JavaScript, de donde vm.runInContext
llamada vm.runInContext
. Y eso significa, en el mismo caso en que se lanzan otros jugadores. Aunque los jugadores están separados por contextos globales aislados, pero, al ser parte de la misma máquina virtual, tienen una memoria de almacenamiento dinámico común, un recolector de basura común y generan basura juntos. Si el jugador "A" generó mucha basura durante la ejecución de su script de juego, terminó el trabajo y el control pasó al jugador "B", entonces en ese momento se puede recolectar toda la basura del proceso, y el jugador "B" pagará el tiempo de CPU para recoger la basura de otra persona. Sin mencionar el hecho de que todos los contextos funcionan en el mismo bucle de eventos y, en teoría, es posible ejecutar la promesa de otra persona en cualquier momento, aunque tratamos de evitarlo. Además, vm
no le permite controlar la cantidad de memoria heap asignada para la ejecución del script, toda la memoria de proceso está disponible.
vm aislado
Allí vive una persona tan maravillosa llamada Marcel Laverde. Para algunos, una vez se hizo notable por escribir una biblioteca de fibras de nodo , para otros, por hackear Facebook y fue contratado para trabajar allí . Y para nosotros es maravilloso porque participó generosamente en nuestra primera campaña de crowdfunding y hasta el día de hoy es un gran admirador de Screeps.
Nuestro proyecto ha estado en código abierto durante varios años: el servidor de juegos se publica en GitHub. Aunque el cliente oficial se vende por una tarifa a través de Steam, existen versiones alternativas y el servidor está disponible para su estudio y modificación a cualquier escala, lo que recomendamos encarecidamente.
Y una vez que Marcel nos escribe: “Chicos, tengo una buena experiencia en el desarrollo nativo de C / C ++ para Node.js, y me gusta su juego, pero no a todos les gusta cómo funciona, escribamos uno nuevo ¿Tecnología de lanzamiento de máquina virtual para Node.js específicamente para Screeps?
Como Marcel no pidió dinero, no pudimos rechazarlo. Después de varios meses de nuestra cooperación, nació la biblioteca aislada-vm . Y eso cambió absolutamente todo.
isolated-vm
difiere de vm
en que no aísla el contexto , sino que lo aísla en términos de V8 . Sin entrar en detalles, esto significa que se crea una instancia separada completa de la máquina JavaScript, que no solo tiene su propio contexto global, sino también su propia memoria de almacenamiento dinámico, recolector de basura y funciona como parte de un bucle de eventos separado. De las desventajas: para cada máquina en ejecución, se requiere una pequeña sobrecarga de RAM (aproximadamente 20 MB), y también es imposible transferir objetos o llamar funciones directamente a la máquina, todo el intercambio debe ser serializado. Esto termina con los contras, el resto, ¡es solo una panacea!

Ahora es realmente posible ejecutar el script de cada jugador en su propio espacio completamente aislado. El jugador tiene sus propios 500 MB de cadera, si termina, significa que es su propia cadera la que ha terminado, y no la cadera del proceso general. Si generó basura, entonces esta es su propia basura, debe recolectarla. Las promesas pendientes se ejecutarán solo cuando su aislamiento se haga cargo la próxima vez, y no antes. Bien y seguridad: bajo ninguna circunstancia es posible acceder a algún lugar fuera del aislamiento, solo si encuentra alguna vulnerabilidad en el nivel V8.
¿Pero qué hay del equilibrio? Otra ventaja de isolated-vm es que inicia las máquinas desde el mismo proceso, pero en subprocesos separados (la experiencia de Marcel con fibras de nodo fue útil aquí). Si tenemos una máquina de 4 núcleos, podemos crear un grupo de 4 subprocesos e iniciar 4 máquinas paralelas a la vez. Al mismo tiempo, al estar dentro del mismo proceso, lo que significa tener una memoria común, podemos transferir cualquier jugador de un hilo a otro dentro de este grupo. Aunque cada jugador permanece atado a un proceso específico en una máquina específica (para no perder el contexto global), el equilibrio entre 4 hilos es suficiente para resolver los problemas de distribución de jugadores "pesados" y "ligeros" entre nodos para que todos los procesadores terminen trabajar al mismo tiempo y a tiempo.
Después de ejecutar esta función en el modo experimental, recibimos una gran cantidad de comentarios positivos de los jugadores cuyas secuencias de comandos comenzaron a funcionar mucho mejor, más estable y más predecible. Y ahora este es nuestro motor predeterminado, aunque los jugadores aún pueden elegir el tiempo de ejecución heredado puramente por compatibilidad con versiones anteriores de scripts (algunos jugadores se centraron conscientemente en los detalles del entorno compartido en el juego).
Por supuesto, todavía hay espacio para la optimización adicional, y también hay otras áreas interesantes del proyecto en las que resolvimos varios problemas técnicos. Pero más sobre eso en otro momento.