Cours programmation réseau en C++

UDP - Introduction et premiers pas

Bousk

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

Voyons quelles sont les différences avec TCP, puis pourquoi UDP est utilisé malgré des garanties plus faibles que celles de TCP.

Enfin il est déjà temps de créer notre premier socket UDP pour commencer à envoyer et recevoir des données.

Commentez

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

- Auteur : Bousk

- Parution : 26 août 2017

- Mise à jour : 27 avril 2018

- 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. UDP en deux mots

- II. Vocabulaire : le datagramme

- III. Qu'attendre du protocole UDP

- IV. Pourquoi utiliser UDP ?

- V. Manipuler un socket UDP

- V-A. Créer un socket UDP

- V-B. Ouvrir le socket

- V-C. Envoyer des données

- V-C-1. sendto

- 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);

- V-D. Recevoir des données

- 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);

- VI. Hello world

- VII. Chronologie du cours

- VIII. Pertes et duplications ?

- VIII-A. Contrôler les tailles de tampon dans sendto et recvfrom

- VIII-B. Que se passe-t-il si mon datagramme est gros ?

- VIII-C. L'unité de transmission maximum

- VIII-D. Aller plus loin : les couches réseau

- VIII-D-1. Modèle OSI

- VIII-D-2. Suite des protocoles Internet

- IX. Conclusion

I - UDP en deux mots

UDP est un autre protocole réseau par-dessus IP, tout comme TCP.

Il s'agit d'un protocole non connecté : un socket UDP ne représente pas une liaison entre la machine locale et une machine distante, mais un point d'échange permettant la réception depuis plusieurs sources et l'envoi à plusieurs destinataires.

II - Vocabulaire : le datagramme

Si vous avez suivi le cours TCP, ou utilisez TCP régulièrement, vous avez l'habitude d'échanger des données sous forme de flux. Ce que nous appelions paquet dans le chapitre 3 de la série TCP. Vous envoyez via une série de send (deux appels par paquet dans notre cas - un premier pour l'en-tête contenant la taille des données, suivi d'un second pour les données elles-mêmes), les données à envoyer et vous attendez qu'elles soient réceptionnées de l'autre côté.

En utilisant UDP, nous ne manipulons plus un flux de données, mais ce qu'on appelle des datagrammes. Le datagramme est notre unité d'envoi, le terme de paquet UDP peut aussi apparaître et correspond généralement à la même chose. Mais il n'est pas à mélanger avec le terme de paquet introduit au chapitre 3 de la série TCP qui représentait les données à envoyer du point de vue de l'application.

III - Qu'attendre du protocole UDP

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 assure que les données que vous envoyez seront reçues par la machine distante en totalité et dans l'ordre d'envoi tant que la connexion est maintenue assez longtemps. Sinon la connexion est perdue et vous en êtes notifié.

À l'inverse UDP est un protocole de transport en mode non connecté et surtout non fiable. Sa seule garantie est que si le datagramme 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 datagrammes reçus peuvent être désordonnés par rapport à leur envoi : si vous envoyez plusieurs datagrammes 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). Et tout comme il est impossible de savoir si un datagramme a été remis, il est impossible de savoir si le destinataire existe, et s'il est toujours présent et valide ou non.

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

IV - 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 datagramme est un moindre mal et préférable à ce que le client se concentre sur le renvoi d'une donnée que l'on sait obsolète. Le datagramme 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 également ê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 plusieurs centaines de millisecondes supplémentaires avant d'envoyer les données. Ceci parce que la taille des données doit être suffisamment importante 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, tandis qu'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, sur Wikipédia ici et ici et cet excellent post du support Cisco.

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

V - Manipuler un socket UDP

La bonne nouvelle est que la manipulation d'un socket UDP est, selon moi, beaucoup plus simple qu'avec 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…) s'utilisent à l'identique avec 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 de la série TCP.

SOCKET sckt = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);

V-B - Ouvrir le socket

Avant de pouvoir utiliser notre socket, il faut l'ouvrir.

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

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 interface 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 sur ce couple interface/port et envoyer les données depuis ce même couple.

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é, il faut donc indiquer le destinataire à chaque envoi. Ce qui se fait via la fonction sendto.

V-C-1 - sendto

Une fois de plus, 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 vue dans le premier chapitre de la série TCP.

1. sockaddr_in dst;

2. if (inet_pton(AF_INET, "127.0.0.1", &dst.sin_addr) <= 0)

3. {

4. // impossible de déterminer l'adresse

5. return;

6. }

7. int ret = sendto(mSocket, "toto", 4, 0, reinterpret_cast<const sockaddr*>(&dst), sizeof(dst));

8. if (ret < 0)

9. // 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'agit 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.

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

