Cours programmation réseau en C++

UDP – Gérer les pertes et duplications d’identifiants

Bousk

Cette première partie présente un moyen simple de détecter les pertes et duplicats d’identifiants.

Commentez

- Titre : Cours programmation réseau en C++

- Auteur : Bousk

- Parution : 12 décembre 2017

- Mise à jour : 14 avril 2019

- Public visé :

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2017 Cyrille (Bousk) Bousquet. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Identifier un datagramme

- II. Taille de l’identifiant

- II-A. Durée de vie d’un datagramme sur le réseau

- II-B. Définir la taille de l’identifiant

- II-C. Vérifier qu’un identifiant est plus récent

- III. Informer l’autre machine de ce que nous avons reçu

- III-A. Un acquittement c’est bien, deux c’est mieux.

- III-B. 17, 33 ou 65 acquittements

- III-C. Mise à jour de l’en-tête

- IV. Vérifier qu’un identifiant est nouveau

- IV-A. Membres et initialisation de AckHandler

- IV-A-1. Gérer les champs de bits

- IV-A-2. Quelques fonctions utilitaires

- IV-B. Implémentation de la fonction update

- IV-B-1. Réacquittement d’un identifiant

- IV-B-2. Acquittement d’un identifiant plus récent

- IV-B-3. Acquittement d’un identifiant non-nouveau

- IV-C. Implémentation des autres fonctions

- IV-C-1. Vérifier un acquittement

- IV-C-2. Vérifier qu’un acquittement est nouveau

- IV-C-3. Récupérer les identifiants nouvellement acquittés

- V. Conclusion

I - Identifier un datagramme

Puisque UDP n’est pas un protocole fiable, chaque datagramme envoyé peut être perdu en route. Ou arriver en plusieurs exemplaires. Il nous faut donc un moyen d’identifier les datagrammes afin que le receveur soit capable de décider si oui ou non il accepte de les traiter.

Afin de pouvoir identifier nos datagrammes, il suffira d’ajouter un identifiant unique à chacun. Un tel identifiant sera simplement un entier non signé, que l’on incrémentera de 1 à chaque envoi et commençant à 0.

Une structure de datagramme prend alors forme, composée d’un en-tête suivi des données :

Datagram.hpp

1. #pragma once

3. #include <cstdint>

4. #include <array>

6. namespace Bousk

7. {

8. namespace UDP

9. {

10. struct Datagram

11. {

12. using ID = ???;

13. static constexpr size_t BufferMaxSize = 1400;

14. static constexpr size_t DataMaxSize = BufferMaxSize - sizeof(ID);

16. ID id;

17. std::array<uint8_t, DataMaxSize> data;

18. };

19. }

20. }

Bien que pouvant théoriquement atteindre 1500 o, la taille d’un datagramme sera limitée à 1400 o afin d’être sûr qu’aucun routeur ne rejette le paquet pour cause de surpoids.

L’identifiant étant un entier non signé, le choix se fera entre uint8_t, uint16_t, uint32_t ou uint64_t.

II - Taille de l’identifiant

Afin de déterminer une taille correcte, il faut avoir une meilleure idée de la quantité de datagrammes que l’on souhaite échanger.

Imaginons envoyer 10 datagrammes par seconde avec un identifiant codé sur 8 bits. L’identifiant sera réinitialisé après la valeur 255, qui sera atteinte en 25,5 s.

Si l’on envoie maintenant 30 datagrammes par seconde, la réinitialisation se produit après seulement 8,5 s.

Voici un tableau récapitulatif afin d’y voir plus clair :

Datagrammes/s

Taille de l'identifiant

Temps avant réinitialisation (s)

Temps avant réinitialisation (mn)

10

8

25,50

0,43

16

6553,50

109,23

32

429496729,50

7158278,83

64

1844674407370960000,00

30744573456182600,00

20

8

12,75

0,21

16

3276,75

54,61

32

214748364,75

3579139,41

64

922337203685478000,00

15372286728091300,00

30

8

8,50

0,14

16

2184,50

36,41

32

143165576,50

2386092,94

64

614891469123652000,00

10248191152060900,00

II-A - Durée de vie d’un datagramme sur le réseau

Afin de pouvoir déterminer une taille d’identifiant correcte, il faut prendre connaissance d’un nouveau paramètre : la durée de vie d’un datagramme sur le réseau.

Cette durée de vie, Time To Live en anglais ou plus communément TTL, est un paramètre de l’en-tête IP. Il s’agissait à l’origine d’une durée en secondes, mais la très grande majorité des routeurs aujourd’hui utilisent ce champ comme nombre de sauts si bien qu’il a été renommé Hop Limit (pour limite du nombre de sauts) dans l’en-tête IPv6.

