DeflateStream、GZipStream 和 CryptoStream 中的部分和零字节读取

DeflateStreamGZipStreamCryptoStream 上的 Read()ReadAsync() 方法可能不再根据请求返回任意数量的字节数。

以前,派生自典型 Stream.ReadStream.ReadAsyncDeflateStreamGZipStreamCryptoStream 有以下两种行为方式,这两种方式都是此更改所解决的:

  • 只有传递给读取操作的缓冲区被完全填充或到达流的末尾,它们才能完成读取操作。
  • 作为包装流,它们没有将零长度缓冲区功能委托给它们包装的流。

请考虑以下示例,它创建并压缩 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 被多次调用,直至收到所请求的所有数据为止。 虽然对 Read 的调用请求 150 个字节,但对 Read 的每个调用都能够成功解压缩一些字节(即,在那时已经收到的所有字节)来返回,并且它就是这样做的:

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

旧行为

当对缓冲区长度为 N 的受影响流类型之一调用 Stream.ReadStream.ReadAsync 时,操作将不会完成,直到:

  • 从流中读取了 N 字节,或
  • 基础流从对读取的调用中返回 0,表示没有更多可用数据。

另外,当使用长度为 0 的缓冲区调用 Stream.ReadStream.ReadAsync 时,操作将立即成功,有时无需对其包装的流执行零长度读取。

新行为

从 .NET 6 开始,当对缓冲区长度为 N 的受影响流类型之一调用 Stream.ReadStream.ReadAsync 时,操作将在以下情况下完成:

  • 至少从流中读取了一个字节,或者
  • 基础流从对读取的调用中返回 0,表示没有更多可用数据。

另外,当使用长度为 0 的缓冲区调用 Stream.ReadStream.ReadAsync 时,一旦使用非零缓冲区的调用成功,操作就会成功。

调用受影响的 Read 方法之一时,如果读取操作可以满足请求的至少一个字节,则无论请求了多少字节,它都会返回在那一时刻它可以返回的任意多的次数。

引入的版本

6.0

更改原因

即使已成功读取数据,流也可能未从读取操作返回。 这意味着,它们不能在任何双向通信情况下使用(在这种情况下,使用的消息小于缓冲区大小)。 这可能会导致死锁:应用程序无法从流中读取继续操作所需的数据。 它还可能任意减慢速度,因为使用者在等待更多数据到达时无法处理可用数据。

此外,在高度可缩放的应用程序中,通常使用零字节读取来延迟缓冲区分配,直到需要缓冲区为止。 应用程序可以使用空缓冲区发出读取,该读取完成后,数据应很快就可以使用。 然后,应用程序可以再次发出读取,这次使用一个缓冲区来接收数据。 如果尚未提供解压缩或转换的数据,则委托给已包装的流,这些流现在将继承其包装的流的任何此类行为。

通常,代码应:

  • 不要对流 ReadReadAsync 请求的操作读取数做任何假设。 调用返回读取的字节数,它可能小于请求的字节数。 如果应用程序在继续前依赖于缓冲区被完全填满,那么它可以在循环中执行读取以重新获取行为。

    int totalRead = 0;
    while (totalRead < buffer.Length)
    {
        int bytesRead = stream.Read(buffer.AsSpan(totalRead));
        if (bytesRead == 0) break;
        totalRead += bytesRead;
    }
    
  • 无论请求多少个字节,始终预计流 ReadReadAsync 调用可能没有完成,直到至少有一个字节的数据可供使用(或流到达其末尾)。 如果应用程序依赖于零字节读取立即完成而无需等待,则它可以检查缓冲区长度本身并完全跳过调用:

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

受影响的 API