Der Irrglaube, man könne über TCP pro Send genau eine Einheit empfangen ── Empfangslogik entwerfen, die es als Bytestrom behandelt

· · TCP, Socket, Netzwerk, .NET, C#, Protokolldesign, Betrieb, Weiternutzung von Altbeständen

1. Das Wichtigste zuerst

Bei der Implementierung von TCP-Kommunikation gibt es einen ziemlich verbreiteten Irrglauben.

Er lautet:

Für jede Einheit, die die Sendeseite mit Send / Write gesendet hat, kann die Empfangsseite dieselbe Einheit mit Receive / Read lesen.

Angenommen, die Sendeseite sendet zum Beispiel Folgendes:

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

Dann geht man davon aus, dass die Empfangsseite es in drei Aufrufen zurückbekommt:

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

Bei TCP ist das aber nicht garantiert.

In der Praxis kann jedes der folgenden Beispiele eintreten:

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"

Alle diese Fälle sind bei TCP völlig normal.

Was TCP garantiert, ist – grob gesagt –, dass „die gesendete Bytefolge in der richtigen Reihenfolge, ohne Duplikate und ohne Verlust ankommt“. Nicht garantiert ist, dass „die Einheit, die die Anwendung an Send übergeben hat, als Einheit erhalten bleibt, die die Empfangsseite von Receive zurückbekommt“.

Deshalb braucht jede Anwendung, die TCP verwendet, auf der Empfangsseite einen Mechanismus, der bestimmt, „wo innerhalb der gerade empfangenen Bytefolge eine Nachricht beginnt und endet“.

Das wird das Framing des Anwendungsprotokolls genannt.

Dieser Artikel ordnet die Missverständnisse, die rund um Send und Receive bei TCP entstehen, und die korrekte Handhabung in .NET / C#.

Der in diesem Artikel gezeigte Code ist außerdem als bau- und lauffähiges Beispielpaket (Bibliothek, Loopback-TCP-Demo und Unit-Tests, die Fragmentierung, Verschmelzung und Abbruch mittendrin reproduzieren) auf GitHub veröffentlicht.

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

Voraussetzungen dieses Artikels

Punkt Voraussetzung
Sprache/Laufzeit Verwendet wird C#-8-Syntax und höher (Range-Operator, Nullable Reference Types) sowie Stream.ReadAsync mit Memory<byte>. Setzen Sie .NET Core 3.1 oder höher / .NET 5 oder höher voraus
Kommunikations-API Der Empfang erfolgt über den von TcpClient bezogenen NetworkStream, also über async/await auf einem Stream
Direkte Nutzung von Socket Die Grundidee des Empfangs ist dieselbe. Auch Socket.Receive / ReceiveAsync werden so behandelt: „nur der zurückgegebenen Bytezahl vertrauen“ und „so lange in einer Schleife lesen, bis die benötigte Menge vollständig gelesen ist“. Unterschiede gibt es bei der Sendeseite – Socket.Send erfordert die Prüfung des Rückgabewerts. Das wird in Kapitel 10 behandelt
.NET Framework Der Entwurfsgedanke ist derselbe, aber da Range-Operator und die Memory<byte>-Überladungen fehlen, müssen Sie auf eine Schleife mit Read(byte[], int, int) umsteigen
Stream.ReadExactly Das in Kapitel 8 erwähnte Stream.ReadExactly / ReadExactlyAsync steht ab .NET 7 zur Verfügung

Dieser Artikel behandelt eine Frage des Anwendungsprotokoll-Entwurfs; ob NoDelay gesetzt wird oder TLS zum Einsatz kommt, ändert nichts an der Schlussfolgerung. Die Gründe dafür werden in Kapitel 13 beziehungsweise 14 erläutert.

2. TCP transportiert keine „Nachrichten“, sondern eine „Bytefolge“

Zunächst ist es wichtig, TCP nicht wie eine Nachrichtenwarteschlange zu betrachten.

TCP behandelt die von der Anwendung übergebenen Daten als fortlaufende Bytefolge.

Ruft die Sendeseite zum Beispiel dreimal Send auf,

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

so ergibt sich aus Sicht von TCP letztlich ein Strom von 9 Bytes:

ABCDEFGHI

Die aus Anwendungssicht gemeinten Grenzen

ABC | DEF | GHI

bleiben darin nicht erhalten.

Die Empfangsseite liest zu irgendeinem Zeitpunkt „das, was gerade im Empfangspuffer liegt“. Das Empfangsergebnis kann daher wie folgt aussehen:

Aufrufe der Sendeseite Beispiel dessen, was die Empfangsseite sieht
Send("ABC"), Send("DEF") Ein Receive() liefert "ABCDEF"
Send("ABCDEF") Zwei Receive()-Aufrufe liefern "AB", "CDEF"
Send("ABC"), Send("DEF"), Send("GHI") Drei Receive()-Aufrufe liefern "A", "BCDEFG", "HI"
UTF-8-Zeichen wie Send("\u3042") Auch mitten in einem Mehrbyte-Zeichen kann geteilt werden

Wichtig ist: Hier liegt keine „Störung“ vor.

Viele Fehler, die wie „hin und wieder fehlen Empfangsdaten“, „mehrere Nachrichten kleben zusammen“ oder „Zeichensalat“ aussehen, sind keine TCP-Fehlfunktion, sondern ein Entwurfsfehler, bei dem die Empfangsseite TCP nachrichtenweise behandelt.

3. Warum es so aussieht, als könnte man pro Send empfangen

Dieser Irrglaube hält sich, weil es in lokalen Umgebungen und bei kleinen Datenmengen oft zufällig so aussieht wie erwartet.

In Entwicklungsumgebungen treffen häufig folgende Bedingungen zusammen:

  • Client und Server befinden sich auf demselben Rechner oder in einem nahen Netzwerk
  • Die Datenmenge ist klein
  • Die Gegenseite liest sofort
  • CPU und Netzwerk haben genug Reserven
  • Tests laufen manuell, mit wenig Timing-Schwankungen
  • Receive erfolgt unmittelbar nach Send