Ce champ est présent afin qu’un paquet IP ne puisse vivre indéfiniment sur le réseau et que le matériel finisse par le défausser. Chaque routeur, lors de la réception d’un paquet, décrémente la valeur du champ. Si le champ vaut maintenant 0, le paquet n’est pas retransmis.

Bien que l’on puisse modifier la valeur dans notre programme, on s’intéressera à la valeur de configuration par défaut des machines, puisque nous ne pouvons pas réécrire le programme de toutes les machines à travers le monde :

OS/Device

Version

TTL

OS/Device

Version

TTL

OS/Device

Version

TTL

AIX

30

OS/2

TCP/IP 3.0

64

VMS/UCX

128

DEC Pathworks

V5

30

OSF/1

V3.2A

30

Windows

for Workgroups

32

FreeBSD

2.1R

64

Solaris

2.x

255

Windows

95

32

HP-UX

9.0x

30

Stratus

TCP_OS (14.2-)

30

Windows

NT 3.51

32

HP-UX

10.01

64

Stratus

TCP_OS (14.3+)

64

Windows

NT 4.0

128

Irix

5.3

60

Stratus

STCP

60

Windows

NT 4.0 SP5-

32

Irix

6.x

60

SunOS

4.1.3/4.1.4

60

Windows

NT 4.0 SP6+

128

Linux

64

Ultrix

V4.1/V4.2A

30

Windows

2000 pro

128

MacOS/MacTCP

2.0.x

60

VMS/Multinet

64

Windows

Server 2003

128

MacOS/MacTCP

X (10.5.6)

64

VMS/TCPware

64

Windows

XP

128

Netgear FVG318

64

VMS/Wollongong

1.1.1.1

30

Sources : http://www.binbert.com/blog/2009/12/default-time-to-live-ttl-values/ & http://www.map.meteoswiss.ch/map-doc/ftp-probleme.htm

Avec ce tableau, on peut avoir une idée grossière de la durée de vie maximum d’un datagramme, et surtout avec quel délai maximum un paquet peut arriver. Ceci afin de déterminer une durée nécessaire avant qu’un identifiant soit réinitialisé et réutilisé, pour éviter qu’un datagramme très en retard soit incorrectement accepté comme nouveau et non pour ce qu’il est réellement : très en retard.

II-B - Définir la taille de l’identifiant

Pour les raisons vues dans le paragraphe précédent, la valeur de 16 bits est particulièrement intéressante : un identifiant de 8 bits se réinitialise bien trop vite, en moins de 10 secondes à 30 datagrammes/s tandis qu’à 16 bits pour l’identifiant, la réinitialisation se produit toutes les 30 minutes à 30 datagrammes/s, et 10 minutes pour 100 datagrammes/s.

Inutile d’utiliser plus de mémoire (qui est précieuse puisque limitée dans un datagramme) pour cette information : ce sera donc un uint16_t.

Si votre débit est limité et très bas, la valeur de 8 bits pourrait devenir utilisable.

À l’opposé, je ne vois absolument aucune nécessité de passer à un identifiant sur 32 bits.

Si vous souhaitez optimiser la mémoire, des valeurs intermédiaires peuvent être explorées : 12, 13, 14, 15 bits, qui entraînerait une réinitialisation pour un débit de 30 datagrammes/s respectivement après 136,5 s, 273 s, 546 s et 1092 s. Cependant, encoder une telle valeur proprement requiert quelques astuces, donc à mon avis le jeu en vaut rarement la chandelle.

Pour tester aisément des valeurs de votre choix, voici ma feuille de calculs.

II-C - Vérifier qu’un identifiant est plus récent

Nos identifiants seront donc toutes les valeurs possibles d’un uint16_t, soit [0, 65535].

Et bien sûr leur ordre va croissant : 0 < 1 < 2 < … < 65535. Mais lors de la réinitialisation 0 doit être identifié comme plus récent que 65535.

Il existe une astuce assez simple pour détecter ce cas, en utilisant de simples opérations arithmétiques :

- Si A > B et A – B est plutôt petit, alors A est plus récent que B ;

- Exemple : A = 5 ; B = 1 ; A – B = 5 – 1 = 4 : 5 plus récent que 1.

- Si A < B et B – A est plutôt élevé, alors A est plus récent que B ;

- Exemple : A = 3 ; B = 65533 ; B – A = 65533 – 3 = 65530 : 3 plus récent que 65533.

