Le malentendu selon lequel TCP renvoie les données dans les mêmes unités que Send — concevoir la réception comme un flux d'octets

· Mis à jour le: · · TCP, Socket, Réseau, .NET, C#, Conception de protocole, Exploitation, Réutilisation des actifs existants

1. Ce qu’il faut comprendre en premier

Il existe un malentendu très courant dans l’implémentation des communications TCP.

C’est celui-ci :

Pour chaque unité que l’émetteur passe à Send / Write, le récepteur peut la relire avec Receive / Read en tant qu’unité identique.

Par exemple, supposons que l’émetteur envoie comme suit :

Send("LOGIN\n")
Send("GET /items\n")
Send("QUIT\n")

On imagine alors que le récepteur peut la relire en trois appels :

Receive() => "LOGIN\n"
Receive() => "GET /items\n"
Receive() => "QUIT\n"

Mais avec TCP, ce n’est pas garanti.

En réalité, n’importe lequel des cas suivants peut se produire.

Receive() => "LOGIN\nGET /items\nQUIT\n"
Receive() => "LOG"
Receive() => "IN\nGET /ite"
Receive() => "ms\nQUIT\n"
Receive() => "LOGIN\nGET /items\n"
Receive() => "QUIT"
Receive() => "\n"

Tous ces cas sont parfaitement normaux pour TCP.

Ce que TCP garantit, pour le dire simplement, c’est que « la suite d’octets envoyée arrive dans l’ordre, sans duplication et sans perte ». Ce qu’il ne garantit pas, c’est que « l’unité passée par l’application à Send soit préservée comme unité de Receive côté récepteur ».

C’est pourquoi toute application utilisant TCP a besoin, côté réception, d’un mécanisme permettant de déterminer « parmi les octets qui viennent d’être reçus, où commence et où finit un message ».

C’est ce qu’on appelle le framing du protocole applicatif.

Cet article présente les malentendus fréquents autour des relations entre Send et Receive en TCP, ainsi que la bonne façon de les traiter en .NET / C#.

Le code de cet article est publié sur GitHub sous forme d’un ensemble d’exemples complets, prêts à être compilés et exécutés (une bibliothèque, une démonstration TCP en bouclage local, ainsi que des tests unitaires reproduisant la fragmentation, la fusion et les déconnexions en cours de transmission).

tcp-send-receive-message-framing - komurasoft-blog-samples (GitHub)

2. TCP transporte des « octets », pas des « messages »

Tout d’abord, il est important de ne pas considérer TCP comme une file de messages.

TCP traite les données que lui transmet l’application comme un flux d’octets continu.

Par exemple, même si l’émetteur appelle Send trois fois comme ceci,

Send("ABC")
Send("DEF")
Send("GHI")

du point de vue de TCP, il s’agit au final d’un flux de 9 octets :

ABCDEFGHI

Les limites propres à l’application

ABC | DEF | GHI

ne subsistent nulle part dans ce flux.

Le récepteur, à un instant donné, lit « ce qui se trouve actuellement dans le tampon de réception ». Le résultat de la réception peut donc prendre les formes suivantes.

Appels côté émetteur Exemple de ce que voit le récepteur
Send("ABC"), Send("DEF") Un seul Receive() renvoyant "ABCDEF"
Send("ABCDEF") Deux appels Receive() renvoyant "AB", "CDEF"
Send("ABC"), Send("DEF"), Send("GHI") Trois appels Receive() renvoyant "A", "BCDEFG", "HI"
Un caractère UTF-8 comme Send("\u3042") Un caractère multi-octets peut même être coupé en son milieu

L’important, c’est que rien de tout cela n’est « anormal ».

Beaucoup de bogues qui ressemblent à « les données reçues sont parfois incomplètes », « plusieurs messages se collent entre eux » ou « le texte est corrompu » ne sont pas des dysfonctionnements de TCP : ce sont des erreurs de conception où le récepteur traite TCP comme s’il était orienté message.

3. Pourquoi on a l’impression de pouvoir recevoir par unité de Send

Si ce malentendu persiste, c’est parce qu’en environnement local et avec de petits volumes de données, les choses se comportent souvent, par hasard, comme on l’attend.

En environnement de développement, les conditions suivantes ont tendance à être réunies :

  • le client et le serveur se trouvent sur la même machine, ou sur un réseau proche
  • le volume de données est faible
  • le correspondant lit immédiatement
  • le CPU et le réseau ont de la marge
  • les tests sont manuels, avec peu de variations de timing
  • Receive est appelé juste après Send

