Pixockets: como escrevemos nossa própria biblioteca de rede para o servidor do jogo



Olá! Stanislav Yablonsky conectado, desenvolvedor de servidores líder da Pixonic.

Quando cheguei à Pixonic, nossos servidores de jogos eram aplicativos baseados no Photon Realtime SDK : uma estrutura multifuncional, mas muito pesada. Parece que essa solução era simplificar o trabalho com o servidor. Então foi - até um certo ponto.

O Photon Realtime nos vinculou a si próprio ao usá-lo para trocar dados entre jogadores e o servidor - e também ao Windows, pois ele só pode funcionar nele. Isso nos impôs restrições do ponto de vista do tempo de execução (tempo de execução): era impossível alterar muitas configurações importantes da máquina virtual .NET e do sistema operacional. Estamos acostumados a trabalhar com servidores Linux, não com Windows. Além disso, eles nos custam menos.

Além disso, o uso do Photon atingiu o desempenho no servidor e no cliente e, ao criar um perfil, uma carga decente no coletor de lixo e uma grande quantidade de boxe / unboxing formada.

Em resumo, a solução com o Photon Realtime estava longe de ser ideal para nós e, por um longo tempo, foi necessário fazer algo com ele - mas sempre havia tarefas mais urgentes e as mãos não alcançavam a solução de problemas com o servidor.

Como era interessante para mim não apenas resolver o problema, mas também entender melhor a rede, decidi tomar a iniciativa com minhas próprias mãos e tentar escrever uma biblioteca. Mas, em casa - em casa, no trabalho -, como resultado, o tempo para desenvolver a biblioteca era apenas no transporte. No entanto, isso não impediu a ideia de se concretizar.

O que aconteceu - continue lendo.

Ideologia da biblioteca


Como estamos desenvolvendo jogos on-line, é muito importante trabalhar sem pausas; portanto, as despesas gerais baixas se tornaram o principal requisito para a biblioteca. Para nós, isso é, antes de tudo, uma carga baixa no coletor de lixo. Para alcançá-lo, tentei evitar alocações e, nos casos em que era difícil de alcançar ou não deu certo, criamos pools (para buffers de bytes, estados de conexão, cabeçalhos etc.).

Para simplificar e facilitar o suporte e a montagem, começamos a usar apenas C # e soquetes do sistema. Além disso, era importante ajustar-se ao orçamento de tempo por quadro, porque os dados do servidor deveriam ter chegado a tempo. Portanto, tentei reduzir o tempo de execução, mesmo ao custo de alguma não otimização: isto é, em alguns lugares, valia a pena substituir os algoritmos e estruturas de dados rápidos e parcialmente mais complexos por outros mais simples e previsíveis. Por exemplo, não usamos filas sem bloqueio, pois elas criaram uma carga no coletor de lixo.

Normalmente, para atiradores multiplayer, nossos dados são enviados via UDP. Ainda por cima, foram adicionadas fragmentação e montagem de pacotes para o envio de dados de tamanho maior que o tamanho do quadro, além de entrega confiável devido ao encaminhamento e estabelecimento de uma conexão.

O quadro UDP em nossa biblioteca tem como padrão 1200 bytes. Pacotes desse tamanho devem ser transmitidos em redes modernas com um risco bastante baixo de fragmentação, já que o MTU na maioria das redes modernas é superior a esse valor. Ao mesmo tempo, geralmente esse valor é suficiente para ajustar as alterações que precisam ser enviadas ao jogador após o próximo tique (atualização de status) no jogo.

Arquitetura


Em nossa biblioteca, usamos um soquete de duas camadas:

  • A primeira camada é responsável por trabalhar com chamadas do sistema e fornece uma API mais conveniente para o próximo nível;
  • A segunda camada é trabalhar diretamente com a sessão, fragmentação / montagem de pacotes, encaminhamento, etc.