L’astuce est de définir ce qui est plutôt petit ou élevé, en fonction de l’intervalle de valeurs possibles et du dépassement (ou overflow). En particulier, il est intéressant d’utiliser la valeur médiane de l’intervalle des valeurs de l’identifiant, un uint16_t dans notre cas : 65535 / 2 = 32767.

Ce qui nous permet d’écrire le code suivant afin de vérifier qu’un identifiant est plus récent qu’un autre :

Utils.hpp

1. #pragma once

3. #include <cstdint>

5. namespace Bousk

6. {

7. namespace Utils

8. {

9. bool IsSequenceNewer(uint16_t sNew, uint16_t sLast);

10. uint16_t SequenceDiff(uint16_t sNew, uint16_t sLast);

11. }

12. }

Utils.cpp

1. #include "Utils.hpp"

2. #include <numeric>

4. namespace Bousk

5. {

6. namespace Utils

7. {

8. bool IsSequenceNewer(uint16_t sNew, uint16_t sLast)

9. {

10. if (sNew == sLast)

11. return false;

12. return (sNew > sLast && sNew - sLast <= std::numeric_limits<uint16_t>::max() / 2) // cas simple : 4 > 2

13. || (sNew < sLast && sLast - sNew > std::numeric_limits<uint16_t>::max() / 2); // dépassement : 2 > 65534

14. }

15. Uint16_t SequenceDiff(uint16_t sNew, uint16_t sLast)

16. {

17. if (sNew == sLast)

18. return 0;

19. assert(IsSequenceNewer(sNew, sLast)); //!< S’ils ne sont pas dans le bon ordre le diff n’a pas beaucoup de sens et n’est pas permis

20. if (sNew > sLast && sNew - sLast <= std::numeric_limits<uint16_t>::max() / 2)

21. return sNew - sLast;

22. //!< dépassement

23. return (std::numeric_limits<uint16_t>::max() - sLast) + sNew + 1; //!< +1 pour compter le 0 : si sLast == sMax && sNew == 0, la difference est 1

24. }

25. }

26. }

Au passage, l’ajout d’une fonction pour récupérer l’écart entre deux identifiants se révélera être utile plus tard.

III - Informer l’autre machine de ce que nous avons reçu

Un client peut déterminer localement s’il lui manque un datagramme ou des données, mais la clé du protocole est d’informer la machine distante de ce que nous avons reçu, et surtout de ce que nous n’avons pas reçu, afin qu’elle agisse en conséquence.

À cette fin, dans notre unité d’échange qui est le datagramme, ajoutons un nouveau champ ack, qui sera l’acquittement du datagramme le plus récent reçu et sera donc un uint16_t.

III-A - Un acquittement c’est bien, deux c’est mieux.

Vous vous serez peut-être déjà fait la réflexion : un acquittement par datagramme, est-ce suffisant pour acquitter tous les datagrammes ?

Un seul acquittement est définitivement trop faible : si les deux parties de la connexion ne vont pas à la même vitesse, des acquittements seront forcément manqués. Ajoutez à ça les pertes inhérentes à l’utilisation d’UDP, acquitter tous les datagrammes devient alors impossible de cette manière.

III-B - 17, 33 ou 65 acquittements

Avec l’astuce que je vais vous présenter, on va pouvoir intégrer jusque 65 acquittements par datagramme. Et ceci n’est en rien une limite absolue, mais sera plus que largement suffisant pour des besoins généraux.

L’astuce consiste à ce que tous ces acquittements soient relatifs au plus récent, et plus précisément qu’ils soient tous les précédents du plus récent. Permettant ainsi de les écrire sur un champ de bits. Ainsi 64 acquittements seront encodés dans un seul et unique uint64_t tel que le premier bit représente l’acquittement ack-1, le deuxième bit représente l’acquittement ack-2, etc. Jusqu’au 64e bit qui représente l’acquittement ack-64.

Ainsi avec 65 acquittements par datagramme, nous devrions être en mesure d’en acquitter un maximum, voire quasiment tous. Le seul moyen de ne pas être acquitté serait que les 64 datagrammes suivants un identifiant donné n’acquittent pas l’identifiant en question. Dans ce dernier cas, il est plutôt sûr et légitime de marquer un tel identifiant comme perdu.

Vous l’aurez compris : quand un identifiant est acquitté, nous avons la certitude qu’il a été reçu, alors qu’il y a une chance, même si très faible, qu’un identifiant acquitté ne soit pas notifié en tant que tel à l’autre partie de la connexion.

Il ne faut pas s’inquiéter pour autant : les pertes font partie du jeu d’utiliser UDP et il convient de s’en accommoder. L’extrême redondance de ce système limite très fortement ce résultat : il faudrait qu’un datagramme soit reçu très désordonné et en retard pour que cela se produise.