Dans ces conditions, on peut avoir l’impression qu’un Send correspond à un Receive.

Mais en production, les conditions changent :

  • les tampons d’émission et de réception de l’OS se remplissent
  • plusieurs petits envois sont regroupés
  • les gros envois sont fragmentés à cause des segments TCP ou des limites du tampon de réception
  • l’ordonnancement du thread de réception est retardé
  • des couches comme TLS, des proxys, des équilibreurs de charge ou des VPN s’intercalent
  • des latences réseau ou de la congestion surviennent
  • l’algorithme de Nagle et les ACK différés entrent en jeu

Le résultat est ce type de bogue particulièrement désagréable : « ça marchait en développement, mais ça casse parfois en production ».

Dans le traitement réseau, ce « ça marche par hasard » est le cas le plus dangereux.

4. Un exemple typique de code de réception fragile

Par exemple, le code suivant est dangereux :

byte[] buffer = new byte[4096];
int read = await stream.ReadAsync(buffer, cancellationToken);

if (read == 0)
{
    // Le correspondant a fermé la connexion normalement
    return;
}

string message = Encoding.UTF8.GetString(buffer, 0, read);
await HandleMessageAsync(message, cancellationToken);

Ce code suppose qu’« un seul ReadAsync fournit un seul message », mais cette hypothèse ne tient pas avec TCP.

Il y a essentiellement trois problèmes.

Le premier est qu’un message peut être fragmenté.

Envoyé :  {"command":"login","user":"komura"}\n
Reçu 1 : {"command":"login",
Reçu 2 : "user":"komura"}\n

Dans ce cas, essayer d’analyser Reçu 1 seul comme du JSON échoue.

Le deuxième est que plusieurs messages peuvent être fusionnés.

Envoyé 1 : {"command":"login"}\n
Envoyé 2 : {"command":"get"}\n
Reçu :     {"command":"login"}\n{"command":"get"}\n

Dans ce cas, essayer de l’analyser comme un seul document JSON échoue.

Le troisième est qu’une coupure peut survenir sur une limite d’encodage de caractères.

En UTF-8, un caractère peut occuper plusieurs octets. Rien ne garantit que la limite d’un ReadAsync coïncide avec la limite d’un caractère.

Par conséquent, si vous convertissez systématiquement et immédiatement en chaîne chaque bloc d’octets reçu avec Encoding.UTF8.GetString, le résultat peut être corrompu si un caractère multi-octets est coupé en son milieu.

La règle de base n’est pas « transformer en chaîne dès réception », mais « accumuler les octets jusqu’à ce que la limite du message soit connue, puis décoder seulement une fois qu’un message complet est assemblé ».

5. Ne jamais utiliser DataAvailable pour déterminer la fin d’un message

On rencontre aussi souvent un code comme celui-ci :

var ms = new MemoryStream();
byte[] buffer = new byte[4096];

while (stream.DataAvailable)
{
    int read = await stream.ReadAsync(buffer, cancellationToken);
    if (read == 0)
    {
        break;
    }

    ms.Write(buffer, 0, read);
}

byte[] message = ms.ToArray();

C’est également dangereux. Ce que représente DataAvailable, c’est « à cet instant précis, y a-t-il des données lisibles dans le tampon de réception local » — cela ne signifie pas qu’un message applicatif est terminé.

Par exemple, si un message fait 100 octets, DataAvailable peut devenir true dès que les 40 premiers octets arrivent, puis redevenir temporairement false juste après la lecture de ces 40 octets. Les 60 octets restants peuvent arriver un peu plus tard.

Si l’on interprète DataAvailable == false comme « fin du message » dans ce cas, on traite comme un message complet des données qui ne sont en fait qu’une partie du message.

DataAvailable peut avoir son utilité pour optimiser une boucle de lecture ou faire une vérification de type non bloquant, mais il vaut mieux ne jamais s’en servir pour décider des limites du protocole.

6. La bonne approche : séparer « réception » et « interprétation »

Le traitement de la réception TCP devient beaucoup plus simple à concevoir si l’on distingue ces deux aspects :

Réception :     lire les octets arrivant de TCP et les ajouter à un tampon
Interprétation : extraire du tampon un message complet au niveau applicatif