Unter solchen Bedingungen kann es so aussehen, als würde ein Send in genau einem Receive gelesen.

In der Produktion ändern sich die Bedingungen jedoch:

  • Daten stauen sich in den Sende-/Empfangspuffern des Betriebssystems
  • Mehrere kleine Sendevorgänge werden zusammengefasst
  • Große Sendevorgänge werden aufgrund von TCP-Segmenten oder Empfangspuffergrenzen aufgeteilt
  • Die Planung des Empfangs-Threads verzögert sich
  • Schichten wie TLS, Proxy, Load Balancer und VPN kommen dazwischen
  • Netzwerklatenz und -stau treten auf
  • Der Nagle-Algorithmus und verzögerte ACKs wirken sich aus

Das Ergebnis ist der lästige Fehler: „In der Entwicklungsumgebung lief es, in der Produktion bricht es hin und wieder.“

Bei der Netzwerkverarbeitung ist genau dieses „funktioniert zufällig“ am gefährlichsten.

4. Häufig anfälliger Empfangscode

Code wie der folgende ist beispielsweise gefährlich:

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

if (read == 0)
{
    // Die Gegenseite hat die Verbindung normal beendet
    return;
}

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

Dieser Code setzt voraus, dass „ein ReadAsync genau eine Nachricht liefert“ – doch bei TCP gilt diese Annahme nicht.

Es gibt im Wesentlichen drei Probleme.

Erstens kann eine Nachricht geteilt werden.

Gesendet:    {"command":"login","user":"komura"}\n
Empfangen1: {"command":"login",
Empfangen2: "user":"komura"}\n

Versucht man in diesem Fall, nur Empfangen1 als JSON zu parsen, schlägt das fehl.

Zweitens können mehrere Nachrichten verschmelzen.

Gesendet1: {"command":"login"}\n
Gesendet2: {"command":"get"}\n
Empfangen: {"command":"login"}\n{"command":"get"}\n

Versucht man hier, das Ganze als ein einzelnes JSON-Dokument zu parsen, schlägt das ebenfalls fehl.

Drittens kann die Aufteilung mitten in einer Zeichenkodierungsgrenze liegen.

In UTF-8 kann ein Zeichen aus mehreren Bytes bestehen. Es gibt keine Garantie, dass die Grenze eines ReadAsync mit einer Zeichengrenze zusammenfällt.

Wandelt man die empfangene Bytefolge deshalb jedes Mal sofort mit Encoding.UTF8.GetString in eine Zeichenkette um, kann das Ergebnis beschädigt werden, wenn ein Mehrbyte-Zeichen dabei geteilt wird.

Grundlegend ist nicht „sofort nach Empfang in eine Zeichenkette umwandeln“, sondern „Bytes sammeln, bis die Nachrichtengrenze feststeht, und erst dekodieren, wenn eine vollständige Nachricht vorliegt“.

5. Mit DataAvailable darf das Nachrichtenende nicht bestimmt werden

Auch folgender Code ist häufig anzutreffen:

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

Auch das ist gefährlich. DataAvailable zeigt an, „ob in diesem Moment im lokalen Empfangspuffer lesbare Daten vorliegen“ – nicht, dass eine Nachricht auf Anwendungsebene abgeschlossen ist.

Ist eine Nachricht zum Beispiel 100 Bytes lang, kann DataAvailable in dem Moment true werden, in dem die ersten 40 Bytes eintreffen, und unmittelbar nach dem Lesen dieser 40 Bytes vorübergehend wieder false sein. Die restlichen 60 Bytes treffen möglicherweise etwas später ein.

Interpretiert man DataAvailable == false hier als „Nachrichtenende“, wird eine unvollständige Nachricht als vollständige Nachricht verarbeitet.

DataAvailable mag sich für die Optimierung von Leseschleifen oder nicht blockierende Prüfungen eignen, für die Bestimmung von Protokollgrenzen sollte es aber besser nicht eingesetzt werden.

6. Der richtige Ansatz: „Empfangen“ und „Interpretieren“ trennen

Die TCP-Empfangsverarbeitung lässt sich leichter entwerfen, wenn man zwischen zwei Dingen unterscheidet:

Empfangen:   die von TCP eintreffende Bytefolge lesen und dem Puffer hinzufügen
Interpretieren: aus dem Puffer eine Nachricht auf Anwendungsebene herausschneiden

Receive / Read ist ausschließlich ein „Bytes lesen“-Vorgang. Wo eine Nachricht endet, muss dagegen vom Anwendungsprotokoll festgelegt werden.

Die vier gängigen Verfahren sind:

Verfahren Inhalt Geeignet für
Festlänge Eine Nachricht besteht immer aus einer festen Bytezahl Altgeräte, Binärtelegramme, Steuerungssysteme
Trennzeichen Eine Nachricht reicht bis zu einer bestimmten Bytefolge wie \n Befehle, Logs, NDJSON, einfache Protokolle
Längenpräfix Die Rumpflänge wird vorangestellt, dann werden genau so viele Bytes des Rumpfs gelesen Binärdaten, JSON, MessagePack, Protocol Buffers usw.
Selbstbeschreibendes Format Wie Content-Length bei HTTP oder chunked encoding, das Länge oder Ende innerhalb des Formats selbst ausdrückt Bestehende Protokolle, Kommunikation mit Erweiterungsbedarf

Persönlich prüfe ich beim Entwurf eines eigenen Protokolls zuerst das Längenpräfix-Verfahren. Gründe dafür sind, dass der Rumpf Zeilenumbrüche oder beliebige Binärdaten enthalten kann, die Empfangsimplementierung eindeutig ist und sich eine Obergrenze für die Größe leicht durchsetzen lässt.

7. Grundlagen des Längenpräfix-Verfahrens

Beim Längenpräfix-Verfahren hat eine Nachricht folgende Form:

[4-Byte-Rumpflänge][Rumpf]

Ist der Rumpf zum Beispiel UTF-8-kodiertes JSON mit einer Länge von 31 Bytes, wird Folgendes gesendet:

00 00 00 1F 7B 22 63 6F 6D 6D 61 6E 64 ...
^---------^ ^------------------------------^
 Rumpflänge              Rumpf

Wie die drei Sendevorgänge auf der Empfangsseite ankommen und wie sich daraus die Nachricht wiederherstellen lässt, zeigt das folgende Diagramm:

zum nächsten FrameSendevorgang 1Rumpf HELLO00 00 00 05 48 45 4C 4C 4FSendevorgang 2Rumpf ABC00 00 00 03 41 42 43Sendevorgang 3Rumpf QUIT00 00 00 04 51 55 49 54TCP ist ein geordneter BytestromSendegrenzen werden nicht mitübertragenes kommen nur 24 Bytes in Folge anRead Nr. 1 = 6 Byte00 00 00 05 48 45Read Nr. 2 = 11 Byte4C 4C 4F 00 00 00 03 41 42 43 00Read Nr. 3 = 7 Byte00 00 04 51 55 49 54Empfangspufferdie per Read erhaltenen Bytes werden der Reihe nach angehängtdie ersten 4 Bytes vollständig lesenRumpflänge = 5den Rumpf vollständig lesen, 5 Byte1 Nachricht fertig = HELLOübrige Bytes nicht verwerfen,sondern als Anfang des nächsten Frames behalten

Abbildung 1: Drei Sendevorgänge entsprechen auf der Empfangsseite nicht drei Read-Aufrufen, sondern werden über den Empfangspuffer zum Frame wiederhergestellt

Im Diagramm ist beim ersten Read nur ein Teil der Rumpflänge angekommen, und beim zweiten Read mischen sich der Rest des ersten Rumpfs, der gesamte zweite Frame und sogar das erste Byte des dritten Headers. Die Sendegrenzen und die Empfangsgrenzen stimmen also nicht überein.

Die Empfangsseite verarbeitet in folgender Reihenfolge:

  1. Zunächst 4 Bytes vollständig lesen
  2. Aus diesen 4 Bytes die Rumpflänge entnehmen
  3. Prüfen, ob die Rumpflänge gültig ist
  4. Den Rumpf vollständig entsprechend der Rumpflänge lesen
  5. Den vollständig gelesenen Rumpf als eine Nachricht verarbeiten
  6. Den nächsten Frame lesen

Wichtig ist hier: „Auch der 4-Byte-Header kann geteilt werden.“

Empfangen1: 00 00
Empfangen2: 00 1F 7B 22 63 ...

Nur weil es sich um den Header handelt, ist also nicht garantiert, dass ein Read 4 Bytes liefert.

Das Gleiche gilt für den Rumpf. Dass der Rückgabewert von Read kleiner ist als die angeforderte Größe, ist völlig normal. Steht die benötigte Bytezahl fest, muss eine Schleife geschrieben werden, die so lange liest, bis diese Menge vollständig gelesen ist.

8. Empfangsimplementierung in .NET: das Längenpräfix-Verfahren

Als Nächstes ein Beispiel, wie man in .NET / C# einen Frame nach dem Längenpräfix-Verfahren liest.

Die ersten 4 Bytes werden hier als Big-Endian-int als Rumpflänge interpretiert.

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

public static class LengthPrefixedProtocol
{
    private const int HeaderSize = 4;
    private const int MaxPayloadSize = 1024 * 1024; // 1 MiB. Passen Sie das an Ihren Anwendungsfall an

    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)
        {
            // Nicht mitten im Frame, sondern vor Beginn des nächsten Frames hat die Gegenseite normal beendet
            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;
    }
}

Der aufrufende Code sieht so aus:

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

    if (payload is null)
    {
        // Die Gegenseite hat sauber an einer Frame-Grenze getrennt
        break;
    }

    await HandleMessageAsync(payload, cancellationToken);
}

Bei dieser Implementierung spielt es keine Rolle, wie viele Bytes ReadAsync jeweils zurückgibt. Selbst wenn nur ein Byte auf einmal zurückkommt, läuft die Schleife weiter, bis Header und Rumpf vollständig gelesen sind.

Umgekehrt gilt: Liegen im OS-Empfangspuffer bereits Daten für mehrere Nachrichten vor, wird nur der erste Frame gemäß seiner Rumpflänge herausgeschnitten, und der nächste Frame wird in der nächsten Schleifeniteration gelesen.

Im aktuellen .NET steht in manchen Umgebungen Stream.ReadExactly / ReadExactlyAsync zur Verfügung. In diesem Fall kann das vollständige Lesen der benötigten Bytezahl der Standard-API überlassen werden. Wie der Verbindungsabbruch behandelt wird, wie ein normales Ende vor Beginn eines Frames von einem abnormalen Abbruch mittendrin unterschieden wird, muss aber weiterhin von der Anwendung selbst entworfen werden.

9. Implementierung der Sendeseite

Auch die Sendeseite sendet nach demselben Frame-Format.

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

In diesem Code werden Header und Rumpf getrennt per WriteAsync geschrieben. Genau hier entsteht wieder leicht ein Missverständnis: Auch wenn die Sendeseite Header und Rumpf in zwei WriteAsync-Aufrufen schreibt, heißt das nicht, dass die Empfangsseite sie in zwei Teilen liest.

Auf der Empfangsseite kann Folgendes zu sehen sein:

Read() => [4-Byte-Header + Teil des Rumpfs]
Read() => [Rest des Rumpfs]

Oder auch dies:

Read() => [erste 2 Bytes des Headers]
Read() => [letzte 2 Bytes des Headers + gesamter Rumpf + Header des nächsten Frames]

Genau deshalb entscheidet die Empfangsseite nicht danach, „wie oft Read aufgerufen wurde“, sondern danach, „wie viele Bytes gemäß dem Frame-Format gelesen werden konnten“.