III-C - Mise à jour de l’en-tête

Afin de transférer cette information entre les machines, elle sera incluse dans l’en-tête de chaque datagramme.

Puisque nous commençons à avoir plusieurs informations, il est temps de créer une structure dédiée.

Datagram.hpp

1. #pragma once

3. #include <cstdint>

4. #include <array>

6. namespace Bousk

7. {

8. namespace UDP

9. {

10. struct Datagram

11. {

12. using ID = uint16_t;

13. struct Header

14. {

15. ID id;

16. ID ack;

17. uint64_t previousAcks;

18. };

19. static constexpr size_t BufferMaxSize = 1400;

20. static constexpr size_t DataMaxSize = BufferMaxSize - sizeof(Header);

22. Header header;

23. std::array<uint8_t, DataMaxSize> data;

24. };

25. }

26. }

IV - Vérifier qu’un identifiant est nouveau

Nous pouvons déterminer qu’un identifiant est plus récent, mais dans certains cas il peut être intéressant d’accepter des données plus anciennes, si le datagramme est reçu désordonné mais contient des données d’une autre nature qui nous intéressent.

Par exemple si je viens de recevoir le datagramme #3 qui contient la dernière position du personnage, puis je reçois le datagramme #2 qui contient une mise à jour de certaines caractéristiques : le #2 est tout autant intéressant à traiter, bien qu’il soit plus ancien que #3 – tant qu’il ne s’agit pas d’un doublon.

L’idée est d’avoir un tampon circulaire, ou une liste glissante, afin de pouvoir identifier les nouveaux paquets désordonnés et les traiter également : l’intérêt des données qu’il contient reviendra à l’application et non à la couche réseau^1.

Ce traitement sera réalisé par un gestionnaire d’acquittements, qui sera une classe AckHandler, qui permettra de contrôler qu’un identifiant reçu n’est pas trop ancien et surtout qu’il est unique afin de valider son traitement. Puisque nous pouvons identifier ces deux fonctionnalités, il est également assez aisé de détecter les identifiants manqués qui seront décrétés perdus par la couche réseau.

Une telle classe aura cette interface :

AckHandler.h

1. #pragma once

3. #include <cstdint>

4. #include <vector>

6. namespace Bousk

7. {

8. namespace UDP

9. {

10. class AckHandler

11. {

12. public:

13. AckHandler() = default;

14. AckHandler(const AckHandler&) = default;

15. AckHandler& operator=(const AckHandler&) = default;

16. AckHandler(AckHandler&&) = default;

17. AckHandler& operator=(AckHandler&&) = default;

18. ~AckHandler() = default;

20. void update(uint16_t newAck, uint64_t previousAcks, bool trackLoss = false);

21. bool isAcked(uint16_t ack) const;

22. bool isNewlyAcked(uint16_t ack) const;

24. uint16_t lastAck() const;

25. uint64_t previousAcksMask() const;

26. std::vector<uint16_t> getNewAcks() const;

27. std::vector<uint16_t>&& loss();

29. };

30. }

31. }

Cette classe aura pour principal objectif de permettre le suivi des acquittements. Elle nous permettra donc de connaître l’acquittement le plus récent, mais également de connaître les acquittements précédents sous forme d’un masque que nous pourrons utiliser pour notre en-tête de datagramme.

La méthode clé sera update qui, à partir d’un identifiant d’acquittement et un masque des acquittements précédents, mettra à jour son état interne.

Puisque cette classe a une parfaite connaissance du suivi des acquittements, elle sera aussi capable de repérer ceux qui ont été perdus. J’en profite donc pour intégrer cette information également via le dernier paramètre de la fonction update qui permet de choisir d’identifier les pertes ou non et la fonction loss qui permet de récupérer les identifiants non acquittés et désormais hors de la plage de suivi (plus ancien que l’acquittement le plus récent - 64). Comme l’indique sa signature, loss déplace cette information vers l’appelant, il s’agira donc des acquittements perdus depuis le dernier appel à loss.

Ensuite, il suffit d’appeler les méthodes isAcked pour vérifier qu’un identifiant spécifique a été acquitté, ou isNewlyAcked pour vérifier qu’un identifiant spécifique a été acquitté lors du dernier appel à update.

IV-A - Membres et initialisation de AckHandler

Au niveau des membres, nous avons besoin :

- du dernier acquittement ;

- des acquittements précédents ;

- des nouveaux acquittements ;

- des acquittements perdus.

Donnons un type à chacun afin d’avoir toutes nos données :