Un appel à sendto sera donc l'envoi d'un datagramme au destinataire passé en paramètre. Un appel à recvfrom sera la réception d'un datagramme depuis une source.

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 vu plus haut afin de créer un socket qui envoie et reçoit des données. Lancez le programme en deux exemplaires afin de les voir s'échanger des données.

Premier programme UDP

1. #include "Sockets.hpp"

2. #include "Errors.hpp"

4. #include <iostream>

5. #include <string>

6. #include <thread>

8. int main()

9. {

10. if (!Bousk::Network::Start())

11. {

12. std::cout << "Erreur initialisation WinSock : " << Bousk::Network::Errors::Get();

13. return -1;

14. }

16. SOCKET myFirstUdpSocket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);

17. if (myFirstUdpSocket == SOCKET_ERROR)

18. {

19. std::cout << "Erreur création socket : " << Bousk::Network::Errors::Get();

20. return -2;

21. }

23. unsigned short port;

24. std::cout << "Port local ? ";

25. std::cin >> port;

27. sockaddr_in addr;

28. addr.sin_addr.s_addr = INADDR_ANY;

29. addr.sin_port = htons(port);

30. addr.sin_family = AF_INET;

31. if (bind(myFirstUdpSocket, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0)

32. {

33. std::cout << "Erreur bind socket : " << Bousk::Network::Errors::Get();

34. return -3;

35. }

37. unsigned short portDst;

38. std::cout << "Port du destinataire ? ";

39. std::cin >> portDst;

40. sockaddr_in to = { 0 };

41. inet_pton(AF_INET, "127.0.0.1", &to.sin_addr.s_addr);

42. to.sin_family = AF_INET;

43. to.sin_port = htons(portDst);

45. std::cout << "Entrez le texte à envoyer (vide pour quitter)> ";

46. while (1)

47. {

48. std::string data;

49. std::getline(std::cin, data);

50. if (data.empty())

51. break;

52. int ret = sendto(myFirstUdpSocket, data.data(), static_cast<int>(data.length()), 0, reinterpret_cast<const sockaddr*>(&to), sizeof(to));

53. if (ret <= 0)

54. {

55. std::cout << "Erreur envoi de données : " << Bousk::Network::Errors::Get() << ". Fermeture du programme.";

56. break;

57. }

58. char buff[1500] = { 0 };

59. sockaddr_in from;

60. socklen_t fromlen = sizeof(from);

61. ret = recvfrom(myFirstUdpSocket, buff, 1499, 0, reinterpret_cast<sockaddr*>(&from), &fromlen);

62. if (ret <= 0)

63. {

64. std::cout << "Erreur réception de données : " << Bousk::Network::Errors::Get() << ". Fermeture du programme.";

65. break;

66. }

67. std::cout << "Recu : " << buff << " de " << Bousk::Network::GetAddress(from) << ":" << Bousk::Network::GetPort(from) << std::endl;

68. }

70. Bousk::Network::CloseSocket(myFirstUdpSocket);

71. Bousk::Network::Release();

72. return 0;

73. }

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

