C# 的 System.IO.Pipelines:.NET 中的高性能 IO

System.IO.Pipelines是一个新的库,旨在使在 .NET 中执行高性能 IO 变得更加容易。它是一个面向 .NET Standard 的库,适用于所有 .NET 实现。

Pipelines 诞生于 .NET Core 团队为使 Kestrel 成为业内最快的 Web 服务器之一所做的工作。最初作为 Kestrel 内部的一个实现细节发展成为一个可重用的 API,在 2.1 中作为第一类 BCL API (System.IO.Pipelines) 提供给所有 .NET 开发人员。

它解决什么问题?
正确解析来自流或套接字的数据由样板代码主导,并且有许多极端情况,导致难以维护的复杂代码。实现高性能和正确性,同时处理这种复杂性是很困难的。Pipelines 旨在解决这种复杂性。

今天存在哪些额外的复杂性?
让我们从一个简单的问题开始。我们想编写一个 TCP 服务器,它从客户端接收以行分隔的消息(由 n 分隔)。

带有 NetworkStream 的 TCP 服务器
免责声明:与所有性能敏感的工作一样,每个场景都应在您的应用程序上下文中进行测量。根据您的网络应用程序需要处理的规模,上述各种技术的开销可能不是必需的。

在管道之前用 .NET 编写的典型代码如下所示

async Task ProcessLinesAsync(NetworkStream stream)
{
    var buffer = new byte[1024];
    await stream.ReadAsync(buffer, 0, buffer.Length);
    
    // Process a single line from the buffer
    ProcessLine(buffer);
}

此代码在本地测试时可能有效,但有几个错误:

在对 的单个调用中可能未收到整个消息(行尾)ReadAsync。
它忽略了返回实际填充到缓冲区中的数据量的结果。stream.ReadAsync()
它不处理在一次ReadAsync调用中返回多条线路的情况。
这些是读取流数据时的一些常见陷阱。为了解决这个问题,我们需要进行一些更改:

我们需要缓冲传入的数据,直到找到新的一行。
我们需要解析缓冲区中返回的所有行

async Task ProcessLinesAsync(NetworkStream stream)
{
    var buffer = new byte[1024];
    var bytesBuffered = 0;
    var bytesConsumed = 0;

    while (true)
    {
        var bytesRead = await stream.ReadAsync(buffer, bytesBuffered, buffer.Length - bytesBuffered);
        if (bytesRead == 0)
        {
            // EOF
            break;
        }
        // Keep track of the amount of buffered bytes
        bytesBuffered += bytesRead;
        
        var linePosition = -1;

        do
        {
            // Look for a EOL in the buffered data
            linePosition = Array.IndexOf(buffer, (byte)'\n', bytesConsumed, bytesBuffered - bytesConsumed);

            if (linePosition >= 0)
            {
                // Calculate the length of the line based on the offset
                var lineLength = linePosition - bytesConsumed;

                // Process the line
                ProcessLine(buffer, bytesConsumed, lineLength);

                // Move the bytesConsumed to skip past the line we consumed (including \n)
                bytesConsumed += lineLength + 1;
            }
        }
        while (linePosition >= 0);
    }
}```

再一次,这可能适用于本地测试,但该行可能大于 1KiB(1024 字节)。我们需要调整输入缓冲区的大小,直到找到新行。

此外,我们在处理更长的行时在堆上分配缓冲区。当我们从客户端解析更长的行时,我们可以通过使用避免重复的缓冲区分配来改进这一点。ArrayPool<byte>

```csharp
async Task ProcessLinesAsync(NetworkStream stream)
{
    byte[] buffer = ArrayPool<byte>.Shared.Rent(1024);
    var bytesBuffered = 0;
    var bytesConsumed = 0;

    while (true)
    {
        // Calculate the amount of bytes remaining in the buffer
        var bytesRemaining = buffer.Length - bytesBuffered;

        if (bytesRemaining == 0)
        {
            // Double the buffer size and copy the previously buffered data into the new buffer
            var newBuffer = ArrayPool<byte>.Shared.Rent(buffer.Length * 2);
            Buffer.BlockCopy(buffer, 0, newBuffer, 0, buffer.Length);
            // Return the old buffer to the pool
            ArrayPool<byte>.Shared.Return(buffer);
            buffer = newBuffer;
            bytesRemaining = buffer.Length - bytesBuffered;
        }

        var bytesRead = await stream.ReadAsync(buffer, bytesBuffered, bytesRemaining);
        if (bytesRead == 0)
        {
            // EOF
            break;
        }
        
        // Keep track of the amount of buffered bytes
        bytesBuffered += bytesRead;
        
        do
        {
            // Look for a EOL in the buffered data
            linePosition = Array.IndexOf(buffer, (byte)'\n', bytesConsumed, bytesBuffered - bytesConsumed);

            if (linePosition >= 0)
            {
                // Calculate the length of the line based on the offset
                var lineLength = linePosition - bytesConsumed;

                // Process the line
                ProcessLine(buffer, bytesConsumed, lineLength);

                // Move the bytesConsumed to skip past the line we consumed (including \n)
                bytesConsumed += lineLength + 1;
            }
        }
        while (linePosition >= 0);
    }
}

