Neste artigo, falarei sobre uma tecnologia pouco conhecida que encontrou um aplicativo-chave em nosso jogo online para programadores. Para não puxar a borracha por um longo tempo, há um spoiler imediatamente: parece que ninguém fez tal xamanismo no código nativo do Node.js. ao qual chegamos após vários anos de desenvolvimento. O mecanismo de máquina virtual isolada (código aberto), executado sob o capô do projeto, foi escrito especificamente para suas necessidades e atualmente é usado na produção por nós e por outra startup. E os recursos de isolamento que ele fornece são únicos e merecem ser informados sobre eles.
Mas vamos falar sobre tudo em ordem.
Antecedentes
Você gosta de programar? Não é a rotina da empresa que muitos de nós somos obrigados a fazer 40 horas por semana, lutando com a procrastinação, derramando litros de café e queimando profissionalmente; e programação é um processo mágico incomparável de transformar pensamentos em um programa de trabalho, aproveitando o fato de que o código que você acabou de escrever está incorporado na tela e começa a viver a vida que o criador pede. Nesses momentos, quero escrever a palavra "Criador" com uma letra maiúscula - um sentimento que surge no processo às vezes é quase uma reverência.

É uma pena que muito poucos projetos reais relacionados aos ganhos diários possam oferecer a seus desenvolvedores tais sentimentos. Na maioria das vezes, para não perder a paixão pela programação, os entusiastas precisam começar um caso paralelo: um hobby de programação, um projeto para animais de estimação, um código aberto na moda, apenas um script em python para automatizar sua casa inteligente ... ou o comportamento de um personagem em algum popular online jogo.
Sim, são os jogos online que geralmente fornecem uma fonte inesgotável de inspiração para os programadores. Até os primeiros jogos desse gênero (Ultima Online, Everquest, sem mencionar todos os tipos de MUDs) atraíram muitos artesãos que não se interessam tanto em desempenhar o papel e apreciar a fantasia do mundo, mas na aplicação de seus talentos para automatizar tudo e tudo em espaço de jogo virtual. E até hoje, isso continua sendo uma disciplina especial da Olimpíada de Jogos MMO on-line: refinar sua mente ao escrever seu bot para que ele passe despercebido pela administração e obtenha o lucro máximo em comparação com outros jogadores. Ou outros bots - como, por exemplo, no EVE Online, onde a negociação em mercados densamente povoados é um pouco menos do que totalmente controlada por scripts de negociação, assim como em bolsas reais.
A ideia de um jogo online, inicialmente e completamente orientada para programadores, pairava no ar. Tal jogo em que escrever um bot não é um ato punível, mas a essência da jogabilidade. Onde a tarefa seria não executar as mesmas ações "Matar monstros X e encontrar itens Y" de tempos em tempos, mas escrever um script que possa executar corretamente essas ações em seu nome. E como implica um jogo online no gênero MMO, a rivalidade ocorre com os scripts de outros jogadores em tempo real em um único mundo de jogo comum.
Então, em 2014, o jogo Screeps (das palavras "Scripts" e "creeps") apareceu - uma caixa de areia MMO estratégica em tempo real com um único mundo persistente grande no qual os jogadores não influenciam o que está acontecendo, exceto escrevendo scripts de IA para suas unidades de jogo . Toda a mecânica de um jogo estratégico comum - extração de recursos, construção de unidades, construção de uma base, tomada de territórios, fabricação e comercialização - o próprio jogador precisa ser programado através da API JavaScript fornecida pelo mundo do jogo. A diferença das diferentes competições na criação de IA é que o mundo do jogo, como deveria estar no mundo dos jogos on-line, constantemente trabalha e vive sua vida em tempo real 24/7 nos últimos 4 anos, lançando a IA de cada jogador a cada ciclo de jogo.
Portanto, basta o jogo em si - isso deve ser suficiente para entender melhor a essência dos problemas técnicos que encontramos durante o desenvolvimento. Você pode obter mais visualizações deste vídeo, mas isso é opcional:
Questões técnicas
A essência da mecânica do mundo do jogo é a seguinte: o mundo inteiro é dividido em salas , que são interconectadas por saídas em quatro pontos cardeais. Uma sala é uma unidade atômica do processo de processamento do estado do mundo do jogo. A sala pode ter certos objetos (por exemplo, unidades) que têm seu próprio estado e, em cada etapa do jogo, eles recebem comandos dos jogadores. O manipulador de servidor ocupa uma sala de cada vez, executa esses comandos, alterando o estado dos objetos e confirma o novo estado da sala no banco de dados. Esse sistema é dimensionado bem horizontalmente: você pode adicionar mais manipuladores ao cluster e, como as salas são arquitetonicamente isoladas uma da outra, é possível processar tantas salas em paralelo quanto os manipuladores em execução.

