Cours programmation réseau en C++

UDP - Introduction et premiers pas

Cette série d'articles permet de comprendre et utiliser UDP afin d'ajouter son support dans notre moteur réseau.

Nous commençons par voir quelles différences il y a par rapport à TCP, puis pourquoi UDP est utile malgré ces différences qui semblent être des lacunes à première vue.

Enfin il est déjà temps de créer notre premier socket UDP et voir comment l'initialiser pour envoyer et recevoir des données.

22 commentaires Donner une note à l'article (5) 

Article lu   fois.

L'auteur

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. UDP vs TCP

UDP est un autre protocole d'échange de données en surcouche à IP, tout comme TCP.

Et les points communs s'arrêtent à peu de choses près là.

Là où TCP fournissait un protocole fiable et une connexion (souvenez-vous l'utilisation de connect pour se connecter à un serveur vue dès le premier chapitre dédié à TCP), les garanties d'UDP sont bien plus maigres.

TCP est un protocole de transport fiable en mode connecté. La connexion, une fois établie, permet un échange de données vers et depuis la machine distante à laquelle vous vous êtes connecté, et vous êtes sûrs que les données que vous envoyez seront reçues par la machine distante tant que la connexion est maintenue assez longtemps.

À l'inverse UDP est un protocole de transport en mode non connecté et surtout non fiable. Sa seule garantie est que si le paquet arrive à destination, il arrive entièrement et intact, sans aucune altération ni partiellement. Par contre il peut ne jamais arriver, ou en plusieurs exemplaires et les paquets reçus peuvent être désordonnés par rapport à leur envoi : si vous envoyez plusieurs paquets A, B et C dans cet ordre, ils pourraient arriver désordonnés B, C, A (en plus de pouvoir être manquants ou dupliqués ce qui donnerait à la réception C, C, B ou encore B, C, B, A, A et toutes les autres possibilités imaginables).

Après avoir utilisé TCP ça parait faible…

II. Pourquoi utiliser UDP ?

Imaginez que vous envoyez régulièrement, à chaque frame, votre position à une machine distante.

Seule la donnée la plus récente vous intéresse : sa position la plus à jour.

Dans un tel cas, perdre un paquet est un moindre mal et préférable à ce que le client ralentisse afin de renvoyer une donnée que l'on sait obsolète. Le paquet est perdu : tant pis, on est déjà en train d'envoyer le suivant qui devrait lui arriver.

Tandis que TCP passera en attente de la réponse indiquant la bonne réception, avant de remarquer que le paquet a été perdu puis renvoyer les données en question. Chaque échange, envoi initial et renvoi des données, envoi de l'accusé de réception, pouvant être sujet à perte.

De plus, TCP peut introduire un délai dans l'envoi des paquets : s'il décide qu'il n'y a pas assez de données pour mériter un envoi, il peut attendre jusque 200ms supplémentaires avant d'envoyer les données. Ceci parce que la taille des données doit être suffisamment important face à la taille de l'en-tête envoyé avec chaque paquet.

Ce qui mène au troisième point : l'en-tête d'un paquet TCP est beaucoup plus gros qu'UDP. Un en-tête TCP varie de 20 à 60 octets selon les options, un en-tête UDP fait toujours 8 octets.

Plus d'informations sur TCP sont disponibles sur cette traduction d'un article de Glenn Fiedler et Wikipédia ici et ici.

Tout ceci (maîtriser les délais, les pertes et la taille des données envoyées) fait qu'implémenter son protocole de fiabilité en utilisant UDP est souvent préférable à utiliser TCP si vous envoyez des données critiques et dont l'intérêt n'est qu'immédiat ou très court (seule la toute dernière position est intéressante et non chaque position intermédiaire). Et puisque tous deux sont des protocoles qui transfèrent leurs données via IP en interne, ce n'est pas une idée si farfelue qu'il y parait à première vue et c'est effectivement totalement faisable.

Mais nous n'en sommes pas encore là.

III. Pertes et duplications ?

Pour comprendre pourquoi ces phénomènes peuvent survenir, il faut avoir une meilleure idée de ce qu'est internet.

Internet peut se résumer grossièrement à une succession de machines, routeurs, qui transmettent les données de proche en proche jusqu'à destination.

Chaque machine peut se retrouver en défaut, et stopper la retransmission.

Ou décider de dupliquer la donnée et la transmettre à plusieurs voisins, peut-être parce qu'elle ne peut pas déterminer lequel serait le plus apte à transférer les données jusqu'à destination, ou que ces voisins sont peu fiables et la redondance devrait limiter les dégâts - la perte totale - supposant qu'au moins un d'eux saura continuer la distribution.

Voilà de façon très simple pourquoi un paquet peut être perdu ou arriver en plusieurs exemplaires.

En pratique la perte de paquet se situerait aux alentours de 5% pour une connexion normale. Il s'agit d'une estimation plus ou moins généralement admise et de la valeur utilisée typiquement lors de simulations. Ce n'est donc pas si dérangeant qu'on pourrait croire et fait partie des « règles du jeu » d'UDP.

Pour la duplication nous verrons un moyen extrêmement simple d'y remédier au fur et à mesure que nous implémentons notre protocole.

Je parle bien ici d'internet et non d'un réseau local (LAN) où ces problèmes sont généralement inexistants.

Ainsi vous ne devriez avoir aucun problème à utiliser UDP lors de vos tests en interne, avant de remarquer que tout est hors de contrôle et plante lamentablement à grandeur réelle et sur internet. Ceci est sûrement un des plus gros pièges d'UDP pour un débutant.

IV. Chronologie du cours

Ces chapitres s'inscrivent dans la chronologie du cours réseau qui ne traitait que de TCP jusque-là. Ainsi je suppose que vous avez déjà lu ces chapitres, et en particulier les abstractions mises en place dans chaque partie puisqu'elles seront réutilisées ici (SOCKET, Error::Get(), etc.).

Si ce n'est pas le cas, une lecture rapide du premier chapitre, du chapitre 4, du chapitre 5 et du chapitre 7 devrait suffire.

V. Manipuler un socket UDP

La bonne nouvelle est que manipuler un socket UDP est, selon moi, beaucoup plus simple qu'un socket TCP.

En effet, notre socket permet basiquement deux actions : envoyer des données, et en recevoir. Il n'y a plus de connexion à effectuer ou à accepter, et, tant que le socket est ouvert, aucune erreur d'envoi ou réception ne devrait survenir.

La plupart des fonctions de manipulation de socket utilisées en TCP (setsockopt, …) sont identiques d'utilisation pour un socket UDP.

Le seul prérequis à l'utilisation d'un socket UDP est de créer le socket.

V-A. Créer un socket UDP

Un socket UDP est un socket de type SOCK_DGRAM utilisant le protocole IPPROTO_UDP. Ceci a déjà été vu lors du tout premier chapitre.

 
Sélectionnez
SOCKET sckt = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);

