天天看點

高性能伺服器io函數,System.IO.Pipelines: .NET高性能IO

本文翻譯自dotnet團隊部落格文章:https://blogs.msdn.microsoft.com/dotnet/2018/07/09/system-io-pipelines-high-performance-io-in-net/

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伺服器,它接收來自用戶端的用行分隔的消息(由\n分隔)。(譯者注:即一行為一條消息)

使用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調用不能處理多條消息。

這些是讀取流資料時常見的一些缺陷。為了解決這個問題,我們需要做一些改變:

我們需要緩沖傳入的資料,直到找到新的行。

我們需要解析緩沖區中傳回的所有行

高性能伺服器io函數,System.IO.Pipelines: .NET高性能IO

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

是以,我們可以在堆上配置設定緩沖區去處理更長的一行。我們從用戶端解析較長的一行時,可以通過使用ArrayPool避免重複配置設定緩沖區來改進這一點。

高性能伺服器io函數,System.IO.Pipelines: .NET高性能IO

這段代碼有效,但現在我們正在重新調整緩沖區大小,進而産生更多緩沖區副本。它将使用更多記憶體,因為根據代碼在處理一行行後不會縮緩沖區的大小。為避免這種情況,我們可以存儲緩沖區序列,而不是每次超過1KiB大小時調整大小。

此外,我們不會增長1KiB的 緩沖區,直到它完全為空。這意味着我們最終傳遞給ReadAsync越來越小的緩沖區,這将導緻對作業系統的更多調用。

為了緩解這種情況,我們将在現有緩沖區中剩餘少于512個位元組時配置設定一個新緩沖區:

譯者注:這段代碼太複雜了,懶得翻譯注釋了,大家将就看吧

高性能伺服器io函數,System.IO.Pipelines: .NET高性能IO

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

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

我們使用的byte[]和ArrayPool的隻是普通的托管數組。這意味着無論何時我們執行ReadAsync或WriteAsync,這些緩沖區都會在異步操作的生命周期内被固定(以便與作業系統上的本機IO API互操作)。這對GC有性能影響,因為無法移動固定記憶體,這可能導緻堆碎片。根據異步操作挂起的時間長短,池的實作可能需要更改。

可以通過解耦讀取邏輯和處理邏輯來優化吞吐量。這會建立一個批處理效果,使解析邏輯可以使用更大的緩沖區塊,而不是僅在解析單個行後才讀取更多資料。這引入了一些額外的複雜性我們需要兩個彼此獨立運作的循環。一個讀取Socket和一個解析緩沖區。

當資料可用時,我們需要一種方法來向解析邏輯發出信号。

我們需要決定如果循環讀取Socket“太快”會發生什麼。如果解析邏輯無法跟上,我們需要一種方法來限制讀取循環(邏輯)。這通常被稱為“流量控制”或“背壓”。

我們需要確定事情是線程安全的。我們現在在讀取循環和解析循環之間共享多個緩沖區,并且這些緩沖區在不同的線程上獨立運作。

記憶體管理邏輯現在分布在兩個不同的代碼段中,從填充緩沖區池的代碼是從套接字讀取的,而從緩沖區池取資料的代碼是解析邏輯。

我們需要非常小心在解析邏輯完成之後我們如何處理緩沖區序列。如果我們不小心,我們可能會傳回一個仍由Socket讀取邏輯寫入的緩沖區序列。

複雜性已經到了極端(我們甚至沒有涵蓋所有案例)。高性能網絡應用通常意味着編寫非常複雜的代碼,以便從系統中獲得更高的性能。

System.IO.Pipelines的目标是使這種類型的代碼更容易編寫。

使用System.IO.Pipelines的TCP伺服器

讓我們來看看這個例子的樣子System.IO.Pipelines:

高性能伺服器io函數,System.IO.Pipelines: .NET高性能IO

我們的行讀取器的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形式讀取的資料和bool IsCompleted,讓reader知道writer是否寫完(EOF)。在找到行尾(EOL)分隔符并解析該行之後,我們将緩沖區切片以跳過我們已經處理過的内容,然後我們調用PipeReader.AdvanceTo告訴PipeReader我們消耗了多少資料。

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

System.IO.Pipelines

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

PipeReader有兩個核心API ReadAsync和AdvanceTo。ReadAsync擷取Pipe資料,AdvanceTo告訴PipeReader不再需要這些緩沖區,以便可以丢棄它們(例如傳回到底層緩沖池)。

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

高性能伺服器io函數,System.IO.Pipelines: .NET高性能IO

ReadOnlySequence

該Pipe實作存儲了在PipeWriter和PipeReader之間傳遞的緩沖區的連結清單。PipeReader.ReadAsync暴露一個ReadOnlySequence新的BCL類型,它表示一個或多個ReadOnlyMemory段的視圖,類似于Span和Memory提供數組和字元串的視圖。

高性能伺服器io函數,System.IO.Pipelines: .NET高性能IO

Pipe内部維護指向reader和writer可以配置設定或更新它們的資料集合,。SequencePosition表示緩沖區連結清單中的單個點,可用于有效地對ReadOnlySequence進行切片。

這段實在翻譯困難,給出原文

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可以支援一個或多個段,是以高性能處理邏輯通常基于單個或多個段來分割快速和慢速路徑(fast and slow paths?)。

例如,這是一個将ASCII ReadOnlySequence轉換為string以下内容的例程:

高性能伺服器io函數,System.IO.Pipelines: .NET高性能IO

背壓和流量控制

在一個完美的世界中,讀取和解析工作是一個團隊:讀取線程消耗來自網絡的資料并将其放入緩沖區,而解析線程負責建構适當的資料結構。通常,解析将比僅從網絡複制資料塊花費更多時間。結果,讀取線程可以輕易地壓倒解析線程。結果是讀取線程必須減慢或配置設定更多記憶體來存儲解析線程的資料。為獲得最佳性能,在頻繁暫停和配置設定更多記憶體之間存在平衡。

為了解決這個問題,管道有兩個設定來控制資料的流量,PauseWriterThreshold和ResumeWriterThreshold。PauseWriterThreshold決定有多少資料應該在調用PipeWriter.FlushAsync之前進行緩沖停頓。ResumeWriterThreshold控制reader消耗多少後寫入可以恢複。

高性能伺服器io函數,System.IO.Pipelines: .NET高性能IO

當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,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的連接配接,并發送基于行的消息以使其正常工作。

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

資料:

轉載自System.IO.Pipelines: High performance IO in .NET

Pipelines - a guided tour of the new IO API in .NET, part 1

Pipelines - a guided tour of the new IO API in .NET, part 2

2号資料的中文翻譯 Pipelines - .NET中的新IO API指引(一)

System.IO.Pipelines-Nuget包

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