A classe para trabalhar com conexão, por sua vez, também é dividida em dois níveis:

  • O nível inferior (SockBase) é responsável pelo envio e recebimento de dados pelo UDP. É um invólucro fino sobre um objeto de sistema de soquete.
  • Nível superior (SmartSock) fornece funcionalidade adicional sobre UDP. Recortar e colar pacotes, encaminhar dados que não chegaram, rejeitar duplicatas - tudo isso é sua área de responsabilidade.

O nível inferior é dividido em duas classes: BareSock e ThreadSock.

  • O BareSock funciona no mesmo segmento em que a chamada foi originada, enviando e recebendo dados no modo sem bloqueio.
  • O ThreadSock coloca pacotes em filas e, portanto, cria threads separados para enviar e receber dados. Ao acessá-lo, há apenas uma operação: adicionar ou remover dados da fila.

O BareSock é frequentemente usado para trabalhar com o cliente ThreadSock - com o servidor.

Características do trabalho


Também escrevi dois tipos de soquetes de baixo nível:

  • O primeiro é o thread único síncrono. Nele, obtemos a sobrecarga mínima para memória e processador, mas, ao mesmo tempo, as chamadas do sistema ocorrem diretamente ao acessar o soquete. Isso minimiza a sobrecarga em geral (não é necessário usar filas e buffers adicionais), mas a chamada em si pode demorar mais do que retirar um item da fila.
  • O segundo é assíncrono com threads separados para leitura e gravação. Nesse caso, obtemos uma sobrecarga adicional para o tempo de fila, sincronização e envio / recebimento (dentro de alguns milissegundos), pois no momento do acesso ao soquete, o encadeamento de leitura ou gravação está pausado.

Também tentamos usar SocketAsyncEventArgs - talvez a API de rede mais avançada em .NET que eu conheço. Mas aconteceu que provavelmente não funciona para o UDP: a pilha TCP funciona bem, mas o UDP comete erros ao obter quadros estranhamente cortados e até travar no .NET - como se a memória na parte nativa da máquina virtual estivesse corrompida. Não encontrei exemplos da operação desse esquema.

Outra característica importante da nossa biblioteca é a perda de dados reduzida. Ficamos com a impressão de que, para se livrar de duplicatas, muitas bibliotecas descartam pacotes de dados antigos, como vimos mais tarde em nossa própria experiência. Obviamente, essa implementação é muito mais simples, porque, no seu caso, basta um contador com o número do último quadro recebido, mas não nos serviu muito. Portanto, o Pixockets usa um buffer circular dos números dos últimos quadros para filtrar duplicatas: os números recém-chegados são substituídos em vez dos antigos e as duplicatas são pesquisadas entre os últimos quadros recebidos.



Portanto, se um pacote foi enviado antes do quadro atual, mas veio depois, ele ainda chegará ao destino. Isso pode ajudar muito, por exemplo, no caso de interpolação de posição. Nesse caso, teremos uma história mais completa.

Estrutura de pacotes de dados


Os dados na biblioteca são transmitidos da seguinte forma:



No início do pacote está o cabeçalho:

  • Começa com o tamanho do pacote, que por sua vez é limitado a 64 kilobytes.
  • O tamanho é seguido por um byte com sinalizadores. A interpretação do restante do título depende da disponibilidade deles.
  • A seguir, é o identificador da sessão ou conexão.

Com os sinalizadores apropriados, obtemos:

  • Se o sinalizador com o número do pacote, por sua vez, estiver definido, o número do pacote será transmitido após o identificador da sessão.
  • Seguindo-o - também no caso do conjunto de sinalizadores - o número de pacotes confirmados e seus números.

No final do cabeçalho, há informações sobre o fragmento:

  • identificador da sequência de fragmentos, necessária para distinguir fragmentos de diferentes mensagens;
  • número de sequência do fragmento;
  • número total de fragmentos na mensagem.

Informações sobre o fragmento também exigem a configuração do sinalizador correspondente.

A biblioteca está gravada. Qual é o próximo?


