Pixockets: comment nous avons écrit notre propre bibliothèque réseau pour le serveur de jeux



salut! Connecté Stanislav Yablonsky, développeur serveur principal de Pixonic.

Quand je suis arrivé chez Pixonic, nos serveurs de jeux étaient des applications basées sur le SDK Photon Realtime : un framework multifonctionnel mais très lourd. Cette solution, semble-t-il, était de simplifier le travail avec le serveur. Il en était ainsi - jusqu'à un certain point.

Photon Realtime nous a liés à lui-même en devant l'utiliser pour échanger des données entre les joueurs et le serveur - et les a également liés à Windows, car il ne peut fonctionner que sur lui. Cela nous imposait des restrictions à la fois du point de vue de l'exécution (runtime): il était impossible de modifier de nombreux paramètres importants de la machine virtuelle .NET et du système d'exploitation. Nous sommes habitués à travailler avec des serveurs Linux, pas Windows. De plus, ils nous coûtent moins cher.

En outre, l'utilisation de Photon a affecté les performances à la fois sur le serveur et sur le client, et lors du profilage, une charge décente sur le garbage collector et une grande quantité de boxe / unboxing se sont formées.

En bref, la solution avec Photon Realtime était loin d'être optimale pour nous, et pendant longtemps, il a fallu faire quelque chose avec elle - mais il y avait toujours des tâches plus urgentes et les mains n'arrivaient pas à résoudre les problèmes avec le serveur.

Comme il était intéressant pour moi non seulement de résoudre le problème, mais aussi de mieux comprendre le réseau, j'ai décidé de prendre l'initiative de mes propres mains et d'essayer d'écrire moi-même une bibliothèque. Mais, vous comprenez, à la maison - à la maison, au travail - au travail, par conséquent, le temps de développer la bibliothèque n'était que dans les transports. Cependant, cela n'a pas empêché l'idée de se concrétiser.

Ce qui en est ressorti - lisez la suite.

Idéologie de la bibliothèque


Puisque nous développons des jeux en ligne, il est très important pour nous de travailler sans pause, donc les frais généraux faibles sont devenus la principale exigence de la bibliothèque. Pour nous, il s'agit tout d'abord d'une faible charge sur le ramasse-miettes. Pour y parvenir, j'ai essayé d'éviter les allocations, et dans les cas où cela était difficile à réaliser ou ne fonctionnait pas du tout, nous avons fait des pools (pour les tampons d'octets, les états de connexion, les en-têtes, etc.).

Pour la simplicité et la commodité du support et de l'assemblage, nous avons commencé à utiliser uniquement des sockets C # et système. De plus, il était important de respecter le budget horaire par trame, car les données du serveur auraient dû arriver à temps. Par conséquent, j'ai essayé de réduire le temps d'exécution, même au prix d'une certaine non-optimalité: c'est-à-dire qu'il valait parfois la peine de remplacer les algorithmes et les structures de données rapides et partiellement plus complexes par des algorithmes plus simples et plus prévisibles. Par exemple, nous n'avons pas utilisé de files d'attente sans verrouillage, car elles ont créé une charge sur le garbage collector.

Typiquement pour les tireurs multijoueurs, nos données sont envoyées via UDP. En plus de cela, une fragmentation et un assemblage de paquets supplémentaires pour l'envoi de données d'une taille supérieure à la taille de la trame ont été ajoutés, ainsi qu'une livraison fiable grâce au transfert et à l'établissement d'une connexion.

Par défaut, la trame UDP de notre bibliothèque est de 1 200 octets. Les paquets de cette taille doivent être transmis dans les réseaux modernes avec un risque de fragmentation assez faible, car le MTU dans la plupart des réseaux modernes est supérieur à cette valeur. En même temps, ce montant est généralement suffisant pour s'adapter aux modifications qui doivent être envoyées au joueur après le prochain tick (mise à jour du statut) dans le jeu.

Architecture


Dans notre bibliothèque, nous utilisons un socket à deux couches:

  • La première couche est chargée de travailler avec les appels système et fournit une API plus pratique pour le niveau suivant;
  • La deuxième couche est le travail direct avec la session, la fragmentation / assemblage des paquets, leur transmission, etc.