1. uint16_t mLastAck{ -1 };

2. uint64_t mPreviousAcks{ -1 };

3. uint64_t mNewAcks{ 0 };

4. std::vector<uint16_t> mLoss;

5. bool mLastAckIsNew{ false };

mLastAck servira pour l’acquittement le plus récent tandis que mPreviousAcks sera le masque des acquittements précédents qui aura le fonctionnement décrit plus haut.

Pour l’information nouvellement acquittée, nous aurons mNewAcks qui sera le masque des acquittements précédents nouvellement acquittés, et mLastAckIsNew qui sera l’information relative au dernier acquittement.

Je fais la part belle aux entiers et masques parce que les manipulations internes seront principalement et quasi uniquement de la manipulation de bits, des décalages et des masques. Seule la fonction getNewAcks retourne des identifiants et transformera le champ de bits en identifiants.

Vous pouvez si vous le souhaitez utiliser std::bitset, seulement cette classe semble avoir des lacunes de performances (au moins sur certaines plateformes), et vu la facilité de manipuler nos champs de bits, autant l’écrire correctement.

IV-A-1 - Gérer les champs de bits

Les champs de bits internes auront les propriétés suivantes :

- un bit à 1 signifie un acquittement reçu, 0 l’identifiant n’a pas encore été acquitté ;

- ils seront utilisés en Endianness-agnostique ;

- les bits seront lus de droite à gauche ;