这段代码有效,但现在我们正在重新调整缓冲区的大小,这会导致更多的缓冲区副本。它还使用更多内存,因为在处理行后逻辑不会缩小缓冲区。为了避免这种情况,我们可以存储一个缓冲区列表,而不是每次超过 1KiB 缓冲区大小时都调整大小。

此外,我们不会增加 1KiB 缓冲区,直到它完全清空。这意味着我们最终可以传递ReadAsync越来越小的缓冲区,这将导致更多的操作系统调用。

为了缓解这种情况,我们将在现有缓冲区中剩余少于 512 个字节时分配一个新缓冲区:

public class BufferSegment
{
    public byte[] Buffer { get; set; }
    public int Count { get; set; }

    public int Remaining => Buffer.Length - Count;
}

async Task ProcessLinesAsync(NetworkStream stream)
{
    const int minimumBufferSize = 512;

    var segments = new List<BufferSegment>();
    var bytesConsumed = 0;
    var bytesConsumedBufferIndex = 0;
    var segment = new BufferSegment { Buffer = ArrayPool<byte>.Shared.Rent(1024) };

    segments.Add(segment);

    while (true)
    {
        // Calculate the amount of bytes remaining in the buffer
        if (segment.Remaining < minimumBufferSize)
        {
            // Allocate a new segment
            segment = new BufferSegment { Buffer = ArrayPool<byte>.Shared.Rent(1024) };
            segments.Add(segment);
        }

        var bytesRead = await stream.ReadAsync(segment.Buffer, segment.Count, segment.Remaining);
        if (bytesRead == 0)
        {
            break;
        }

        // Keep track of the amount of buffered bytes
        segment.Count += bytesRead;

        while (true)
        {
            // Look for a EOL in the list of segments
            var (segmentIndex, segmentOffset) = IndexOf(segments, (byte)'\n', bytesConsumedBufferIndex, bytesConsumed);

            if (segmentIndex >= 0)
            {
                // Process the line
                ProcessLine(segments, segmentIndex, segmentOffset);

                bytesConsumedBufferIndex = segmentOffset;
                bytesConsumed = segmentOffset + 1;
            }
            else
            {
                break;
            }
        }

        // Drop fully consumed segments from the list so we don't look at them again
        for (var i = bytesConsumedBufferIndex; i >= 0; --i)
        {
            var consumedSegment = segments[i];
            // Return all segments unless this is the current segment
            if (consumedSegment != segment)
            {
                ArrayPool<byte>.Shared.Return(consumedSegment.Buffer);
                segments.RemoveAt(i);
            }
        }
    }
}