VII - 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 et étendues (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.

VIII - Pertes et duplications ?

Essayons d'avoir une meilleure idée de ce qu'est Internet afin de comprendre pourquoi un datagramme peut être perdu ou dupliqué.

Internet peut se résumer grossièrement à une succession de machines, de 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 l'un d'eux saura continuer la distribution.

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

En pratique la perte de datagramme serait inférieure à 1 % pour une connexion normale. Lors de simulations une valeur de 5 % est généralement utilisée afin de renforcer la robustesse du code face aux pertes. Ce n'est donc pas si dérangeant qu'on pourrait croire et fait partie des « règles du jeu » d'UDP.

Je parle bien ici d'Internet et non d'un réseau local (LAN) ou d'une machine unique 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.

VIII-A - Contrôler les tailles de tampon dans sendto et recvfrom

Une possibilité de perte de données est si le tampon de réception utilisé pour l'appel à recvfrom est trop petit pour accueillir le datagramme, le système se contentera de le défausser silencieusement comme s'il n'avait jamais existé - ce qui est bien dommage alors qu'il avait atteint sa destination.

Pour régler ce problème, il faut alors juste choisir une taille de tampon assez grande pour le plus gros datagramme et on est tranquille !

Mais ça ne suffit pas totalement.

Vous pouvez certes contrôler la taille du tampon et des datagrammes échangés dans votre application, et ça marchera très certainement lors de vos tests sur votre seule machine.

Mais si vous faites maintenant le test entre deux machines : il y a un intermédiaire qui est la carte réseau de chaque machine et le câble Ethernet entre ces machines, voire le système d'exploitation sur chaque machine. Comment contrôler la taille des tampons de chaque intermédiaire ?

Quand bien même vous seriez assez fou pour réécrire les différents pilotes afin de contrôler ceci, qu'en est-il lorsque vous utilisez votre application en ligne ? Où vos échanges peuvent être transférés entre de très nombreux routeurs que vous ne pourrez jamais contrôler ?

VIII-B - Que se passe-t-il si mon datagramme est gros ?

Le standard IP indique que lors de la réception d'un datagramme de taille importante, la machine (ou le routeur) peut choisir de le fragmenter, ou de le défausser. Cette fragmentation se produit sur la couche IP, qui entoure chaque datagramme UDP. Le système d'exploitation de son côté ne peut pas rendre disponible un fragment seul (d'après l'unique garantie d'UDP). Cumulé aux probabilités de pertes inhérentes au protocole, il s'agit d'un risque supplémentaire de ne pas voir vos données arriver.

Or on veut envoyer des données afin de les recevoir et non juste pour le plaisir d'en envoyer.

VIII-C - L'unité de transmission maximum

Plus communément appelée MTU de par son nom anglais Maximum Transmission Unit, il s'agit de la taille maximale d'un datagramme IP qui peut être transféré sans fragmentation. Lorsque plusieurs intermédiaires interviennent, on parle alors de path MTU qui est défini par le plus petit MTU parmi eux et définit le MTU des échanges.

Cette valeur peut évoluer au cours du temps puisque les intermédiaires peuvent changer d'un échange à l'autre.

Heureusement il existe certaines normes et celle qui nous intéressera tout particulièrement sera que le MTU Ethernet est de 1500 octets.

Enlevez quelques octets pour l'en-tête UDP (8 o) et IP (20 o IPv4, 40 o IPv6), et vous trouverez régulièrement le nombre de 1472 à travers les articles pour de l'UDP IPv4.

Prenez une marge et nous utiliserons des datagrammes d'un maximum de 1400 o dans notre bibliothèque.

VIII-D - Aller plus loin : les couches réseau

Si vous souhaitez plus d'informations sur le pourquoi ces limitations, il faudra vous renseigner sur les différentes couches réseau, du programme que nous écrivons jusqu'au câble de transfert de données, et comment chaque couche traite les données.

VIII-D-1 - Modèle OSI

Si vous vous intéressez un peu au réseau, vous avez sans doute déjà entendu parler du modèle OSI qui est un standard de communication entre systèmes.

Le modèle OSI définit sept couches, de la plus haute à la plus basse : application, présentation, session, transport, réseau, liaison de données, physique.

Ce cours ne se veut pas traiter de réseau au sens large, mais de la programmation UDP, aussi je vous invite à lire cet excellent article dans la rubrique réseau pour approfondir ce point si vous le souhaitez. Ceci n'est nullement nécessaire dans le cadre du cours, mais peut aider à appréhender certains aspects.

VIII-D-2 - Suite des protocoles Internet

Il s'agit d'un autre modèle, qui se calque sur OSI, et simplifie son modèle en quatre couches qui seront plus intéressantes, parce qu'ayant plus de sens selon moi, dans notre cas :

Application

Transport : TCP ou UDP

Internet : IPv4 ou IPv6

Accès réseau : drivers et matériel

Pour comprendre l'interaction entre les couches, voici une représentation des données échangées :

Données

MaClasse::send(tampon fourni par l'utilisateur)

Application

En-tête UDP

Données UDP

sendto

Transport

En-tête IP

IP

Début de trame

Fin de trame

Accès réseau

UDP et TCP peuvent être interchangés, ici UDP est pris à titre d'exemple puisqu'il s'agit d'un article sur UDP et nous ne nous intéressions pas à ce genre de choses lors du travail avec TCP du fait des garanties plus importantes qu'il apporte.

Nous contrôlons totalement l'application, ce qui permet de gérer quelque peu les couches en dessous qui sont parfaitement définies via différentes RFC et normes. Comprendre le fonctionnement des couches inférieures permet de savoir comment manipuler nos données correctement et prédire leurs comportements. Par exemple, nous pouvons expliquer pourquoi la valeur de 1472 octets trouvée plus tôt.

Tandis que TCP réalisait tous ces traitements à notre insu, ce qui nous facilitait la tâche, il n'est plus possible d'ignorer complètement ce fonctionnement interne en utilisant UDP.

C'est là que la connaissance du MTU intervient. Il s'agira de la seule information qui nous importe pour le moment.

IX - Conclusion

Cette série traitant d'UDP détaillera et vous permettra de mettre en place votre propre protocole en utilisant des échanges de données via UDP.

Les implémentations proposées dans les articles suivants ne seront pas la seule et unique façon de parvenir au résultat souhaité, mais une approche parmi d'autres que je suis et détaillerai.

Article précédent

<< Introduction

Gérer les pertes et duplications d'identifiants >>