Receive / Read n’est qu’une opération de « lecture d’octets ». À l’inverse, l’endroit où se termine un message doit être décidé par le protocole applicatif.

Les quatre approches courantes sont les suivantes :

Approche Description Cas d’usage adaptés
Longueur fixe Chaque message occupe toujours le même nombre d’octets Équipements existants, télégrammes binaires, systèmes de contrôle
Délimiteur Un message se termine par une séquence d’octets précise, comme \n Commandes, journaux, NDJSON, protocoles simples
Préfixe de longueur La longueur du corps est placée en tête, puis on lit exactement ce nombre d’octets Binaire, JSON, MessagePack, Protocol Buffers, etc.
Format auto-descriptif Le format lui-même indique la longueur ou la fin, comme le Content-Length HTTP ou l’encodage chunked Protocoles existants, communications nécessitant de l’extensibilité

Personnellement, lorsque je conçois un protocole propriétaire, le préfixe de longueur est la première approche que j’envisage. Le corps peut contenir des retours à la ligne ou des données binaires quelconques, l’implémentation côté récepteur est sans ambiguïté, et il est facile d’y imposer une taille maximale.

7. Les bases du préfixe de longueur

Avec le préfixe de longueur, les messages prennent la forme suivante :

[longueur du corps sur 4 octets][corps]

Par exemple, si le corps est du JSON en UTF-8 et fait 31 octets, on envoie :

00 00 00 1F 7B 22 63 6F 6D 6D 61 6E 64 ...
^---------^ ^------------------------------^
 longueur                 corps

Le récepteur procède dans cet ordre :

  1. lire exactement 4 octets
  2. en extraire la longueur du corps
  3. vérifier que cette longueur n’est pas invalide
  4. lire exactement le nombre d’octets correspondant au corps
  5. traiter le corps ainsi lu comme un message complet
  6. lire la trame suivante

Le point crucial ici : même l’en-tête de 4 octets peut être fragmenté.

Reçu 1 : 00 00
Reçu 2 : 00 1F 7B 22 63 ...

Le fait qu’il s’agisse « seulement » de l’en-tête ne garantit donc pas qu’un seul Read renverra 4 octets.

C’est la même chose pour le corps. Il est parfaitement normal que Read renvoie moins d’octets que demandé. Lorsque le nombre d’octets requis est connu, il faut écrire une boucle qui lit jusqu’à obtenir ce nombre complet.

8. Implémentation de la réception en .NET : le préfixe de longueur

Voici un exemple de lecture de trames à préfixe de longueur en .NET / C#.

Ici, les 4 premiers octets sont interprétés comme un int big-endian représentant la longueur du corps.

using System.Buffers.Binary;
using System.IO;

public static class LengthPrefixedProtocol
{
    private const int HeaderSize = 4;
    private const int MaxPayloadSize = 1024 * 1024; // 1 MiB. À définir selon l'usage

    public static async ValueTask<byte[]?> ReadFrameAsync(
        Stream stream,
        CancellationToken cancellationToken)
    {
        byte[] header = new byte[HeaderSize];

        int headerBytes = await ReadUntilFullOrEndAsync(
            stream,
            header,
            cancellationToken);

        if (headerBytes == 0)
        {
            // Le correspondant a terminé normalement avant le début de la trame suivante, pas en plein milieu d'une trame
            return null;
        }

        if (headerBytes != HeaderSize)
        {
            throw new EndOfStreamException("Frame header was truncated.");
        }

        int payloadLength = BinaryPrimitives.ReadInt32BigEndian(header);

        if (payloadLength < 0 || payloadLength > MaxPayloadSize)
        {
            throw new InvalidDataException(
                $"Invalid payload length: {payloadLength} bytes.");
        }

        byte[] payload = new byte[payloadLength];

        int payloadBytes = await ReadUntilFullOrEndAsync(
            stream,
            payload,
            cancellationToken);

        if (payloadBytes != payloadLength)
        {
            throw new EndOfStreamException("Frame payload was truncated.");
        }

        return payload;
    }

    private static async ValueTask<int> ReadUntilFullOrEndAsync(
        Stream stream,
        Memory<byte> buffer,
        CancellationToken cancellationToken)
    {
        int totalRead = 0;

        while (totalRead < buffer.Length)
        {
            int read = await stream.ReadAsync(
                buffer[totalRead..],
                cancellationToken);

            if (read == 0)
            {
                break;
            }

            totalRead += read;
        }

        return totalRead;
    }
}