(int segmentIndex, int segmentOffest) IndexOf(List<BufferSegment> segments, byte value, int startBufferIndex, int startSegmentOffset)
{
    var first = true;
    for (var i = startBufferIndex; i < segments.Count; ++i)
    {
        var segment = segments[i];
        // Start from the correct offset
        var offset = first ? startSegmentOffset : 0;
        var index = Array.IndexOf(segment.Buffer, value, offset, segment.Count - offset);

        if (index >= 0)
        {
            // Return the buffer index and the index within that segment where EOL was found
            return (i, index);
        }

        first = false;
    }
    return (-1, -1);
}

这段代码变得更加复杂。在寻找分隔符时,我们会跟踪已填满的缓冲区。为此,我们在查找新行分隔符时使用here 来表示缓冲数据。其结果是,和现在接受的,而不是一个,和。我们的解析逻辑现在需要处理一个或多个缓冲区段。ListProcessLineIndexOfListbyte[]offsetcount

我们的服务器现在处理部分消息,它使用池化内存来减少整体内存消耗,但我们还需要做一些更改:

在我们从使用的只是普通的管理阵列。这意味着无论何时我们执行 a or ,这些缓冲区都会在异步操作的生命周期内被固定(以便与操作系统上的本地 IO API 互操作)。这对垃圾收集器的性能有影响,因为无法移动固定内存,这会导致堆碎片。根据异步操作挂起的时间长短,可能需要更改池实现。byte[]ArrayPoolReadAsyncWriteAsync
可以通过分离读取和处理逻辑来优化吞吐量。这会产生批处理效果,让解析逻辑消耗更大的缓冲区块,而不是仅在解析一行之后才读取更多数据。这引入了一些额外的复杂性:
我们需要两个相互独立运行的循环。一个从 读取Socket,一个解析缓冲区。
我们需要一种在数据可用时向解析逻辑发出信号的方法。
我们需要决定如果循环读取Socket“太快”会发生什么。如果解析逻辑跟不上,我们需要一种方法来限制读取循环。这通常称为“流量控制”或“背压”。
我们需要确保事情是线程安全的。我们现在在读取循环和解析循环之间共享一组缓冲区,这些缓冲区在不同的线程上独立运行。
内存管理逻辑现在分布在两段不同的代码中,从缓冲池租用的代码是从套接字读取,而从缓冲池返回的代码是解析逻辑。
在解析逻辑完成后,我们需要非常小心我们如何返回缓冲区。如果我们不小心,可能会返回一个仍在被Socket读取逻辑写入的缓冲区。
复杂性已经达到顶峰(我们甚至还没有涵盖所有情况)。高性能网络通常意味着编写非常复杂的代码,以便从系统中获得更高的性能。

的目标是使编写此类代码更容易。System.IO.Pipelines

带有 System.IO.Pipelines 的 TCP 服务器
让我们来看看这个例子是什么样的:System.IO.Pipelines

async Task ProcessLinesAsync(Socket socket)
{
    var pipe = new Pipe();
    Task writing = FillPipeAsync(socket, pipe.Writer);
    Task reading = ReadPipeAsync(pipe.Reader);

    return Task.WhenAll(reading, writing);
}

async Task FillPipeAsync(Socket socket, PipeWriter writer)
{
    const int minimumBufferSize = 512;

    while (true)
    {
        // Allocate at least 512 bytes from the PipeWriter
        Memory<byte> memory = writer.GetMemory(minimumBufferSize);
        try 
        {
            int bytesRead = await socket.ReceiveAsync(memory, SocketFlags.None);
            if (bytesRead == 0)
            {
                break;
            }
            // Tell the PipeWriter how much was read from the Socket
            writer.Advance(bytesRead);
        }
        catch (Exception ex)
        {
            LogError(ex);
            break;
        }

        // Make the data available to the PipeReader
        FlushResult result = await writer.FlushAsync();

        if (result.IsCompleted)
        {
            break;
        }
    }

    // Tell the PipeReader that there's no more data coming
    writer.Complete();
}

