Net 8.0
File I/O
.NET 6對如何實現文件I/O進行了重大改革,重寫了FileStream
類,引入了RandomAccess
類以及大量的其他更改。 .NET 8通過進一步改進文件I/O性能而繼續提升性能。
對於提高系統性能的一種有趣的方法是取消操作。畢竟,最快的工作是不做,而取消操作是關於停止不必要的額外工作的。在.NET中,異步編程的原始模式基於不可取消的模型(如何使用異步/await真正工作?)進行了深入的歷史和討論),隨着時間的推移,所有支持都轉向了基於CancellationToken
的Task
基礎模型,越來越多的實現也變得完全可取消。到.NET 7爲止,大多數接受CancellationToken
的代碼路徑實際上都遵循了CancellationToken
,而不僅僅是進行一個前饋檢查,看看是否已經請求了取消,然後在操作過程中沒有注意它。大多數遺留的異步代碼路徑都是非常小心的,但有一個值得注意的是,有沒有使用FileOptions.Asynchronous
創建的文件流。
FileStream
繼承了Windows中的異步模型,當時您打開文件句柄時,需要指定它是用於同步還是異步訪問("overlapped")。以異步訪問打開的文件句柄要求所有操作都是異步的,反之,如果以非異步訪問打開的文件句柄,則所有操作都必須是同步的。這導致與FileStream
的交互有些影響,因爲它同時暴露了同步(如Read
)和非同步(如ReadAsync
)方法,這意味着需要同時實現這兩種行爲的同步。如果FileStream
以異步訪問打開,那麼Read
需要異步執行並阻塞等待其完成(我們不太喜歡稱之爲“同步-異步”]),而如果以同步訪問打開的文件句柄,那麼ReadAsync
需要將一個工作項安排爲同步執行(我們不太喜歡稱之爲“異步-同步”])。儘管ReadAsync
方法接受了一個CancellationToken
,但最終在[ThreadPool
工作項中結束的同步Read
操作是不可取消的。在.NET 8中,由於dotnet/runtime#87103,在Windows上,至少在Windows上,這是如此。
在.NET 7中,針對相同的問題,PipeStream
得到了修復,它依賴於一個內部的AsyncOverSyncWithIoCancellation
輔助程序,它會使用Windows中的CancelSynchronousIo
來中斷正在進行的I/O,同時使用適當的同步來確保只有預期的相關工作被中斷,而不是在當前工作線程之前或之後運行的其他工作(Linux已完全支持PipeStream
的取消,從.NET 5開始)。與此PR相同,還進一步改進了該輔助程序的實現,以減少分配並進一步優化處理,以便現有對PipeStream
的支持變得更好。
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.IO.Pipes;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
private readonly CancellationTokenSource _cts = new();
private readonly byte[] _buffer = new byte[1];
private AnonymousPipeServerStream _server;
private AnonymousPipeClientStream _client;
[GlobalSetup]
public void Setup()
{
_server = new AnonymousPipeServerStream(PipeDirection.Out);
_client = new AnonymousPipeClientStream(PipeDirection.In, _server.ClientSafePipeHandle);
}
[GlobalCleanup]
public void Cleanup()
{
_server.Dispose();
_client.Dispose();
}
[Benchmark(OperationsPerInvoke = 100_000)]
public async Task ReadWriteAsync()
{
for (int i = 0; i < 100_000; i++)
{
ValueTask<int> read = _client.ReadAsync(_buffer, _cts.Token);
await _server.WriteAsync(_buffer, _cts.Token);
await read;
}
}
}
Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
---|---|---|---|---|---|
ReadWriteAsync | .NET 7.0 | 3.863 us | 1.00 | 181 B | 1.00 |
ReadWriteAsync | .NET 8.0 | 2.941 us | 0.76 | – | 0.00 |
通過Path
和File
與路徑進行交互已經取得了改進。 dotnet/runtime#74855 改善了 Windows 上 Path.GetTempFileName()
的功能和性能;在過去,我們曾努力使 .NET 在 Unix 上的行爲與在 Windows 上的行爲相一致,但這次 PR 非常有趣地採取了一種相反的方向。在 Unix 上,Path.GetTempFileName()
使用 libc mkstemp
函數,該函數接受一個必須以“XXXXXX”結尾的模板,並在創建新文件時填充其中的 X
。在 Windows 上,GetTempFileName()
使用類似但只有 4 個 X
的 GetTempFileNameW
函數。通過在 Windows 上填充字符,這使得只有 65,536 個可能的名稱,並且隨着臨時目錄的填充,創建重複名稱的臨時文件可能性越來越高,進而導致創建時間越來越長(這也同時也意味着在 Windows 上 Path.GetTempFileName()
只能創建 65,536 個同時存在的臨時文件)。這次 PR 改變了 Windows 的格式以與 Unix 上的格式匹配,避免了使用 GetTempFileNameW
,而是進行了隨機名稱分配和衝突檢測。結果是操作系統之間的 consistency 更好,可能的臨時文件數量大幅增加(十億而不是幾萬),以及更好的性能:
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
// NOTE: The results for this benchmark will vary wildly based on how full the temp directory is.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private readonly List<string> _files = new();
// NOTE: The performance of this benchmark is highly influenced by what's currently in your temp directory.
[Benchmark]
public void GetTempFileName()
{
for (int i = 0; i < 1000; i++) _files.Add(Path.GetTempFileName());
}
[IterationCleanup]
public void Cleanup()
{
foreach (string path in _files) File.Delete(path);
_files.Clear();
}
}
Method | Runtime | Mean | Ratio |
---|---|---|---|
GetTempFileName | .NET 7.0 | 1,947.8 ms | 1.00 |
GetTempFileName | .NET 8.0 | 276.5 ms | 0.34 |
Path.GetFileName
是另一個通過利用 IndexOf
方法提高性能的方法列表中的方法。在這裏,dotnet/runtime#75318 使用 LastIndexOf
(在 Unix 上,唯一的目錄分隔符是 '/'
)或 LastIndexOfAny
(在 Windows 上,'/'
和 '\''
都可以作爲目錄分隔符)來查找文件名開始的位置。
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private string _path = Path.Join(Path.GetTempPath(), "SomeFileName.cs");
[Benchmark]
public ReadOnlySpan<char> GetFileName() => Path.GetFileName(_path.AsSpan());
}
Method | Runtime | Mean | Ratio |
---|---|---|---|
GetFileName | .NET 7.0 | 9.465 ns | 1.00 |
GetFileName | .NET 8.0 | 4.733 ns | 0.50 |
與 File
和 Path
相關,Environment
中的許多方法也返回路徑。Microsoft.Extensions.Hosting.HostingHostBuilderExtensions
曾經使用 Environment.GetSpecialFolder(Environment.SpecialFolder.System)
來獲取系統路徑,但這會導致啓動 ASP.NET 應用程序時出現明顯的性能開銷。 dotnet/runtime#83564 將此更改爲直接使用 Environment.SystemDirectory
,在 Windows 上可以更有效地獲取路徑(從而使代碼更簡單),但然後 dotnet/runtime#83593 還在 Windows 上修復了 Environment.GetSpecialFolder(Environment.SpecialFolder.System)
,使其在 Windows 上使用 Environment.SystemDirectory
,從而使其性能歸因於更高級別的使用。
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
[Benchmark]
public string GetFolderPath() => Environment.GetFolderPath(Environment.SpecialFolder.System);
}
Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
---|---|---|---|---|---|
GetFolderPath | .NET 7.0 | 1,560.87 ns | 1.00 | 88 B | 1.00 |
GetFolderPath | .NET 8.0 | 45.76 ns | 0.03 | 64 B | 0.73 |
dotnet/runtime#73983 改進了 DirectoryInfo
和 FileInfo
,使 FileSystemInfo.Name
屬性延遲創建。之前,在構建信息對象時,如果只有完整名稱存在(而不僅僅是目錄或文件本身),構造函數會立即創建 Name
字符串,即使信息對象從未被使用(通常是像從方法 CreateDirectory
返回時的情況)。現在,在 Name
屬性的第一次使用中,該屬性將延遲創建 Name
字符串。
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
private readonly string _path = Environment.CurrentDirectory;
[Benchmark]
public DirectoryInfo Create() => new DirectoryInfo(_path);
}
Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
---|---|---|---|---|---|
Create | .NET 7.0 | 225.0 ns | 1.00 | 240 B | 1.00 |
Create | .NET 8.0 | 170.1 ns | 0.76 | 200 B | 0.83 |
File.Copy
在 macOS 上已經變得更快了,多虧了 dotnet/runtime#79243 和 @hamarb123 的貢獻。現在,File.Copy
使用操作系統中的 clonefile
函數(如果可用)執行復制,如果源文件和目標文件都在同一個卷中,clonefile
會爲目標目錄創建一個 copy-on-write 克隆文件。這使得在操作系統層面上進行復制,只需要複製數據,而不需要寫入數據,從而使複製速度更快,僅在寫入數據時纔會複製數據的情況下的複製成本占主導地位。
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD", "Min", "Max")]
public class Tests
{
private string _source;
private string _dest;
[GlobalSetup]
public void Setup()
{
_source = Path.GetTempFileName();
File.WriteAllBytes(_source, Enumerable.Repeat((byte)42, 1_000_000).ToArray());
_dest = Path.GetRandomFileName();
}
[Benchmark]
public void FileCopy() => File.Copy(_source, _dest, overwrite: true);
[GlobalCleanup]
public void Cleanup()
{
File.Delete(_source);
File.Delete(_dest);
}
}
Method | Runtime | Mean | Ratio |
---|---|---|---|
FileCopy | .NET 7.0 | 1,624.8 us | 1.00 |
FileCopy | .NET 8.0 | 366.7 us | 0.23 |
一些更具體的更改已經融入了其中。TextWriter
是一個用於將文本寫入任意目的地的核心抽象,但有時您希望目的地既不是空閒的,也不是/dev/null這樣的地方。爲此,TextWriter
提供了TextWriter.Null
屬性,它返回一個在所有成員上都截斷的TextWriter
實例。或者,至少從可見的行爲來看是這樣。在實踐中,只有其成員中的一部分被覆蓋,這意味着儘管不會輸出任何內容,但仍然會有一些工作要做,然後將工作的成果扔掉。dotnet/runtime#83293 確保所有寫入方法都被覆蓋,以消除所有浪費的工作。
更進一步,TextWriter
的一個使用場景是在 Console
中,Console.SetOut
允許您用您的自定義輸出器替換 stdout
,此時所有 Console
中的寫入方法都會輸出到該 TextWriter
。爲了提供寫入的線程安全性,Console
會同步對底層寫入器的訪問,但是如果寫入器本身執行了 nops,則無需進行同步。 dotnet/runtime#83296 在這種情況下解決了這個問題,如果您想暫時阻止 Console
的輸出,您可以直接將 Console
的輸出設置爲 TextWriter.Null
,這樣 Console
的開銷操作將最小化。
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
private readonly string _value = "42";
[GlobalSetup]
public void Setup() => Console.SetOut(TextWriter.Null);
[Benchmark]
public void WriteLine() => Console.WriteLine("The value was {0}", _value);
}
Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
---|---|---|---|---|---|
WriteLine | .NET 7.0 | 80.361 ns | 1.00 | 56 B | 1.00 |
WriteLine | .NET 8.0 | 1.743 ns | 0.02 | – | 0.00 |
net7
File I/O
.NET 6帶來了一些巨大的文件I/O改進,特別是FileStream
的完全重寫。雖然.NET 7沒有任何單一的改變可以與之相比,但它確實有大量的改進可以顯著地“move the needle(移動指針)”,並且方式各異。
一種可靠性改進也可以視爲性能改進,那就是提高對取消請求的響應速度。某件事情能被取消得越快,系統就能越早地回收正在使用的寶貴資源,等待該操作完成的事情也能越早地解除阻塞。在.NET 7中,有幾處這樣的改進。
在某些情況下,它來自添加可取消的重載,其中之前是不可取消的。這正是 dotnet/runtime#61898 從 @bgrainger 添加的新可取消的重載的案例,包括對 TextReader.ReadLineAsync
和 TextReader.ReadToEndAsync
的方法重寫,以及包含對 StreamReader
和 StringReader
方法的重載;dotnet/runtime#64301 從 @bgrainger 接着重載了這些方法(以及其他缺失的重載),並將其應用於返回自 TextReader.Null
和 StreamReader.Null
的 NullStreamReader
類型(有趣的是,這些被定義爲兩種不同的類型,不必要的,因此這個 PR 還統一了它們的使用,滿足所需類型)。您可以在 dotnet/runtime#66492 從 @lateapexearlyspeed 添加的新 File.ReadLinesAsync
方法中看到這一點。該方法基於對 new StreamReader.ReadLineAsync
的簡單環形遍歷生成的 IAsyncEnumerable<string>
,因此本身也是可取消的。
從我的角度來看,更有趣的情況是,當一個現有的重載形式看似可取消但實際上不可取消。例如,基本 Stream.ReadAsync
方法只是封裝了 Stream.BeginRead
/EndRead
方法,這些方法是不可取消的,所以如果一個 Stream
類繼承 ReadAsync
,但不想覆蓋它,那麼嘗試取消調用其 ReadAsync
調用將至少沒有效果。它會先提前進行取消檢查,以便在請求取消之前調用,它將立即取消,但是在此之後的檢查中,提供的 CancellationToken
實際上會被忽略。隨着時間的推移,我們試圖消除所有剩餘的此類情況,但仍有幾顆頑固的棋子。一種可疑的情況是關於管道。對於此討論,有兩種相關的管道類型,即匿名和命名管道,它們在 .NET 中表示爲一對流:AnonymousPipeClientStream
/AnonymousPipeServerStream
和 NamedPipeClientStream
/NamedPipeServerStream
。另外,在 Windows 上,操作系統對同步 I/O 和異步 I/O(即並行 I/O)打開的端口和打開的端口之間做出了區分,這在 .NET API 中有所體現:您可以在構建時指定 PipeOptions.Asynchronous
選項來打開一個命名管道用於同步或異步 I/O。在 Unix 上,與它們的命名相反,命名管道實際上是利用 Unix 的 sockets 實現的。現在是一些歷史記錄:
- .NET Framework 4.8:不支持取消操作。管道
Stream
派生類型甚至沒有重寫ReadAsync
或WriteAsync
,所以它們只進行了取消檢查,然後忽略了token。 - .NET Core 1.0:在Windows上,對於開啓異步I/O的命名管道,完全支持取消操作。實現會向
CancellationToken
註冊,一旦收到取消請求,就會使用CancelIoEx
取消與異步操作相關的NativeOverlapped*
。在Unix上,命名管道是基於套接字實現的,如果管道是用PipeOptions.Asynchronous
打開的,實現會通過輪詢來模擬取消:而不是簡單地發出Socket.ReceiveAsync
/Socket.SendAsync
(當時不可取消),它會將一個工作項排隊到ThreadPool
,這個工作項會運行一個輪詢循環,進行帶有小超時的Socket.Poll
調用,檢查token,然後繼續循環,直到Poll
指示操作會成功或者請求取消。在Windows和Unix上,除了用Asynchronous
打開的命名管道,一旦操作開始,取消就是一個空操作。 - .NET Core 2.1:在Unix上,實現得到了改進,避免了輪詢循環,但它仍然缺乏真正可取消的
Socket.ReceiveAsync
/Socket.SendAsync
。相反,此時Socket.ReceiveAsync
支持零字節讀取,調用者可以傳遞一個零長度的緩衝區給ReceiveAsync
,並使用它作爲數據可用於消費的通知,而無需實際消費它。然後,Unix上的異步命名管道流的實現改爲發出零字節讀取,並將await
一個Task.WhenAny
,既包括該操作的任務,也包括在請求取消時完成的任務。更好,但仍然遠非理想。 - .NET Core 3.0:在Unix上,
Socket
獲得了真正可取消的ReceiveAsync
和SendAsync
方法,異步命名管道被更新以使用它們。此時,Windows和Unix的實現在取消方面實際上是相當的;對於異步命名管道都很好,對於其他所有東西只是擺設。 - .NET 5:在Unix上,暴露了
SafeSocketHandle
,並且可以爲任意提供的SafeSocketHandle
創建一個Socket
,這使得可以創建一個實際上指向匿名管道的Socket
。這反過來使得Unix上的每個PipeStream
都可以用Socket
來實現,這使得ReceiveAsync
/SendAsync
對於匿名和命名管道都是完全可取消的,無論它們是如何打開的。
通過.NET 5,問題在Unix上得到了解決,但在Windows上仍然是一個問題。直到現在。在.NET 7中,我們通過dotnet/runtime#72503 (和一個後續的修改dotnet/runtime#72612)使Windows上的所有操作都變得完全可取消。Windows目前不支持非匿名I/O的overlapped I/O,因此對於非匿名I/O和同步I/O打開的命名管道,Windows實現將直接委託到基本Stream
實現,這將在一個線程上隊列一個工作項到ThreadPool
調用同步對應方法,只需在另一個線程上執行。相反,實現現在將工作項隊列,但不僅僅是調用同步方法,還在工作項中執行一些預工作和後工作,註冊爲取消,並傳遞線程ID,以便在執行I/O時調用。如果要求取消,實現 then使用CancelSynchronousIo
來中斷它。這裏存在一個死鎖,即在註冊取消之前請求取消,操作可能已經開始了。所以,有一個小的死鎖循環,即在註冊取消和實際執行同步I/O之間的時間段內請求取消,取消線程將一直旋轉,直到I/O開始,但這種情況被期望爲非常罕見。在另一側,CancelSynchronousIo
在I/O完成之後被請求;爲解決這個死鎖,實現依賴於CancellationTokenRegistration.Dispose
的保證,該保證承諾與關聯的回調永遠不會被調用或已經完成執行。不僅這個實現完成了在Windows和Unix上所有非同步讀/寫操作的取消,而且實際上還提高了正常吞吐量。
private Stream _server;
private Stream _client;
private byte[] _buffer = new byte[1];
private CancellationTokenSource _cts = new CancellationTokenSource();
[Params(false, true)]
public bool Cancelable { get; set; }
[Params(false, true)]
public bool Named { get; set; }
[GlobalSetup]
public void Setup()
{
if (Named)
{
string name = Guid.NewGuid().ToString("N");
var server = new NamedPipeServerStream(name, PipeDirection.Out);
var client = new NamedPipeClientStream(".", name, PipeDirection.In);
Task.WaitAll(server.WaitForConnectionAsync(), client.ConnectAsync());
_server = server;
_client = client;
}
else
{
var server = new AnonymousPipeServerStream(PipeDirection.Out);
var client = new AnonymousPipeClientStream(PipeDirection.In, server.ClientSafePipeHandle);
_server = server;
_client = client;
}
}
[GlobalCleanup]
public void Cleanup()
{
_server.Dispose();
_client.Dispose();
}
[Benchmark(OperationsPerInvoke = 1000)]
public async Task ReadWriteAsync()
{
CancellationToken ct = Cancelable ? _cts.Token : default;
for (int i = 0; i < 1000; i++)
{
ValueTask<int> read = _client.ReadAsync(_buffer, ct);
await _server.WriteAsync(_buffer, ct);
await read;
}
}
Method | Runtime | Cancelable | Named | Mean | Ratio | Allocated | Alloc Ratio |
---|---|---|---|---|---|---|---|
ReadWriteAsync | .NET 6.0 | False | False | 22.08 us | 1.00 | 400 B | 1.00 |
ReadWriteAsync | .NET 7.0 | False | False | 12.61 us | 0.76 | 192 B | 0.48 |
ReadWriteAsync | .NET 6.0 | False | True | 38.45 us | 1.00 | 400 B | 1.00 |
ReadWriteAsync | .NET 7.0 | False | True | 32.16 us | 0.84 | 220 B | 0.55 |
ReadWriteAsync | .NET 6.0 | True | False | 27.11 us | 1.00 | 400 B | 1.00 |
ReadWriteAsync | .NET 7.0 | True | False | 13.29 us | 0.52 | 193 B | 0.48 |
ReadWriteAsync | .NET 6.0 | True | True | 38.57 us | 1.00 | 400 B | 1.00 |
ReadWriteAsync | .NET 7.0 | True | True | 33.07 us | 0.86 | 214 B | 0.54 |
除了對.NET 7中I/O進行的其他性能相關更改,主要集中在兩個問題上:減少系統調用和減少分配。
幾個PR將精力投入到了Unix上的系統調用減少上,例如File.Copy
和FileInfo.CopyTo
。 dotnet/runtime#59695從@tmds 減少了多種方式的開銷。該代碼首先執行stat
調用以確定源是否實際上是目錄,如果是,則操作將出錯。相反,PR只是嘗試打開源文件,而無論如何,對於複製操作,它都需要這樣做,然後只執行stat
調用,如果打開文件失敗。如果文件打開成功,代碼已經執行了fstat
來收集有關文件的數據,例如文件是否可讀取;通過這個改變,它現在還從單次fstat
的結果中提取源文件大小,然後將其傳遞給核心複製程序,從而避免了由於獲取大小而執行的fstat
系統調用。節省這些系統調用非常棒,尤其是對於非常小的文件來說,設置複製的開銷可能比複製字節還要昂貴。但這個PR最大的好處是它利用了Linux上的IOCTL-FICLONERANGE
。
一些Linux文件系統,如XFS和Btrfs,支持“寫入時複製”,這意味着文件系統不僅將所有數據複製到新文件,而且還會在底層存儲中記錄有兩個指向相同數據的文件。這使得“複製”變得非常快速,因爲無需複製數據,文件系統只需要更新一些賬目;此外,由於只有一個存儲數據,因此磁盤上的空間消耗更少。文件系統只需要複製其中一個文件中已覆蓋的數據。
這個PR使用ioctl
和FICLONE
在源和目標文件系統相同且文件系統支持此操作時執行復制。類似地,dotnet/runtime#64264 從@tmds 進一步改進了File.Copy
/FileInfo.CopyTo
,如果支持(並且只有新內核足夠新來解決之前版本中此函數的一些問題),則在Linux上利用copy_file_range
。與典型的讀/寫循環不同,copy_file_range
被設計爲完全在內核態,而不需要每讀取和寫入數據過渡到用戶空間。
另一個避免系統調用的方法是在 Unix 上的 File.WriteXx
和 File.AppendXx
方法。這些方法的實現會直接打開一個 FileStream
或一個 SafeFileHandle
,並指定了 FileOptions.SequentialScan
。SequentialScan
主要與從文件中讀取數據相關,它暗示了操作系統緩存,期望數據是從文件按順序而不是隨機地讀取。然而,這些寫入/追加方法並不會讀取,它們只會寫入,而且 FileOptions.SequentialScan
在 Unix 上的實現還需要通過 posix_fadvise
發出一個額外的系統調用(傳遞 POSIX_FADV_SEQUENTIAL
)。因此,我們爲此支付了系統調用費用,但卻沒有從中受益。這種情況與著名的 Henny Youngman 笑話類似: “病人說,‘醫生,這很疼’;醫生說,‘那麼別那麼做!’”。在這裏,答案也是“別那麼做”,所以 dotnet/runtime#59247 從 @tmds 停止在不會有所幫助但可能會帶來損害的地方 passing SequentialScan
。
目錄處理在目錄生命週期內減少了系統調用,尤其是在 Unix 系統上。 dotnet/runtime#58799 從 @tmds 處加速了 Unix 系統上的目錄創建。之前,目錄創建的實現會首先檢查目錄是否已存在,這涉及一個系統調用。在預期的小多數情況下,目錄已經存在,代碼可以提前退出。但是,在預期的大多數情況下,目錄不存在,然後它會解析文件路徑以查找其中的所有目錄,並遞歸地遍歷目錄列表,直到找到一個存在的目錄,然後嘗試創建該目錄下的所有子目錄。然而,預期的大多數情況是父目錄已經存在,而子目錄不存在,在這種情況下,我們仍然要爲解析付費,而本來可以只創建目標目錄。該拉票修復了這個問題,通過將開頭的存在檢查更改爲簡單地嘗試創建目標目錄;如果成功,那麼我們就可以直接結束,如果失敗,可以使用錯誤碼來判斷 mkdir
是否失敗,因爲 mkdir
沒有工作可做。 dotnet/runtime#61777 更進一步,通過使用棧內存爲傳遞給 mkdir
的路徑暫時需要的棧內存進行目錄創建,避免了在創建目錄時使用字符串分配內存。
dotnet/runtime#63675 改進了移動目錄的性能,在 Unix 和 Windows 上都減少了幾個系統調用。Directory.Move
和 DirectorInfo.MoveTo
的共享代碼在源和目標目錄都進行了顯式的目錄存在檢查,但在 Windows 上,Win32 API 調用自身進行移動時不需要進行這些檢查,因此它們不需要預先阻止。在 Unix 上,我們也可以類似地避免源目錄的存在檢查,因爲 rename
函數調用時,類似地,如果源目錄不存在,它將失敗並拋出適當的錯誤,我們可以通過錯誤信息得出發生了什麼,以便拋出正確的異常。對於目標目錄,代碼在移動時曾經爲目標目錄的存在作爲文件或目錄分別進行單獨的檢查,但只需要一個 stat
調用就可以滿足要求。
private string _path1;
private string _path2;
[GlobalSetup]
public void Setup()
{
_path1 = Path.GetTempFileName();
_path2 = Path.GetTempFileName();
File.Delete(_path1);
File.Delete(_path2);
Directory.CreateDirectory(_path1);
}
[Benchmark]
public void Move()
{
Directory.Move(_path1, _path2);
Directory.Move(_path2, _path1);
}
Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
---|---|---|---|---|---|
Move | .NET 6.0 | 31.70 us | 1.00 | 256 B | 1.00 |
Move | .NET 7.0 | 26.31 us | 0.83 | – | 0.00 |
在Unix系統上,dotnet/runtime#59520 從@tmds 引入了目錄刪除功能,特別是遞歸刪除(刪除目錄及其所有內容),通過利用文件系統枚舉提供的信息來避免進行二次存在檢查。
Syscalls減少也是爲了支持內存映射文件。 dotnet/runtime#63754 在打開內存映射文件時,利用特殊的裝載方式進行操作,同時調用 File.Exists
來確定指定文件是否已存在。這是因爲後來在處理錯誤和異常時,實現需要知道是否要刪除可能存在的文件,實現會構造一個 FileStream
,而調用 might 將指定文件放入存在狀態。但是,只有某些 FileMode
值才支持這種操作,這是通過傳遞給 CreateFromFile
的參數進行可配置的。FileMode
的常見和默認值是 FileMode.Open
,這意味着如果構建 FileStream
出錯,將會拋出異常。這意味着我們只有在 FileMode
不是 Open
或 CreateNew
時才需要調用 File.Exists
,從而在大多數情況下,我們實際上只需要調用一次 File.Exists
,就可以避免不必要的系統調用。 dotnet/runtime#63790 也有助於解決這個問題,主要有兩個方面。首先,在 CreateFromFile
操作過程中,實現可能會多次訪問 FileStream
的 Length
,但每次調用都會系統調用讀取文件底層長度。我們可以先讀取一次並使用該值進行所有檢查。其次,.NET 6 引入了 File.OpenHandle
方法,它允許我們直接在 SafeFileHandle
中打開文件句柄/文件描述符,而不必通過 FileStream
進行操作。在 MemoryMappedFile
中使用 FileStream
實際上非常小,因此使用 SafeFileHandle
直接打開文件句柄/文件描述符會更加簡單,而不必構建冗餘的 FileStream
及其相關狀態。這有助於減少分配。
最後,有 dotnet/runtime#63794,它認識到一個以只讀方式打開的 MemoryMappedViewAccessor
或 MemoryMappedViewStream
無法被寫入。這聽起來很正常,但這一舉措的實際意義在於:如果視圖不可寫,那麼關閉視圖不必擔心刷新,因爲那裏的數據在實現中沒有變化,而刷新視圖可能代價高昂,尤其是對於較大的視圖。因此,對不可寫視圖進行一個簡單的修改以避免刷新,可以顯著提高 MemoryMappedViewAccessor
/MemoryMappedViewStream
‘的 Dispose
功能。
private string _path;
[GlobalSetup]
public void Setup()
{
_path = Path.GetTempFileName();
File.WriteAllBytes(_path, Enumerable.Range(0, 10_000_000).Select(i => (byte)i).ToArray());
}
[GlobalCleanup]
public void Cleanup()
{
File.Delete(_path);
}
[Benchmark]
public void MMF()
{
using var mmf = MemoryMappedFile.CreateFromFile(_path, FileMode.Open, null);
using var s = mmf.CreateViewStream(0, 10_000_000, MemoryMappedFileAccess.Read);
}
Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
---|---|---|---|---|---|
MMF | .NET 6.0 | 315.7 us | 1.00 | 488 B | 1.00 |
MMF | .NET 7.0 | 227.1 us | 0.68 | 336 B | 0.69 |
除了系統調用之外,還針對減少分配進行了諸多改進。一個典型的改進是dotnet/runtime#58167,它改進了常用的File.WriteAllText{Async}
和File.AppendAllText{Async}
方法。PR修改了兩點:第一,這些操作足夠常見,值得避免通過FileStream
帶來的小但可測量的開銷,而直接使用底層的SafeFileHandle
,第二,由於方法將整個負載傳遞給輸出,實現可以利用這些已知信息(特別是長度)來優化之前使用的StreamWriter
。在這樣做的過程中,實現避免了流、寫入器和臨時緩衝區的開銷(主要是分配)。
private string _path;
[GlobalSetup]
public void Setup() => _path = Path.GetRandomFileName();
[GlobalCleanup]
public void Cleanup() => File.Delete(_path);
[Benchmark]
public void WriteAllText() => File.WriteAllText(_path, Sonnet);
Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
---|---|---|---|---|---|
WriteAllText | .NET 6.0 | 488.5 us | 1.00 | 9944 B | 1.00 |
WriteAllText | .NET 7.0 | 482.9 us | 0.99 | 392 B | 0.04 |
dotnet/runtime#61519 類似地,它將 File.ReadAllBytes{Async}
從通過 FileStream
獲取直接使用 SafeFileHandle
(並使用 RandomAccess
)更改爲通過 FileStream
獲取。它還像之前一樣做了同樣的 SequentialScan
更改。雖然這個案例是關於讀取(而之前的更改認爲附加的系統調用開銷沒有好處),但 ReadAllBytes{Async}
確實非常常用於讀取較小的文件,其中附加的系統調用開銷可能達到總成本的 10%(對於較大的文件,現代內核在沒有序列化提示的情況下進行緩存表現相當好,因此這裏的負面影響可以忽略)。
另一個 such 更改是 dotnet/runtime#68662,它改進了 Path.Join
對空或空路徑段的處理。Path.Join
有接受 string
的重載和接受 ReadOnlySpan<char>
的重載,但所有重載都產生 string
。基於 string
的重載只是將每個字符串包裹在 span
中,並將其委託給基於 span
的重載。然而,在執行連接操作時是 nop(例如,有兩個路徑段,第二個是空,所以連接應該只返回第一個),基於 span
的實現仍然需要創建一個新的字符串(ReadOnlySpan<char>
-based 的重載無法從 span
中提取字符串)。因此,在其中一個爲空或空的情況下,基於 string
的重載可以略微好一點;它們可以做的事情與 Path.Combine
重載相同,即 M 參數重載委託給 M-1 參數重載,排除空或空路徑段,在帶兩個參數的重載的基情況下,如果路徑段是空或空,則另一個(或空)可以直接返回。
除此之外,還有許多以分配爲基礎的 PR,例如 dotnet/runtime#69335 來自 @pedrobsaila,它爲我們在 Unix 上使用的任何地方添加了基於堆棧分配的快速路徑到內部 ReadLink
輔助函數;或者 dotnet/runtime#68752 更新了 NamedPipeClientStream.ConnectAsync
,通過明確通過調用 Task.Factory.StartNew
傳遞狀態,以刪除委託分配;或者 dotnet/runtime#69412爲 Assembly.GetManifestResourceStream
返回的 Stream
添加了優化的 Read(Span<byte>)
覆蓋。
但是我在這個領域個人最喜歡的改進來自於dotnet/runtime#69272,它爲Stream
添加了幾個輔助方法:
public void ReadExactly(byte[] buffer, int offset, int count);
public void ReadExactly(Span<byte> buffer);
public ValueTask ReadExactlyAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default);
public ValueTask ReadExactlyAsync(Memory<byte> buffer, CancellationToken cancellationToken = default);
public int ReadAtLeast(Span<byte> buffer, int minimumBytes, bool throwOnEndOfStream = true);
public ValueTask<int> ReadAtLeastAsync(Memory<byte> buffer, int minimumBytes, bool throwOnEndOfStream = true, CancellationToken cancellationToken = default);
公平地說,這些更多地是關於可用性而不是性能,但在這方面,兩者之間有很強的相關性。通常,在需要功能時,我們會爲自己編寫這些輔助函數(前述PR刪除了許多核心庫中的開箱即用的循環),這是非常常見的,而且不幸的是,在這些方面很容易弄錯,從而影響性能,比如通過使用需要返回一個Task<int>
的Stream.ReadAsync
重載或者讀取的字節數少於允許的這種情況。這些實現是正確的且高效的。
net6
IO
在.NET 6中,投入了大量精力來修復.NET中最古老類型之一FileStream
的性能問題。每個應用程序和服務都需要讀寫文件。不幸的是,FileStream
多年來也受到許多與性能相關的問題的困擾,這些問題主要源於其在Windows上的異步I/O實現。例如,調用ReadAsync
可能會發出一個 overlapped I/O讀操作,但通常情況下,那個讀操作都會以同步/異步方式結束,以避免可能導致潛在競態條件中出現的問題。或者,當緩衝區溢出時,即使是在異步情況下進行緩衝,那些溢出也會以同步寫的方式結束。這些問題往往使得使用異步I/O的好處大打折扣,同時還要承擔異步I/O所帶來的開銷(異步I/O往往比同步I/O具有更高的開銷,因此也更加可伸縮)。這一切變得更加複雜化,因爲FileStream
代碼是一個錯綜複雜、難以理清的迷宮,很大程度上是因爲它試圖將幾種不同的功能集成到同一個代碼路徑中:是否使用 overlapped I/O,是否進行緩衝,目標是磁盤文件還是管道等,每種情況都有不同的邏輯,所有這些都交織在一起。綜合來看,這意味着,除了少數例外,FileStream
的代碼在很大程度上保持不變,直到現在。
.NET 6對FileStream
進行了完全重寫,在此過程中解決了所有這些問題。得到了一個更易於維護且速度更快(特別是對於異步操作)的實現。爲此,進行了大量的PR,我這裏列舉其中的一些。首先,dotnet/runtime#47128爲新的實現奠定了基礎,將FileStream
重構爲一個“策略”(即策略設計模式中的策略)的封裝,然後在運行時實現替換和組合(類似於與Random
討論的方法),將現有的實現移動到可以用於.NET 6的策略中(最大兼容性要求時,它是默認的,但可以通過環境變量或AppContext
開關啓用)。dotnet/runtime#48813和dotnet/runtime#49750則引入了新實現的開始,將其拆分爲多個策略,用於在Windows上的文件打開方式,一個用於同步I/O打開的文件,一個用於異步I/O打開的文件,以及一個允許在策略上緩衝任何策略。dotnet/runtime#55191則引入了一個Unix優化的策略,用於新的設計。在此期間,還出現了許多其他PR,以優化各種條件。dotnet/runtime#49975和dotnet/runtime#56465避免了在Windows上每個操作上跟蹤文件長度的昂貴syscall,而dotnet/runtime#44097在Unix上禁用了一個不必要的文件長度設置syscall。dotnet/runtime#50802和dotnet/runtime#51363更改了Windows上異步I/O實現的實現,使其不再基於TaskCompletionSource
,而是基於可重用的IValueTaskSource
實現,這使得(非緩衝)異步讀寫操作的延遲-分配-免費。dotnet/runtime#55206從@tmds使用了在Unix上現有的syscall的知識,從而避免了之後的不必要的stat
syscall。dotnet/runtime#56095利用了之前討論的PoolingAsyncValueTaskMethodBuilder
,減少了在使用緩衝時FileStream
上異步操作的分配。dotnet/runtime#56387避免了在Windows上進行ReadFile
調用,如果我們已經擁有了足夠的信息來證明沒有內容可以讀取。而dotnet/runtime#56682則將Unix上的Read/WriteAsync
的優化應用於Windows,並在FileStream
以同步I/O打開時應用這些優化。最終,所有這些加起來爲FileStream
帶來了巨大的可維護性改進、巨大的性能改進(特別是對於異步操作)以及更好的可擴展性。這裏列舉一些微基準測試來突出其影響:
private FileStream _fileStream;
private byte[] _buffer = new byte[1024];
[Params(false, true)]
public bool IsAsync { get; set; }
[Params(1, 4096)]
public int BufferSize { get; set; }
[GlobalSetup]
public void Setup()
{
byte[] data = new byte[10_000_000];
new Random(42).NextBytes(data);
string path = Path.GetTempFileName();
File.WriteAllBytes(path, data);
_fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, IsAsync);
}
[GlobalCleanup]
public void Cleanup()
{
_fileStream.Dispose();
File.Delete(_fileStream.Name);
}
[Benchmark]
public void Read()
{
_fileStream.Position = 0;
while (_fileStream.Read(_buffer
#if !NETCOREAPP2_1_OR_GREATER
, 0, _buffer.Length
#endif
) != 0) ;
}
[Benchmark]
public async Task ReadAsync()
{
_fileStream.Position = 0;
while (await _fileStream.ReadAsync(_buffer
#if !NETCOREAPP2_1_OR_GREATER
, 0, _buffer.Length
#endif
) != 0) ;
}
Method | Runtime | IsAsync | BufferSize | Mean | Ratio | Allocated |
---|---|---|---|---|---|---|
Read | .NET Framework 4.8 | False | 1 | 30.717 ms | 1.00 | – |
Read | .NET Core 3.1 | False | 1 | 30.745 ms | 1.00 | – |
Read | .NET 5.0 | False | 1 | 31.156 ms | 1.01 | – |
Read | .NET 6.0 | False | 1 | 30.772 ms | 1.00 | – |
ReadAsync | .NET Framework 4.8 | False | 1 | 50.806 ms | 1.00 | 2,125,865 B |
ReadAsync | .NET Core 3.1 | False | 1 | 44.505 ms | 0.88 | 1,953,592 B |
ReadAsync | .NET 5.0 | False | 1 | 39.212 ms | 0.77 | 1,094,096 B |
ReadAsync | .NET 6.0 | False | 1 | 36.018 ms | 0.71 | 247 B |
Read | .NET Framework 4.8 | False | 4096 | 9.593 ms | 1.00 | – |
Read | .NET Core 3.1 | False | 4096 | 9.761 ms | 1.02 | – |
Read | .NET 5.0 | False | 4096 | 9.446 ms | 0.99 | – |
Read | .NET 6.0 | False | 4096 | 9.569 ms | 1.00 | – |
ReadAsync | .NET Framework 4.8 | False | 4096 | 30.920 ms | 1.00 | 2,141,481 B |
ReadAsync | .NET Core 3.1 | False | 4096 | 23.758 ms | 0.81 | 1,953,592 B |
ReadAsync | .NET 5.0 | False | 4096 | 25.101 ms | 0.82 | 1,094,096 B |
ReadAsync | .NET 6.0 | False | 4096 | 13.108 ms | 0.42 | 382 B |
Read | .NET Framework 4.8 | True | 1 | 413.228 ms | 1.00 | 2,121,728 B |
Read | .NET Core 3.1 | True | 1 | 217.891 ms | 0.53 | 3,050,056 B |
Read | .NET 5.0 | True | 1 | 219.388 ms | 0.53 | 3,062,741 B |
Read | .NET 6.0 | True | 1 | 83.070 ms | 0.20 | 2,109,867 B |
ReadAsync | .NET Framework 4.8 | True | 1 | 355.670 ms | 1.00 | 3,833,856 B |
ReadAsync | .NET Core 3.1 | True | 1 | 262.625 ms | 0.74 | 3,048,120 B |
ReadAsync | .NET 5.0 | True | 1 | 259.284 ms | 0.73 | 3,047,496 B |
ReadAsync | .NET 6.0 | True | 1 | 119.573 ms | 0.34 | 403 B |
Read | .NET Framework 4.8 | True | 4096 | 106.696 ms | 1.00 | 530,842 B |
Read | .NET Core 3.1 | True | 4096 | 56.785 ms | 0.54 | 353,151 B |
Read | .NET 5.0 | True | 4096 | 54.359 ms | 0.51 | 353,966 B |
Read | .NET 6.0 | True | 4096 | 22.971 ms | 0.22 | 527,930 B |
ReadAsync | .NET Framework 4.8 | True | 4096 | 143.082 ms | 1.00 | 3,026,980 B |
ReadAsync | .NET Core 3.1 | True | 4096 | 55.370 ms | 0.38 | 355,001 B |
ReadAsync | .NET 5.0 | True | 4096 | 54.436 ms | 0.38 | 354,036 B |
ReadAsync | .NET 6.0 | True | 4096 | 32.478 ms | 0.23 | 420 B |
一些FileStream
的改進包括將其實現中的讀/寫方面移動到單獨的公共類:System.IO.RandomAccess
。此類的實現已在dotnet/runtime#53669 dotnet/runtime#54266和dotnet/runtime#55490 (來自@teo-tsirpanis)中進行了優化,其中包括使用靜態方法提供同步和異步讀/寫功能,可以同時使用一個或多個緩衝區,並指定在文件中讀/寫的精確偏移量。所有這些靜態方法都接受一個SafeFileHandle
,現在可以從新的File.OpenHandle
方法中獲取。這意味着,如果基於FileStream
的接口不理想,代碼現在就可以直接訪問文件,而無需通過FileStream
進行訪問。這意味着,如果想要並行處理文件,代碼現在就可以併發讀/寫相同的SafeFileHandle
。(後續PR如dotnet/runtime#55150 利用了這些新API,避免了使用FileStream
時所需的額外分配和複雜性,當時只需要一個文件句柄和執行單讀或寫操作的能力。)@adamsitnik正在編寫一篇專注於這些FileStream
改進的專篇博客,不久後將在.NET博客上發佈。
當然,文件操作遠不止 FileStream
所能提供的功能。 dotnet/runtime#55210 從 @tmds 消除了一份 stat
系統調用,當目標不存在時,Directory/File.Exists
中的 FileStream
將不會調用 stat
系統調用。 dotnet/runtime#47118 從 @gukoff 消除了一份在 Unix 上移動文件時可能發生的 rename
系統調用。 dotnet/runtime#55644 簡化了 File.WriteAllTextAsync
的實現,並使它更快速,且需要更少的分配(當然,這個基準測試也得益於 FileStream
的改進)。
private static string s_contents = string.Concat(Enumerable.Range(0, 100_000).Select(i => (char)('a' + (i % 26))));
private static string s_path = Path.GetRandomFileName();
[Benchmark]
public Task WriteAllTextAsync() => File.WriteAllTextAsync(s_path, s_contents);
Method | Runtime | Mean | Ratio | Allocated |
---|---|---|---|---|
WriteAllTextAsync | .NET Core 3.1 | 1.609 ms | 1.00 | 23 KB |
WriteAllTextAsync | .NET 5.0 | 1.590 ms | 1.00 | 23 KB |
WriteAllTextAsync | .NET 6.0 | 1.143 ms | 0.72 | 15 KB |
當然,IO遠不止文件。在Windows上,NamedPipeServerStream
提供了類似於FileStream
的基於覆蓋的I/O實現。隨着FileStream
的實現被重構,dotnet/runtime#52695從@manandre也重構了文件的管道實現,以模仿FileStream
中使用的相同更新結構,從而產生了許多相同的好處,特別是在由於可重用IValueTaskSource
實現而不是TaskCompletionSource
實現而導致的分配減少方面。
在壓縮方面,除了引入了新的ZlibStream
(dotnet/runtime#42717)外,用於BrotliStream
、BrotliEncoder
和BrotliDecoder
背後的底層Brotli
代碼已從v1.0.7升級到v1.0.9。這次升級帶來了許多性能改進,包括更好地利用內存路徑的代碼。並不是所有的壓縮/解壓縮測量都有顯著的益處,但有些確實是的:
private byte[] _toCompress;
private MemoryStream _destination = new MemoryStream();
[GlobalSetup]
public async Task Setup()
{
using var hc = new HttpClient();
_toCompress = await hc.GetByteArrayAsync(@"https://raw.githubusercontent.com/dotnet/performance/5584a8b201b8c9c1a805fae4868b30a678107c32/src/benchmarks/micro/corefx/System.IO.Compression/TestData/alice29.txt");
}
[Benchmark]
public void Compress()
{
_destination.Position = 0;
using var ds = new BrotliStream(_destination, CompressionLevel.Fastest, leaveOpen: true);
ds.Write(_toCompress);
}
Method | Runtime | Mean | Ratio |
---|---|---|---|
Compress | .NET 5.0 | 1,050.2 us | 1.00 |
Compress | .NET 6.0 | 786.6 us | 0.75 |
dotnet/runtime#47125 是由 @NewellClark 貢獻的,他向各種 Stream
類型添加了一些缺失的覆蓋,包括 DeflateStream
,它具有減少 DeflateStream.WriteAsync
開銷的效果。
DeflateStream
(以及 GZipStream
和 BrotliStream
)中有一個有趣的性能改進。對於異步讀取操作的 Stream
合同規定,只要您請求至少一個字節的數據,操作不會完成,直到至少一個字節被讀取爲止。然而,合同沒有保證該操作將返回您請求的所有數據,事實上,很少有流能做出這樣的承諾,並且在很多情況下,當它這樣做時會帶來問題。不幸的是,作爲實現細節,DeflateStream
實際上試圖返回儘可能多的數據,通過向底層流發出儘可能多的讀取請求來實現,只有在解碼足夠多的數據以滿足請求或在底層流上遇到 EOF(文件末尾)時才停止。這對多個原因都有問題。首先,這阻止了已經收到但需要等待更多數據來處理的數據的並行處理;如果已經準備好了100個字節的數據,但您要求200個字節,那麼我被迫等待直到另一個100個字節的數據到達,或者流到達文件末尾。其次,更嚴重的是,它實際上阻止了 DeflateStream
在任何雙向通信場景中使用。想象一下一個 DeflateStream
圍繞在一個 NetworkStream
上,流正在用於向遠程方發送和接收壓縮消息。假設我向 DeflateStream
傳遞一個1K緩衝區,遠程方發送我一個100個字節的消息,我應該讀取並響應(遠程方將在發送任何進一步消息之前等待我的響應)。DeflateStream
在這裏的行為將導致整個系統陷入死鎖,因爲它將阻止接收等待另一個900個字節或 EOF(文件末尾)的消息。修復此問題通過允許 DeflateStream
(以及其他流)在擁有可處理數據時返回,即使不是請求的總字節數。這已經被記錄爲破壞性更改(https://docs.microsoft.com/dotnet/core/compatibility/core-libraries/6.0/partial-byte-reads-in-streams),不是因爲先前的行爲得到了保證(它沒有),但是我們已經看到太多代碼錯誤地依賴先前的行爲,因此這個問題很重要。
PR還修復了一個與性能相關的問題。需要意識到可擴展 Web 服務器的一個問題是內存利用率。如果您有1000個打開的連接,並且您正在等待每個連接的數據到來,您可以使用緩衝區對每個連接進行異步讀取,但是如果您使用的緩衝區大小爲4K,那麼裏面有4MB的緩衝區在浪費工作帶。相反,您可以使用零字節讀取,其中您只是通知有數據可以接收時進行空讀,從而避免緩衝區對工作帶的影響,只需在知道要放入數據時分配或租賃緩衝區。許多旨在實現雙向通信的Stream
,如NetworkStream
和SslStream
,都支持這種零字節讀取,不會在空讀操作返回之前返回空讀。對於.NET 6,DeflateStream
也可以用於這種用途,PR 對實現進行了修改,以確保在DeflateStream
的輸出緩衝區爲空時,即使調用者要求零字節,DeflateStream
也會向其底層Stream
發出讀取請求。想要避免這種行爲的調用者可以簡單地避免進行0字節調用。
繼續前進,對於 System.IO.Pipelines
,幾個PR提高了性能。dotnet/runtime#55086 添加了ReadByte
和WriteByte
的覆蓋,分別在緩衝區中已經讀取或緩衝區中可寫字節時避免了異步代碼路徑。此外,dotnet/runtime#52159從@manandre那裏添加了一個CopyToAsync
覆蓋到用於從Stream
中讀取的PipeReader
,優化了它首先複製已經緩衝的數據,然後將複製委託給Stream
的CopyToAsync
,利用可能提供的任何優化。
除了這些,還有一些小改進。來自@steveberdy(https://github.com/steveberdy)的dotnet/runtime#55373和dotnet/runtime#56568刪除了不必要的Contains('\0')
調用,從@lateapexearlyspeed(https://github.com/lateapexearlyspeed)獲得了改進的BufferedStream.Position
設置,以避免在將緩衝讀數據傳給新位置時發生滾動;來自@DavidKarlas(https://github.com/DavidKarlas)的改進避免了在File.GetLastWriteTimeUtc
中本地時間上不必要的文件時間輪詢;來自@dotnet/runtime#53070(https://github.com/dotnet/runtime/pull/53070)的改進使得在Unix上通過本地時間獲取最後寫入時間時,避免了不必要的回滾文件時間。最後,來自@dotnet/runtime#43968(https://github.com/dotnet/runtime/pull/43968)將基於派生的Stream
類型的參數驗證邏輯合併到公有輔助函數(Stream.ValidateBufferArguments
和Stream.ValidateCopyToArguments
),除了消除重複代碼外,還有助於確保行爲的一致性,並使用共享且高效的實現來簡化驗證邏輯。