System.IO.Pipelines: .NET高效能IO

瀟湘風夜發表於2018-07-10

System.IO.Pipelines是一個新的庫,旨在簡化在.NET中執行高效能IO的過程。它是一個依賴.NET Standard的庫,適用於所有.NET實現

Pipelines誕生於.NET Core團隊,為使Kestrel成為業界最快的Web伺服器之一。最初從作為Kestrel內部的實現細節發展成為可重用的API,它在.Net Core 2.1中作為可用於所有.NET開發人員的最高階BCL API(System.IO.Pipelines)提供。

它解決了什麼問題?

為了正確解析Stream或Socket中的資料,程式碼有固定的樣板,並且有許多極端情況,為了處理他們,不得不編寫難以維護的複雜程式碼。
實現高效能和正確性,同時也難以處理這種複雜性。Pipelines旨在解決這種複雜性。

有多複雜?

讓我們從一個簡單的問題開始吧。我們想編寫一個TCP伺服器,它接收來自客戶端的用行分隔的訊息(由
分隔)。(譯者注:即一行為一條訊息)

使用NetworkStream的TCP伺服器

宣告:與所有對效能敏感的工作一樣,應在應用程式中測量每個方案的實際情況。根據您的網路應用程式需要處理的規模,可能不需要在乎的各種技術的開銷。

在Pipelines之前用.NET編寫的典型程式碼如下所示:

async Task ProcessLinesAsync(NetworkStream stream)
{
    var buffer = new byte[1024];
    await stream.ReadAsync(buffer, 0, buffer.Length);
    
    // 在buffer中處理一行訊息
    ProcessLine(buffer);
}

此程式碼可能在本地測試時正確工作,但它有幾個潛在錯誤:

  • 一次ReadAsync呼叫可能沒有收到整個訊息(行尾)。
  • 它忽略了stream.ReadAsync()返回值中實際填充到buffer中的資料量。(譯者注:即不一定將buffer填充滿)
  • 一次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;
        }
        // 跟蹤已緩衝的位元組數
        bytesBuffered += bytesRead;
        
        var linePosition = -1;

        do
        {
            // 在緩衝資料中查詢找一個行末尾
            linePosition = Array.IndexOf(buffer, (byte)`
`, bytesConsumed, bytesBuffered - bytesConsumed);

            if (linePosition >= 0)
            {
                // 根據偏移量計算一行的長度
                var lineLength = linePosition - bytesConsumed;

                // 處理這一行
                ProcessLine(buffer, bytesConsumed, lineLength);

                // 移動bytesConsumed為了跳過我們已經處理掉的行 (包括
)
                bytesConsumed += lineLength + 1;
            }
        }
        while (linePosition >= 0);
    }
}

這一次,這可能適用於本地開發,但一行可能大於1KiB(1024位元組)。我們需要調整輸入緩衝區的大小,直到找到新行。

因此,我們可以在堆上分配緩衝區去處理更長的一行。我們從客戶端解析較長的一行時,可以通過使用ArrayPool<byte>避免重複分配緩衝區來改進這一點。

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

    while (true)
    {
        // 在buffer中計算中剩餘的位元組數
        var bytesRemaining = buffer.Length - bytesBuffered;

        if (bytesRemaining == 0)
        {
            // 將buffer size翻倍 並且將之前緩衝的資料複製到新的緩衝區
            var newBuffer = ArrayPool<byte>.Shared.Rent(buffer.Length * 2);
            Buffer.BlockCopy(buffer, 0, newBuffer, 0, buffer.Length);
            // 將舊的buffer丟回池中
            ArrayPool<byte>.Shared.Return(buffer);
            buffer = newBuffer;
            bytesRemaining = buffer.Length - bytesBuffered;
        }

        var bytesRead = await stream.ReadAsync(buffer, bytesBuffered, bytesRemaining);
        if (bytesRead == 0)
        {
            // EOF 末尾
            break;
        }
        
        // 跟蹤已緩衝的位元組數
        bytesBuffered += bytesRead;
        
        do
        {
            // 在緩衝資料中查詢找一個行末尾
            linePosition = Array.IndexOf(buffer, (byte)`
`, bytesConsumed, bytesBuffered - bytesConsumed);

            if (linePosition >= 0)
            {
                // 根據偏移量計算一行的長度
                var lineLength = linePosition - bytesConsumed;

                // 處理這一行
                ProcessLine(buffer, bytesConsumed, lineLength);

                // 移動bytesConsumed為了跳過我們已經處理掉的行 (包括
)
                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)`
`, 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);
}