No momento, temos 42.060 quartos no jogo. Um cluster de servidores de 36 máquinas físicas de quatro núcleos contém 144 processadores. Usamos o Redis para criar filas, todo o back-end é escrito em Node.js.
Essa foi uma etapa do jogo. Mas de onde vêm as equipes de jogadores? A especificidade do jogo é que não há interface onde você possa clicar em uma unidade e pedir para ele ir a um determinado ponto ou construir uma estrutura específica. O máximo que pode ser feito na interface é colocar uma bandeira intangível no lugar certo na sala. Para que a unidade chegue a este local e tome as medidas necessárias, é necessário que o seu script faça algo como o seguinte para vários ticks do jogo:
module.exports.loop = function() { let creep = Game.creeps['Creep1']; let flag = Game.flags['Flag1']; if(!creep.pos.isEqualTo(flag.pos)) { creep.moveTo(flag.pos); } }
Acontece que, em cada etapa do jogo, você precisa loop
função de loop
do jogador, executá-la em um ambiente JavaScript completo desse jogador em particular (no qual o objeto Game
formado para ele existe), obter um conjunto de pedidos para as unidades e enviá-las para a próxima fase de processamento. Tudo parece ser bem simples.

Os problemas começam quando se trata das nuances da implementação. No momento, temos 1600 jogadores ativos no mundo. Os scripts de players individuais já não podem ser chamados de "scripts" - alguns deles contêm até 25k linhas de código , são compilados a partir de TypeScript ou mesmo a partir de C / C ++ / Rust via WebAssembly (sim, apoiamos wasm!), E implementamos o conceito de sistemas operacionais em miniatura reais, em que os jogadores desenvolveram seu próprio conjunto de tarefas - processos e gerenciamento de jogos por meio do núcleo, que executa tantas tarefas quantas forem necessárias para executar um determinado tato de um jogo, as executa e as coloca de volta nas filas até a próxima medida. Como a CPU e a memória do player são limitadas a cada ciclo de clock, este modelo funciona bem. Embora não seja obrigatório - para iniciar o jogo, é suficiente para um iniciante pegar um script de 15 linhas, que também já está escrito como parte do tutorial.
Mas agora vamos lembrar que o script do player deve funcionar em uma máquina JavaScript real. E que o jogo funcione em tempo real - ou seja, a máquina JavaScript de cada jogador deve existir constantemente, trabalhando em um determinado ritmo, para não desacelerar o jogo como um todo. O estágio de execução de scripts de jogos e formação de ordens para unidades funciona aproximadamente no mesmo princípio das salas de processamento - o script de cada jogador é uma tarefa que um manipulador do pool assume, muitos manipuladores paralelos trabalham no cluster. Mas, diferentemente do estágio das salas de processamento, já existem muitas dificuldades.
Em primeiro lugar, não é possível distribuir tarefas pelos manipuladores aleatoriamente em cada ciclo do relógio, como é possível fazer no caso de salas. A máquina JavaScript do jogador deve funcionar sem interrupção, cada medida subseqüente é apenas uma nova chamada de função de loop
, mas o contexto global deve continuar o mesmo. Grosso modo, o jogo permite que você faça algo assim:
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 fluência vai cantar em uma linha da música a cada jogo. O número da linha do counter
músicas é armazenado em um contexto global que é armazenado entre as medidas. Se cada vez que o script deste player for executado em um novo processo de tratamento, o contexto será perdido. Isso significa que todos os jogadores devem ser alocados para manipuladores específicos e devem ser alterados o menos possível. Mas e o balanceamento de carga? Um jogador pode gastar 500ms de execução nesse nó, e o outro jogador pode gastar 10ms, e é muito difícil prever isso com antecedência. Se 20 jogadores 500ms cada caírem em um nó, a operação desse nó levará 10 segundos, durante os quais todos os outros aguardarão sua conclusão e ficarão inativos. E para reequilibrar esses jogadores e jogá-los para outros nós, você precisa perder o contexto deles.
Em segundo lugar, o ambiente do jogador deve estar bem isolado de outros jogadores e do ambiente do servidor. E isso diz respeito não apenas à segurança, mas também ao conforto dos próprios usuários. Se um jogador vizinho rodando no mesmo nó no cluster que eu, horrivelmente, gera muito lixo e geralmente se comporta de maneira inadequada, não devo sentir isso. Como o recurso da CPU no jogo é o tempo de execução do script (é calculado do início ao fim do método de loop
), o desperdício de recursos em tarefas externas durante a execução do meu script pode ser muito sensível, porque é gasto com o orçamento de recursos da CPU.
Ao tentar lidar com esses problemas, criamos várias soluções.
Primeira versão
A primeira versão do mecanismo de jogo foi baseada em duas coisas básicas:
- módulo
vm
em tempo integral na entrega do Node.js, - bifurcação dos processos de tempo de execução.
Parecia assim. Em cada máquina do cluster, havia 4 processos (de acordo com o número de núcleos) de manipuladores de scripts de jogos. Quando uma nova tarefa era recebida da fila de scripts de jogos, o manipulador solicitava os dados necessários do banco de dados e transferia-os para o processo filho, que era mantido em um estado de execução constante, reiniciado em caso de falha e reutilizado por diferentes jogadores. O processo filho, sendo isolado do pai (que continha a lógica de negócios do cluster), só podia fazer uma coisa: criar um objeto Game
partir dos dados recebidos e iniciar a máquina virtual do jogador. Para começar, usamos o módulo vm
no Node.js.
Por que essa decisão foi imperfeita? A rigor, os dois problemas acima não foram resolvidos aqui.
vm
funciona no mesmo modo de thread único que o Node.js. Portanto, para ter quatro processadores paralelos em cada núcleo em uma máquina de 4 núcleos, você precisa ter 4 processos. Mover um jogador "vivendo" em um processo para outro processo leva a uma recriação completa do contexto global, mesmo que isso aconteça na mesma máquina.