10. Wird Socket.Send direkt verwendet, muss auch die Sendeseite den Rückgabewert prüfen

Wird NetworkStream.Write / WriteAsync verwendet, kann man sie im Grunde als API behandeln, die den angegebenen Bereich schreibt.

Bei direkter Verwendung von Socket.Send ist der Rückgabewert dagegen mit Vorsicht zu behandeln.

Socket.Send gibt „die Anzahl der erfolgreich gesendeten Bytes“ zurück. Insbesondere bei nicht blockierenden Sockets kann es vorkommen, dass weniger Bytes als angefordert erfolgreich gesendet werden.

Wird Socket.Send also direkt verwendet, braucht auch die Sendeseite eine Schleife, die „so lange wiederholt, bis alles gesendet ist“.

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..];
    }
}

„Gesendet“ bedeutet hier allerdings nicht, dass „die Anwendung der Gegenseite diese Nachricht verarbeitet hat“. Der Erfolg der Sende-API ist etwas anderes als eine Erfolgsantwort auf Ebene des Anwendungsprotokolls.

Möchten Sie geschäftlich zum Beispiel bestätigen, dass „die Bestellung angenommen wurde“, „die Datei gespeichert wurde“ oder „der Befehl ausgeführt wurde“, müssen Sie nicht auf den erfolgreichen TCP-Sendevorgang, sondern auf ein ACK oder eine Antwortnachricht der Gegenseite als Teil des Protokolls setzen.

11. Hinweise zum Trennzeichen-Verfahren

Bei Textprotokollen wird manchmal eine Trennung per Zeilenumbruch verwendet.

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

Dieses Verfahren ist verständlich und passt gut zu Log- und Befehlsformaten.

Zu beachten sind aber folgende Punkte:

  • Escaping-Regeln festlegen, falls das Trennzeichen im Rumpf selbst vorkommt
  • Den Umgang mit \r\n und \n festlegen
  • Eine maximale Zeilenlänge festlegen
  • Nicht unbegrenzt Speicher ansammeln, bis das Trennzeichen kommt
  • Sicherstellen, dass eine geteilte UTF-8-Mehrbyte-Sequenz nicht zum Absturz führt

Besonders folgender Code sollte vermieden werden:

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

Dieser Code berücksichtigt weder, dass das Ende des empfangenen Bereichs mitten in einer Zeile liegen kann, noch, dass mitten in einem UTF-8-Zeichen geteilt werden kann.

Wenn Sie Trennung per Zeilenumbruch verwenden, sollten Sie zumindest „Bytes sammeln, das Trennzeichen suchen und erst dekodieren, wenn eine vollständige Zeile vorliegt“, oder eine API wie StreamReader.ReadLineAsync verwenden, die Zeilen direkt aus dem Stream liest.

Aber auch bei Verwendung von StreamReader.ReadLineAsync sollten maximale Zeilenlänge, Timeout, Abbruch (Cancellation) und der Umgang mit Verbindungsende entworfen werden.

12. Hinweise zum Festlängenverfahren

Bei Festlängentelegrammen wird zum Beispiel festgelegt: „Eine Nachricht besteht immer aus 128 Bytes.“ Dieses Verfahren findet sich in alten Fachanwendungen, Steuerungssystemen und der Gerätekopplung.

Auch beim Festlängenverfahren ist der Grundgedanke derselbe.

Ist

1 Nachricht = 128 Byte

festgelegt, liest die Empfangsseite in einer Schleife, bis 128 Bytes vollständig gelesen sind.

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

Auch hier liefert ein einzelnes ReadAsync nicht zwingend 128 Bytes.

Das Festlängenverfahren ist einfach zu implementieren, weil die Grenzen eindeutig sind, hat aber Nachteile: Daten variabler Länge sind schwer zu handhaben, künftige Erweiterungen sind schwierig, der Umgang mit Füllbytes ist lästig, und Zeichenkodierungsumwandlungen ändern die Bytezahl.

13. Das Deaktivieren von Nagle löst das Problem der Nachrichtengrenzen nicht

Sollen kleine Datenmengen sofort gesendet werden, wird gelegentlich Socket.NoDelay = true erwogen. Damit wird der Nagle-Algorithmus deaktiviert.

NoDelay ist jedoch eine Einstellung zur Sendeverzögerung und -effizienz, die betrifft, „wie kleine Sendevorgänge zusammengefasst werden“ – keine Einstellung, die „Send-Einheiten als Receive-Einheiten bewahrt“.

Das bedeutet: Auch mit NoDelay = true bleiben folgende Probleme bestehen:

  • Ein Send teilt sich in mehrere Receive-Aufrufe auf
  • Mehrere Send-Aufrufe verschmelzen in einem Receive
  • Es wird mitten in einem Zeichen geteilt
  • Die Empfangsseite kann die Nachrichtengrenzen nicht bestimmen

NoDelay ist als Anpassung der Latenz sinnvoll, aber kein Ersatz für Framing.

14. Auch bei TLS/SslStream gilt derselbe Grundgedanke

Auch bei Verwendung von SslStream zur TLS-Verschlüsselung bleibt der Umgang aus Sicht der Anwendung im Grunde derselbe.

TLS kennt eine interne Einheit namens TLS-Record, doch das ist keine Grenze der Anwendungsnachricht.

Auch bei SslStream.ReadAsync ist nicht garantiert, dass ein Aufruf genau die von der Anwendung erwartete Nachricht liefert.

Ob mit oder ohne TLS: Auf Anwendungsebene muss also eines der folgenden Verfahren entworfen werden:

  • Längenpräfix
  • Ein Trennzeichen wie ein Zeilenumbruch
  • Festlänge
  • Ein bestehendes Protokollformat

TLS ist eine Schicht für Verschlüsselung und Authentifizierung – keine Schicht, die automatisch Nachrichtengrenzen erzeugt.