此程式碼只是得到很多更加複雜。當我們正在尋找分隔符時,我們同時跟蹤已填充的緩衝區序列。為此,我們此處使用List<BufferSegment>查詢新行分隔符時表示緩衝資料。其結果是,ProcessLineIndexOf現在接受List<BufferSegment>作為引數,而不是一個byte[],offset和count。我們的解析邏輯現在需要處理一個或多個緩衝區序列。

我們的伺服器現在處理部分訊息,它使用池化記憶體來減少總體記憶體消耗,但我們還需要進行更多更改:

  1. 我們使用的byte[]ArrayPool<byte>的只是普通的託管陣列。這意味著無論何時我們執行ReadAsyncWriteAsync,這些緩衝區都會在非同步操作的生命週期內被固定(以便與作業系統上的本機IO API互操作)。這對GC有效能影響,因為無法移動固定記憶體,這可能導致堆碎片。根據非同步操作掛起的時間長短,池的實現可能需要更改。
  2. 可以通過解耦讀取邏輯處理邏輯來優化吞吐量。這會建立一個批處理效果,使解析邏輯可以使用更大的緩衝區塊,而不是僅在解析單個行後才讀取更多資料。這引入了一些額外的複雜性
    • 我們需要兩個彼此獨立執行的迴圈。一個讀取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)
    {
        // 從PipeWriter至少分配512位元組
        Memory<byte> memory = writer.GetMemory(minimumBufferSize);
        try 
        {
            int bytesRead = await socket.ReceiveAsync(memory, SocketFlags.None);
            if (bytesRead == 0)
            {
                break;
            }
            // 告訴PipeWriter從套接字讀取了多少
            writer.Advance(bytesRead);
        }
        catch (Exception ex)
        {
            LogError(ex);
            break;
        }

        // 標記資料可用,讓PipeReader讀取
        FlushResult result = await writer.FlushAsync();

        if (result.IsCompleted)
        {
            break;
        }
    }

    // 告訴PipeReader沒有更多的資料
    writer.Complete();
}

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

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

        do 
        {
            // 在緩衝資料中查詢找一個行末尾
            position = buffer.PositionOf((byte)`
`);

            if (position != null)
            {
                // 處理這一行
                ProcessLine(buffer.Slice(0, position.Value));
                
                // 跳過 這一行+
 (basically position 主要位置?)
                buffer = buffer.Slice(buffer.GetPosition(1, position.Value));
            }
        }
        while (position != null);

        // 告訴PipeReader我們以及處理多少緩衝
        reader.AdvanceTo(buffer.Start, buffer.End);

        // 如果沒有更多的資料,停止都去
        if (result.IsCompleted)
        {
            break;
        }
    }

    // 將PipeReader標記為完成
    reader.Complete();
}

我們的行讀取器的pipelines版本有2個迴圈:

  • FillPipeAsync從Socket讀取並寫入PipeWriter。
  • ReadPipeAsync從PipeReader中讀取並解析傳入的行。

與原始示例不同,在任何地方都沒有分配顯式緩衝區。這是管道的核心功能之一。所有緩衝區管理都委託給PipeReader/PipeWriter實現。

這使得使用程式碼更容易專注於業務邏輯而不是複雜的緩衝區管理。

在第一個迴圈中,我們首先呼叫PipeWriter.GetMemory(int)從底層編寫器獲取一些記憶體; 然後我們呼叫PipeWriter.Advance(int)告訴PipeWriter我們實際寫入緩衝區的資料量。然後我們呼叫PipeWriter.FlushAsync()來提供資料給PipeReader。

在第二個迴圈中,我們正在使用PipeWriter最終來自的緩衝區Socket。當呼叫PipeReader.ReadAsync()返回時,我們得到一個ReadResult包含2條重要資訊,包括以ReadOnlySequence<byte>形式讀取的資料和bool IsCompleted,讓reader知道writer是否寫完(EOF)。在找到行尾(EOL)分隔符並解析該行之後,我們將緩衝區切片以跳過我們已經處理過的內容,然後我們呼叫PipeReader.AdvanceTo告訴PipeReader我們消耗了多少資料。

在每個迴圈結束時,我們完成了reader和writer。這允許底層Pipe釋放它分配的所有記憶體。

System.IO.Pipelines

除了處理記憶體管理之外,其他核心管道功能還包括能夠在Pipe不實際消耗資料的情況下檢視資料。

PipeReader有兩個核心API ReadAsyncAdvanceToReadAsync獲取Pipe資料,AdvanceTo告訴PipeReader不再需要這些緩衝區,以便可以丟棄它們(例如返回到底層緩衝池)。