Além disso, o vm
não cria realmente uma máquina virtual totalmente isolada. O que ele faz é apenas criar um contexto ou escopo isolado, mas executar o código na mesma instância da máquina virtual JavaScript, de onde vm.runInContext
chamada vm.runInContext
. E isso significa - no mesmo caso em que outros jogadores são lançados. Embora os players sejam separados por contextos globais isolados, mas, fazendo parte da mesma máquina virtual, eles têm uma memória de pilha comum, um coletor de lixo comum e geram lixo juntos. Se o jogador "A" gerou muito lixo durante a execução do script do jogo, o trabalho concluído e o controle foram passados para o jogador "B", nesse momento todo o lixo do processo pode ser coletado e o jogador "B" pagará o tempo da CPU para coletar o lixo de outra pessoa. Sem mencionar o fato de que todos os contextos funcionam no mesmo loop de eventos e, teoricamente, é possível executar a promessa de outra pessoa a qualquer momento, embora tenhamos tentado evitar isso. Além disso, o vm
não permite controlar a quantidade de memória heap que é alocada para a execução do script, toda a memória do processo está disponível.
vm isolado
Mora uma pessoa tão maravilhosa chamada Marcel Laverde. Para alguns, ele se tornou notável por escrever uma biblioteca de fibras de nó , para outros, por invadir o Facebook e foi contratado para trabalhar lá . E para nós ele é maravilhoso porque participou generosamente de nossa primeira campanha de crowdfunding e até hoje é um grande fã de Screeps.
Nosso projeto está em código aberto há vários anos - o servidor do jogo é publicado no GitHub. Embora o cliente oficial seja vendido por uma taxa via Steam, há versões alternativas e o próprio servidor está disponível para estudo e modificação em qualquer escala, o que é altamente recomendável.
E quando Marcel nos escreve: "Pessoal, tenho uma boa experiência no desenvolvimento nativo de C / C ++ para o Node.js, e gosto do seu jogo, mas não gosto de como ele funciona em tudo - vamos escrever um novo. tecnologia de lançamento de máquina virtual para Node.js especificamente para Screeps? ”
Como Marcel não pediu dinheiro, não poderíamos recusar. Após vários meses de nossa cooperação, a biblioteca isolated-vm nasceu. E isso mudou absolutamente tudo.
isolated-vm
difere de vm
por isolar não o contexto , mas isolar em termos de V8 . Sem entrar em detalhes, isso significa que é criada uma instância separada completa da máquina JavaScript, que possui não apenas seu próprio contexto global, mas também sua própria memória heap, coletor de lixo e funciona como parte de um loop de eventos separado. Das desvantagens: para cada máquina em execução, é necessária uma pequena sobrecarga de RAM (cerca de 20 MB) e também é impossível transferir objetos ou chamar funções diretamente para a máquina, toda a troca deve ser serializada. Isso acaba com os contras, o resto - é apenas uma panacéia!