La classe pour travailler avec la connexion, à son tour, est également divisée en deux niveaux:

  • Le niveau inférieur (SockBase) est responsable de l'envoi et de la réception des données via UDP. Il s'agit d'une enveloppe mince sur un objet système de socket.
  • Top Level (SmartSock) offre des fonctionnalités supplémentaires par rapport à UDP. Couper et coller des colis, transmettre des données non atteintes, rejet de doublons - tout cela est de son ressort.

Le niveau inférieur est divisé en deux classes: BareSock et ThreadSock.

  • BareSock fonctionne dans le même thread où l'appel a commencé, envoyant et recevant des données en mode non bloquant.
  • ThreadSock place les paquets dans les files d'attente et crée ainsi des threads séparés pour l'envoi et la réception de données. Lorsque vous y accédez, il n'y a qu'une seule opération: ajouter ou supprimer des données de la file d'attente.

BareSock est souvent utilisé pour travailler avec le client, ThreadSock - avec le serveur.

Caractéristiques du travail


J'ai également écrit deux types de sockets de bas niveau:

  • Le premier est synchrone monothread. Dans ce document, nous obtenons la surcharge minimale pour la mémoire et le processeur, mais en même temps, les appels système se produisent directement lors de l'accès au socket. Cela minimise les frais généraux en général (pas besoin d'utiliser des files d'attente et des tampons supplémentaires), mais l'appel lui-même peut prendre plus de temps que de prendre un élément de la file d'attente.
  • Le second est asynchrone avec des threads séparés pour la lecture et l'écriture. Dans ce cas, nous obtenons une surcharge supplémentaire pour la file d'attente, la synchronisation et le temps d'envoi / réception (en quelques millisecondes), car au moment de l'accès au socket, le thread de lecture ou d'écriture est suspendu.

Nous avons également essayé d'utiliser SocketAsyncEventArgs - peut-être l'API de mise en réseau la plus avancée de .NET que je connaisse. Mais il s'est avéré que cela ne fonctionne probablement pas pour UDP: la pile TCP fonctionne correctement, mais UDP donne des erreurs sur l'obtention de trames étrangement coupées et même sur le plantage dans .NET - comme si la mémoire dans la partie native de la machine virtuelle était corrompue. Je n'ai pas trouvé d'exemples de fonctionnement d'un tel système.

Une autre caractéristique importante de notre bibliothèque est la perte de données réduite. Nous avons eu l'impression que pour se débarrasser des doublons, de nombreuses bibliothèques éliminent les anciens paquets de données, comme nous l'avons vu plus tard par notre propre expérience. Bien sûr, une telle implémentation est beaucoup plus simple, car dans son cas un compteur avec le numéro de la dernière trame arrivée suffit, mais cela ne nous convenait pas beaucoup. Par conséquent, Pixockets utilise un tampon circulaire à partir des numéros des dernières images pour filtrer les doublons: les numéros nouvellement arrivés sont remplacés au lieu des anciens et les doublons sont recherchés parmi les dernières images reçues.



Ainsi, si un paquet a été envoyé avant la trame actuelle, mais est venu après, il atteindra toujours la destination. Cela peut grandement aider, par exemple, dans le cas d'une interpolation de position. Dans ce cas, nous aurons une histoire plus complète.

Structure des paquets de données


Les données de la bibliothèque sont transmises comme suit:



Au début du package se trouve l'en-tête:

  • Cela commence par la taille du paquet, qui à son tour est limitée à 64 kilo-octets.
  • La taille est suivie d'un octet avec des drapeaux. L'interprétation du reste du titre dépend de leur disponibilité.
  • Vient ensuite l'identifiant de la session ou de la connexion.

Avec les drapeaux appropriés, nous obtenons alors:

  • Si le drapeau avec le numéro de paquet à son tour est activé, le numéro de paquet est transmis après l'identifiant de session.
  • Le suivre - également dans le cas du jeu de drapeaux - le nombre de paquets confirmés et leur nombre.

À la fin de l'en-tête se trouvent des informations sur le fragment:

  • identifiant de la séquence de fragments, nécessaire pour distinguer les fragments de différents messages;
  • numéro de séquence du fragment;
  • nombre total de fragments dans le message.

Les informations sur le fragment nécessitent également de définir l'indicateur correspondant.

La bibliothèque est écrite. Et après?