15. Fehlerbehandlung, auf die man in der Empfangsschleife achten sollte

Bei der TCP-Empfangsverarbeitung ist es wichtig, nicht nur den Normalfall, sondern auch Verbindungsabbrüche und vorzeitige Beendigungen klar zu behandeln.

Liefert Read / Receive als Rückgabewert 0, bedeutet das im Allgemeinen, dass die Gegenseite das Senden normal beendet hat.

Auf Ebene des Anwendungsprotokolls müssen jedoch zwei Fälle unterschieden werden:

Zustand Behandlung
Beendigung mit 0 Bytes vor dem Lesen des nächsten Frames Kann teilweise als normale Beendigung behandelt werden
Beendigung mitten im Header oder mitten im Rumpf Ein unvollständiges Telegramm, als Fehler zu behandeln

Beim Längenpräfix-Verfahren denkt man zum Beispiel so:

Trennung an einer Frame-Grenze:
  darf als normale Beendigung behandelt werden

Trennung nach nur 2 von 4 Header-Bytes:
  Protokollfehler

Trennung nach nur 60 von 100 angekündigten Rumpf-Bytes:
  Protokollfehler

Wird diese Unterscheidung eingebaut, wird die Log-Analyse deutlich einfacher.

Statt nur „die Gegenseite hat getrennt“ auszugeben, lässt sich zum Beispiel Folgendes ausgeben:

Frame payload was truncated. expected=100 actual=60

Damit lässt sich leichter vermuten, ob auf der Gegenseite ein abnormaler Abbruch, ein Timeout oder eine Protokoll-Inkonsistenz vorliegt.

16. Immer eine Höchstgröße festlegen

Beim Längenpräfix-Verfahren steht die Rumpflänge am Anfang.

Gefährlich ist es, wenn die Gegenseite eine riesige Länge angibt.

FF FF FF FF

Verwendet man diesen Wert unverändert für eine Array-Zuweisung, versucht die Anwendung, eine gewaltige Menge Speicher zu reservieren, und wird instabil.

Daher muss die Empfangsseite immer eine Höchstgröße festlegen.

private const int MaxPayloadSize = 1024 * 1024;

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

Die Höchstgröße richtet sich nach den fachlichen Anforderungen. Für Befehle reichen vielleicht 64 KiB, sollen dagegen Bilder oder Dateien übertragen werden, sollten Sie eher ein anderes Übertragungsverfahren oder Streaming in Betracht ziehen. Wichtig ist, kein Design zu wählen, das „theoretisch beliebig viel annimmt“.

17. Bei Zeichenkettenprotokollen zählt man „Bytes“, nicht „Zeichen“

TCP transportiert Bytes, keine Zeichenketten.

Trägt man beim Längenpräfix-Verfahren also eine Rumpflänge ein, verwendet man normalerweise die Bytezahl, nicht die Zeichenzahl.

Wandelt man zum Beispiel folgende Zeichenkette in UTF-8 um:

こんにちは

so sind das 5 Zeichen, aber 15 Bytes in UTF-8.

Trägt man 5 als Protokolllänge ein, liest die Empfangsseite nur 5 Bytes des Rumpfs und bricht mitten im Zeichen ab.

Die Sendeseite muss die Länge immer anhand des kodierten Byte-Arrays berechnen.

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

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

Die Empfangsseite liest den Nachrichtenrumpf vollständig als Bytes, bevor sie ihn in eine Zeichenkette zurückverwandelt.

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

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

In dieser Reihenfolge spielt es keine Rolle, wenn Read mitten in einem UTF-8-Zeichen aufteilt.

18. Auch bei paralleler Verwendung von Write auf Anwendungsebene entstehende Vermischung beachten

Noch etwas, das leicht übersehen wird: paralleles Schreiben.

Angenommen, mehrere Tasks schreiben gleichzeitig Frames auf dieselbe TCP-Verbindung:

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

Ohne Steuerung kann auf Anwendungsebene eine Vermischung wie diese entstehen:

Header von A
Header von B
Rumpf von A
Rumpf von B

Die Empfangsseite liest den Header von A und erwartet als Nächstes den Rumpf von A. Schiebt sich dazwischen der Header von B, bricht das Protokoll.

Deshalb ist es sicherer, das Schreiben auf eine Verbindung zu serialisieren. Verwenden Sie zum Beispiel ein SemaphoreSlim oder eine Sendewarteschlange, damit sich Schreibvorgänge auf Frame-Ebene nicht vermischen.

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 wahrt die Reihenfolge der Bytes – schreibt die Anwendung jedoch aus mehreren Tasks vermischte Bytefolgen, liefert TCP genau diese „vermischte Reihenfolge“ zuverlässig aus.

19. In Tests bewusst teilen und verschmelzen lassen

Testet man die TCP-Empfangsverarbeitung auf naheliegende Weise, übersieht man leicht den Zustand „funktioniert zufällig“.

Erzeugen Sie in Tests deshalb absichtlich folgende Muster:

Testaspekt Beispiel
Bytes kommen einzeln an Header und Rumpf werden byteweise per Read gelesen
Abbruch mitten im Header Nur 2 von 4 Header-Bytes kommen an, dann Ende
Abbruch mitten im Rumpf Von 100 angekündigten Rumpf-Bytes kommen nur 60 an, dann Ende
Mehrere Frames verschmolzen Zwei Frames liegen in einem internen Puffer
Riesige angegebene Größe Eine Rumpflänge senden, die die Höchstgröße überschreitet
Rumpf mit 0 Byte Prüfen, ob eine Rumpflänge von 0 erlaubt ist
UTF-8-Teilung Bytefolgen von japanischem Text oder Emojis werden mitten im Zeichen geteilt

In Unit-Tests müssen Sie keinen echten TCP-Socket verwenden – ersetzen Sie den Stream durch einen, der „nur eine festgelegte Chunk-Größe zurückgibt“, um die Empfangslogik einfach zu prüfen.

