Concevoir son propre protocole réseau par-dessus UDP - MTU et congestion

Savoir transmettre des données est une chose, savoir ne pas en transmettre trop en est une autre.

Concevoir son propre protocole réseau par-dessus UDP - MTU et congestion

Envoyer des données c'est bien, ne pas en envoyer trop c'est mieux.

Sommaire

Dans le chapitre précédent nous avons vu comment implémenter un protocole connecté, fiable, ordonné et assurant l'intégrité des données.

Bien que ce soit suffisant pour commencer, il est impératif de prendre connaissance de deux notions liées à la taille et au volume de données qu'on souhaite transmettre, afin d'assurer que nos données transitent correctement et sans surcharger la connexion de l'utilisateur.

La taille maximale des paquets (MTU)

Le MTU, pour Maximum Transmission Unit, désigne la taille maximale acceptée par la route réseau que vos paquets empruntent. C'est-à-dire la taille de paquet maximale qui va être acceptée par l'ensemble des routeurs qui feront transiter vos paquets.

Envoyer un paquet dépassant cette taille est risqué : par défaut cela entraînera une fragmentation en IPv4[1] et en IPv6, un rejet pur et simple du paquet (et donc une perte).

De ce fait, votre propre protocole doit être capable, au niveau de l'application, de découper le paquet en plusieurs "fragments" qui se recomposeront à l'arrivée. Une façon assez courante de faire ça consiste à envoyer un paquet de type "début de fragment" (contenant le nombre de fragments à suivre) ainsi que des paquets de type "fragment" continuant la suite (et indiquant à chaque fois quel fragment ils représentent sur l'ensemble).
Lorsque vous avez reçu tous les fragments, vous avez votre paquet, si vous avez perdu un fragment (et que vous ne souhaitez pas qu'il soit renvoyé) alors l'ensemble est perdu.

Bon, dans l'idée c'est assez simple. Il ne nous manque ici qu'une information essentielle, c'est quoi la taille de ce fameux MTU ?

Et c'est bien là qu'on a un problème : cette valeur n'est pas fixe. Vous retrouverez des valeurs légèrement différentes en fonction d'où vous vous connectez et de comment vous vous y connectez. Et plus drôle encore, cette valeur peut évoluer au cours du temps (en plein milieu de connexion), en fonction de l'évolution de la route empruntée.
Techniquement rien n'empêche que chaque paquet emprunte une route différente et donc dispose d'un MTU différent (même si en pratique ça n'arrive pas).

Il est donc possible de déterminer cette valeur à un instant T en effectuant du PMTU, le principe étant d'envoyer des paquets de plus en plus gros jusqu'à ce qu'ils dépassent cette limite. À ce moment-là un paquet ICMP est envoyé par le routeur à l'origine du blocage vers l'émetteur.

À ce moment-là, soit nous avons les droits d'administration et sommes en mesure de récupérer ce paquet, et nous ajustons jusqu'à ce que les paquets passent (par exemple par dichotomie). Soit, plus probable, nous n'avons pas les droits d'administration et sommes obligés de détecter la perte d'un paquet pour comprendre qu'il a été refusé (en sachant bien sûr qu'un paquet peut se perdre pour d'autres raisons).
En résumé, c'est une affaire complexe et personne n'a envie de se prendre la tête avec ça, surtout sans la capacité d'écoute des paquets ICMP[2].

Mais alors que faire ?

Comme toutes les bibliothèques implémentant un protocole RUDP (pour Reliable UDP), on va cacher la poussière sous le tapis et définir une constante qui représentera le MTU du protocole, c'est-à-dire en prenant une valeur arbitraire qui fonctionnera partout.

Il nous faut donc une valeur suffisamment faible pour fonctionner sur tout le réseau (routeurs, antennes 3G/4G, etc.) et suffisamment élevée pour ne pas perdre trop en efficacité.

Si on se penche sur la norme IPv6, celle-ci défini le MTU minimal devant être supporté comme étant de 1280 octets, c'est pas mal. Et IPv4 ? 68 octets. Ouch.
Comptez à ça qu'il faut enlever entre 18 et 22 octets pour les en-têtes Ethernet, 20 et 60 octets pour les en-têtes IP et 8 octets pour l'en-tête UDP. Ça ne nous laisse pas grand chose.

Heureusement nous avons un moyen de nous en sortir. En effet, Ethernet est la couche physique utilisée par quasiment tout le monde, nous pouvons nous baser sur le MTU minimal garanti par ce protocole, 1500 octets.

En enlevant les en-têtes cités plus haut et en se donnant un peu de marge, on tombe sur une valeur de 1400 octets, c'est par exemple la valeur reprise par ENet. À côté de ça netcode.io se donne encore un peu plus de marge et fixe cette valeur à 1200 octets.

Personnellement je vous recommande de vous baser sur une valeur de 1200 ou 1300 octets pour avoir un peu de marge, les headers IPv6 étant légèrement plus lourds que les headers IPv4 et 1400 octets (pour notre protocole) étant une valeur très proche de la limite de certains relais (vous ne souhaitez pas connaître la frustration d'avoir un paquet passant avec certaines antennes mobiles et ne passant pas avec d'autres).

Ce point étant réglé, nous pouvons passer au deuxième problème important concernant la quantité de données transmises, qui est ...

La gestion de la congestion

La bande-passante (la quantité d'information qui peut transiter par seconde) est limitée sur internet. Certaines personnes ont des connexions de quelques dizaines de mégabits, certaines ont moins, d'autres plus. Certains enfoirés privilégiés ont même via la fibre des connexions atteignant le gigabit par seconde.

Alors je tiens à faire une précision importante : ne confondez pas les mégabits (Mb) et les megabytes (MB) / mégaoctets (Mo). Un octet/byte contient 8bits, donc si vous avez une connexion de 30mégabits (30Mbps), vous téléchargerez à 30/8 = 3,75 mégaoctets (3,75Mo/s).

Dans le cadre du jeu vidéo (le public le plus souvent visé par le RUDP), la gestion de la congestion est assez secondaire (et même ignorée par la plupart des protocoles par-dessus UDP), de façon générale vous êtes en charge d'optimiser le trafic de votre application afin que la bande-passante requise soit la plus faible possible, et cette valeur deviendra alors le prérequis pour jouer à votre jeu, tant pis pour ceux qui ont moins que ça (de nos jours la bande-passante requise pour un jeu conçu correctement est bien plus faible que la limite de la connexion de la plupart des joueurs).

Néanmoins, si vous utilisez votre protocole pour envoyer des éléments plus gros, comme des modèles, textures, etc. vous aurez besoin de gérer cette congestion.

Nous allons une nouvelle fois partir du principe que vous ne pouvez pas écouter les paquets ICMP qui vous préviennent que vous envoyez trop de données (voir Source Quench), il faut donc pouvoir détecter la congestion par un autre moyen, et surtout agir dessus.

Vous pouvez vous baser sur votre valeur de RTT (Round-Trip Time) qui est grosso-modo le temps entre l'envoi d'un paquet et la confirmation de bonne réception de celui-ci (autrement dit le temps entre l'envoi et la réponse).
Si votre RTT augmente, vous êtes probablement en train d'envoyer trop de données à votre destinataire et il vaut mieux réduire le trafic.

Classiquement on va avoir tendance à augmenter le trafic doucement tant que les conditions sont bonnes mais à le baisser brutalement dès que les conditions se dégradent. Une mise en application de ce procédé est l'AIMD (Additive increase/Multiplicative decrease), notamment utilisé par TCP.
À noter que le RTT n'est pas la seule mesure que vous allez pouvoir utiliser pour déterminer les conditions réseau, vous pouvez aussi vous baser par exemple sur le pourcentage de paquets perdu.[3]

Gérer la congestion est donc très loin d'être une mince affaire (j'en veux pour preuve le nombre d'algorithmes existants pour TCP) et à ma connaissance la plupart des bibliothèques que j'ai cité proposent soit de limiter le trafic à une bande-passante fixe et précisée à l'avance (comme ENet), soit ne limitent rien du tout et laissent l'application se débrouiller. Les autres sont RakNet, qui dispose de mécanismes gérant la congestion et bien sûr SCTP qui dispose d'un mécanisme similaire à TCP.

Le conseil que je peux vous donner est de ne pas chercher à gérer la congestion inutilement, c'est un sujet complexe et qui ne se prête pas trop au domaine du jeu vidéo. Si vous vous retrouvez dans un cas où la congestion peut poser problème (téléchargement de ressources), n'hésitez pas à avoir recours à TCP (évitez juste de télécharger des ressources en plein milieu de partie sous peine de potentiellement augmenter la perte de paquets UDP, surtout si vous téléchargez depuis le serveur de jeu).
Néanmoins si vous souhaitez vraiment implémenter la congestion, alors vous pouvez vous baser sur le mécanisme employé par RakNet (page assez détaillée) ou alors implémenter un mécanisme beaucoup plus simple (en fin d'article) qui peut néanmoins suffire à vos besoins.

En bref

Ce chapitre est avant tout théorique, il y a de grandes chances qu'excepté limiter la taille de vos paquets à une valeur fixe vous n'avez pas besoin de faire quoi que ce soit. Néanmoins il est important d'être informé de ces mécanismes, le réseau peut se révéler très capricieux et nous allons confirmer cette phrase dans le chapitre suivant.


  1. C'est-à-dire séparer votre paquet en plusieurs sous-paquets de taille inférieure au MTU, ce qui augmente la probabilité d'un problème : chaque "sous-paquet" ayant autant de chance de se perdre qu'un paquet classique, avec la différence ici que si un de ces morceaux de paquet se perd, l'entiereté de votre paquet sera perdu. ↩︎

  2. Pour information, TCP effectue du PMTU à l'aide de ces paquets ICMP, ce qu'il peut se permettre de faire étant implémenté directement au niveau du système d'exploitation. ↩︎

  3. Attention qu'avec certaines technologies (comme le Wi-Fi) la perte de paquets est inévitable et il faudra réussir à distinguer les pertes normales des pertes anormales. ↩︎