async Task ReadPipeAsync(PipeReader reader)
{
    while (true)
    {
        ReadResult result = await reader.ReadAsync();

        ReadOnlySequence<byte> buffer = result.Buffer;
        SequencePosition? position = null;

        do 
        {
            // Look for a EOL in the buffer
            position = buffer.PositionOf((byte)'\n');

            if (position != null)
            {
                // Process the line
                ProcessLine(buffer.Slice(0, position.Value));
                
                // Skip the line + the \n character (basically position)
                buffer = buffer.Slice(buffer.GetPosition(1, position.Value));
            }
        }
        while (position != null);

        // Tell the PipeReader how much of the buffer we have consumed
        reader.AdvanceTo(buffer.Start, buffer.End);

        // Stop reading if there's no more data coming
        if (result.IsCompleted)
        {
            break;
        }
    }

    // Mark the PipeReader as complete
    reader.Complete();
}

我们的行阅读器的管道版本有 2 个循环:

FillPipeAsync从 中读取Socket并写入PipeWriter.
ReadPipeAsync读取PipeReader并解析传入的行。
与原始示例不同,没有在任何地方分配显式缓冲区。这是管道的核心功能之一。所有缓冲区管理都委托给PipeReader/PipeWriter实现。

这使得使用代码更容易专注于业务逻辑而不是复杂的缓冲区管理。

在第一个循环中,我们首先调用从底层编写器获取一些内存;然后我们打电话告诉我们实际写入缓冲区的数据量。然后我们调用使数据可用于.PipeWriter.GetMemory(int)PipeWriter.Advance(int)PipeWriterPipeWriter.FlushAsync()PipeReader

在第二个循环中,我们消耗了PipeWriter最终来自Socket. 当调用返回时,我们得到一个包含 2 条重要信息的信息,以 的形式读取的数据和一个让读者知道作者是否完成写入(EOF)的布尔值。在找到行尾 (EOL) 分隔符并解析该行后,我们将缓冲区切片以跳过我们已经处理的内容,然后我们调用以告诉我们已经消耗了多少数据。PipeReader.ReadAsync()ReadResultReadOnlySequenceIsCompletedPipeReader.AdvanceToPipeReader

在每个循环结束时,我们完成读者和作者。这让底层Pipe释放它分配的所有内存。

系统.IO.管道
部分读取
除了处理内存管理之外,另一个核心管道功能是能够在Pipe不实际消耗数据的情况下查看数据。

PipeReader有两个核心 APIReadAsync和AdvanceTo. ReadAsync获取 中的数据Pipe,AdvanceTo告诉PipeReader读取器不再需要这些缓冲区,因此可以将它们丢弃(例如返回到底层缓冲池)。

下面是一个 http 解析器的例子,它读取部分数据缓冲数据,Pipe直到接收到有效的起始行。

在这里插入图片描述

只读序列
该Pipe实现存储在PipeWriter和之间传递的缓冲区的链接列表PipeReader。公开 a ,它是一种新的 BCL 类型,表示对 的一个或多个段的视图,类似于并提供对数组和字符串的视图。PipeReader.ReadAsyncReadOnlySequenceReadOnlyMemorySpanMemory

在这里插入图片描述

该Pipe内部维护指向的地方,读者和作家都在整个组分配的数据和数据写入或读取更新它们。在SequencePosition表示在缓冲区链表的单个点,并且可以用于有效地切片。ReadOnlySequence

由于可以支持一个或多个段,因此高性能处理逻辑通常会根据单个或多个段拆分快速和慢速路径。ReadOnlySequence

例如,下面是一个ASCII转换例程为:ReadOnlySequencestring

string GetAsciiString(ReadOnlySequence<byte> buffer)
{
    if (buffer.IsSingleSegment)
    {
        return Encoding.ASCII.GetString(buffer.First.Span);
    }

    return string.Create((int)buffer.Length, buffer, (span, sequence) =>
    {
        foreach (var segment in sequence)
        {
            Encoding.ASCII.GetChars(segment.Span, span);

            span = span.Slice(segment.Length);
        }
    });
}

