Leituras parciais e de bytes zero no DeflateStream, GZipStream e CryptoStream

Os métodos Read() e ReadAsync() em DeflateStream, GZipStream e CryptoStream podem não retornar mais tantos bytes quanto solicitados.

Anteriormente, DeflateStream, GZipStream e CryptoStream divergiam do comportamento típico de Stream.Read e Stream.ReadAsync nas duas maneiras a seguir, ambas as quais essa alteração aborda:

  • Eles não concluíam a operação de leitura até que o buffer passado para a operação de leitura fosse completamente preenchido ou o fim do fluxo fosse atingido.
  • Como fluxos de wrapper, eles não delegavam a funcionalidade de buffer de comprimento zero para o fluxo que eles encapsulam.

Considere este exemplo que cria e compacta 150 bytes aleatórios. Em seguida, ele envia os dados compactados um byte de cada vez do cliente para o servidor, e o servidor descompacta os dados chamando Read e solicitando todos os 150 bytes.

using System.IO.Compression;
using System.Net;
using System.Net.Sockets;

internal class Program
{
    private static async Task Main()
    {
        // Connect two sockets and wrap a stream around each.
        using (Socket listener = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
        using (Socket client = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
        {
            listener.Bind(new IPEndPoint(IPAddress.Loopback, 0));
            listener.Listen(int.MaxValue);
            client.Connect(listener.LocalEndPoint!);
            using (Socket server = listener.Accept())
            {
                var clientStream = new NetworkStream(client, ownsSocket: true);
                var serverStream = new NetworkStream(server, ownsSocket: true);

                // Create some compressed data.
                var compressedData = new MemoryStream();
                using (var gz = new GZipStream(compressedData, CompressionLevel.Fastest, leaveOpen: true))
                {
                    byte[] bytes = new byte[150];
                    new Random().NextBytes(bytes);
                    gz.Write(bytes, 0, bytes.Length);
                }

                // Trickle it from the client stream to the server.
                Task sendTask = Task.Run(() =>
                {
                    foreach (byte b in compressedData.ToArray())
                    {
                        clientStream.WriteByte(b);
                    }
                    clientStream.Dispose();
                });

                // Read and decompress all the sent bytes.
                byte[] buffer = new byte[150];
                int total = 0;
                using (var gz = new GZipStream(serverStream, CompressionMode.Decompress))
                {
                    int numRead = 0;
                    while ((numRead = gz.Read(buffer.AsSpan(numRead))) > 0)
                    {
                        total += numRead;
                        Console.WriteLine($"Read: {numRead} bytes");
                    }
                }
                Console.WriteLine($"Total received: {total} bytes");

                await sendTask;
            }
        }
    }
}

Nas versões anteriores do .NET e do .NET Framework, a saída a seguir mostra que Read só foi chamado uma vez. Mesmo que os dados estivessem disponíveis para serem retornados por GZipStream, Read foi forçado a aguardar até que o número solicitado de bytes estivesse disponível.

Read: 150 bytes
Total received: 150 bytes

No .NET 6 e versões posteriores, a saída a seguir mostra que Read foi chamado várias vezes até que todos os dados solicitados fossem recebidos. Embora a chamada para Read solicite 150 bytes, cada chamada para Read foi capaz de descompactar com êxito alguns bytes (ou seja, todos os bytes que haviam sido recebidos naquele momento) para retornar, e isso ocorreu:

Read: 1 bytes
Read: 101 bytes
Read: 4 bytes
Read: 4 bytes
Read: 2 bytes
Read: 2 bytes
Read: 2 bytes
Read: 2 bytes
Read: 3 bytes
Read: 2 bytes
Read: 3 bytes
Read: 2 bytes
Read: 2 bytes
Read: 2 bytes
Read: 2 bytes
Read: 1 bytes
Read: 2 bytes
Read: 1 bytes
Read: 1 bytes
Read: 1 bytes
Read: 2 bytes
Read: 1 bytes
Read: 1 bytes
Read: 2 bytes
Read: 1 bytes
Read: 1 bytes
Read: 2 bytes
Total received: 150 bytes

Comportamento antigo

Quando Stream.Read ou Stream.ReadAsync foi chamado em um dos tipos de fluxo afetados com um buffer de comprimento N, a operação não foi concluída até:

  • N bytes fossem lidos do fluxo ou
  • O fluxo subjacente retornou 0 de uma chamada para sua leitura, indicando que não havia mais dados disponíveis.

Além disso, quando Stream.Read ou Stream.ReadAsync eram chamados com um buffer de comprimento 0, a operação tinha êxito imediatamente, às vezes sem fazer uma leitura de comprimento zero no fluxo que encapsula.

Novo comportamento

Quando Stream.Read ou Stream.ReadAsync eram chamados em um dos tipos de fluxo afetados com um buffer de comprimento N, a operação não era concluída até que:

  • Pelo menos 1 byte foi lido do fluxo ou
  • O fluxo subjacente retorna 0 de uma chamada para sua leitura, indicando que não há mais dados disponíveis.

Além disso, quando Stream.Read ou Stream.ReadAsync é chamado com um buffer de comprimento 0, a operação é bem-sucedida quando uma chamada com um buffer diferente de zero tem êxito.

Quando você chama um dos métodos Read afetados, se a leitura pode satisfazer pelo menos um byte da solicitação, independentemente de quantos foram solicitados, ele retorna o máximo possível naquele momento.

Versão introduzida

6,0

Motivo da alteração

Era possível que os fluxos não retornassem de uma operação de leitura mesmo se os dados tivessem sido lidos com êxito. Isso significava que eles não podiam ser prontamente usados em qualquer situação de comunicação bidirecional em que mensagens menores que o tamanho do buffer estavam sendo usadas. Isso podia levar a deadlocks: o aplicativo não consegue ler os dados do fluxo necessário para continuar a operação. Isso também pode levar a desacelerações arbitrárias, com o consumidor incapaz de processar dados disponíveis enquanto aguarda a chegada de mais dados.

Além disso, em aplicativos altamente escalonáveis, é comum usar leituras de bytes zero como forma de atrasar a alocação de buffer até que um buffer seja necessário. Um aplicativo pode emitir uma leitura com um buffer vazio e, quando essa leitura for concluída, os dados deverão estar disponíveis em breve para serem consumidos. Em seguida, o aplicativo pode emitir a leitura novamente, desta vez com um buffer para receber os dados. Ao delegar ao fluxo encapsulado se nenhum dado já descompactado ou transformado disponível, esses fluxos agora herdarão qualquer comportamento desses fluxos que eles encapsulam.

Em geral, o código:

  • Não deve fazer suposições sobre um fluxo Read ou operação de leitura ReadAsync tanto quanto foi solicitado. A chamada retorna o número de bytes lidos, que pode ser menor do que o solicitado. Se um aplicativo depender do buffer ser completamente preenchido antes de progredir, ele poderá executar a leitura em um loop para recuperar o comportamento.

    int totalRead = 0;
    while (totalRead < buffer.Length)
    {
        int bytesRead = stream.Read(buffer.AsSpan(totalRead));
        if (bytesRead == 0) break;
        totalRead += bytesRead;
    }
    
  • Espere que uma chamada de fluxo Read ou ReadAsync não seja concluída até que pelo menos um byte de dados esteja disponível para consumo (ou o fluxo atinja seu fim), independentemente de quantos bytes foram solicitados. Se um aplicativo depender de uma leitura de byte zero concluída imediatamente sem aguardar, ele poderá verificar o comprimento do buffer em si e ignorar totalmente a chamada:

    int bytesRead = 0;
    if (!buffer.IsEmpty)
    {
        bytesRead = stream.Read(buffer);
    }
    

APIs afetadas