Le code appelant ressemble à ceci :

while (true)
{
    byte[]? payload = await LengthPrefixedProtocol.ReadFrameAsync(
        stream,
        cancellationToken);

    if (payload is null)
    {
        // Le correspondant s'est déconnecté proprement à une limite de trame
        break;
    }

    await HandleMessageAsync(payload, cancellationToken);
}

Avec cette implémentation, peu importe le nombre d’octets renvoyés par chaque ReadAsync. Même si les octets arrivent un par un, la boucle continue jusqu’à ce que l’en-tête et le corps soient entièrement lus.

Inversement, si les données de plusieurs messages se sont accumulées dans le tampon de réception de l’OS, seule la première trame est extraite selon la longueur de son corps, et l’itération suivante de la boucle lit la trame suivante.

Notez que sur les versions actuelles de .NET, vous disposez parfois de Stream.ReadExactly / ReadExactlyAsync, ce qui permet de déléguer à l’API standard la logique de lecture jusqu’à complétion. Même dans ce cas, il reste nécessaire de concevoir la façon dont l’application distingue la fin normale de connexion avant le début d’une trame d’une fin anormale en plein milieu d’une trame.

9. Implémentation côté émetteur

L’émetteur écrit en suivant le même format de trame.

using System.Buffers.Binary;
using System.IO;

public static class LengthPrefixedProtocolWriter
{
    private const int HeaderSize = 4;
    private const int MaxPayloadSize = 1024 * 1024;

    public static async ValueTask WriteFrameAsync(
        Stream stream,
        ReadOnlyMemory<byte> payload,
        CancellationToken cancellationToken)
    {
        if (payload.Length > MaxPayloadSize)
        {
            throw new InvalidDataException(
                $"Payload is too large: {payload.Length} bytes.");
        }

        byte[] header = new byte[HeaderSize];
        BinaryPrimitives.WriteInt32BigEndian(header, payload.Length);

        await stream.WriteAsync(header, cancellationToken);
        await stream.WriteAsync(payload, cancellationToken);
    }
}

Ce code appelle WriteAsync séparément pour l’en-tête et pour le corps. C’est là que peut naître un autre malentendu : même si l’émetteur écrit l’en-tête et le corps en deux appels distincts à WriteAsync, rien ne garantit que le récepteur les lira en deux morceaux.

Le récepteur pourrait voir ceci :

Read() => [en-tête de 4 octets + une partie du corps]
Read() => [le reste du corps]

Ou bien ceci :