Afin d'avoir des informations de connexion synchrone plus précises, nous avons ensuite organisé une connexion explicite. Cela nous a aidés à comprendre clairement les situations où un côté pense que la connexion est établie et non interrompue, et l'autre - qu'elle a été interrompue.

Dans la première version de Pixockets, ce n'était pas le cas: le client n'avait pas besoin d'appeler la méthode Connect (hôte, port) - il a juste commencé à envoyer des données à une adresse et un port connus. Ensuite, le serveur a appelé la méthode Listen (port) et a commencé à recevoir des données d'une adresse spécifique. Les données de session ont été initialisées lors de la réception / transmission du paquet.

Maintenant, pour établir une connexion, une «prise de contact» est devenue nécessaire - l'échange de paquets spécialement formés - et le client doit appeler Connect.

De plus, un de mes collègues a bifurqué la bibliothèque, en accordant plus d'attention à la sécurité du réseau et en ajoutant également certaines fonctionnalités, telles que la possibilité de se reconnecter directement à l'intérieur du socket: par exemple, lors du basculement entre le Wi-Fi et la 4G, la connexion est maintenant rétablie automatiquement. Mais nous en parlerons plus tard.

Essai


Bien sûr, nous avons écrit des tests unitaires pour la bibliothèque: ils vérifient toutes les principales façons d'établir une connexion, d'envoyer et de recevoir des données, la fragmentation et l'assemblage des paquets, diverses anomalies dans l'envoi et la réception de données - telles que la duplication, la perte, la non-correspondance dans l'ordre d'envoi et de réception. Pour la vérification des performances initiale, j'ai écrit des applications de test spéciales pour les tests d'intégration: un client ping, un serveur ping et une application qui synchronise la position, la couleur et le nombre de cercles colorés à l'écran sur le réseau.

Après que les applications de test ont prouvé l'efficacité de notre bibliothèque, nous avons commencé à la comparer avec d'autres bibliothèques: avec notre ancien Photon Realtime et avec la bibliothèque UDP LiteNetLib 0.7.

Nous avons testé une version simplifiée d'un serveur de jeu qui recueille simplement les données des joueurs et renvoie le résultat «collé». Nous avons pris 500 joueurs dans des salles de 6 personnes, le taux de rafraîchissement est de 30 fois par seconde.



La charge sur le garbage collector et la consommation du processeur s'est avérée inférieure dans le cas des Pixockets, ainsi que le pourcentage de paquets manquants - apparemment en raison du fait que, contrairement aux autres versions UDP, nous n'ignorons pas les paquets en retard.

Après avoir reçu la confirmation de l'avantage de notre solution dans les tests synthétiques, l'étape suivante consistait à exécuter la bibliothèque sur un projet réel.

À cette époque, dans le projet que nous avons sélectionné, les clients et les serveurs de jeux étaient synchronisés via Photon Server. J'ai ajouté la prise en charge Pixockets au client et au serveur, ce qui permet de contrôler le choix du protocole à partir du serveur de matchmaking - celui auquel les clients envoient une demande pour entrer dans le jeu.

Pendant une certaine période, les clients ont joué simultanément sur les deux protocoles, et à ce moment-là, nous avons collecté des statistiques sur la façon dont ils fonctionnaient. À la fin de la collecte des statistiques, il s'est avéré que les résultats ne diffèrent pas des tests synthétiques: la charge sur le garbage collector et le processeur a diminué, la perte de paquets aussi. Dans le même temps, le ping est devenu un peu plus bas. Par conséquent, la prochaine version du jeu a déjà été publiée complètement sur Pixockets sans utiliser le SDK Photon Realtime.



Plans futurs


Maintenant, nous voulons implémenter les fonctionnalités suivantes dans la bibliothèque:

  • Connexion simplifiée: maintenant, elle ne fonctionne pas de manière optimale et après avoir appelé Connect sur le client, vous devez appeler Read jusqu'à ce que l'état de la connexion change;
  • Arrêt explicite: pour le moment, l'arrêt de l'autre côté se produit uniquement par minuterie;
  • Pings intégrés pour maintenir la connectivité;
  • Détermination automatique de la taille de trame optimale (seule une constante est désormais utilisée).

Vous pouvez visualiser et participer au développement ultérieur de Pixockets à l'adresse du référentiel.

All Articles