Частичные и нулевые операции чтения в DeflateStream, GZipStream и CryptoStream

Методы Read() и ReadAsync() методы DeflateStreamв , GZipStreamи CryptoStream больше не могут возвращать столько байтов, сколько было запрошено.

DeflateStreamРанее , GZipStreamи CryptoStream отличались от типичных Stream.Read и Stream.ReadAsync поведения следующими двумя способами, оба из которых это изменение адресов:

  • Операция чтения не завершена до тех пор, пока не будет полностью заполнен буфер, переданный в операцию чтения, или не будет достигнут конец потока.
  • Будучи потоками-оболочками, они не делегировали функцию буфера нулевой длины потоку, для которого они служат оболочкой.

Рассмотрим этот пример, который создает и сжимает 150 случайных байтов. Затем он отправляет сжатые данные один байт за раз от клиента к серверу, а сервер распаковывает данные путем вызова Read и запроса всех 150 байтов.

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

В предыдущих версиях .NET и платформа .NET Framework в следующих выходных данных показано, что Read он был вызван только один раз. Несмотря на то, что данные были доступны для GZipStream возврата, Read вынуждены ждать, пока запрашиваемое число байтов не было доступно.

Read: 150 bytes
Total received: 150 bytes

В .NET 6 и более поздних версиях в следующих выходных данных показано, что Read вызывается несколько раз до получения всех запрошенных данных. Несмотря на то, что вызов запросов к 150 байтам, каждый вызов ReadRead смог успешно распаковать некоторые байты (т. е. все байты, полученные в то время), чтобы вернуться, и он сделал:

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

Старое поведение

Когда Stream.Read или Stream.ReadAsync был вызван один из затронутых типов потоков с буфером длины N, операция не завершится до тех пор, пока:

  • из потока не будет считано N байт;
  • Базовый поток вернул 0 из вызова его чтения, что указывает, что больше данных не было доступно.

Кроме того, при вызове Stream.Read или Stream.ReadAsync с буфером нулевой длины операция будет выполнена немедленно, иногда без выполнения операции чтения нулевой длины в потоке, для которого они служат оболочкой.

Новое поведение

Начиная с .NET 6 при вызове Stream.Read или Stream.ReadAsync для одного из затрагиваемых типов потока с буфером длиной N операция будет завершена, если:

  • По крайней мере 1 байт был считывается из потока или
  • Базовый поток возвращает значение 0 из вызова его чтения, указывая, что больше данных не доступно.

Кроме того, когда Stream.Read или Stream.ReadAsync вызывается с буфером длины 0, операция завершается после успешного вызова с ненулевым буфером.

При вызове одного из затронутых Read методов, если чтение может удовлетворить по крайней мере один байт запроса, независимо от того, сколько было запрошено, он возвращает столько, сколько он может в этот момент.

Представленные версии

6,0

Причина изменения

Потоки могли не возвращаться из операции чтения, даже если данные были успешно считаны. Это означало, что их нельзя было использовать в ситуациях с двунаправленной связью, когда использовались сообщения меньше размера буфера. Это могло привести к взаимоблокировкам: приложению не удавалось считать данные из потока, необходимого для продолжения операции. Это также может привести к произвольному замедлению, при этом потребитель не может обрабатывать доступные данные во время ожидания получения дополнительных данных.

Кроме того, в приложениях с высокой степенью масштабируемости нулевые операции чтения обычно используются для задержки выделения буфера, пока он не потребуется. Приложение может выдать операцию чтения с пустым буфером, после завершения которой данные вскоре станут доступны для использования. Затем приложение может повторно выполнить чтение, но на этот раз с буфером для получения данных. При делегировании упакованному потоку в случае отсутствия распакованных или преобразованных данных эти потоки наследуют любое такое поведение потоков, для которых они служат оболочкой.

Как правило, код должен соответствовать следующим требованиям.

  • В нем не должно быть никаких предположений о том, что операция Read или ReadAsync потока считывает столько данных, сколько запрошено. Вызов возвращает число операций чтения байтов, которое может быть меньше запрошенного значения. Если приложение зависит от буфера, который полностью заполнен до выполнения, оно может выполнить чтение в цикле, чтобы восстановить поведение.

    int totalRead = 0;
    while (totalRead < buffer.Length)
    {
        int bytesRead = stream.Read(buffer.AsSpan(totalRead));
        if (bytesRead == 0) break;
        totalRead += bytesRead;
    }
    
  • Ожидается, что поток Read или ReadAsync вызов может не завершиться до тех пор, пока не будет доступен по крайней мере байт данных для потребления (или поток достигает конца), независимо от того, сколько байтов было запрошено. Если работа приложения зависит от немедленного завершения нулевой операции чтения без ожидания, оно может проверить длину буфера и полностью пропустить вызов:

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

Затронутые API