這是一個http解析器的示例,它在接收Pipe到有效起始行之前讀取部分資料緩衝區資料。

此處輸入圖片的描述

ReadOnlySequence<T>

該Pipe實現儲存了在PipeWriter和PipeReader之間傳遞的緩衝區的連結列表。PipeReader.ReadAsync暴露一個ReadOnlySequence<T>新的BCL型別,它表示一個或多個ReadOnlyMemory<T>段的檢視,類似於Span<T>和Memory<T>提供陣列和字串的檢視。

此處輸入圖片的描述

該Pipe內部維護指向reader和writer可以分配或更新它們的資料集合,。SequencePosition表示緩衝區連結串列中的單個點,可用於有效地對ReadOnlySequence<T>進行切片。

這段實在翻譯困難,給出原文
The Pipe internally maintains pointers to where the reader and writer are in the overall set of allocated data and updates them as data is written or read. The SequencePosition represents a single point in the linked list of buffers and can be used to efficiently slice the ReadOnlySequence

由於ReadOnlySequence<T>可以支援一個或多個段,因此高效能處理邏輯通常基於單個或多個段來分割快速和慢速路徑(fast and slow paths?)。

例如,這是一個將ASCII ReadOnlySequence<byte>轉換為string以下內容的例程:

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.FlushAsync之前進行緩衝停頓。ResumeWriterThreshold控制reader消耗多少後寫入可以恢復。

此處輸入圖片的描述

當Pipe的資料量超過PauseWriterThreshold,PipeWriter.FlushAsync會非同步阻塞。資料量變得低於ResumeWriterThreshold,它會解鎖時。兩個值用於防止在極限附近發生反覆阻塞和解鎖。

IO排程

通常在使用async / await時,會線上程池執行緒或當前執行緒上呼叫continuation SynchronizationContext。

在執行IO時,對執行IO的位置進行細粒度控制非常重要,這樣可以更有效地利用CPU快取,這對於Web伺服器等高效能應用程式至關重要。Pipelines公開了一個PipeScheduler確定非同步回撥執行位置的方法。這使得呼叫者可以精確控制用於IO的執行緒。

實踐中的一個示例是在Kestrel Libuv傳輸中,其中IO回撥在專用事件迴圈執行緒上執行。

PipeReader模式的其他好處:

  • 一些底層系統支援“無緩衝等待”,即,在底層系統中實際可用資料之前,永遠不需要分配緩衝區。例如,在帶有epoll的Linux上,可以等到資料準備好之後再實際提供緩衝區來進行讀取。這避免了具有大量執行緒等待資料的問題不會立即需要保留大量記憶體。
  • 預設情況下Pipe,可以輕鬆地針對網路程式碼編寫單元測試,因為解析邏輯與網路程式碼分離,因此單元測試僅針對記憶體緩衝區執行解析邏輯,而不是直接從網路中消耗。它還可以輕鬆測試那些難以測試傳送部分資料的模式。ASP.NET Core使用它來測試Kestrel的http解析器的各個方面。
  • 允許將底層OS緩衝區(如Windows上的Registered IO API)暴露給使用者程式碼的系統非常適合管道,因為緩衝區始終由PipeReader實現提供。

其他相關型別

作為製作System.IO.Pipelines的一部分,我們還新增了許多新的原始BCL型別:

  • MemoryPool<T>IMemoryOwner<T>MemoryManager<T> – .NET Core 1.0新增了ArrayPool<T>,在.NET Core 2.1中,我們現在有一個更通用的抽象,適用於任何工作的池Memory<T>。這提供了一個可擴充套件點,允許您插入更高階的分配策略以及控制緩衝區的管理方式(例如,提供預先固定的緩衝區而不是純託管的陣列)。
  • IBufferWriter<T> – 表示用於寫入同步緩衝資料的接收器。(PipeWriter實現這個)
  • IValueTaskSource – ValueTask<T>自.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的連線,併傳送基於行的訊息以使其正常工作。

今天Pipelines為Kestrel和SignalR提供支援,我們希望看見它作為.NET社群中許多網路庫和元件的核心。

資料:

  1. 轉載自System.IO.Pipelines: High performance IO in .NET
  2. Pipelines – a guided tour of the new IO API in .NET, part 1
  3. Pipelines – a guided tour of the new IO API in .NET, part 2
  4. 2號資料的中文翻譯 Pipelines – .NET中的新IO API指引(一)
  5. System.IO.Pipelines-Nuget包

PS: 首次翻譯英文文章,不足錯漏請指出,多謝支援

相關文章