I. Réaliser un envoi fiable via un protocole non fiable ?▲
UDP est un protocole non fiable. Chaque datagramme envoyé peut être perdu, dupliqué ou désordonné.
Pourtant dans le chapitre précédent nous avons réussi à proposer un protocole, utilisant UDP, et permettant d’échanger des données ordonnées, sans duplication, mais toujours non fiables.
Cette fois nous allons franchir la marche suivante et rendre l’envoi de données ordonnées fiables possible.
La faisabilité ne devrait pas vous surprendre : UDP utilise IP tout comme TCP, et ce dernier est un protocole ordonné fiable alors que IP ne l’est pas.
Il ne s’agit pas de remplacer TCP mais de proposer cette fonctionnalité (envoi ordonné fiable) dans notre moteur, parce qu’il s’agit d’un aspect nécessaire dans certains cas.
Toutefois, la fiabilité de notre protocole sera différente de ce qui est proposé par TCP.
II. Comment créer de la fiabilité ?▲
Il n’y a pas de secret : pour ajouter de la fiabilité, il va falloir renvoyer les données non reçues.
Dès le 2e chapitre, nous avons introduit un système nous permettant d’acquitter des identifiants et d’informer la machine distante de ceux que nous avons reçus, et donc de déduire lesquels ont été à priori perdus. Cette information est, comme dit alors, la pierre angulaire de notre protocole, et tout particulièrement pour le chapitre actuel.
II-A. Quand renvoyer un paquet ?▲
Il s’agit ici de la partie critique de ce protocole : à quel moment renvoyer un paquet ?
- Attendre trop longtemps entraîne une latence qui peut être dérangeante.
- Le renvoyer trop rapidement (ou trop souvent) signifie une bande passante plus élevée ;
- Ou retarder l’envoi d’autres données pour éviter un pic d’envoi.
- Ou, dans le pire des cas, provoquer une congestion et une mise en défaut de la connexion.
Il y a donc deux solutions ici
- Soit attendre que le datagramme qui contenait le paquet soit identifié comme perdu ;
- Soit renvoyer plusieurs fois chaque paquet dans des datagrammes différents afin que l’un d’eux parvienne à l’autre machine.
Dans ce chapitre, nous implémenterons la première solution.
Un tel protocole sera idéal pour des phases plus lentes ou des données moins critiques.
Pensez au cas où les joueurs sont dans les menus, par exemple en train de se préparer pour la partie à venir, ou à l’utilisation d’un tchat textuel.
Dans le premier cas, puisqu’il n’y a pas encore de simulation, la quantité de données à envoyer est faible (seules les données du menu sont envoyées, qui sont généralement négligeables comparées à ce qui transite pendant les phases de jeu).
Dans le deuxième cas, si le texte arrive avec quelques frames de retard on estime que ce n’est pas critique pour l’expérience des joueurs.
Dans tous les cas vous voyez ici émerger une des problématiques du réseau dans un jeu vidéo : le compromis.
Puisque notre en-tête contient l’acquittement de 64 datagrammes, si notre vitesse d’envoi est de 30 datagrammes par seconde, nous remarquerons sa perte après ~2s. À 60 datagrammes par seconde, il sera marqué perdu après ~1s.
II-B. Identifier les paquets perdus▲
La clé de la fiabilité est de pouvoir identifier quels paquets ne sont pas parvenus à la machine distante afin de les renvoyer.
Nous avons un suivi des datagrammes reçus, et manquants, il nous faut donc un moyen d’identifier quels paquets étaient présents dans un datagramme donné pour agir en conséquence.
Il se trouve que nous envoyons nous-même le datagramme, il suffit donc de traquer les paquets que nous y insérons afin de pouvoir savoir, lors de la réception de l’acquittement d’un datagramme donné, quels paquets ont été reçus et perdus.
Il ne s’agit pas de la seule manière de réaliser la fiabilité, mais il s’agira de la méthode introduite et présentée dans ce chapitre.
III. Multiplexeur, Démultiplexeur▲
Comme dans le chapitre précédent, un multiplexeur pour l’envoi et un démultiplexeur pour la réception seront mis en place. Leurs fonctionnements seront similaires mais leurs implémentations permettront cette fois de créer un protocole ordonné fiable.
IV. Paquets▲
La structure de paquet du chapitre précédent sera réutilisée.
Afin de ne pas avoir à modifier cette structure, le multiplexeur fera le suivi des datagrammes incluant chaque paquet en interne.
V. Multiplexeur▲
V-A. Fonctionnement▲
Le multiplexeur sera le moteur et contrôleur de ce protocole. C’est lui qui prend la décision d’envoyer les données suivantes ou non en coordination avec le démultiplexeur que nous allons voir juste après.
Le principe de base est simple : quand un paquet est envoyé, on note le datagramme dans lequel il a été inclus. Quand ce datagramme est acquitté ou déclaré perdu, on le supprime enfin de la file d’envoi ou on le prépare à être renvoyé.
V-A-1. État d’envoi d’un paquet▲
Ajoutons donc un état d’envoi à nos paquets en attente dans le multiplexeur : un simple booléen fera l’affaire.
Ce booléen définira si le paquet doit être (r)envoyé ou non. Quand un paquet est inclus dans un datagramme, l’état passe à false
. Quand le datagramme qui l’inclut est perdu, il repasse à true
.
Ceci est le fonctionnement de base qui pourra être complexifié par la suite.
Par exemple si un paquet un peu vieux n’a toujours pas été reçu, peut-être serait-ce une bonne idée de le renvoyer plus rapidement afin de ne pas congestionner le protocole (puisque les messages peuvent uniquement être extraits dans l’ordre d’envoi).
V-B. Interface▲
L’interface du multiplexeur sera quasi identique. On ajoutera une interface pour notifier quand un datagramme a été acquitté ou perdu ainsi que le suivi des paquets inclus dans chaque datagramme.
Partons donc sur cette base :
V-C. Implémentations▲
V-C-1. void queue(std::vector<uint8_t>&& data);▲
L’implémentation de queue sera quasi identique à ce qui a été fait pour le protocole ordonné non fiable du chapitre précédent.
L’unique différence réside dans le type de données que l’on met en file d’envoi puisque nous avons une structure intermédiaire englobant le paquet.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
void
Multiplexer::
queue(std::
vector<
uint8_t>&&
msgData)
{
assert(msgData.size() <=
Packet::
MaxMessageSize);
if
(msgData.size() >
Packet::
DataMaxSize)
{
size_t queuedSize =
0
;
while
(queuedSize <
msgData.size())
{
const
auto
fragmentSize =
std::
min(Packet::
DataMaxSize, static_cast
<
uint16_t>
(msgData.size() -
queuedSize));
mQueue.resize(mQueue.size() +
1
);
Packet&
packet =
mQueue.back().packet();
packet.header.id =
mNextId++
;
packet.header.type =
((queuedSize ==
0
) ? Packet::Type::
FirstFragment : Packet::Type::
Fragment);
packet.header.size =
fragmentSize;
memcpy(packet.data(), msgData.data() +
queuedSize, fragmentSize);
queuedSize +=
fragmentSize;
}
mQueue.back().packet().header.type =
Packet::Type::
LastFragment;
assert(queuedSize ==
msgData.size());
}
else
{
mQueue.resize(mQueue.size() +
1
);
Packet&
packet =
mQueue.back().packet();
packet.header.id =
mNextId++
;
packet.header.type =
Packet::Type::
Packet;
packet.header.size =
static_cast
<
uint16_t>
(msgData.size());
memcpy(packet.data(), msgData.data(), msgData.size());
}
}
V-C-2. size_t serialize(uint8_t* buffer, const size_t buffersize, Datagram::ID datagramId);▲
Ici aussi l’implémentation sera similaire. Nous itérons sur la liste d’envoi, et sérialisons les paquets qui le peuvent et le doivent dans le tampon fourni. Sans oublier de mettre à jour le paquet avec l’information du datagramme qui le contient quand un paquet a été sérialisé.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
size_t Multiplexer::
serialize(uint8_t*
buffer, const
size_t buffersize, Datagram::
ID datagramId)
{
size_t serializedSize =
0
;
for
(auto
&
packetHolder : mQueue)
{
if
(!
packetHolder.shouldSend())
continue
;
const
auto
&
packet =
packetHolder.packet();
if
(serializedSize +
packet.size() >
buffersize)
break
; //!
< Paquet trop gros
memcpy(buffer, packet.buffer(), packet.size());
serializedSize +=
packet.size();
buffer +=
packet.size();
//!
< Quand un paquet a été sérialisé, on enregistre dans quel datagramme il a été inclus
packetHolder.onSent(datagramId);
}
return
serializedSize;
}
V-C-3. void onDatagramAcked(Datagram::ID datagramId);▲
Ici il s’agit de supprimer de la file d’envoi les paquets qui étaient contenus dans le datagramme #datagramId. Une simple itération sur notre file d’envoi fera l’affaire :
2.
3.
4.
5.
6.
void
Multiplexer::
onDatagramAcked(Datagram::
ID datagramId)
{
mQueue.erase(std::
remove_if(mQueue.begin(), mQueue.end()
, [&
](const
ReliablePacket&
packetHolder) {
return
packetHolder.isIncludedIn(datagramId); }
)
, mQueue.cend());
}
V-C-4. void onDatagramLost(Datagram::ID datagramId);▲
Similaire à l’acquittement, nous itérons sur la liste d’envoi afin cette fois de remettre les paquets concernés dans un état pour être renvoyés :
void
Multiplexer::
onDatagramLost(Datagram::
ID datagramId)
{
for
(auto
&
packetHolder : mQueue)
{
if
(packetHolder.isIncludedIn(datagramId))
packetHolder.resend();
}
}
Avec resend de la forme void
resend() {
mShouldSend =
true
; }
Ces implémentations sont loin d’être optimales mais sont faciles à comprendre et traduisent directement le besoin de chaque fonction. Aussi il s’agira de la première version mise en place.
VI. Démultiplexeur ▲
VI-A. Fonctionnement▲
Le démultiplexeur aura l’exact même fonctionnement que pour le protocole ordonné non fiable : réceptionner les paquets, les réordonner puis reformer les messages complets.
Mais contrairement au protocole non fiable, celui-ci devra sortir les messages de façon ordonnée et fiable. Donc quand un paquet est perdu, il faudra attendre qu’il soit renvoyé et reçu avec succès avant de pouvoir extraire et fournir les messages complets à l’application.
VI-B. Interface ▲
Son interface sera identique à la version pour le protocole ordonné non fiable.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
class
Demultiplexer
{
friend
class
ReliableOrdered_Demultiplexer_Test;
public
:
Demultiplexer() =
default
;
~
Demultiplexer() =
default
;
void
onDataReceived(const
uint8_t*
data, const
size_t datasize);
std::
vector<
std::
vector<
uint8_t>>
process();
private
:
void
onPacketReceived(const
Packet*
pckt);
private
:
std::
vector<
Packet>
mPendingQueue;
Packet::
Id mLastProcessed{
std::
numeric_limits<
Packet::
Id>
::
max() }
;
}
;
VI-C. Implémentations▲
Seule l’implémentation de process sera différente puisque, contrairement au protocole ordonné non fiable, celui-ci ne pourra plus passer un paquet et devra les traiter dans l’ordre sans interruption.
Vous pouvez retrouver les implémentations dans le chapitre précédent.
VI-C-1. std::vector<std::vector<uint8_t>> process();▲
L’implémentation est extrêmement similaire au protocole ordonné non fiable.
L’unique différence est, pour avoir la fiabilité, qu’on ne peut plus sauter les paquets non reçus. À la place, il faut maintenant interrompre le traitement.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
std::
vector<
std::
vector<
uint8_t>>
Demultiplexer::
process()
{
std::
vector<
std::
vector<
uint8_t>>
messagesReady;
auto
itPacket =
mPendingQueue.cbegin();
auto
itEnd =
mPendingQueue.cend();
std::
vector<
Packet>
::
const_iterator newestProcessedPacket;
Packet::
Id expectedPacketId =
mLastProcessed +
1
;
//!
< La queue est ordonnée, il suffit d’itérer et réassembler les paquets
while
(itPacket !=
itEnd &&
itPacket->
id() ==
expectedPacketId)
{
if
(itPacket->
type() ==
Packet::Type::
Packet)
{
//!
< Paquet-Message, on recopie ses données
std::
vector<
uint8_t>
msg(itPacket->
data(), itPacket->
data() +
itPacket->
datasize());
messagesReady.push_back(std::
move(msg));
newestProcessedPacket =
itPacket;
++
itPacket;
++
expectedPacketId;
}
else
if
(itPacket->
type() ==
Packet::Type::
FirstFragment)
{
//!
< Vérifier si le message est prêt (tous les fragments ont été reçus)
std::
vector<
uint8_t>
msg =
[&
]()
{
std::
vector<
uint8_t>
msg(itPacket->
data(), itPacket->
data() +
itPacket->
datasize());
++
itPacket;
++
expectedPacketId;
while
(itPacket !=
itEnd &&
itPacket->
id() ==
expectedPacketId)
{
if
(itPacket->
type() ==
Packet::Type::
LastFragment)
{
//!
< Dernier fragment reçu, le message est complet
msg.insert(msg.cend(), itPacket->
data(), itPacket->
data() +
itPacket->
datasize());
return
msg;
}
else
if
(itPacket->
type() !=
Packet::Type::
Fragment)
{
//!
< Si on rentre ici, nous avons reçu un paquet mal formé ou mal intentionné
msg.clear();
return
msg;
}
msg.insert(msg.cend(), itPacket->
data(), itPacket->
data() +
itPacket->
datasize());
++
itPacket;
++
expectedPacketId;
}
msg.clear();
return
msg;
}
();
if
(!
msg.empty())
{
//!
< Nous avons un message
messagesReady.push_back(std::
move(msg));
newestProcessedPacket =
itPacket;
//!
< On déplace l’itérateur de parcours de la liste après le dernier paquet traité, qui est le dernier paquet du message
++
itPacket;
}
}
else
{
//!
< Si on arrive ici, il s’agit d’un message incomplet
break
;
}
}
//!
< Si des messages ont été extraits, on doit mettre à jour notre état interne en supprimant les paquets traités de la file
if
(!
messagesReady.empty())
{
mLastProcessed =
newestProcessedPacket->
id();
mPendingQueue.erase(mPendingQueue.cbegin(), std::
next(newestProcessedPacket));
}
return
messagesReady;
}
Nous voici maintenant avec un couple multiplexeur & démultiplexeur capable d’échanger des paquets de façon ordonnée et fiable. Si vous avez lu le chapitre précédent, vous auriez sans doute pu y parvenir de vous-même : le code est en grande partie identique.
Allons donc un peu plus loin pour améliorer ces nouveaux objets.
VII. Attention aux attaques malicieuses : amélioration et optimisation▲
J’attire votre attention sur un premier point faible des protocoles créés : le vector qui accueille les paquets reçus.
Imaginez ce qu’il se passe si, volontairement ou non (paquet mal formé), un client reçoit de nombreux fragments, mais jamais le dernier afin de compléter un message : le vector grandit indéfiniment, jusqu’à ce que le programme crash par manque de mémoire.
Il existe un moyen relativement simple d’éviter ceci en complexifiant un peu le multiplexeur et le démultiplexeur et, qui plus est, améliore les performances du démultiplexeur : utiliser un tableau statique. Grâce à ce dernier, nous avons le contrôle de la mémoire utilisée, aucune allocation, aucune réallocation et donc aucune fragmentation et un parcours plus efficace des paquets reçus.
VII-A. Adieu vector, bonjour array▲
Afin de réaliser cette amélioration, le démultiplexeur n’utilisera plus de vector pour les paquets reçus, mais un array.
VII-A-1. Quelle taille pour l’array ?▲
L’array, contrairement au vector, a une taille définie à la compilation. Et bien sûr cette taille doit être suffisamment grande pour pouvoir supporter le message le plus grand et fragmenté possible défini.
Cette taille est totalement arbitraire et va induire la mémoire utilisée par le protocole, mais aura aussi une incidence sur la latence de réception des messages en cas de perte.
Définissons cette taille au double du max de fragments d’un message. Ainsi, un message de taille maximale pourra être contenu, et il reste de la place pour d’autres messages plus petits afin de ne pas engorger l’envoi de cet unique gros message.
2.
3.
4.
class
Demultiplexer
{
std::
array<
Packet, 2
*
Packet::
MaxPacketsPerMessage>
mPendingQueue;
}
;
Il reste un dernier problème : puisque notre file de réception est maintenant statique, tous les paquets qu’elle contient ne sont pas forcément légitimes. Un paquet qui n’a pas été reçu via le réseau est toutefois alloué et présent dans le tableau. Ce n’est pas pour autant un paquet valide et il ne doit donc pas être traité.
Pour repérer qu’un paquet est légitime, plusieurs astuces sont possibles :
- Utiliser un tuple
<
bool
, Packet>
dont la valeur du booléen mise àfalse
par défaut indique s’il s’agit d’un paquet correct ou non ; - Utiliser un masque de bits pour marquer les paquets corrects.
Mais je vais présenter ma petite astuce, qui ne nécessite aucune donnée supplémentaire : utiliser la taille des données du paquet. Si le paquet n’a aucune donnée, il n’a pas été initialisé par le réseau. Pour que cela fonctionne, il faut bien entendu que Packet::Header::
size soit initialisée par défaut à 0 – ce qui n’était pas le cas jusqu’à présent.
VII-A-2. Modifications du démultiplexeur▲
Mettons maintenant à jour le démultiplexeur.
La liste des paquets reçus est maintenant de taille fixe. Elle est donc définie comme std::
array<
Packet, QueueSize>
avec QueueSize étant static
constexpr
size_t QueueSize =
2
*
Packet::
MaxPacketsPerMessage;.
VII-A-2-a. onPacketReceived(const Packet* pckt)▲
Maintenant à la réception d’un paquet, nous n’avons plus besoin de parcourir notre liste pour le positionner à la bonne place. L’opération devient beaucoup plus simple :
- On définit l’index dans notre tableau en fonction de l’identifiant du paquet et la taille du tableau ;
- On place le paquet à cet index si l’emplacement est libre ;
- S’il est non vide, alors il devrait déjà contenir notre paquet et nous avons reçu un doublon.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
void
Demultiplexer::
onPacketReceived(const
Packet*
pckt)
{
if
(!
Utils::
IsSequenceNewer(pckt->
id(), mLastProcessed))
return
; //!
< Paquet obsolète
//!
< Calcul de l’index dans le tableau
const
size_t index =
pckt->
id() %
mPendingQueue.size();
Packet&
pendingPacket =
mPendingQueue[index];
if
(pendingPacket.datasize() ==
0
)
{
// Emplacement disponible, copier simplement les données du réseau dans notre tableau
pendingPacket =
*
pckt;
}
else
{
// Emplacement NON disponible, s’assurer qu’il contient déjà notre paquet, sinon il y a un problème
assert(pendingPacket.id() ==
pckt->
id() &&
pendingPacket.datasize() ==
pckt->
datasize());
}
}
VII-A-2-b. process()▲
Il reste à mettre à jour le processus d’extraction des messages.
La principale modification consiste à libérer l’emplacement d’un paquet une fois consommé pour créer un message. Puisque nous avons établi plus haut qu’un paquet est valide quand sa taille est non nulle, libérer un paquet va donc simplement consister à réinitialiser sa taille à 0.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
83.
84.
85.
86.
87.
88.
89.
90.
std::
vector<
std::
vector<
uint8_t>>
Demultiplexer::
process()
{
//!
< Fonction de réinitialisation d’un paquet
auto
ResetPacket =
[](Packet&
pckt) {
pckt.header.size =
0
; }
;
std::
vector<
std::
vector<
uint8_t>>
messagesReady;
Packet::
Id expectedPacketId =
mLastProcessed +
1
;
//!
< Il faut itérer sur notre tableau en commençant par le paquet attendu, qui peut ne pas être en index 0
const
size_t startIndexOffset =
expectedPacketId %
mPendingQueue.size();
for
(size_t i =
0
; i <
mPendingQueue.size(); ++
i, ++
expectedPacketId)
{
//!
< On calcule l’index dans notre tableau du prochain paquet à traiter
const
size_t packetIndex =
(i +
startIndexOffset) %
mPendingQueue.size();
Packet&
packet =
mPendingQueue[packetIndex];
if
(packet.type() ==
Packet::Type::
Packet)
{
//!
< Message complet
std::
vector<
uint8_t>
msg(packet.data(), packet.data() +
packet.datasize());
mLastProcessed =
packet.id();
ResetPacket(packet);
messagesReady.push_back(std::
move(msg));
}
else
if
(packet.type() ==
Packet::Type::
FirstFragment)
{
//!
< Vérifier que le message est prêt
const
bool
isMessageFull =
[=
]() mutable
{
// On saute le premier fragment déjà traité par la boucle sur i
++
i;
++
expectedPacketId;
// On itère sur les paquets restants pour vérifier que notre message soit complet
for
(size_t j =
i; j <
mPendingQueue.size(); ++
j, ++
expectedPacketId)
{
const
size_t idx =
(j +
startIndexOffset) %
mPendingQueue.size();
const
Packet&
pckt =
mPendingQueue[idx];
if
(pckt.id() !=
expectedPacketId ||
pckt.datasize() ==
0
)
break
; // Un paquet est manquant
if
(pckt.type() ==
Packet::Type::
LastFragment)
{
//!
< Nous avons atteint et reçu le dernier fragment, le message est complet
return
true
;
}
else
if
(pckt.type() !=
Packet::Type::
Fragment)
{
//!
< Si nous arrivons ici nous avons probablement reçu un paquet mal formé ou malicieux
break
;
}
}
return
false
;
}
();
if
(!
isMessageFull)
break
; // Protocole ordonné fiable : si le message suivant à extraire est incomplet, nous pouvons arrêter le processus d’extraction
// Nous avons un message fragmenté complet, nous pouvons maintenant extraire les données et réinitialiser chaque paquet utilisé
std::
vector<
uint8_t>
msg(packet.data(), packet.data() +
packet.datasize());
++
i;
++
expectedPacketId;
// Itération sur les paquets restants pour compléter le message
for
(size_t j =
i; j <
mPendingQueue.size(); ++
i, ++
j, ++
expectedPacketId)
{
const
size_t idx =
(j +
startIndexOffset) %
mPendingQueue.size();
Packet&
pckt =
mPendingQueue[idx];
if
(pckt.type() ==
Packet::Type::
LastFragment)
{
//!
< Dernier fragment du message maintenant complet
msg.insert(msg.cend(), pckt.data(), pckt.data() +
pckt.datasize());
mLastProcessed =
pckt.id();
ResetPacket(pckt);
messagesReady.push_back(std::
move(msg));
break
;
}
else
if
(pckt.type() !=
Packet::Type::
Fragment)
{
//!
< Paquet mal formé ou malicieux
break
;
}
msg.insert(msg.cend(), pckt.data(), pckt.data() +
pckt.datasize());
ResetPacket(pckt);
}
}
else
{
// Protocole ordonné fiable : si le message suivant à extraire est incomplet, nous pouvons arrêter le processus d’extraction
break
;
}
}
return
messagesReady;
}
Dans cette implémentation il est très important que l’application appelle process(); avant de réaliser l’envoi de données.
En effet, le multiplexeur du client A va gérer ses envois en fonction des datagrammes reçus par le client B. Il est donc important que B extraie ses messages avant que A ne soit informé de quelles données ont été reçues et commence à envoyer les suivantes.
Si A envoie des données alors que B n’a pas libéré le tableau de réception des paquets, B finira en état de deadlock : des nouveaux messages vont écraser les précédents, alors qu’il essayera d’extraire le message précédent puisqu’il s’agit d’un protocole ordonné fiable. Le résultat sera que B ne recevra plus aucune donnée sur ce canal.
VII-A-3. Modifications du multiplexeur▲
Il faut ensuite mettre à jour le multiplexeur pour qu’il fonctionne de pair avec le démultiplexeur.
Le changement consiste à limiter quels paquets peuvent être envoyés, en fonction de ceux reçus.
Par exemple, si notre démultiplexeur avait une file de réception pouvant contenir deux paquets, alors si le dernier paquet reçu est le paquet #4, nous pouvons envoyer les paquets 5 et 6 uniquement.
Pour cela, commençons par ajouter un membre au multiplexeur : l’identifiant du paquet le plus ancien autorisé à être envoyé, nommé mFirstAllowedPacket, et qui sera initialisé à 0.
VII-A-3-a. serialize(uint8_t* buffer, const size_t buffersize, Datagram::ID datagramId)▲
Il s’agira d’ajouter un test afin de s’assurer que le paquet que l’on veut sérialiser, soit dans les bornes d’identifiants pouvant être envoyés.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
size_t Multiplexer::
serialize(uint8_t*
buffer, const
size_t buffersize, Datagram::
ID datagramId)
{
size_t serializedSize =
0
;
for
(auto
&
packetHolder : mQueue)
{
//!
< S’assurer avant tout que le paquet est dans les bornes d’envoi, sinon on peut arrêter d’itérer sur notre file
if
(!
(Utils::
SequenceDiff(packetHolder.packet().id(), mFirstAllowedPacket) <
Demultiplexer::
QueueSize))
break
;
if
(!
packetHolder.shouldSend())
continue
;
const
auto
&
packet =
packetHolder.packet();
if
(serializedSize +
packet.size() >
buffersize)
continue
; //!
< Si le paquet est trop gros, essayons d’inclure les suivants
memcpy(buffer, packet.buffer(), packet.size());
serializedSize +=
packet.size();
buffer +=
packet.size();
packetHolder.onSent(datagramId);
}
return
serializedSize;
}
VII-A-3-b. onDatagramAcked(Datagram::ID datagramId)▲
Apportons maintenant un micro changement à onDatagramAcked(Datagram::
ID datagramId) afin de mettre à jour notre champ mNewestAck si nécessaire.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
void
Multiplexer::
onDatagramAcked(Datagram::
ID datagramId)
{
if
(mQueue.empty())
return
;
mQueue.erase(std::
remove_if(mQueue.begin(), mQueue.end()
, [&
](const
ReliablePacket&
packetHolder) {
return
packetHolder.isIncludedIn(datagramId); }
)
, mQueue.cend());
if
(mQueue.empty())
mFirstAllowedPacket =
mNextId; //!
< Si la file est maintenant vide, la borne commence au prochain paquet mis en file
else
if
(Utils::
IsSequenceNewer(mQueue.front().packet().id(), mFirstAllowedPacket))
mFirstAllowedPacket =
mQueue.front().packet().id(); // Sinon, on déplace les bornes d’envoi au plus ancien paquet en file
}
Ce type d’amélioration est bien entendu applicable au protocole ordonné non fiable du chapitre précédent également. Le cours ne présente pas cette partie, mais vous pouvez utiliser ce chapitre pour améliorer le code du chapitre précédent.
VIII. Tests▲
Vu que ce chapitre a fait la part belle à la réutilisation du chapitre précédent, les tests seront également très fortement inspirés de celui-ci.
Récupérez le code source sous le tag V1.0.6 dans le dépôt Mercurial à l’adresse suivante.
Article précédent | Article suivant |
---|---|
<< UDP – Découpage et unification de paquets |
UDP – Combiner tous les protocoles : les canaux de communication >> |