Teilung nachbilden: einen Stream umhüllen

Ohne zusätzliche Bibliothek lässt sich das nachbilden, indem man Stream ableitet und die von Read zurückgegebene Bytezahl begrenzt.

using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

// Ein Stream, der pro Read maximal maxChunkSize Bytes zurückgibt.
// Er umhüllt nur den inneren Stream, der zu testende Empfangscode
// muss nicht angepasst werden.
public sealed class ChunkedReadStream : Stream
{
    private readonly Stream _inner;
    private readonly int _maxChunkSize;

    public ChunkedReadStream(Stream inner, int maxChunkSize)
    {
        if (inner is null) throw new ArgumentNullException(nameof(inner));
        if (maxChunkSize < 1) throw new ArgumentOutOfRangeException(nameof(maxChunkSize));

        _inner = inner;
        _maxChunkSize = maxChunkSize;
    }

    public override int Read(byte[] buffer, int offset, int count)
        => _inner.Read(buffer, offset, Math.Min(count, _maxChunkSize));

    public override ValueTask<int> ReadAsync(
        Memory<byte> buffer,
        CancellationToken cancellationToken = default)
        => _inner.ReadAsync(
            buffer[..Math.Min(buffer.Length, _maxChunkSize)],
            cancellationToken);

    public override bool CanRead => true;
    public override bool CanSeek => false;
    public override bool CanWrite => false;
    public override long Length => throw new NotSupportedException();

    public override long Position
    {
        get => throw new NotSupportedException();
        set => throw new NotSupportedException();
    }

    public override void Flush() { }
    public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
    public override void SetLength(long value) => throw new NotSupportedException();
    public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}

Damit lässt sich sowohl „es kommt nur ein Byte auf einmal an“ als auch „zwei Frames kommen auf einmal an“ mit demselben Testcode und nur unterschiedlichen Argumenten abbilden.

using System.Buffers.Binary;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Xunit;

public class LengthPrefixedProtocolTests
{
    // Testet LengthPrefixedProtocol.ReadFrameAsync aus Kapitel 8
    [Theory]
    [InlineData(1)]      // Header und Rumpf kommen byteweise an
    [InlineData(3)]      // Abbruch mitten im Header
    [InlineData(1024)]   // Zwei Frames kommen gebündelt an
    public async Task ZweiFramesWerdenUnabhaengigVonDerChunkGroesseKorrektWiederhergestellt(int chunkSize)
    {
        using var source = new MemoryStream();
        WriteFrame(source, "HELLO");
        WriteFrame(source, "ABC");
        source.Position = 0;

        using var stream = new ChunkedReadStream(source, chunkSize);

        byte[]? first = await LengthPrefixedProtocol.ReadFrameAsync(
            stream, CancellationToken.None);
        byte[]? second = await LengthPrefixedProtocol.ReadFrameAsync(
            stream, CancellationToken.None);
        byte[]? afterLast = await LengthPrefixedProtocol.ReadFrameAsync(
            stream, CancellationToken.None);

        Assert.NotNull(first);
        Assert.NotNull(second);
        Assert.Equal("HELLO", Encoding.UTF8.GetString(first!));
        Assert.Equal("ABC", Encoding.UTF8.GetString(second!));
        Assert.Null(afterLast); // normale Beendigung an einer Frame-Grenze
    }

    private static void WriteFrame(Stream destination, string text)
    {
        byte[] payload = Encoding.UTF8.GetBytes(text);
        byte[] header = new byte[4];
        BinaryPrimitives.WriteInt32BigEndian(header, payload.Length);

        destination.Write(header, 0, header.Length);
        destination.Write(payload, 0, payload.Length);
    }
}

Auch ein Test für den Abbruch mittendrin lässt sich nachbilden, indem der MemoryStream nur unvollständig beschrieben wird. Schreibt man zum Beispiel statt WriteFrame nur 2 der 4 Header-Bytes, entsteht „Abbruch mitten im Header“, und man kann prüfen, dass ReadFrameAsync eine EndOfStreamException wirft.

Teilung im Loopback erzeugen

Wollen Sie in einem Integrationstest tatsächlich über echtes TCP prüfen, schreiben Sie auf der Sendeseite einen Frame absichtlich in zwei Teilen und legen dazwischen eine Wartezeit ein.

using System;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;

public static class SplitSender
{
    // Sendet frame aufgeteilt in firstChunkSize Bytes.
    // Ohne gesetztes NoDelay können der erste und der zweite Teil
    // durch den Nagle-Algorithmus zu einem Segment zusammengefasst
    // werden, sodass die Teilung ausbleibt.
    public static async Task SendSplitAsync(
        TcpClient client,
        byte[] frame,
        int firstChunkSize,
        CancellationToken cancellationToken)
    {
        if (client is null) throw new ArgumentNullException(nameof(client));
        if (frame is null) throw new ArgumentNullException(nameof(frame));
        if (firstChunkSize < 1 || firstChunkSize >= frame.Length)
        {
            throw new ArgumentOutOfRangeException(nameof(firstChunkSize));
        }

        client.NoDelay = true;
        NetworkStream stream = client.GetStream();

        await stream.WriteAsync(frame.AsMemory(0, firstChunkSize), cancellationToken);
        await Task.Delay(50, cancellationToken);
        await stream.WriteAsync(frame.AsMemory(firstChunkSize), cancellationToken);
    }
}

Übergeben Sie firstChunkSize den Wert 2, lässt sich „Abbruch mitten im 4-Byte-Header“ ausprobieren, übergeben Sie Rumpflänge + 2, lässt sich „Abbruch mitten im Rumpf“ ausprobieren.

Das garantiert allerdings nicht als TCP-Spezifikation, dass eine Teilung stattfindet. Es werden lediglich Bedingungen geschaffen, unter denen „in der Praxis so gut wie sicher“ die Grenzen verschoben sind. Tests, die Verlässlichkeit brauchen, sollten Sie nicht über den Socket, sondern über den Unit-Test mit ausgetauschtem Stream absichern.

