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/Writegesendet hat, kann die Empfangsseite dieselbe Einheit mitReceive/Readlesen.
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
Receiveerfolgt unmittelbar nachSend
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:
flowchart TD
M1["Sendevorgang 1<br/>Rumpf HELLO<br/>00 00 00 05 48 45 4C 4C 4F"]
M2["Sendevorgang 2<br/>Rumpf ABC<br/>00 00 00 03 41 42 43"]
M3["Sendevorgang 3<br/>Rumpf QUIT<br/>00 00 00 04 51 55 49 54"]
WIRE["TCP ist ein geordneter Bytestrom<br/>Sendegrenzen werden nicht mitübertragen<br/>es kommen nur 24 Bytes in Folge an"]
R1["Read Nr. 1 = 6 Byte<br/>00 00 00 05 48 45"]
R2["Read Nr. 2 = 11 Byte<br/>4C 4C 4F 00 00 00 03 41 42 43 00"]
R3["Read Nr. 3 = 7 Byte<br/>00 00 04 51 55 49 54"]
BUF["Empfangspuffer<br/>die per Read erhaltenen Bytes werden der Reihe nach angehängt"]
P1["die ersten 4 Bytes vollständig lesen<br/>Rumpflänge = 5"]
P2["den Rumpf vollständig lesen, 5 Byte<br/>1 Nachricht fertig = HELLO"]
P3["übrige Bytes nicht verwerfen,<br/>sondern als Anfang des nächsten Frames behalten"]
M1 --> WIRE
M2 --> WIRE
M3 --> WIRE
WIRE --> R1 --> BUF
WIRE --> R2 --> BUF
WIRE --> R3 --> BUF
BUF --> P1 --> P2 --> P3
P3 -.->|"zum nächsten Frame"| P1
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:
- Zunächst 4 Bytes vollständig lesen
- Aus diesen 4 Bytes die Rumpflänge entnehmen
- Prüfen, ob die Rumpflänge gültig ist
- Den Rumpf vollständig entsprechend der Rumpflänge lesen
- Den vollständig gelesenen Rumpf als eine Nachricht verarbeiten
- 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\nund\nfestlegen - 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
Sendteilt sich in mehrereReceive-Aufrufe auf - Mehrere
Send-Aufrufe verschmelzen in einemReceive - 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:
- 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
- Ist der Wert wie erwartet, hat die Sendeseite den Frame korrekt aufgebaut. Das Problem liegt dann bei der Wiederherstellung auf der Empfangsseite
- 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
- 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
readwird nicht verwendet - Der gesamte Puffer wird in eine Zeichenkette umgewandelt
- Ein
Receivewird 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/Writebleiben nicht als Einheiten vonReceive/Readauf 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
NoDelayundDataAvailablesind 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
- Das vollständige Beispielcode-Paket zu diesem Artikel (Bibliothek, Demo, Unit-Tests) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/tcp-send-receive-message-framing
- RFC 9293: Transmission Control Protocol (TCP) https://www.rfc-editor.org/rfc/rfc9293.html
- Microsoft Learn:
Socket.Receivehttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.receive?view=net-10.0 - Microsoft Learn:
NetworkStream.Readhttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.networkstream.read?view=net-10.0 - Microsoft Learn:
Socket.Sendhttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.send?view=net-10.0 - Microsoft Learn:
NetworkStream.Writehttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.networkstream.write?view=net-10.0 - Microsoft Learn:
Stream.ReadExactly/ReadExactlyAsynchttps://learn.microsoft.com/en-us/dotnet/api/system.io.stream.readexactly?view=net-10.0 https://learn.microsoft.com/en-us/dotnet/api/system.io.stream.readexactlyasync?view=net-10.0 - Microsoft Learn:
Socket.NoDelayhttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.nodelay?view=net-10.0 - Microsoft Learn: Packet Monitor (Pktmon) https://learn.microsoft.com/en-us/windows-server/networking/technologies/pktmon/pktmon
- Wireshark Wiki: CaptureSetup/Loopback https://wiki.wireshark.org/CaptureSetup/Loopback
- Wireshark Wiki: CaptureSetup/Offloading https://wiki.wireshark.org/CaptureSetup/Offloading
Verwandte Artikel
Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.
Das OSI-Modell wirklich verstehen — eine einzelne HTTP-Anfrage in ihre sieben Schichten zerlegen
Wir verstehen das OSI-Modell anhand der Praxis statt durch Auswendiglernen. In C# bauen wir den Ethernet-Frame, der eine einzelne HTTP-GE...
Datum, Uhrzeit und Zeitzonen in Business-Apps — Von den Fallstricken von DateTime über das UTC-Speicherprinzip bis zum Testdesign
Zeitstempel verschieben sich nach einer Servermigration um neun Stunden; nur beim Auslandsbüro springt das Datum auf den Vortag zurück – ...
SQLite aus C# in Business-Apps nutzen — WAL-Modus, exklusive Sperren, Schutz vor Datenbankbeschädigung und wann sich EF Core lohnt
Ein praxisnaher Überblick zur Einbindung von SQLite in eine Windows-Businessanwendung mit Microsoft.Data.Sqlite: Verbindungszeichenfolgen...
Windows-Dienste erstellen und betreiben ── Von der Abgrenzung zur Aufgabenplanung bis zur Umwandlung eines BackgroundService in einen Dienst
Sollte ein Hintergrundprozess ein Windows-Dienst werden, oder reicht die Aufgabenplanung? Dieser Artikel ordnet die Entscheidungstabelle,...
Was ist eine PDB (Program Database)? — Debugging-Informationen, Symbole und Source Link verstehen
Was eine PDB (Program Database) ist, was darin enthalten ist und was nicht, sowie der Zusammenhang mit Debug/Release, Portable PDB, Sourc...
Verwandte Themen
Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.
Technische Windows-Themen
Portal zu Windows-Entwicklung, Fehleranalyse und der Nutzung bestehender Assets.
Fehleranalyse und Langzeitprobleme
Sporadische Fehler, Kommunikationsdiagnose, Langzeitabstürze und Tests von Fehlerpfaden.
Leistungen zu diesem Thema
Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.
Windows-App-Entwicklung
Geschäftsanwendungen, Geräteintegration und Kommunikationstools von den Anforderungen bis zur Umsetzung.
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.