Agora é realmente possível executar o script de cada jogador em seu próprio espaço completamente isolado. O jogador tem seus próprios 500 MB de quadril; se terminar, significa que é o seu próprio quadril que terminou, e não o quadril de todo o processo. Se você gerou lixo - então esse é seu próprio lixo, você precisa coletá-lo. As promessas de oscilação serão executadas somente quando o seu isolamento durar na próxima vez e não antes. Bem e segurança - sob nenhuma circunstância é possível acessar em algum lugar fora do isolado, apenas se você encontrar alguma vulnerabilidade no nível V8.
Mas e o balanceamento? Outra vantagem do isolated-vm é que ele inicia as máquinas do mesmo processo, mas em threads separados (a experiência de Marcel com fibras de nó foi útil aqui). Se tivermos uma máquina com 4 núcleos, podemos criar um pool de 4 threads e iniciar 4 máquinas paralelas ao mesmo tempo. Ao mesmo tempo, estando no mesmo processo, o que significa ter uma memória comum, podemos transferir qualquer jogador de um segmento para outro dentro desse pool. Embora cada player permaneça vinculado a um processo específico em uma máquina específica (para não perder o contexto global), o equilíbrio entre quatro threads é suficiente para resolver os problemas de distribuição de players "pesados" e "leves" entre os nós, para que todos os processadores terminem. trabalhar ao mesmo tempo e no horário.
Após executar esta função no modo experimental, recebemos uma enorme quantidade de feedback positivo de jogadores cujos scripts começaram a funcionar muito melhor, mais estável e mais previsível. E agora esse é o nosso mecanismo padrão, embora os jogadores ainda possam escolher o tempo de execução herdado apenas para compatibilidade com os scripts antigos (alguns jogadores conscientemente se concentraram nas especificidades do ambiente compartilhado no jogo).
Obviamente, ainda há espaço para otimização adicional, e também existem outras áreas interessantes do projeto nas quais resolvemos vários problemas técnicos. Mas mais sobre isso em outra hora.