Auch ein Integrationstest, der wirklich über TCP prüft, ist notwendig, aber wenn Sie den Empfangsparser zunächst als reine Verarbeitung über einem Stream herauslösen, lässt er sich leichter testen.

Die Qualität der Netzwerkverarbeitung bemisst sich nicht daran, ob „es funktioniert, wenn man normal sendet“, sondern daran, ob es sich „auch bei Teilung, bei Verschmelzung und bei Abbruch mittendrin wie vorgesehen verhält“.

20. Beobachtungsmittel, wenn „es in der Produktion hin und wieder bricht“

Fehler im Framing treten in der Entwicklungsumgebung nicht auf, sondern erst in der Produktion, und dort auch noch unregelmäßig. Denn erst wenn die Datenmenge steigt, die Leitung langsamer wird oder sich die Implementierung der Gegenseite ändert, ändert sich auch die Art der Teilung.

Als Erstes sollte hier geklärt werden, ob die Bytefolge der Sendeseite oder die Wiederherstellung der Empfangsseite defekt ist. Allein diese Unterscheidung halbiert den zu untersuchenden Bereich.

Für die Beobachtung stehen drei Mittel zur Wahl, je nach Bedarf:

Mittel Was man erfährt Zu beachten
Anwendungslog Erwartete und tatsächlich gelesene Bytezahl, Länge des herausgeschnittenen Frames, Zeitpunkt der Trennung Das Mittel, das zuerst eingebaut werden sollte. Immer expected und actual gemeinsam ausgeben. Bei nur einem von beiden weiß man nur, dass „etwas gefehlt hat“
Wireshark Die tatsächlich übertragene Bytefolge, TCP-Segmentgrenzen, erneute Übertragungen, ob ein RST vorliegt Um unter Windows Loopback (127.0.0.1) mitzuschneiden, wird Npcap benötigt. Ab Wireshark 3.0.0 wählen Sie in der Schnittstellenliste „Adapter for loopback traffic capture“
pktmon Kann mit Windows-Bordmitteln mitschneiden. Die erfasste ETL-Datei lässt sich in pcapng umwandeln und in Wireshark öffnen pktmon.exe ist ab Windows 10 Build 19041 standardmäßig vorhanden

Haben Sie einen Mitschnitt geöffnet, gehen Sie in dieser Reihenfolge vor:

  1. Sehen Sie sich mit Follow > TCP Stream die wiederhergestellte Bytefolge an. Prüfen Sie hier, ob die 4 Bytes der Rumpflänge den erwarteten Wert ergeben
  2. Ist der Wert wie erwartet, hat die Sendeseite den Frame korrekt aufgebaut. Das Problem liegt dann bei der Wiederherstellung auf der Empfangsseite
  3. Ist der Wert nicht wie erwartet, ist der Aufbau des Frames auf der Sendeseite verdächtig, oder es liegt die Vermischung durch paralleles Write aus Kapitel 18 vor
  4. Liegt ein RST oder ein Abbruch mittendrin vor, prüfen Sie, wie die Empfangsseite den Abbruch mitten im Frame behandelt

An einer Stelle kommt es leicht zu Verwechslungen.

Auch die Grenzen eines TCP-Segments sind keine Grenzen der Anwendungsnachricht. Sieht es in Wireshark so aus, als würde eine Nachricht genau in ein Segment passen, ist das Zufall. Die Einheit, in der die Empfangsanwendung per Read Daten erhält, stimmt ebenfalls nicht mit den Segmentgrenzen überein. Ein Mitschnitt dient dazu, „die tatsächlich übertragene Bytefolge“ zu prüfen, nicht dazu, daran „hier ist eine Nachricht“ abzulesen.

Zudem kann ein auf dem Sende-Host aufgenommener Mitschnitt aufgrund von Segmentation Offloading (LSO/TSO) Pakete zeigen, die größer als die MTU sind. Das entspricht nicht der tatsächlich auf der Leitung übertragenen Segmentgröße. Geht es Ihnen speziell um die Segmentgröße selbst, nehmen Sie den Mitschnitt auf der Empfangsseite oder einem zwischengeschalteten Host auf, oder deaktivieren Sie das Offloading vorübergehend.

21. Checkliste beim Überarbeiten von Bestandscode

Prüfen Sie bestehenden TCP-Kommunikationscode, helfen folgende Gesichtspunkte, Probleme zu finden:

Gesichtspunkt Zu prüfen
Empfangseinheit Wird ein einzelnes Read / Receive als eine Nachricht behandelt?
Rückgabewert Wird die von Read / Receive zurückgegebene Bytezahl immer verwendet?
Sammlung Werden Bytes gesammelt, bis eine Nachricht vollständig ist?
Grenze Gibt es eine Regel wie Festlänge, Trennzeichen oder Längenpräfix?
Zeichenkodierung Wird vor der Vervollständigung der Nachricht bereits in eine Zeichenkette umgewandelt?
Höchstlänge Gibt es eine Obergrenze für Länge oder Zeilenlänge?
Trennung Wird zwischen Trennung an der Frame-Grenze und Trennung mittendrin unterschieden?
Senden Wird der Rückgabewert von Socket.Send ignoriert?
Parallelität Können sich Schreibvorgänge mehrerer Tasks auf dieselbe Verbindung vermischen?
Logging Lassen sich expected/actual-Bytezahlen ausgeben?
Tests Gibt es Tests für Teilung, Verschmelzung und Abbruch mittendrin?

Besonders gefährlich sieht Code wie der folgende aus:

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

Es gibt mehrere Probleme:

  • Der Wert von read wird nicht verwendet
  • Der gesamte Puffer wird in eine Zeichenkette umgewandelt
  • Ein Receive wird als eine Nachricht behandelt
  • Es gibt keine Nachrichtengrenze
  • Eine Teilung mitten im Zeichen wird nicht berücksichtigt