背压和流量控制
在一个完美的世界中,读取和解析是一个团队工作:读取线程消耗来自网络的数据并将其放入缓冲区,而解析线程负责构建适当的数据结构。通常,解析将花费更多时间,而不仅仅是从网络复制数据块。因此,读取线程很容易压倒解析线程。结果是读取线程将不得不减慢速度或分配更多内存来存储解析线程的数据。为了获得最佳性能,需要在频繁暂停和分配更多内存之间取得平衡。

为了解决这个问题,管道有两个设置来控制数据流,PauseWriterThreshold和ResumeWriterThreshold。该PauseWriterThreshold决定有多少数据应该调用之前进行缓冲停顿。该读者拥有多少控制消耗写之前可以恢复。PipeWriter.FlushAsyncResumeWriterThreshold、
在这里插入图片描述
PipeWriter.FlushAsync当数据量Pipe交叉时“阻塞”,当数据量PauseWriterThreshold低于 时“解除阻塞” ResumeWriterThreshold。两个值用于防止在限制附近颠簸。

调度IO
通常在使用 async/await 时,在线程池线程或当前SynchronizationContext.

在执行 IO 时,对执行 IO 的位置​​进行细粒度控制非常重要,以便可以更有效地利用 CPU 缓存,这对于 Web 服务器等高性能应用程序至关重要。管道公开了一个PipeScheduler决定异步回调运行的位置。这使调用者可以精确控制用于 IO 的线程。

实践中的一个示例是在 Kestrel Libuv 传输中,其中 IO 回调在专用事件循环线程上运行。

PipeReader 模式的其他好处:
一些底层系统支持“无缓冲等待”,即在底层系统中有实际可用数据之前,永远不需要分配缓冲区。例如,在带有 epoll 的 Linux 上,可以等到数据准备好后再实际提供缓冲区来进行读取。这避免了等待数据的大量线程并不立即需要保留大量内存的问题。
默认设置Pipe使得针对网络代码编写单元测试变得容易,因为解析逻辑与网络代码分离,因此单元测试仅针对内存缓冲区运行解析逻辑,而不是直接从网络消耗。它还可以轻松测试发送部分数据的那些难以测试的模式。ASP.NET Core 使用它来测试 Kestrel 的 http 解析器的各个方面。
允许将底层操作系统缓冲区(如 Windows 上的注册 IO API)暴露给用户代码的系统非常适合管道,因为缓冲区始终由PipeReader实现提供。
其他相关类型
作为制作 System.IO.Pipelines 的一部分,我们还添加了许多新的原始 BCL 类型:

MemoryPool、IMemoryOwner、MemoryManager – .NET Core 1.0 添加了ArrayPool并且在 .NET Core 2.1 中,我们现在有一个更通用的抽象池,可以在任何. 这提供了一个可扩展点,允许您插入更高级的分配策略以及控制缓冲区的管理方式(例如,提供预先固定的缓冲区而不是纯粹管理的数组)。Memory
IBufferWriter – 表示用于写入同步缓冲数据的接收器。(PipeWriter实现这个)
IValueTaskSource – ValueTask从 .NET Core 1.1 开始就已经存在,但在 .NET Core 2.1 中获得了一些超能力,允许无分配等待异步操作。有关更多详细信息,请参阅https://github.com/dotnet/corefx/issues/27445。
我如何使用管道?
API 存在于System.IO.Pipelines nuget 包中。

这是一个 .NET Core 2.1 服务器应用程序的示例,它使用管道来处理基于行的消息(我们上面的示例)https://github.com/davidfowl/TcpEcho。它应该运行dotnet run(或通过在 Visual Studio 中运行它)。它侦听端口 8087 上的套接字并将接收到的消息写出到控制台。您可以使用像 netcat 或 putty 这样的客户端来连接到 8087 并发送基于行的消息以查看它是否正常工作。

今天,管道为 Kestrel 和 SignalR 提供支持,我们希望看到它成为 .NET 社区的许多网络库和组件的中心。

参与评论 您还未登录,请先 登录 后发表或查看评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:大白 设计师:CSDN官方博客 返回首页

打赏作者

望天hous

你的鼓励是我最大动力~谢谢啦!

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值