1. Lo primero que hay que tener claro
Al implementar comunicación TCP existe un malentendido bastante habitual.
Es el siguiente:
Por cada unidad que el emisor envía con
Send/Write, el receptor puede recibirla conReceive/Read
Ese es el malentendido.
Supongamos, por ejemplo, que el emisor envía lo siguiente:
Send("LOGIN\n")
Send("GET /items\n")
Send("QUIT\n")
En ese momento, se tiende a pensar que el receptor recibirá 3 veces, así:
Receive() => "LOGIN\n"
Receive() => "GET /items\n"
Receive() => "QUIT\n"
Pero en TCP eso no está garantizado.
En la práctica, puede ocurrir cualquiera de los siguientes casos.
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"
Todos estos casos son normales para TCP.
Dicho en pocas palabras, lo que TCP garantiza es que “la secuencia de bytes enviada llegue en orden, sin duplicados y sin pérdidas”. Lo que no garantiza es que “la unidad con la que la aplicación hizo Send se conserve como la unidad de Receive en el lado receptor”.
Por eso, en una aplicación que usa TCP, el lado receptor necesita un mecanismo para determinar “desde dónde hasta dónde de la secuencia de bytes recién recibida constituye un mensaje”.
A esto se le llama framing del protocolo de aplicación.
En este artículo se ordenan los malentendidos habituales sobre la relación entre Send y Receive en TCP, y la forma correcta de tratarlos en .NET / C#.
Además, el código que aparece en este artículo está publicado en GitHub como un conjunto de ejemplos que se puede compilar y ejecutar (una librería, una demo de TCP en loopback y pruebas unitarias que reproducen la fragmentación, la unión y los cortes a mitad de mensaje).
tcp-send-receive-message-framing - komurasoft-blog-samples (GitHub)
Requisitos previos de este artículo
| Elemento | Requisito previo |
|---|---|
| Idioma y runtime | Se usa la sintaxis de C# 8 o posterior (operador de rango, tipos de referencia que admiten valores NULL) y Stream.ReadAsync que recibe Memory<byte>. Se asume .NET Core 3.1 o posterior / .NET 5 o posterior |
| API de comunicación | La recepción se escribe con async/await sobre un Stream, concretamente el NetworkStream obtenido de TcpClient |
Uso directo de Socket |
La forma de pensar la recepción es la misma. Socket.Receive / ReceiveAsync también se tratan con “solo confiar en la cantidad de bytes del valor devuelto” y “repetir en bucle hasta leer todo lo necesario”. La diferencia aparece en el lado del envío, donde hay que revisar el valor devuelto por Socket.Send. Se trata en el capítulo 10 |
| .NET Framework | La forma de diseñarlo es la misma, pero como no se dispone del operador de rango ni de las sobrecargas que reciben Memory<byte>, hay que reescribirlo como un bucle que use Read(byte[], int, int) |
Stream.ReadExactly |
Stream.ReadExactly / ReadExactlyAsync, que se trata en el capítulo 8, está disponible desde .NET 7 |
Este artículo trata sobre el diseño del protocolo de aplicación, así que la conclusión no cambia según se configure o no NoDelay, ni según se use TLS o no. Las razones se explican en los capítulos 13 y 14 respectivamente.
2. TCP transporta una “secuencia de bytes”, no “mensajes”
Antes que nada, es importante no pensar en TCP como si fuera una cola de mensajes.
TCP trata los datos que le pasa la aplicación como una secuencia continua de bytes.
Por ejemplo, aunque el emisor haga Send 3 veces así:
Send("ABC")
Send("DEF")
Send("GHI")
Desde el punto de vista de TCP, esto termina siendo un flujo de 9 bytes como el siguiente:
ABCDEFGHI
En este flujo no queda un límite propio de la aplicación como el siguiente:
ABC | DEF | GHI
El receptor lee, en un momento dado, “lo que haya en ese instante en el búfer de recepción”. Por lo tanto, el resultado de la recepción puede ser el siguiente:
| Llamadas del emisor | Ejemplo de cómo lo ve el receptor |
|---|---|
Send("ABC"), Send("DEF") |
Con 1 Receive(): "ABCDEF" |
Send("ABCDEF") |
Con 2 Receive(): "AB", "CDEF" |
Send("ABC"), Send("DEF"), Send("GHI") |
Con 3 Receive(): "A", "BCDEFG", "HI" |
Un carácter UTF-8 como Send("あ") |
También puede dividirse a la mitad de un carácter multibyte |
Lo importante es que aquí no hay ninguna “anomalía”.
Muchos de los defectos que parecen “a veces faltan datos recibidos”, “varios mensajes se pegan” o “aparecen caracteres corruptos” no son anomalías de TCP, sino errores de diseño en los que el receptor trata TCP como si operara por unidades de mensaje.
3. Por qué parece que se puede recibir por unidad de Send
Este malentendido persiste porque, en un entorno local o con datos pequeños, con frecuencia parece funcionar tal como se espera, por pura casualidad.
En un entorno de desarrollo es fácil que se den las siguientes condiciones:
- El cliente y el servidor están en la misma máquina, o en una red cercana
- El volumen de datos es pequeño
- El otro extremo lee los datos enseguida
- Sobra capacidad de CPU y de red
- Las pruebas son manuales y hay poca variación en los tiempos
- Se hace
Receivejusto después deSend
Con estas condiciones, puede parecer que un Receive lee exactamente lo que envió un Send.
Pero en producción las condiciones cambian.
- Los datos se acumulan en los búferes de envío y recepción del sistema operativo
- Varios envíos pequeños se agrupan en uno
- Un envío grande se divide por motivos del segmento TCP o del búfer de recepción
- Se retrasa la planificación del hilo receptor
- Intervienen capas como TLS, proxies, balanceadores de carga o VPN
- Se producen latencia o congestión de red
- Se sufre el efecto del algoritmo de Nagle o del ACK retardado
El resultado es un defecto molesto: “funcionaba en desarrollo, pero a veces falla en producción”.
En el procesamiento de red, este “funciona por casualidad” es lo más peligroso.
4. Código de recepción frágil y habitual
Por ejemplo, un código como el siguiente es peligroso:
byte[] buffer = new byte[4096];
int read = await stream.ReadAsync(buffer, cancellationToken);
if (read == 0)
{
// El otro extremo cerró la conexión correctamente
return;
}
string message = Encoding.UTF8.GetString(buffer, 0, read);
await HandleMessageAsync(message, cancellationToken);
Este código asume que “un ReadAsync obtiene un mensaje completo”, pero en TCP esa suposición no se cumple.
Hay tres problemas principales.
El primero es que un mensaje se fragmenta.
Envío: {"command":"login","user":"komura"}\n
Recepción 1: {"command":"login",
Recepción 2: "user":"komura"}\n
En este caso, si se intenta interpretar solo la Recepción 1 como JSON, falla.
El segundo es que varios mensajes se unen.
Envío 1: {"command":"login"}\n
Envío 2: {"command":"get"}\n
Recepción: {"command":"login"}\n{"command":"get"}\n
En este caso, si se intenta interpretar todo como un único JSON, también falla.
El tercero es que la división ocurre en el límite de la codificación de caracteres.
En UTF-8, un carácter puede ocupar varios bytes. No hay garantía de que el límite de ReadAsync coincida con el límite de un carácter.
Por eso, si cada vez que se recibe una secuencia de bytes se convierte de inmediato a cadena con Encoding.UTF8.GetString, el resultado puede corromperse cuando un carácter multibyte queda dividido a la mitad.
La base no es “convertir a cadena en cuanto se recibe”, sino “acumular como bytes hasta conocer el límite del mensaje, y decodificar solo cuando ya se tiene un mensaje completo”.
5. No usar DataAvailable para decidir el final de un mensaje
También es habitual ver un código como el siguiente:
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();
Esto también es peligroso. Lo que indica DataAvailable es “si en ese instante hay datos legibles en el búfer de recepción local”, no que se haya completado un mensaje a nivel de aplicación.
Por ejemplo, si un mensaje tiene 100 bytes, en el instante en que llegan los primeros 40 bytes DataAvailable puede pasar a true, y justo después de leer esos 40 bytes puede volverse false temporalmente. Los 60 bytes restantes podrían llegar un poco después.
Si en ese momento se interpreta DataAvailable == false como “fin del mensaje”, se acaba procesando como un mensaje completo unos datos que solo llegan hasta la mitad.
DataAvailable puede usarse para optimizar el bucle de lectura o para comprobaciones de estilo no bloqueante, pero es más seguro no usarlo para determinar los límites del protocolo.
6. El enfoque correcto es separar “recepción” e “interpretación”
En el procesamiento de recepción de TCP, resulta más fácil de diseñar si se separan estas dos cosas:
Recepción: lee la secuencia de bytes que llega de TCP y la acumula en un búfer
Interpretación: extrae del búfer un mensaje a nivel de aplicación
Receive / Read son, ante todo, un proceso de “leer bytes”. En cambio, “dónde termina un mensaje” es algo que debe decidir el protocolo de aplicación.
Los cuatro métodos representativos son los siguientes.
| Método | Contenido | Usos adecuados |
|---|---|---|
| Longitud fija | Siempre se trata como un mensaje una cantidad fija de bytes | Equipos heredados, mensajes binarios, sistemas de control |
| Carácter delimitador | Se trata como un mensaje todo hasta una secuencia de bytes concreta, como \n |
Comandos, logs, NDJSON, protocolos sencillos |
| Prefijo de longitud | Se coloca la longitud del cuerpo al principio y se lee esa cantidad de bytes como cuerpo | Binario, JSON, MessagePack, Protocol Buffers, etc. |
| Formato autodescriptivo | El propio formato expresa la longitud o el final, como el Content-Length de HTTP o chunked |
Protocolos existentes, comunicaciones que necesitan extensibilidad |
En lo personal, si voy a crear un protocolo propio, lo primero que considero es el método de prefijo de longitud. Las razones son que el cuerpo puede incluir saltos de línea o binario arbitrario, que la implementación del receptor queda clara y que es fácil imponer un límite de tamaño máximo.
7. Fundamentos del método de prefijo de longitud
En el método de prefijo de longitud, el mensaje tiene el siguiente formato:
[longitud del cuerpo de 4 bytes][cuerpo]
Por ejemplo, si el cuerpo es un JSON en UTF-8 y su longitud es de 31 bytes, se envía así:
00 00 00 1F 7B 22 63 6F 6D 6D 61 6E 64 ...
^---------^ ^------------------------------^
longitud del cuerpo cuerpo
Si se resume en una sola imagen cómo ven 3 envíos en el lado receptor y cómo se reconstruyen los frames a partir de ahí, queda así:
flowchart TD
M1["Envío 1<br/>cuerpo HELLO<br/>00 00 00 05 48 45 4C 4C 4F"]
M2["Envío 2<br/>cuerpo ABC<br/>00 00 00 03 41 42 43"]
M3["Envío 3<br/>cuerpo QUIT<br/>00 00 00 04 51 55 49 54"]
WIRE["TCP es un flujo de bytes ordenado<br/>no transporta los límites del envío<br/>solo llegan 24 bytes en orden"]
R1["Read 1 = 6 bytes<br/>00 00 00 05 48 45"]
R2["Read 2 = 11 bytes<br/>4C 4C 4F 00 00 00 03 41 42 43 00"]
R3["Read 3 = 7 bytes<br/>00 00 04 51 55 49 54"]
BUF["Búfer de recepción<br/>va sumando en orden los bytes obtenidos con Read"]
P1["Lee por completo los primeros 4 bytes<br/>longitud del cuerpo = 5"]
P2["Lee por completo los 5 bytes del cuerpo<br/>mensaje completo = HELLO"]
P3["Los bytes sobrantes no se descartan<br/>quedan como inicio del siguiente frame"]
M1 --> WIRE
M2 --> WIRE
M3 --> WIRE
WIRE --> R1 --> BUF
WIRE --> R2 --> BUF
WIRE --> R3 --> BUF
BUF --> P1 --> P2 --> P3
P3 -.->|"al siguiente frame"| P1
Figura 1: cómo 3 envíos no corresponden a 3 Read en el lado receptor, sino que se reconstruyen en frames a través del búfer de recepción
En la figura, el primer Read solo alcanza hasta la mitad de la longitud del cuerpo, y el segundo Read mezcla el resto del primer cuerpo, el segundo frame completo y hasta el primer byte de la cabecera del tercer frame. Se aprecia que los límites de envío y los límites de recepción no coinciden.
El receptor procesa en el siguiente orden:
- Primero lee por completo los 4 bytes
- Extrae la longitud del cuerpo de esos 4 bytes
- Verifica que la longitud del cuerpo no sea inválida
- Lee por completo la cantidad de bytes indicada por esa longitud
- Procesa el cuerpo leído como un mensaje completo
- Lee el siguiente frame
Lo importante aquí es que “la cabecera de 4 bytes también puede fragmentarse”.
Recepción 1: 00 00
Recepción 2: 00 1F 7B 22 63 ...
Por lo tanto, el hecho de que sea la cabecera no garantiza que se obtengan los 4 bytes en un solo Read.
Lo mismo pasa con el cuerpo. Es normal que el valor devuelto por Read sea menor que el tamaño solicitado. Si se conoce la cantidad de bytes necesaria, hay que escribir un bucle que lea hasta completar esa cantidad.
8. Ejemplo de implementación de recepción en .NET: método de prefijo de longitud
A continuación se muestra un ejemplo de cómo leer un frame con el método de prefijo de longitud en .NET / C#.
Aquí, los primeros 4 bytes se interpretan como un int en big-endian que indica la longitud del cuerpo.
using System.Buffers.Binary;
using System.IO;
public static class LengthPrefixedProtocol
{
private const int HeaderSize = 4;
private const int MaxPayloadSize = 1024 * 1024; // 1 MiB. Se decide según el uso
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)
{
// No es a mitad de un frame: el otro extremo terminó normalmente antes de empezar el siguiente frame
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;
}
}
El lado que lo consume queda así:
while (true)
{
byte[]? payload = await LengthPrefixedProtocol.ReadFrameAsync(
stream,
cancellationToken);
if (payload is null)
{
// El otro extremo cerró limpiamente en el límite del frame
break;
}
await HandleMessageAsync(payload, cancellationToken);
}
En esta implementación no importa cuántos bytes devuelva cada ReadAsync. Aunque devuelva de a un byte, el bucle sigue hasta leer por completo la cabecera y el cuerpo.
Al revés, aunque en el búfer de recepción del sistema operativo se acumulen datos de varios mensajes, solo se extrae el primer frame según la longitud del cuerpo, y el siguiente frame se lee en la siguiente vuelta del bucle.
Además, en algunos entornos con .NET actual se puede usar Stream.ReadExactly / ReadExactlyAsync. En ese caso, se puede delegar en la API estándar el proceso de leer por completo la cantidad de bytes necesaria. Sin embargo, la aplicación debe seguir diseñando cómo distinguir el cierre de la conexión, la terminación normal antes de empezar un frame y la terminación anómala a mitad de frame.
9. Ejemplo de implementación del lado emisor
El emisor también envía siguiendo el mismo formato de frame.
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);
}
}
En este código, la cabecera y el cuerpo se escriben con WriteAsync por separado. Aquí vuelve a surgir un malentendido fácil: aunque el emisor haga WriteAsync en dos pasos separados para la cabecera y el cuerpo, eso no garantiza que el receptor los lea también en dos partes separadas.
En el lado receptor, podría verse así:
Read() => [cabecera de 4 bytes + parte del cuerpo]
Read() => [el resto del cuerpo]
O también podría verse así:
Read() => [primeros 2 bytes de la cabecera]
Read() => [últimos 2 bytes de la cabecera + todo el cuerpo + la cabecera del siguiente frame]
Precisamente por eso, el receptor decide no por “cuántas veces hizo Read”, sino por “cuántos bytes ha podido leer según el formato del frame”.
10. Si se usa Socket.Send directamente, el emisor también debe revisar el valor devuelto
Cuando se usa NetworkStream.Write / WriteAsync, básicamente se puede tratar como una API que escribe todo el rango indicado.
En cambio, si se usa Socket.Send directamente, hay que prestar atención al valor devuelto.
Socket.Send devuelve “la cantidad de bytes que se pudieron enviar”. Especialmente con sockets no bloqueantes, puede tener éxito con menos bytes de los solicitados.
Por eso, si se usa Socket.Send directamente, el emisor también necesita un proceso que “repita hasta enviarlo todo”.
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..];
}
}
Sin embargo, “se pudo enviar” aquí no significa que “la aplicación del otro extremo haya procesado ese mensaje”. El éxito de la API de envío es independiente de la respuesta de éxito a nivel del protocolo de aplicación.
Por ejemplo, si a nivel de negocio se quiere confirmar que “se aceptó el pedido”, que “se guardó el archivo” o que “se ejecutó el comando”, no basta con el éxito del envío en TCP: hay que definir en el protocolo un ACK o un mensaje de respuesta procedente de la aplicación del otro extremo.
11. Puntos a tener en cuenta al usar el método de carácter delimitador
En los protocolos de texto, a veces se usa la separación por saltos de línea.
LOGIN komura secret\n
GET item-001\n
QUIT\n
Este método es fácil de entender y encaja bien con logs y formatos de comandos.
Sin embargo, hay que prestar atención a los siguientes puntos.
- Definir la regla de escape para cuando el delimitador aparece dentro del cuerpo
- Decidir cómo tratar
\r\nfrente a\n - Definir la longitud máxima de una línea
- No acumular memoria de forma ilimitada hasta que llegue el delimitador
- Evitar que se rompa aunque un carácter multibyte UTF-8 quede dividido
En particular, conviene evitar un código como el siguiente:
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);
}
Este código no tiene en cuenta que el final del rango recibido puede caer a mitad de línea, ni que puede dividirse a mitad de un carácter UTF-8.
Si se usa la separación por saltos de línea, como mínimo hay que “acumular bytes, buscar el byte de salto de línea y decodificar solo cuando ya se tiene una línea completa”, o bien usar una API que lea líneas directamente sobre el stream, como StreamReader.ReadLineAsync.
Aun así, incluso usando StreamReader.ReadLineAsync, se debe diseñar de antemano la longitud máxima de línea, el tiempo de espera, la cancelación y el tratamiento del cierre de la conexión.
12. Puntos a tener en cuenta al usar el método de longitud fija
En un mensaje de longitud fija se establece algo como “siempre 128 bytes por mensaje”. Es un método que se ve en sistemas de negocio antiguos, sistemas de control e integración de equipos.
Con longitud fija, la forma de pensar es la misma.
1 mensaje = 128 bytes
Si es así, el receptor hace un bucle hasta leer por completo los 128 bytes.
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);
Aquí tampoco está garantizado que un solo ReadAsync devuelva los 128 bytes.
La longitud fija tiene límites claros y es fácil de implementar, pero presenta problemas como que es difícil manejar datos de longitud variable, que es complicado ampliarla en el futuro, que el tratamiento del relleno es engorroso, y que la conversión de codificación de caracteres cambia la cantidad de bytes.
13. Desactivar Nagle no resuelve el problema de los límites del mensaje
Cuando se quiere enviar datos pequeños de inmediato, a veces se considera Socket.NoDelay = true. Es un ajuste que desactiva el algoritmo de Nagle.
Sin embargo, NoDelay es un ajuste relacionado con el retraso y la eficiencia del envío, sobre “cómo agrupar los envíos pequeños”, no un ajuste que “conserve la unidad de Send como unidad de Receive”.
Es decir, aunque se ponga NoDelay = true, no se resuelven los siguientes problemas:
- Un solo
Sendse divide en variosReceive - Varios
Sendse juntan en un soloReceive - Se divide a mitad de un carácter
- El receptor no puede determinar los límites del mensaje
NoDelay tiene sentido como ajuste de latencia, pero no sustituye al framing.
14. Al usar TLS o SslStream, la forma de pensar es la misma
Aunque se use SslStream para aplicar TLS, el tratamiento desde el punto de vista de la aplicación es básicamente el mismo.
TLS tiene una unidad interna llamada registro TLS, pero no coincide con los límites de los mensajes de la aplicación.
Tampoco con SslStream.ReadAsync está garantizado que se devuelva de una vez el mensaje completo que espera la aplicación.
Por lo tanto, haya o no TLS, hay que diseñar en la capa de aplicación alguno de los siguientes métodos:
- Prefijo de longitud
- Carácter delimitador, como el salto de línea
- Longitud fija
- Formato de protocolo existente
TLS es una capa de cifrado y autenticación, no una capa que cree automáticamente los límites de los mensajes.
15. Manejo de errores a tener en cuenta en el bucle de recepción
En el procesamiento de recepción de TCP es importante tratar con claridad no solo el caso normal, sino también las desconexiones y las terminaciones a mitad de proceso.
Cuando el valor devuelto por Read / Receive es 0, en general significa que el otro extremo terminó de enviar de forma normal.
Sin embargo, a nivel del protocolo de aplicación hay que distinguir estos dos casos:
| Estado | Tratamiento |
|---|---|
| Termina con 0 bytes antes de leer el siguiente frame | En algunos casos se puede tratar como terminación normal |
| Termina a mitad de la cabecera o a mitad del cuerpo del frame | Se trata como anomalía, porque el mensaje quedó a medias |
Con el método de prefijo de longitud, por ejemplo, se razona así:
Corte en el límite del frame:
se puede tratar como terminación normal
Corte tras recibir solo 2 de los 4 bytes de la cabecera:
error de protocolo
Corte tras recibir solo 60 de los 100 bytes indicados como longitud del cuerpo:
error de protocolo
Si se incorpora esta distinción, investigar los logs resulta mucho más sencillo.
En lugar de mostrar solo “el otro extremo se desconectó”, si se puede mostrar algo como:
Frame payload was truncated. expected=100 actual=60
resulta más fácil sospechar de una terminación anómala del otro extremo, un tiempo de espera agotado o una discrepancia de protocolo.
16. Definir siempre un tamaño máximo
En el método de prefijo de longitud, la longitud del cuerpo va al principio.
Lo peligroso aquí es que el otro extremo indique una longitud enorme.
FF FF FF FF
Si esto se usa tal cual para reservar un array, la aplicación intenta reservar una cantidad enorme de memoria y se vuelve inestable.
Por eso, el receptor siempre debe definir un tamaño máximo.
private const int MaxPayloadSize = 1024 * 1024;
if (payloadLength < 0 || payloadLength > MaxPayloadSize)
{
throw new InvalidDataException(
$"Invalid payload length: {payloadLength} bytes.");
}
El tamaño máximo se decide según los requisitos de negocio. Para comandos, 64 KiB puede ser suficiente, y para enviar imágenes o archivos quizá convenga plantear otro método de transferencia o streaming. Lo importante es no diseñar el sistema para que “acepte cualquier cantidad en teoría”.
17. En los protocolos de texto, hay que fijarse en “bytes”, no en “caracteres”
Lo que transporta TCP es una secuencia de bytes, no una cadena de caracteres.
Por eso, cuando se coloca la longitud del cuerpo en el método de prefijo de longitud, normalmente se coloca la cantidad de “bytes”, no la cantidad de “caracteres”.
Por ejemplo, supongamos que la siguiente cadena se convierte a UTF-8:
こんにちは
Son 5 caracteres, pero en UTF-8 son 15 bytes.
Si se pone 5 como la longitud a nivel de protocolo, el receptor solo leerá 5 bytes del cuerpo, y quedará cortado a mitad de un carácter.
El emisor siempre debe calcular la longitud tomando como referencia el array de bytes ya codificado.
string json = "{\"message\":\"こんにちは\"}";
byte[] payload = Encoding.UTF8.GetBytes(json);
await LengthPrefixedProtocolWriter.WriteFrameAsync(
stream,
payload,
cancellationToken);
El receptor lee por completo el cuerpo del frame como bytes y solo después lo convierte de nuevo a cadena.
byte[]? payload = await LengthPrefixedProtocol.ReadFrameAsync(
stream,
cancellationToken);
if (payload is not null)
{
string json = Encoding.UTF8.GetString(payload);
await HandleJsonAsync(json, cancellationToken);
}
Si se sigue este orden, no hay problema aunque Read se divida a mitad de un carácter UTF-8.
18. Prestar atención también a la interferencia a nivel de aplicación por escrituras concurrentes
Otro punto que sorprendentemente se pasa por alto es la escritura concurrente.
Supongamos, por ejemplo, que varias tareas escriben frames al mismo tiempo sobre la misma conexión TCP.
_ = WriteFrameAsync(stream, messageA, cancellationToken);
_ = WriteFrameAsync(stream, messageB, cancellationToken);
Si esto no se controla, a nivel de aplicación puede producirse una interferencia como la siguiente:
Cabecera de A
Cabecera de B
Cuerpo de A
Cuerpo de B
El receptor procesa asumiendo que, tras leer la cabecera de A, a continuación llega el cuerpo de A. Si en medio se cuela la cabecera de B, el protocolo se rompe.
Por eso, es más seguro serializar las escrituras sobre una misma conexión. Por ejemplo, usando SemaphoreSlim o una cola de envío para evitar que se mezclen las escrituras a nivel de frame.
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 conserva el orden de los bytes, pero si la aplicación escribe la secuencia de bytes mezclada desde varias tareas, TCP entregará correctamente ese “orden mezclado”.
19. En las pruebas, forzar deliberadamente la fragmentación y la unión
Si el procesamiento de recepción de TCP se prueba de forma normal, es fácil pasar por alto un estado en el que “funciona por casualidad”.
Por eso, en las pruebas se generan deliberadamente los siguientes patrones.
| Aspecto a probar | Ejemplo |
|---|---|
| Llega de a 1 byte | Tanto la cabecera como el cuerpo se leen con Read de a 1 byte |
| Corte a mitad de la cabecera | De los 4 bytes de la cabecera, solo llegan 2 y termina |
| Corte a mitad del cuerpo | De una longitud de cuerpo de 100, solo llegan 60 bytes y termina |
| Varios frames se unen | 2 frames entran en un solo búfer interno |
| Longitud enorme indicada | Se envía una longitud de cuerpo que supera el tamaño máximo |
| Cuerpo de 0 bytes | Se comprueba si se permite una longitud de cuerpo de 0 |
| División en UTF-8 | La secuencia de bytes de texto japonés o emojis se divide a la mitad |
En las pruebas unitarias, sin usar un socket TCP real, si se sustituye el Stream por uno que crea un “stream que solo puede leerse en el tamaño de fragmento indicado”, resulta más fácil verificar el procesamiento de recepción.
Reproducir la fragmentación: envolver el Stream
Se puede reproducir sin necesidad de instalar una librería especializada, simplemente heredando de Stream y limitando la cantidad de bytes que devuelve Read.
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
// Stream que en cada Read devuelve como máximo maxChunkSize bytes.
// Solo envuelve el stream interno, así que no toca el procesamiento de recepción que se está probando.
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();
}
Con esto, tanto el caso de que “solo llegue de a 1 byte” como el de que “2 frames lleguen de una vez” se pueden escribir con el mismo código de prueba, cambiando solo el argumento.
using System.Buffers.Binary;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
public class LengthPrefixedProtocolTests
{
// Prueba el método ReadFrameAsync de LengthPrefixedProtocol del capítulo 8
[Theory]
[InlineData(1)] // La cabecera y el cuerpo llegan de a 1 byte
[InlineData(3)] // Se corta a mitad de la cabecera
[InlineData(1024)] // Los 2 frames llegan juntos
public async Task PuedeRecomponerDosFramesSinImportarElTamanioDeChunk(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); // Terminación normal en el límite del frame
}
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);
}
}
La prueba de corte a mitad de mensaje también se puede reproducir escribiendo en el MemoryStream solo hasta la mitad. Por ejemplo, en lugar de WriteFrame, si se escriben solo 2 de los 4 bytes de la cabecera se obtiene un “corte a mitad de la cabecera”, y se puede comprobar que ReadFrameAsync lanza EndOfStreamException.
Provocar la fragmentación en loopback
Si se quiere comprobar en una prueba de integración a través de TCP real, en el emisor se escribe el frame deliberadamente en dos partes, con una espera intermedia.
using System;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
public static class SplitSender
{
// Envía frame dividido en firstChunkSize bytes.
// Si no se configura NoDelay, el algoritmo de Nagle puede agrupar
// la primera y la segunda parte en un solo segmento, y entonces no se produce la fragmentación.
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);
}
}
Si se pasa 2 en firstChunkSize se puede probar “el corte a mitad de la cabecera de 4 bytes”, y si se pasa la longitud del cuerpo + 2, se puede probar “el corte a mitad del cuerpo”.
Sin embargo, esto no garantiza la fragmentación como parte de la especificación de TCP. Solo crea condiciones en las que, en la práctica, es casi seguro que los límites se desajustan. Para las pruebas que necesitan certeza, asegúrelas en el lado de las pruebas unitarias con Stream sustituido, no con el socket.
También son necesarias pruebas de integración que verifiquen realmente a través de TCP, pero si primero se extrae el analizador de recepción como un proceso puro sobre un Stream, resulta más fácil de probar.
La calidad del procesamiento de red no se juzga por “funciona si se envía de forma normal”, sino por “se comporta como se espera aunque se fragmente, se una o se corte a la mitad”.
20. Cómo observar el problema cuando “a veces falla en producción”
Los defectos de framing no aparecen en desarrollo, sino solo en producción, y además de forma irregular. Esto se debe a que la forma en que se fragmenta solo cambia cuando aumenta el volumen de datos, cuando la línea se vuelve más lenta o cuando cambia la implementación del otro extremo.
En ese momento, lo primero que conviene determinar es si lo que está fallando es la secuencia de bytes del emisor o el proceso de reconstrucción del receptor. Con solo separar esto, el ámbito de investigación se reduce a la mitad.
Se usan estos tres medios de observación según el caso.
| Medio | Qué permite saber | Puntos a tener en cuenta |
|---|---|---|
| Logs de la aplicación | La cantidad de bytes esperada frente a la realmente leída, la longitud del frame extraído, el momento del corte | El primer medio que conviene incorporar. Hay que mostrar siempre expected y actual juntos. Con uno solo, únicamente se sabe que “faltó algo” |
| Wireshark | La secuencia de bytes que realmente circuló, los límites de los segmentos TCP, las retransmisiones, la presencia de RST | Para ver el loopback (127.0.0.1) en Windows hace falta Npcap. Desde Wireshark 3.0.0, se selecciona “Adapter for loopback traffic capture” en la lista de interfaces |
| pktmon | Permite capturar sin nada adicional, con lo estándar de Windows. El ETL obtenido se puede convertir a pcapng y abrir con Wireshark | pktmon.exe viene de forma estándar desde Windows 10 build 19041 en adelante |
Al abrir la captura, se revisa en el siguiente orden.
- Con Follow > TCP Stream, se observa la secuencia de bytes reconstruida. Aquí se comprueba si los 4 bytes del prefijo de longitud tienen el valor esperado
- Si es el esperado, el emisor está construyendo el frame correctamente. El problema está en el proceso de reconstrucción del receptor
- Si no es el esperado, se sospecha del ensamblado del frame en el emisor, o de la interferencia por escrituras concurrentes del capítulo 18
- Si hay RST o un corte a mitad de proceso, se comprueba cómo trata el receptor el corte a mitad de un frame
Aquí hay un punto que se confunde con facilidad.
Los límites de los segmentos TCP tampoco son los límites de los mensajes de la aplicación. Que en Wireshark parezca que un mensaje cabe exactamente en un segmento es pura casualidad. La unidad que la aplicación receptora recibe con Read tampoco coincide con los límites de los segmentos. La captura sirve para confirmar “la secuencia de bytes que realmente circuló”, no para leerla como “aquí hay un mensaje”.
Además, si la captura se toma en el host emisor, por efecto de la descarga de segmentación (LSO / TSO) puede quedar registrada como un paquete de un tamaño mayor que el MTU. Esa forma es distinta de los segmentos que realmente circularon por la línea. Si lo que se quiere analizar es el propio tamaño de los segmentos, conviene capturar en el receptor o en un host intermedio, o desactivar temporalmente la descarga.
21. Lista de verificación para corregir código existente
Al revisar código de comunicación TCP existente, resulta más fácil encontrar problemas si se mira desde los siguientes puntos de vista.
| Aspecto | Qué comprobar |
|---|---|
| Unidad de recepción | ¿Se trata un solo Read / Receive como un mensaje completo? |
| Valor devuelto | ¿Se usa siempre la cantidad de bytes del valor devuelto por Read / Receive? |
| Acumulación | ¿Se acumulan los bytes hasta que el mensaje está completo? |
| Límites | ¿Existe una regla, como longitud fija, carácter delimitador o prefijo de longitud? |
| Codificación | ¿Se evita convertir a cadena antes de que el mensaje esté completo? |
| Longitud máxima | ¿Hay un límite superior para la longitud o el largo de línea? |
| Corte | ¿Se distingue entre un corte en el límite del frame y un corte a mitad de proceso? |
| Envío | ¿No se ignora el valor devuelto por Socket.Send? |
| Concurrencia | ¿No se mezclan las escrituras de varias tareas sobre la misma conexión? |
| Logs | ¿Se puede mostrar la cantidad de bytes expected / actual? |
| Pruebas | ¿Existen pruebas de fragmentación, unión y corte a mitad de proceso? |
Un código especialmente peligroso tiene esta forma:
int read = socket.Receive(buffer);
string message = Encoding.UTF8.GetString(buffer);
Handle(message);
Los problemas son varios.
- No se usa el valor de
read - Se convierte a cadena todo el búfer
- Se trata un solo
Receivecomo un mensaje completo - No hay límites de mensaje
- No se contempla la división a mitad de un carácter
Como mínimo, hay que cambiar al siguiente enfoque:
Añadir al búfer de recepción solo los read bytes obtenidos con Receive
↓
Comprobar si, según el protocolo, se puede extraer 1 frame del búfer de recepción
↓
Si se puede extraer, procesarlo
↓
Dejar los bytes sobrantes como inicio del siguiente frame
↓
Si no alcanza, esperar al siguiente Receive
22. Resumen
En la comunicación TCP no está garantizado poder recibir con Receive cada unidad enviada con Send. Esto no es un comportamiento excepcional, sino algo básico al usar TCP.
Los puntos clave a tener en cuenta son los siguientes.
- TCP ofrece un flujo de bytes ordenado, no mensajes
- La unidad de cada llamada a
Send/Writeno se conserva como la unidad deReceive/Readen el receptor - Un solo envío puede dividirse en varias recepciones, y varios envíos pueden juntarse en una sola recepción
- El receptor debe determinar los límites del mensaje como parte del protocolo de aplicación
- En un protocolo propio, el método de prefijo de longitud suele ser el más fácil de manejar
- El diseño debe incluir un bucle que lea hasta completar la cantidad de bytes necesaria, un tamaño máximo, el corte a mitad de proceso, la codificación de caracteres y la escritura concurrente
NoDelayyDataAvailableno sustituyen a los límites del mensaje- Cuando algo falla solo en producción, primero hay que usar una captura para separar si lo que falla es “la secuencia de bytes del emisor” o “la reconstrucción del receptor”
El procesamiento de red parece sencillo si solo se mira el caso normal. Pero, en realidad, la comunicación se vuelve estable únicamente al decidir “dónde se corta”, “cómo esperar cuando falta”, “cómo dejar el sobrante cuando hay demasiado” y “cómo tratar un corte a mitad de proceso”.
Si se usa TCP, Receive no devuelve un mensaje, sino solo una parte de la secuencia de bytes. La responsabilidad de convertirla en un mensaje recae en el diseño del protocolo de la aplicación.
Referencias
- El paquete completo de código de ejemplo de este artículo (librería, demo, pruebas unitarias) 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
Artículos relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
¿Qué es un PDB (Program Database)? — Cómo entender la información de depuración, los símbolos y Source Link
Qué es un PDB: qué contiene, qué no, y su relación con Debug/Release, Portable PDB, Source Link, servidores de símbolos y el análisis de ...
Cómo manejar correctamente los tokens de suplantación de Windows — préstamo de privilegios por hilo y una forma segura de revertirlos
Guía práctica sobre los tokens de suplantación de Windows: tokens de acceso, primarios y de hilo, niveles de suplantación, RevertToSelf y...
Formarse una imagen clara del modelo OSI — diseccionar una única petición HTTP en sus siete capas
Entendemos el modelo OSI con un ejemplo real: construimos y diseccionamos en C# la trama Ethernet que transporta una petición HTTP GET, c...
¿Qué es Roslyn? ── Leer, corregir y generar código C# desde la perspectiva del compilador
Resumen de Roslyn (.NET Compiler Platform): Syntax Tree, SemanticModel, Workspace, Analyzer, Source Generator, y sus usos y precauciones ...
Tipos de datos algebraicos en .NET Framework / .NET — Diseño que representa estados y resultados mediante tipos
Cómo usar los tipos de datos algebraicos, en especial los tipos suma y las uniones discriminadas, en .NET Framework y .NET: F#, jerarquía...
Temas relacionados
Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.
Temas técnicos de Windows
Portal sobre desarrollo de Windows, investigación de fallos y aprovechamiento de activos existentes.
Investigación de fallos y problemas prolongados
Fallos intermitentes, diagnóstico de comunicaciones, bloqueos prolongados y pruebas de rutas de error.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Desarrollo de aplicaciones para Windows
Aplicaciones empresariales, integración de dispositivos y herramientas de comunicación, de los requisitos al desarrollo.
Preguntas frecuentes
Preguntas habituales en las consultas sobre el tema del artículo.
- ¿Por qué TCP no permite recibir por cada unidad enviada con Send?
- Porque lo único que garantiza TCP es que "la secuencia de bytes enviada llegue en orden, sin duplicados y sin pérdidas"; no garantiza que la unidad enviada con Send se conserve como la unidad de Receive en el lado receptor. Como TCP transporta una secuencia continua de bytes y no mensajes, es normal que un solo envío se divida en varias recepciones, o que varios envíos se junten en una sola recepción. El lado receptor necesita un mecanismo para determinar los límites del mensaje (framing).
- ¿Qué métodos existen para el framing de TCP (cómo determinar los límites de un mensaje)?
- Hay cuatro métodos representativos: el de longitud fija, que trata siempre una cantidad fija de bytes como un mensaje; el de carácter delimitador, que trata como un mensaje todo hasta una secuencia de bytes concreta, como un salto de línea; el de prefijo de longitud, que coloca la longitud del cuerpo al principio; y el formato autodescriptivo, que expresa la longitud o el final dentro del propio formato, como el Content-Length de HTTP. Si se va a crear un protocolo propio, el prefijo de longitud suele ser el primer candidato a considerar, porque permite incluir binario arbitrario en el cuerpo y facilita imponer un límite de tamaño máximo.
- ¿Poner Socket.NoDelay en true resuelve el problema de fragmentación y unión de TCP?
- No lo resuelve. NoDelay es un ajuste que desactiva el algoritmo de Nagle, relacionado con el retraso y la eficiencia de los envíos pequeños, pero no un ajuste que conserve la unidad de Send como unidad de Receive. Aunque se ponga NoDelay en true, los problemas de que un solo Send se divida en varios Receive, de que varios Send se junten en un solo Receive, o de que un carácter se corte a la mitad, siguen ocurriendo igual. No sustituye al framing.
- ¿Por qué los datos recibidos por TCP aparecen con caracteres corruptos?
- Porque en UTF-8 un carácter puede ocupar varios bytes, y no hay garantía de que el límite de un Read coincida con el límite de un carácter. Si cada vez que se recibe una secuencia de bytes se convierte de inmediato a cadena con Encoding.UTF8.GetString, el resultado se corrompe cuando un carácter multibyte queda cortado a la mitad. La solución es acumular los bytes hasta conocer el límite del mensaje y decodificar solo cuando ya se tiene un mensaje completo. Además, la longitud del cuerpo en el prefijo de longitud debe calcularse en bytes tras la codificación, no en número de caracteres.
Perfil del autor
Página de presentación del autor del artículo.
Go Komura
Representante de KomuraSoft LLC
Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.