Cours programmation réseau en C++

UDP – Envoi de paquets ordonné fiable

Lors du dernier chapitre, nous avons implémenté un envoi de paquets ordonné non fiable.

Cette fois nous allons implémenter un envoi ordonné fiable.

28 commentaires Donner une note  l'article (5) 

Article lu   fois.

L'auteur

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

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 :

Multiplexeur
Cacher/Afficher le codeSélectionnez

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.

queue
Cacher/Afficher le codeSélectionnez
1.
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é.

Multiplexer::serialize
Cacher/Afficher le codeSélectionnez
1.
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 :

Multiplexer::onDatagramAcked
Cacher/Afficher le codeSélectionnez
1.
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 :

 
Cacher/Afficher le codeSélectionnez
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.

Demultiplexer
Cacher/Afficher le codeSélectionnez
1.
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.

Demultiplexer::process
Cacher/Afficher le codeSélectionnez
1.
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.

ReliableOrdered.hpp
Cacher/Afficher le codeSélectionnez
1.
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.
void Demultiplexer::onPacketReceived(const Packet* pckt)
Cacher/Afficher le codeSélectionnez
1.
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.

std::vector<std::vector<uint8_t>> Demultiplexer::process()
Cacher/Afficher le codeSélectionnez
1.
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.

size_t Multiplexer::serialize(uint8_t* buffer, const size_t buffersize, Datagram::ID datagramId)
Cacher/Afficher le codeSélectionnez
1.
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.

void Multiplexer::onDatagramAcked(Datagram::ID datagramId)
Cacher/Afficher le codeSélectionnez
1.
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.

ReliableOrdered_Test.hpp
Cacher/Afficher le codeSélectionnez

Récupérez le code source sous le tag V1.0.6 dans le dépôt Mercurial à l’adresse suivante.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

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 © 2019 Cyrille (Bousk) Bousquet. Aucune reproduction, même partielle, ne peut être faite de ce site ni 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.

Les développeurs logiciels actifs sont actuellement estimés à un peu moins de 19 millions dans le monde, 13 millions d'entre eux seraient des pros
Dropbox explique pourquoi son appli pour Android et iOS passe du multiplateforme via C++
Les nouveautés de Visual Studio 2019, un tutoriel de Hinault Romaric
Cours programmation réseau en C++ : envoi de paquets ordonné fiable avec UDP, un tutoriel de Bousk