V-B. Ouvrir le socket

Avant de pouvoir utiliser notre socket, il faut l'ouvrir afin qu'il utilise un port choisi.

Pour ceci, nous utilisons la même fonction bind que nous utilisons pour le serveur, introduite dans le chapitre 4 :

 
Sélectionnez
sockaddr_in addr;
addr.sin_addr.s_addr = INADDR_ANY; // permet d'écouter sur toutes les interfaces locales
addr.sin_port = htons(port); // toujours penser à traduire le port en endianess réseau
addr.sin_family = AF_INET; // notre adresse est IPv4
int res = bind(sckt, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
if (res != 0)
	// erreur

Oui, le code est identique ! Il s'agit d'associer une adresse locale et un port à un socket dans les deux cas. Dans le cas d'un serveur TCP, c'est requis pour appeler listen et accepter les connexions entrantes. Dans le cas d'un socket UDP, c'est afin de recevoir les paquets entrants.

V-C. Envoyer des données

Maintenant que nous avons un socket ouvert, il est déjà prêt à être utilisé !

Puisqu'il s'agit d'un protocole non connecté, notre socket ne représente pas un destinataire particulier - il ne représente pas une connexion directe comme un socket TCP - mais est un point d'entrée pour envoyer (et recevoir) des données. Il faut donc indiquer le destinataire à chaque envoi, ce qui se fait via la fonction sendto.

V-C-1. sendto

Une fois n'est pas coutume, les déclarations changent légèrement entre les plateformes Windows et Unix.

V-C-1-a. Windows - int sendto(SOCKET sckt, const char* buffer, int len, int flags, const sockaddr* dst, int dstlen);

V-C-1-b. Unix - int sendto(int sckt, const void* buffer, size_t len, int flags, const sockaddr* dst, socklen_t dstlen);

Permet d'envoyer des données depuis un socket vers une adresse.

  • sckt est le socket depuis lequel envoyer les données.
  • buffer est le tampon de données à envoyer.
  • len est la taille du tampon en octets.
  • flags permet de spécifier des options pour cet envoi, généralement 0 pour aucune option particulière.
  • dst est l'adresse du destinataire.
  • dstlen est la taille de la structure de l'adresse du destinataire.

Retourne le nombre d'octets envoyés. Peut retourner 0. Retourne -1 en cas d'erreur sous Unix, SOCKET_ERROR sous Windows.

La création de la structure du destinataire est identique à celle utilisée pour se connecter à un serveur que nous avons vu dans le premier chapitre.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
sockaddr_in dst;
if (inet_pton(AF_INET, "127.0.0.1", &dst.sin_addr) <= 0)
{
	// impossible de déterminer l'adresse
	return;
}
int ret = sendto(mSocket, "toto", 4, 0, reinterpret_cast<const sockaddr*>(&dst), sizeof(dst));
if (ret < 0)
	// erreur d'envoi

V-D. Recevoir des données

Puisque nous pouvons recevoir des données de plusieurs sources, la fonction de réception devra aussi permettre de connaître leur origine. Il s'agira de la fonction recvfrom.

V-D-1. Windows - int recvfrom(SOCKET sckt, char* buffer, int len, int flags, sockaddr* from, int* fromlen);

V-D-2. Unix - int recvfrom(int sckt, void* buffer, size_t len, int flags, sockaddr* from, socklen_t* fromlen);

Permet de recevoir des données de notre socket et extraire l'adresse de l'émetteur.

  • sckt est le socket duquel recevoir les données.
  • buffer est le tampon où copier les données reçues.
  • len est la taille du tampon en octets, la quantité maximale de données à recevoir.
  • flags permet de spécifier des options pour cette réception, généralement 0 pour aucune option particulière.
  • from est la structure où copier l'adresse de l'émetteur.
  • fromlen est la taille maximale de la structure de l'adresse de l'émetteur et contiendra la taille réelle de la structure après réception.
 
Sélectionnez
char buffer[1500];
sockaddr_in from;
socklen_t fromlen = sizeof(from);
int ret = recvfrom(mSocket, buffer, 1500, 0, reinterpret_cast<sockaddr*>(&from), &fromlen);
if (ret <= 0)
	// erreur de réception

VI. Hello world

Il est déjà temps d'écrire notre premier programme utilisant des sockets UDP. Ne nous soucions d'aucune encapsulation ni rien de ce genre pour le moment.

Contentons-nous d'appliquer ce que nous avons plus haut afin de créer un socket qui envoie et reçoit des données toutes les 100ms. Lancez le programme en deux exemplaires afin de les voir s'échanger des données.

Premier programme UDP
Sé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.
#include "Sockets.hpp"
#include "Errors.hpp"

#include <iostream>
#include <string>
#include <thread>

int main()
{
	if (!Bousk::Network::Start())
	{
		std::cout << "Erreur initialisation WinSock : " << Bousk::Network::Errors::Get();
		return -1;
	}

	unsigned short port;
	std::cout << "Port ? ";
	std::cin >> port;

	SOCKET myFirstUdpSocket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
	if (myFirstUdpSocket == SOCKET_ERROR)
	{
		std::cout << "Erreur création socket : " << Bousk::Network::Errors::Get();
		return -2;
	}

	sockaddr_in to = { 0 };
	inet_pton(AF_INET, "127.0.0.1", &to.sin_addr.s_addr);
	to.sin_family = AF_INET;
	to.sin_port = htons(port);

	std::cout << "Entrez le texte a envoyer (vide pour quitter)> ";
	while (1)
	{
		std::string data;
		std::getline(std::cin, data);
		if (data.empty())
			break;
		int ret = sendto(myFirstUdpSocket, data.data(), static_cast<int>(data.length()), 0, reinterpret_cast<const sockaddr*>(&to), sizeof(to));
		if (ret <= 0)
		{
			std::cout << "Erreur envoi de données : " << Bousk::Network::Errors::Get() << ". Fermeture du programme.";
			break;
		}
	}

	Bousk::Network::CloseSocket(myFirstUdpSocket);
	Bousk::Network::Release();
	return 0;
}

Maintenant que nous savons ouvrir un socket UDP et l'utiliser, il s'agit de construire autour de ça pour mettre en place notre protocole.

Article précédent  
<< Introduction  

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 © 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.