El malentendido de que TCP permite recibir por cada unidad enviada con Send ── diseño de recepción para tratarlo como flujo de bytes

· Actualizado el: · · TCP, Socket, Network, .NET, CSharp, ProtocolDesign, Operación, Aprovechamiento de activos existentes

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 con Receive / 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 Receive justo después de Send

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í:

al siguiente frameEnvío 1cuerpo HELLO00 00 00 05 48 45 4C 4C 4FEnvío 2cuerpo ABC00 00 00 03 41 42 43Envío 3cuerpo QUIT00 00 00 04 51 55 49 54TCP es un flujo de bytes ordenadono transporta los límites del envíosolo llegan 24 bytes en ordenRead 1 = 6 bytes00 00 00 05 48 45Read 2 = 11 bytes4C 4C 4F 00 00 00 03 41 42 43 00Read 3 = 7 bytes00 00 04 51 55 49 54Búfer de recepciónva sumando en orden los bytes obtenidos con ReadLee por completo los primeros 4 byteslongitud del cuerpo = 5Lee por completo los 5 bytes del cuerpomensaje completo = HELLOLos bytes sobrantes no se descartanquedan como inicio del siguiente frame

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:

  1. Primero lee por completo los 4 bytes
  2. Extrae la longitud del cuerpo de esos 4 bytes
  3. Verifica que la longitud del cuerpo no sea inválida
  4. Lee por completo la cantidad de bytes indicada por esa longitud
  5. Procesa el cuerpo leído como un mensaje completo
  6. 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\n frente 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 Send se divide en varios Receive
  • Varios Send se juntan en un solo Receive
  • 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.

  1. 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
  2. Si es el esperado, el emisor está construyendo el frame correctamente. El problema está en el proceso de reconstrucción del receptor
  3. 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
  4. 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 Receive como 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 / Write no se conserva como la unidad de Receive / Read en 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
  • NoDelay y DataAvailable no 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

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

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.

Volver al blog