Zumindest muss man zu folgendem Denkansatz wechseln:

Nur die per Receive erhaltenen read Bytes dem Empfangspuffer hinzufügen
  ↓
Prüfen, ob sich anhand des Protokolls ein Frame aus dem Puffer herausschneiden lässt
  ↓
Lässt er sich herausschneiden, verarbeiten
  ↓
Übrige Bytes als Anfang des nächsten Frames behalten
  ↓
Reicht es nicht, auf das nächste Receive warten

22. Zusammenfassung

Bei der TCP-Kommunikation können Sie sich nicht darauf verlassen, pro Send genau eine Einheit per Receive zu erhalten. Das ist kein Ausnahmeverhalten, sondern grundlegend für die Verwendung von TCP.

Die wichtigsten Punkte:

  • TCP liefert keine Nachrichten, sondern einen geordneten Bytestrom
  • Die Aufrufeinheiten von Send / Write bleiben nicht als Einheiten von Receive / Read auf der Empfangsseite erhalten
  • Ein Sendevorgang kann sich auf mehrere Empfangsvorgänge aufteilen, mehrere Sendevorgänge können in einem Empfangsvorgang zusammenlaufen
  • Die Empfangsseite muss die Nachrichtengrenzen als Teil des Anwendungsprotokolls festlegen
  • Bei eigenen Protokollen ist das Längenpräfix-Verfahren oft am einfachsten zu handhaben
  • Beziehen Sie eine Schleife, die die benötigte Bytezahl vollständig liest, eine Höchstgröße, Abbruch mittendrin, Zeichenkodierung und paralleles Schreiben in den Entwurf ein
  • NoDelay und DataAvailable sind kein Ersatz für Nachrichtengrenzen
  • Bricht es nur in der Produktion, klären Sie zuerst per Mitschnitt, ob „die Bytefolge der Sendeseite“ oder „die Wiederherstellung der Empfangsseite“ defekt ist

Netzwerkverarbeitung wirkt einfach, solange man nur den Normalfall betrachtet. Tatsächlich wird eine Kommunikation aber erst stabil, wenn Sie festlegen, „wo Sie trennen“, „wie Sie warten, wenn zu wenig da ist“, „wie Sie den Rest behalten, wenn zu viel da ist“ und „wie Sie einen Abbruch mittendrin behandeln“.

Verwenden Sie TCP, liefert Receive keine Nachrichten, sondern nur einen Ausschnitt einer Bytefolge. Die Verantwortung, daraus Nachrichten zu machen, liegt beim Protokollentwurf der Anwendung.

Quellen

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Häufige Fragen

Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.

Warum kann ich nicht davon ausgehen, dass ich über TCP pro Send-Aufruf genau einen Receive erhalte?
TCP garantiert nur, dass die gesendete Bytefolge in der richtigen Reihenfolge, ohne Duplikate und ohne Verlust ankommt – nicht, dass die Einheit, die gesendet wurde, auch als Empfangseinheit auf der Gegenseite erhalten bleibt. TCP transportiert keine Nachrichten, sondern eine fortlaufende Bytefolge. Deshalb ist es völlig normal, dass ein einzelner Sendevorgang in mehreren Empfangsvorgängen ankommt oder mehrere Sendevorgänge in einem einzigen Empfangsvorgang zusammenlaufen. Die Empfangsseite braucht daher einen Mechanismus, der die Nachrichtengrenzen selbst bestimmt – das Framing.
Welche Verfahren gibt es für das Framing (die Festlegung der Nachrichtengrenzen) bei TCP?
Es gibt vier gängige Verfahren: das Festlängenverfahren, bei dem eine Nachricht immer aus einer festen Bytezahl besteht; das Trennzeichen-Verfahren, bei dem eine Nachricht bis zu einer bestimmten Bytefolge wie einem Zeilenumbruch reicht; das Längenpräfix-Verfahren, bei dem die Länge des Rumpfs vorangestellt wird; und das selbstbeschreibende Format, das wie der Content-Length-Header bei HTTP Länge oder Ende innerhalb des Formats selbst kodiert. Für ein eigenes Protokoll ist das Längenpräfix-Verfahren meist die erste Wahl, weil der Rumpf beliebige Binärdaten enthalten kann und sich eine Obergrenze für die Größe leicht durchsetzen lässt.
Löst das Setzen von Socket.NoDelay auf true das Problem der Fragmentierung und Verschmelzung bei TCP?
Nein. NoDelay deaktiviert den Nagle-Algorithmus und betrifft die Verzögerung und Effizienz kleiner Sendevorgänge – es ist keine Einstellung, die die Send-Einheiten als Receive-Einheiten bewahrt. Auch mit NoDelay auf true bleiben die Probleme bestehen, dass ein einzelner Send in mehreren Receive-Aufrufen ankommt, mehrere Sends in einem Receive zusammenlaufen oder mitten in einem Zeichen aufgeteilt wird. NoDelay ist kein Ersatz für Framing.
Warum kommt es beim TCP-Empfang zu Zeichensalat?
In UTF-8 kann ein Zeichen aus mehreren Bytes bestehen, und es gibt keine Garantie, dass die Grenze eines Read mit einer Zeichengrenze zusammenfällt. Wandelt man die empfangene Bytefolge jedes Mal sofort mit Encoding.UTF8.GetString in eine Zeichenkette um, entsteht bei einer Aufteilung mitten in einem Mehrbyte-Zeichen ein defektes Ergebnis. Die Abhilfe besteht darin, Bytes so lange zu sammeln, bis die Nachrichtengrenze bekannt ist, und erst dann, wenn eine vollständige Nachricht vorliegt, zu dekodieren. Auch die Rumpflänge im Längenpräfix wird nicht als Zeichenzahl, sondern als Bytezahl nach der Kodierung berechnet.

Autorenprofil

Profilseite des Artikelautors.

Go Komura

Geschäftsführer von KomuraSoft LLC

Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.

Zurück zum Blog