Para obter informações de conexão síncrona mais precisas, organizamos mais tarde uma conexão explícita. Isso nos ajudou a entender claramente as situações em que um lado pensa que a conexão está estabelecida e não é interrompida, e o outro - que foi interrompido.

Na primeira versão do Pixockets, isso não era o seguinte: o cliente não precisava chamar o método Connect (host, porta) - apenas começou a enviar dados para um endereço e porta conhecidos. Em seguida, o servidor chamou o método Listen (porta) e começou a receber dados de um endereço específico. Os dados da sessão foram inicializados após o recebimento / transmissão do pacote.

Agora, para estabelecer uma conexão, um "aperto de mão" tornou-se necessário - a troca de pacotes especialmente formados - e o cliente deve ligar para o Connect.

Além disso, um dos meus colegas bifurcou a biblioteca, prestando mais atenção à segurança da rede e também adicionando alguns recursos, como a capacidade de se reconectar diretamente dentro do soquete: por exemplo, ao alternar entre Wi-Fi e 4G, a conexão agora é restaurada automaticamente. Mas falaremos sobre isso mais tarde.

Teste


Obviamente, escrevemos testes de unidade para a biblioteca: eles verificam todas as principais maneiras de estabelecer uma conexão, enviar e receber dados, fragmentação e montagem de pacotes, várias anomalias no envio e recebimento de dados - como duplicação, perda, incompatibilidade na ordem de envio e recebimento. Para a verificação de desempenho inicial, escrevi aplicativos de teste especiais para testes de integração: um cliente de ping, um servidor de ping e um aplicativo que sincroniza a posição, cor e número de círculos coloridos na tela pela rede.

Depois que os aplicativos de teste comprovaram a eficiência de nossa biblioteca, começamos a compará-lo com outras bibliotecas: com nosso antigo Photon Realtime e com a biblioteca UDP LiteNetLib 0.7.

Testamos uma versão simplificada de um servidor de jogo que simplesmente coleta informações dos jogadores e envia de volta o resultado "colado". Levamos 500 jogadores em salas de 6 pessoas, a taxa de atualização é de 30 vezes por segundo.



A carga no consumo do coletor de lixo e do processador acabou sendo menor no caso dos Pixockets, bem como a porcentagem de pacotes ausentes - aparentemente devido ao fato de que, diferentemente de outras versões do UDP, não ignoramos os pacotes atrasados.

Depois que recebemos a confirmação da vantagem de nossa solução em testes sintéticos, o próximo passo foi executar a biblioteca em um projeto real.

Naquele momento, no projeto que selecionamos, clientes e servidores de jogos foram sincronizados através do Photon Server. Adicionei suporte ao Pixockets no cliente e no servidor, possibilitando controlar a escolha do protocolo no servidor de matchmaking - aquele para o qual os clientes enviam uma solicitação para entrar no jogo.

Por um período, os clientes tocaram simultaneamente nos dois protocolos e, na época, coletamos estatísticas sobre o desempenho deles. No final da coleta de estatísticas, os resultados não diferem dos testes sintéticos: a carga no coletor de lixo e no processador diminuiu, a perda de pacotes também. Ao mesmo tempo, o ping ficou um pouco mais baixo. Portanto, a próxima versão do jogo já foi lançada completamente no Pixockets sem o uso do Photon Realtime SDK.



Planos futuros


Agora, queremos implementar os seguintes recursos na biblioteca:

  • Conexão simplificada: agora não funciona da melhor maneira possível e, depois de ligar para o Connect no cliente, você precisa ligar para Ler até que o status da conexão seja alterado;
  • Desligamento explícito: no momento, o desligamento do outro lado ocorre apenas pelo temporizador;
  • Pings internos para manter a conectividade;
  • Determinação automática do tamanho ideal do quadro (agora apenas uma constante é usada).

Você pode visualizar e participar do desenvolvimento futuro do Pixockets no endereço do repositório.

All Articles