- le premier bit (bit #0, le plus à droite) représente l’identifiant précédent directement du dernier acquittement, le dernier bit (#63) sera l’identifiant dernier acquittement – 64.

Ceci permettra d’utiliser nos uint64_t comme fenêtres glissantes d’acquittements.

IV-A-2 - Quelques fonctions utilitaires

Afin de manipuler aisément nos champs de bits, quelques fonctions utilitaires se montrent particulièrement intéressantes :

Utils.hpp

1. inline void SetBit(uint64_t& bitfield, uint8_t n);

2. inline void UnsetBit(uint64_t& bitfield, uint8_t n);

3. inline bool HasBit(uint64_t bitfield, uint8_t n);

5. template<class INTEGER>

6. struct Bit {};

7. template<>

8. struct Bit<uint64_t> {

9. static constexpr uint64_t Right = 0b0000000000000000000000000000000000000000000000000000000000000001;

10. };

Utils.inl

1. void SetBit(uint64_t& bitfield, const uint8_t n)

2. {

3. assert(n < 64);

4. bitfield |= (Bit<uint64_t>::Right << n);

5. }

6. void UnsetBit(uint64_t& bitfield, const uint8_t n)

7. {

8. assert(n < 64);

9. bitfield &= (~(Bit<uint64_t>::Right << n));

10. }

11. bool HasBit(uint64_t bitfield, const uint8_t n)

12. {

13. assert(n < 64);

14. return (bitfield & (Bit<uint64_t>::Right << n)) != 0;

15. }

Je ne fournis qu’une version pour un uint64_t parce que c’est le seul type avec lequel j’utilise ceci pour l’instant, mais les versions pour d’autres tailles d’entier sont possibles et très simples à écrire.

On pourrait penser la structure permettant d’avoir le bit le plus à droite superflue, mais si la machine cible n’est pas en Big-Endian, la représentation de 1 en mémoire n’est pas du tout 0x00000001. Par exemple, en Little-Endian, 1 serait représenté en mémoire par 0x01000000 soit le 8e bit en partant de la gauche à 1. Ceci entraînerait des erreurs dans le reste des opérations puisque notre champ de bits n’est pas en représentation locale mais agnostique.

IV-B - Implémentation de la fonction update

Cette fonction étant la clé de voûte du système, prenez le temps nécessaire pour l’implémenter correctement et vous assurer de son bon comportement.

Elle devra mettre à jour l’acquittement le plus récent, les acquittements précédents, les nouveaux acquittements ainsi que les acquittements ratés et désormais hors de notre portée si nécessaire.

IV-B-1 - Réacquittement d’un identifiant

Commençons par le plus simple, la réception d’un duplicata du dernier acquittement :

AckHandler::update avec le même acquittement

1. #include "UDP/AckHandler.hpp"

3. namespace Bousk

4. {

5. namespace UDP

6. {

7. void AckHandler::update(uint16_t newAck, uint64_t previousAcks, bool trackLoss /*= false*/)

8. {

9. mLastAckIsNew = false;

10. if (newAck == mLastAck)

11. {

12. //!< Doublon du dernier acquittement, mais le masque peut contenir de nouvelles informations

13. mNewAcks = (mPreviousAcks & previousAcks) ^ previousAcks;

14. mPreviousAcks |= previousAcks;

15. }

16. }

17. }

18. }

Si l’acquittement est un doublon, on se contente de marquer les nouveaux acquittements et mettre à jour les masques.

IV-B-2 - Acquittement d’un identifiant plus récent

La seconde étape sera la réception d’un acquittement plus récent, ce qui entraîne de multiples traitements :

- il faut vérifier si des acquittements en queue de masque sont manquants pour les marquer perdus ;

- décaler correctement les masques afin qu’ils restent cohérents ;

- si le saut est important s’assurer de marquer correctement comme perdus les acquittements ratés et irrécupérables ;

- sinon, introduire le précédent acquittement dans le masque décalé.

En déroulant l’algorithme ci-dessus, on parvient à cette implémentation :

AckHandler::update acquittement d’un nouvel identifiant

1. namespace Bousk

2. {

3. namespace UDP

4. {

5. void AckHandler::update(uint16_t newAck, uint64_t previousAcks, bool trackLoss /*= false*/)

6. {

7. …

8. else if (Utils::IsSequenceNewer(newAck, mLastAck))

9. {

10. //!< Acquittement plus récent, vérifier les pertes, etc.

11. const auto diff = Utils::SequenceDiff(newAck, mLastAck);

12. const auto gap = diff - 1;

13. //!< Nombre de bits à décaler du masque

14. const auto bitsToShift = std::min(diff, static_cast<uint16_t>(64));

15. if (trackLoss)

16. {

17. for (uint32_t i = 0; i < bitsToShift; ++i)

18. {

19. const auto packetDiffWithLastAck = 64 - i;

20. const auto bitInPreviousMask = packetDiffWithLastAck - 1;

21. if (!Utils::HasBit(mPreviousAcks, bitInPreviousMask))

22. {

23. //!< Cet identifiant n’a pas été acquitté et est maintenant hors bornes : marquer comme perdu

24. const uint16_t packetid = mLastAck - packetDiffWithLastAck;

25. mLoss.push_back(packetid);

26. }

27. }

28. }

29. //!< Décaller le masque vers la gauche : supprimer les paquets les plus anciens du masque

30. mPreviousAcks <<= bitsToShift;

31. if (gap >= 64)

32. {

33. //!< Il s’agit d’un saut qui supprime entièrement le masque

34. mPreviousAcks = mNewAcks = 0;

35. //!< Vérifier chaque identifiant du masque pour marquer comme perdus les non-acquittés

36. if (trackLoss)

37. {

38. for (uint32_t p = 64; p < gap; ++p)

39. {

40. const uint16_t packetid = mLastAck + (p - 64) + 1;

41. mLoss.push_back(packetid);

42. }

43. }

44. }

45. else

46. {

47. //!< Marquer l’ancien acquittement comme acquitté dans le masque décalé

48. Utils::SetBit(mPreviousAcks, gap);

49. }

50. mLastAck = newAck;

51. mLastAckIsNew = true;

52. mNewAcks = (mPreviousAcks & previousAcks) ^ previousAcks;

53. mPreviousAcks |= previousAcks;

54. }

55. }

56. }

57. }

IV-B-3 - Acquittement d’un identifiant non-nouveau

Enfin il reste un troisième et dernier cas à traiter, celui où l’acquittement reçu n’est pas nouveau, mais suffisamment récent pour être d’intérêt puisqu’il pourrait contenir des acquittements à prendre en compte.

Un tel acquittement est digne d’intérêt s’il n’est pas plus vieux que la borne inférieure du masque actuel, auquel cas on l’alignera avec le masque actuel afin de réaliser les mêmes opérations pour en extraire les nouveaux acquittements.

AckHandler::update acquittement d’un ancien identifiant

1. namespace Bousk

2. {

3. namespace UDP

4. {

5. void AckHandler::update(uint16_t newAck, uint64_t previousAcks, bool trackLoss /*= false*/)

6. {

7. …

8. else

9. {

10. //!< Il s’agit d’un vieil acquittement, s’il n’est pas trop dépassé il peut contenir des informations intéressantes

11. const auto diff = Utils::SequenceDiff(mLastAck, newAck);

12. if (diff <= 64)

13. {

14. //!< Aligner le masque reçu avec notre masque actuel

15. previousAcks <<= diff;

16. //!< Insérer l’acquittement dans le masque

17. const auto ackBitInMask = diff - 1;

18. Utils::SetBit(previousAcks, ackBitInMask);

19. //!< Puis mise à jour des acquittements

20. mNewAcks = (mPreviousAcks & previousAcks) ^ previousAcks;

21. mPreviousAcks |= previousAcks;

22. }

23. else

24. {

25. //!< Acquittement plus vieux que la borne inférieure du masque actuel, on l’ignore

26. }

27. }

28. }

29. }

30. }

Nous voilà donc avec la totalité de la gestion des acquittements. Notre classe sait reconnaître les acquittements plus récents ainsi que les identifiants acquittés lors du dernier appel à update et les identifiants perdus car non acquittés.

Nous pouvons désormais grâce à cet objet identifier les doublons, traiter de manière unique chaque identifiant et être informé quand un identifiant a été perdu. Appliquer ceci aux identifiants de datagramme et nous sommes en mesure de connaître lesquels ont été reçus, et lesquels probablement pas. Il s’agit d’un élément clé de la réussite de tout protocole, et de celui que nous bâtissons en particulier.

AckHandler::update

1. #include "UDP/AckHandler.hpp"

2. #include "Utils.hpp"

3. #include <algorithm>

5. namespace Bousk

6. {

7. namespace UDP

8. {

9. void AckHandler::update(uint16_t newAck, uint64_t previousAcks, bool trackLoss /*= false*/)

10. {

11. mLastAckIsNew = false;

12. if (newAck == mLastAck)

13. {

14. //!< Doublon du dernier acquittement, mais le masque peut contenir de nouvelles informations

15. mNewAcks = (mPreviousAcks & previousAcks) ^ previousAcks;

16. mPreviousAcks |= previousAcks;

17. }

18. else if (Utils::IsSequenceNewer(newAck, mLastAck))

19. {

20. //!< Acquittement plus récent, vérifier les pertes, etc.

21. const auto diff = Utils::SequenceDiff(newAck, mLastAck);

22. const auto gap = diff - 1;

23. //!< Nombre de bits à décaler du masque

24. const auto bitsToShift = std::min(diff, static_cast<uint16_t>(64));

25. if (trackLoss)

26. {

27. for (uint32_t i = 0; i < bitsToShift; ++i)

28. {

29. const auto packetDiffWithLastAck = 64 - i;

30. const auto bitInPreviousMask = packetDiffWithLastAck - 1;

31. if (!Utils::HasBit(mPreviousAcks, bitInPreviousMask))

32. {

33. //!< Cet identifiant n’a pas été acquitté et est maintenant hors bornes : marquer comme perdu

34. const uint16_t packetid = mLastAck - packetDiffWithLastAck;

35. mLoss.push_back(packetid);

36. }

37. }

38. }

39. //!< Décaller le masque vers la gauche : supprimer les paquets les plus anciens du masque

40. mPreviousAcks <<= bitsToShift;

41. if (gap >= 64)

42. {

43. //!< Il s’agit d’un saut qui supprime entièrement le masque

44. mPreviousAcks = mNewAcks = 0;

45. //!< Vérifier chaque identifiant du masque pour marquer comme perdus les non-acquittés

46. if (trackLoss)

47. {

48. for (uint32_t p = 64; p < gap; ++p)

49. {

50. const uint16_t packetid = mLastAck + (p - 64) + 1;

51. mLoss.push_back(packetid);

52. }

53. }

54. }

55. else

56. {

57. //!< Marquer l’acquittement du précédent dernier identifiant comme acquitté dans le masque des précédents acquittements après décalage

58. Utils::SetBit(mPreviousAcks, gap);

59. }

60. mLastAck = newAck;

61. mLastAckIsNew = true;

62. mNewAcks = (mPreviousAcks & previousAcks) ^ previousAcks;

63. mPreviousAcks |= previousAcks;

64. }

65. else

66. {

67. //!< Il s’agit d’un vieil acquittement, s’il n’est pas trop dépassé il peut contenir des informations intéressantes

68. const auto diff = Utils::SequenceDiff(mLastAck, newAck);

69. if (diff <= 64)

70. {

71. //!< Aligner le masque reçu avec notre masque actuel

72. previousAcks <<= diff;

73. //!< Insérer l’acquittement dans le masque

74. const auto ackBitInMask = diff - 1;

75. Utils::SetBit(previousAcks, ackBitInMask);

76. mNewAcks = (mPreviousAcks & previousAcks) ^ previousAcks;

77. mPreviousAcks |= previousAcks;

78. }

79. else

80. {

81. //!< Acquittement plus vieux que la borne inférieure du masque actuel, on l’ignore

82. }

83. }

84. }

85. }

86. }

IV-C - Implémentation des autres fonctions

Maintenant que le plus dur est fait, mettons en place l’interface pour consulter l’état de notre gestionnaire d’acquittement.

IV-C-1 - Vérifier un acquittement

La première fonction proposée permet de vérifier qu’un identifiant a été acquitté.

AckHandler::isAcked

1. namespace Bousk

2. {

3. namespace UDP

4. {

5. bool AckHandler::isAcked(uint16_t ack) const

6. {

7. if (ack == mLastAck)

8. return true;

9. if (Utils::IsSequenceNewer(ack, mLastAck))

10. return false;

11. const auto diff = Utils::SequenceDiff(mLastAck, ack);

12. if (diff > 64)

13. return false;

14. const uint8_t bitPosition = static_cast<uint8_t>(diff - 1);

15. return Utils::HasBit(mPreviousAcks, bitPosition);

16. }

17. }

18. }

La seule complexité ici est de traiter correctement et dans le bon ordre les bits du masque.

Puisque nos acquittements sont sur une fenêtre glissante, l’historique complet n’est pas disponible – et il n’aurait de toute façon aucune utilité pratique.

Seuls les 65 derniers identifiants peuvent être ainsi demandés et ont du sens pour notre utilisation. C’est-à-dire qu’une fois l’identifiant 65 acquitté, vous n’avez plus aucune information concernant l’identifiant 0.

Si par hasard vous utilisez cette classe pour votre propre besoin et souhaitez conserver cette information, il vous faudra enregistrer au fur et à mesure les identifiants nouvellement acquittés et perdus.

IV-C-2 - Vérifier qu’un acquittement est nouveau

La deuxième fonctionnalité proposée permet de savoir si un identifiant a été acquitté lors du dernier appel à update.

AckHandler::isNewlyAcked

1. namespace Bousk

2. {

3. namespace UDP

4. {

5. bool AckHandler::isNewlyAcked(uint16_t ack) const

6. {

7. if (ack == mLastAck)

8. return mLastAckIsNew;

9. if (Utils::IsSequenceNewer(ack, mLastAck))

10. return false;

11. const auto diff = Utils::SequenceDiff(mLastAck, ack);

12. if (diff > 64)

13. return false;

14. const uint8_t bitPosition = static_cast<uint8_t>(diff - 1);

15. return Utils::HasBit(mNewAcks, bitPosition);

16. }

17. }

18. }

Ici aussi, faites attention à traiter correctement les bits et le tour est joué.

IV-C-3 - Récupérer les identifiants nouvellement acquittés

La troisième et dernière fonction permet de récupérer une liste d’identifiants nouvellement acquittés lors du dernier appel à update.

AckHandler::getNewAcks

1. namespace Bousk

2. {

3. namespace UDP

4. {

5. std::vector<uint16_t> AckHandler::getNewAcks() const

6. {

7. std::vector<uint16_t> newAcks;

8. newAcks.reserve(65);

9. for (uint8_t i = 64; i != 0; --i)

10. {

11. const uint8_t bitToCheck = i - 1;

12. if (Utils::HasBit(mNewAcks, bitToCheck))

13. {

14. const uint16_t id = mLastAck - i;

15. newAcks.push_back(id);

16. }

17. }

18. if (mLastAckIsNew)

19. newAcks.push_back(mLastAck);

20. return newAcks;

21. }

22. }

23. }

Remarquez les indices de la boucle : je parcours mon champ de bits dans l’ordre inverse pour pouvoir retourner les identifiants dans l’ordre du plus ancien au plus récent.

V - Conclusion

Grâce à cette classe vous pouvez facilement suivre des identifiants qui s’incrémentent, vous assurer de ne pas traiter de doublons ou de données trop anciennes et gérer correctement la réinitialisation lorsque l’identifiant atteint la valeur maximale.

Son utilisation première sera le suivi des datagrammes afin de les acquitter et faire parvenir cette information à la machine distante.

Parmi les évolutions possibles et relativement aisées, la taille du masque peut être modifiée ainsi que la taille des identifiants. Vous pouvez par exemple les passer en paramètres template. Ceci fera l’objet d’un futur article si le besoin se présente.

Article précédent

<< UDP – Introduction et premiers pas

S’assurer du bon fonctionnement de son code >>

1 : il peut s’agir d’une optimisation intéressante que d’ajouter une telle responsabilité à votre moteur réseau, si vous savez exactement comment les données sont utilisées par l’application hôte. Dans le cadre du cours il s’agit d’écrire un moteur réseau, et des briques logicielles, (ré)utilisables dans de nombreux cas. Si vous souhaitez personnaliser le moteur, libre à chacun d’adapter son implémentation selon ses besoins. Plus de détails à ce sujet dans un prochain article.