Read() => [les 2 premiers octets de l'en-tête]
Read() => [les 2 derniers octets de l'en-tête + tout le corps + l'en-tête de la trame suivante]

C’est précisément pour cela que le récepteur doit se baser non pas sur « le nombre de fois où Read a été appelé », mais sur « le nombre d’octets lus conformément au format de trame ».

10. Avec Socket.Send en direct, l’émetteur doit aussi vérifier la valeur de retour

Lorsque vous utilisez NetworkStream.Write / WriteAsync, vous pouvez globalement le traiter comme une API qui écrit exactement la plage indiquée.

En revanche, lorsque vous utilisez directement Socket.Send, la valeur de retour mérite une attention particulière.

Socket.Send renvoie « le nombre d’octets effectivement envoyés ». En particulier avec des sockets non bloquants, il peut réussir en envoyant moins d’octets que demandé.

C’est pourquoi, si vous utilisez directement Socket.Send, l’émetteur a lui aussi besoin d’une boucle « répéter jusqu’à ce que tout soit envoyé ».

using System.Net.Sockets;

public static async ValueTask SendAllAsync(
    Socket socket,
    ReadOnlyMemory<byte> buffer,
    CancellationToken cancellationToken)
{
    while (!buffer.IsEmpty)
    {
        int sent = await socket.SendAsync(
            buffer,
            SocketFlags.None,
            cancellationToken);

        if (sent == 0)
        {
            throw new IOException("Socket was closed while sending data.");
        }

        buffer = buffer[sent..];
    }
}

Attention toutefois : « envoyé » ici ne signifie pas « l’application du correspondant a traité ce message ». Le succès de l’API d’envoi est distinct d’une réponse de succès au niveau du protocole applicatif.

Si, par exemple, vous devez confirmer d’un point de vue métier que « la commande a été acceptée », « le fichier a été enregistré » ou « la commande a été exécutée », vous devez définir dans votre protocole un ACK ou un message de réponse — le succès de l’envoi TCP ne suffit pas à cela.

11. Précautions avec l’approche par délimiteur

Les protocoles textuels utilisent parfois un délimiteur de type retour à la ligne.

LOGIN komura secret\n
GET item-001\n
QUIT\n

Cette approche est facile à comprendre et se marie bien avec les journaux et les formats de commande.

Mais il faut faire attention aux points suivants :

  • définir des règles d’échappement pour le cas où le délimiteur apparaît dans le corps
  • décider comment traiter \r\n par rapport à \n
  • décider de la longueur maximale d’une ligne
  • ne pas accumuler indéfiniment en mémoire en attendant le délimiteur
  • s’assurer qu’une coupure au milieu d’un caractère UTF-8 multi-octets ne corrompt rien

En particulier, il faut éviter ce code :

int read = await stream.ReadAsync(buffer, cancellationToken);
string text = Encoding.UTF8.GetString(buffer, 0, read);

foreach (string line in text.Split('\n'))
{
    await HandleLineAsync(line, cancellationToken);
}

Ce code ne tient compte ni du fait que la fin de la plage reçue puisse se trouver en plein milieu d’une ligne, ni du fait qu’une coupure puisse survenir au milieu d’un caractère UTF-8.

Si vous utilisez un délimiteur de type retour à la ligne, il faut au minimum soit « accumuler les octets, rechercher l’octet de retour à la ligne, et ne décoder qu’une fois qu’une ligne complète est assemblée », soit utiliser une API qui lit des lignes directement sur le flux, comme StreamReader.ReadLineAsync.

Cela dit, même avec StreamReader.ReadLineAsync, vous devez tout de même concevoir la longueur maximale de ligne, les délais d’expiration, l’annulation et le traitement de la fin de connexion.

12. Précautions avec l’approche à longueur fixe

Avec les télégrammes à longueur fixe, on décide par exemple que « chaque message fait toujours exactement 128 octets ». On rencontre cette approche dans les anciens systèmes métier, les systèmes de contrôle et l’intégration d’équipements.

Le principe reste le même avec la longueur fixe.

Si

1 message = 128 octets

alors le récepteur boucle jusqu’à avoir lu les 128 octets en entier.

byte[] message = new byte[128];
int read = await ReadUntilFullOrEndAsync(stream, message, cancellationToken);

if (read != message.Length)
{
    throw new EndOfStreamException("Fixed-length message was truncated.");
}

await HandleMessageAsync(message, cancellationToken);

Ici aussi, un seul ReadAsync ne renvoie pas nécessairement 128 octets.

La longueur fixe est facile à implémenter car les limites sont explicites, mais elle présente des inconvénients : les données de longueur variable sont difficiles à gérer, les extensions futures sont compliquées, le remplissage (padding) est pénible, et les conversions d’encodage de caractères modifient le nombre d’octets.

13. Désactiver Nagle ne résout pas le problème des limites de message

Lorsqu’on veut envoyer immédiatement de petites données, on peut envisager Socket.NoDelay = true. Ce réglage désactive l’algorithme de Nagle.

Cependant, NoDelay est un réglage relatif au délai d’envoi et à l’efficacité — « comment regrouper les petits envois » — et non un réglage qui « préserve les unités de Send comme unités de Receive ».

Autrement dit, même avec NoDelay = true, les problèmes suivants ne disparaissent pas :

  • un seul Send réparti sur plusieurs Receive
  • plusieurs Send regroupés en un seul Receive
  • une coupure au milieu d’un caractère
  • l’impossibilité pour le récepteur de déterminer les limites des messages

NoDelay a un sens en tant que réglage de latence, mais il ne remplace pas le framing.

14. Avec TLS et SslStream, le raisonnement reste le même

Lorsque vous utilisez SslStream pour chiffrer avec TLS, le traitement vu depuis l’application reste fondamentalement le même.

TLS possède une unité interne appelée enregistrement TLS (TLS record), mais ce n’est pas une limite de message applicatif.

Avec SslStream.ReadAsync non plus, rien ne garantit qu’un seul appel renvoie exactement un message applicatif complet.

Par conséquent, que TLS soit présent ou non, il faut concevoir l’une des approches suivantes au niveau applicatif :

  • préfixe de longueur
  • délimiteur tel qu’un retour à la ligne
  • longueur fixe
  • format de protocole existant

TLS est une couche de chiffrement et d’authentification, pas une couche qui crée automatiquement les limites de message.

15. La gestion des erreurs à garder à l’esprit dans la boucle de réception

Dans le traitement de la réception TCP, il est important de traiter explicitement non seulement le cas nominal, mais aussi les déconnexions et les fins prématurées.

Lorsque Read / Receive renvoie 0, cela signifie en général que le correspondant a terminé son envoi normalement.

Cependant, au niveau du protocole applicatif, il faut distinguer les deux cas suivants :

État Traitement
0 octet avant la lecture de la trame suivante Peut parfois être traité comme une fin normale
Fin en plein milieu de l’en-tête ou du corps de la trame Télégramme incomplet : à traiter comme une erreur

Avec l’approche à préfixe de longueur, on peut par exemple raisonner ainsi :

Déconnexion à une limite de trame :
  peut être traitée comme une fin normale

Déconnexion après réception de seulement 2 des 4 octets de l'en-tête :
  erreur de protocole

Déconnexion après réception de seulement 60 octets sur un corps annoncé de 100 octets :
  erreur de protocole

Intégrer cette distinction facilite considérablement les investigations dans les journaux.

Plutôt que de simplement indiquer « le correspondant s’est déconnecté », pouvoir afficher

Frame payload was truncated. expected=100 actual=60

permet de suspecter beaucoup plus facilement une fin anormale côté correspondant, un délai d’expiration, ou une incompatibilité de protocole.

16. Toujours définir une taille maximale

Avec l’approche à préfixe de longueur, la longueur du corps est placée en tête.

Le danger ici, c’est que le correspondant indique une longueur énorme.

FF FF FF FF

Si vous utilisez cette valeur telle quelle pour allouer un tableau, l’application tente d’allouer une quantité massive de mémoire et devient instable.

C’est pourquoi le récepteur doit toujours imposer une taille maximale.

private const int MaxPayloadSize = 1024 * 1024;

if (payloadLength < 0 || payloadLength > MaxPayloadSize)
{
    throw new InvalidDataException(
        $"Invalid payload length: {payloadLength} bytes.");
}

La taille maximale se décide selon les besoins métier. Pour des commandes, 64 KiB peuvent suffire ; pour envoyer des images ou des fichiers, il vaut sans doute mieux envisager un autre mécanisme de transfert ou du streaming. Ce qui compte, c’est de ne jamais concevoir un système qui « accepte en théorie une taille arbitrairement grande ».

17. Dans les protocoles textuels, comptez des octets, pas des caractères

TCP transporte des octets, pas des chaînes de caractères.

C’est pourquoi, lorsque vous indiquez une longueur de corps dans une trame à préfixe de longueur, vous utilisez normalement le nombre d’octets, et non le nombre de caractères.

Par exemple, prenons cette chaîne encodée en UTF-8 :

こんにちは

Cela fait 5 caractères, mais 15 octets en UTF-8.

Si vous indiquez 5 comme longueur dans le protocole, le récepteur ne lira que 5 octets de corps et s’arrêtera en plein milieu d’un caractère.

L’émetteur doit toujours calculer la longueur à partir du tableau d’octets encodé.

string json = "{\"message\":\"こんにちは\"}";
byte[] payload = Encoding.UTF8.GetBytes(json);

await LengthPrefixedProtocolWriter.WriteFrameAsync(
    stream,
    payload,
    cancellationToken);

Le récepteur lit entièrement le corps de la trame en octets avant de le reconvertir en chaîne.

byte[]? payload = await LengthPrefixedProtocol.ReadFrameAsync(
    stream,
    cancellationToken);

if (payload is not null)
{
    string json = Encoding.UTF8.GetString(payload);
    await HandleJsonAsync(json, cancellationToken);
}

Dans cet ordre, peu importe qu’un Read se soit coupé en plein milieu d’un caractère UTF-8.

18. Attention aussi au mélange applicatif dû aux écritures concurrentes

Autre point souvent négligé : les écritures concurrentes.

Supposons que plusieurs tâches écrivent des trames simultanément sur la même connexion TCP :

_ = WriteFrameAsync(stream, messageA, cancellationToken);
_ = WriteFrameAsync(stream, messageB, cancellationToken);

Sans contrôle, un mélange comme celui-ci peut se produire au niveau applicatif :

En-tête de A
En-tête de B
Corps de A
Corps de B

Le récepteur, après avoir lu l’en-tête de A, s’attend à ce que le corps de A suive. Si l’en-tête de B s’y intercale, le protocole est cassé.

Il est donc plus sûr de sérialiser les écritures sur une même connexion. Utilisez par exemple un SemaphoreSlim ou une file d’envoi pour éviter que les écritures au niveau des trames ne se mélangent.

private readonly SemaphoreSlim _sendLock = new(1, 1);

public async ValueTask SendFrameSafelyAsync(
    Stream stream,
    byte[] payload,
    CancellationToken cancellationToken)
{
    await _sendLock.WaitAsync(cancellationToken);

    try
    {
        await LengthPrefixedProtocolWriter.WriteFrameAsync(
            stream,
            payload,
            cancellationToken);
    }
    finally
    {
        _sendLock.Release();
    }
}

TCP préserve l’ordre des octets — donc si l’application écrit des séquences d’octets entrelacées depuis plusieurs tâches, TCP livrera fidèlement cet « ordre entrelacé ».

19. Dans les tests, provoquez volontairement fragmentation et fusion

Si vous testez la réception TCP de façon naïve, vous risquez de passer à côté de l’état « ça marche par hasard ».

C’est pourquoi, dans les tests, il faut créer volontairement les schémas suivants.

Aspect testé Exemple
Arrivée octet par octet En-tête et corps sont tous deux lus (Read) un octet à la fois
Déconnexion en plein milieu de l’en-tête Seulement 2 des 4 octets de l’en-tête arrivent avant la fin
Déconnexion en plein milieu du corps Seulement 60 octets sur un corps annoncé de 100 arrivent avant la fin
Fusion de plusieurs trames Deux trames se trouvent dans un seul tampon interne
Taille annoncée énorme Envoi d’une longueur de corps dépassant le maximum
Corps de 0 octet Vérifier si une longueur de corps de 0 est autorisée
Coupure UTF-8 Une séquence d’octets de texte japonais ou d’emoji est coupée en son milieu

Dans les tests unitaires, inutile d’utiliser de vrais sockets TCP : substituer un Stream qui « ne peut être lu que par blocs d’une taille donnée » permet de vérifier facilement la logique de réception.

Des tests d’intégration réellement effectués via TCP sont aussi nécessaires, mais extraire d’abord l’analyseur de réception comme une logique pure agissant sur un Stream rend l’ensemble beaucoup plus facile à tester.

La qualité du traitement réseau ne se juge pas à « ça marche quand on envoie normalement », mais à « ça se comporte comme prévu en cas de fragmentation, de fusion, ou de coupure en cours de transmission ».

20. Liste de vérification pour corriger du code existant

Lors de la relecture d’un code TCP existant, les points suivants permettent de repérer facilement les problèmes.

Point Ce qu’il faut vérifier
Unité de réception Un seul Read / Receive est-il traité comme un message complet ?
Valeur de retour Le nombre d’octets renvoyé par Read / Receive est-il toujours utilisé ?
Accumulation Les octets sont-ils accumulés jusqu’à ce qu’un message complet soit assemblé ?
Limites Existe-t-il une règle : longueur fixe, délimiteur, préfixe de longueur ?
Encodage de caractères Les données sont-elles converties en chaîne avant que le message soit complet ?
Longueur maximale Les longueurs et les longueurs de ligne sont-elles bornées ?
Déconnexion Les déconnexions à une limite de trame sont-elles distinguées de celles en cours de trame ?
Envoi La valeur de retour de Socket.Send est-elle ignorée ?
Concurrence Les écritures de plusieurs tâches sur la même connexion peuvent-elles s’entrelacer ?
Journalisation Peut-on afficher les nombres d’octets attendus et réels (expected / actual) ?
Tests Existe-t-il des tests de fragmentation, de fusion et de déconnexion en cours de transmission ?

Le code le plus dangereux ressemble à ceci :

int read = socket.Receive(buffer);
string message = Encoding.UTF8.GetString(buffer);
Handle(message);

Il présente plusieurs problèmes :

  • la valeur de read n’est pas utilisée
  • tout le tampon est converti en chaîne
  • un seul Receive est traité comme un message complet
  • il n’y a aucune limite de message
  • les coupures en plein milieu d’un caractère ne sont pas prises en compte

Il faut au minimum adopter le raisonnement suivant :

N'ajouter au tampon de réception que les read octets obtenus par Receive
  ↓
Vérifier si une trame complète peut être extraite du tampon selon le protocole
  ↓
Si oui, la traiter
  ↓
Conserver les octets restants comme début de la trame suivante
  ↓
Si ce n'est pas suffisant, attendre le prochain Receive

21. Conclusion

En communication TCP, on ne peut pas compter sur le fait de recevoir dans les mêmes unités que celles envoyées avec Send. Ce n’est pas un comportement exceptionnel : c’est un principe fondamental de l’utilisation de TCP.

Les points à retenir :

  • TCP fournit un flux d’octets ordonné, pas des messages
  • les unités des appels à Send / Write ne sont pas préservées comme unités de Receive / Read côté récepteur
  • un seul envoi peut être réparti sur plusieurs réceptions, et plusieurs envois peuvent être fusionnés en une seule réception
  • le récepteur doit définir les limites des messages dans le cadre du protocole applicatif
  • pour un protocole propriétaire, le préfixe de longueur est souvent l’approche la plus simple à mettre en œuvre
  • il faut intégrer à la conception : une boucle qui lit jusqu’à obtenir le nombre d’octets requis, une taille maximale, les déconnexions en cours de transmission, l’encodage des caractères et les écritures concurrentes
  • NoDelay et DataAvailable ne remplacent pas les limites de message

Le traitement réseau paraît simple tant qu’on ne regarde que le cas nominal. En réalité, la communication ne devient stable qu’une fois qu’on a décidé où placer les limites, comment attendre quand il manque des données, comment conserver le surplus quand il y en a trop, et comment traiter une coupure en cours de transmission.

Si vous utilisez TCP, Receive ne renvoie pas des messages : il renvoie seulement une portion d’un flux d’octets. La responsabilité de transformer cela en messages revient à la conception du protocole applicatif.

Références

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

Pourquoi ne peut-on pas recevoir en TCP dans les mêmes unités que celles envoyées avec Send ?
Ce que TCP garantit, c'est que « la suite d'octets envoyée arrive dans l'ordre, sans duplication et sans perte », et non que « l'unité passée à Send soit préservée comme unité de Receive côté récepteur ». TCP transporte un flux d'octets continu, pas des messages : il est donc parfaitement normal qu'un seul envoi soit réparti sur plusieurs réceptions, ou que plusieurs envois soient regroupés en une seule réception. Le récepteur a besoin d'un mécanisme pour déterminer les limites des messages : c'est ce qu'on appelle le framing.
Quelles sont les approches possibles pour le framing TCP (la façon de déterminer les limites des messages) ?
Il existe quatre approches principales : la longueur fixe, où chaque message occupe toujours le même nombre d'octets ; le délimiteur, où un message se termine par une séquence d'octets particulière comme un retour à la ligne ; le préfixe de longueur, qui place la longueur du corps en tête du message ; et le format auto-descriptif, où le format lui-même indique la longueur ou la fin du message, comme le Content-Length de HTTP. Pour un protocole propriétaire, le préfixe de longueur est en général le premier candidat à envisager, car le corps peut contenir n'importe quelle donnée binaire et il est facile d'y imposer une taille maximale.
Le fait de mettre Socket.NoDelay à true résout-il les problèmes de fragmentation et de fusion en TCP ?
Non. NoDelay désactive l'algorithme de Nagle : c'est un réglage relatif au délai et à l'efficacité des petits envois, pas un réglage qui préserve les unités de Send comme unités de Receive. Même avec NoDelay à true, les problèmes suivants restent présents : un seul Send peut être réparti sur plusieurs Receive, plusieurs Send peuvent être regroupés en un seul Receive, et une coupure peut survenir au milieu d'un caractère. NoDelay ne remplace pas le framing.
Pourquoi les données reçues en TCP peuvent-elles être corrompues (mojibake) ?
Parce qu'en UTF-8, un caractère peut occuper plusieurs octets, et rien ne garantit que la limite d'un Read coïncide avec la limite d'un caractère. Si vous convertissez systématiquement et immédiatement chaque bloc d'octets reçu en chaîne avec Encoding.UTF8.GetString, le résultat peut être corrompu chaque fois qu'un caractère multi-octets est coupé en deux. La parade consiste à accumuler les octets jusqu'à ce que la limite du message soit connue, puis à ne décoder qu'une fois un message complet assemblé. De même, la longueur du corps dans un préfixe de longueur doit être calculée en nombre d'octets après encodage, pas en nombre de caractères.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog