Performance Improvements in .NET 8 & 7 & 6 -- Thread【翻譯】

線程

.NET 的最近版本在線程、並行、併發和異步等方面做出了巨大的改進,例如 ThreadPool 的完全重寫(在 .NET 6 和 .NET 7 中),異步方法基礎設施的完全重寫(在 .NET Core 2.1 中),ConcurrentQueue 的完全重寫(在 .NET Core 2.0 中)等等。這個版本沒有包含這樣的大規模改革,但它確實包含了一些深思熟慮和有影響力的改進。

ThreadStatic

.NET 運行時使得將數據與線程關聯起來變得很容易,這通常被稱爲線程本地存儲(TLS)。實現這一點的最常見方式是用 [ThreadStatic] 屬性註解一個靜態字段(另一個用於更高級用途的是通過 ThreadLocal 類型),這會導致運行時將該字段的存儲複製到每個線程,而不是全局的進程。

private static int s_onePerProcess;

[ThreadStatic]
private static int t_onePerThread;

歷史上,訪問這樣一個 [ThreadStatic] 字段需要一個非內聯的 JIT 輔助方法(例如 CORINFO_HELP_GETSHARED_NONGCTHREADSTATIC_BASE_NOCTOR),但現在有了 dotnet/runtime#82973 和 dotnet/runtime#85619,那個輔助方法的常見和快速路徑可以被內聯到調用者中。我們可以通過一個簡單的基準測試來看到這一點,該基準測試只是增加了一個存儲在 [ThreadStatic] 中的 int。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
// dotnet run -c Release -f net7.0 --filter "*" --runtimes nativeaot7.0 nativeaot8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
    [ThreadStatic]
    private static int t_value;

    [Benchmark]
    public int Increment() => ++t_value;
}
方法 運行時 平均值 比率
Increment .NET 7.0 8.492 ns 1.00
Increment .NET 8.0 1.453 ns 0.17

[ThreadStatic] 同樣通過 dotnet/runtime#84566 和 dotnet/runtime#87148 爲 Native AOT 優化:

方法 運行時 平均值 比率
Increment NativeAOT 7.0 2.305 ns 1.00
Increment NativeAOT 8.0 1.325 ns 0.57

ThreadPool

讓我們試驗一下。創建一個新的控制檯應用程序,並在 .csproj 中添加 <PublishAot>true</PublishAot>。然後將程序的全部內容設爲這樣:

// dotnet run -c Release -f net8.0

Task.Run(() => Console.WriteLine(Environment.StackTrace)).Wait();

這個想法是看看在 ThreadPool 線程上運行的工作項的堆棧跟蹤。現在運行它,你應該會看到類似這樣的內容:

  at System.Environment.get_StackTrace()
   at Program.<>c.<<Main>$>b__0_0()
   at System.Threading.ExecutionContext.RunFromThreadPoolDispatchLoop(Thread threadPoolThread, ExecutionContext executionContext, ContextCallback callback, Object state)
   at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread)
   at System.Threading.ThreadPoolWorkQueue.Dispatch()
   at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()

這裏重要的部分是最後一行:我們看到我們是從 PortableThreadPool 被調用的,這是自 .NET 6 以來在所有操作系統上使用的託管線程池實現。現在,不是直接運行,讓我們發佈爲 Native AOT 並運行結果應用程序(對於我們正在尋找的特定事情,這部分應該在 Windows 上完成)。

dotnet publish -c Release -r win-x64
D:\examples\tmpapp\bin\Release\net8.0\win-x64\publish\tmpapp.exe

現在,我們看到這個:

  at System.Environment.get_StackTrace() + 0x21
   at Program.<>c.<<Main>$>b__0_0() + 0x9
   at System.Threading.ExecutionContext.RunFromThreadPoolDispatchLoop(Thread, ExecutionContext, ContextCallback, Object) + 0x3d
   at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task&, Thread) + 0xcc
   at System.Threading.ThreadPoolWorkQueue.Dispatch() + 0x289
   at System.Threading.WindowsThreadPool.DispatchCallback(IntPtr, IntPtr, IntPtr) + 0x45

再次注意最後一行:“WindowsThreadPool”。在 Windows 上發佈的 Native AOT 應用程序歷來都使用包裝 Windows 線程池的 ThreadPool 實現。工作項隊列和調度代碼都與線程池相同,但線程管理本身是委託給 Windows 池的。現在在 .NET 8 中,通過 dotnet/runtime#85373,Windows 上的項目可以選擇使用任一池;Native AOT 應用程序可以選擇使用線程池,其他應用程序可以選擇使用 Windows 池。選擇加入或退出很簡單:在 .csproj 中的 <PropertyGroup/> 中,添加 <UseWindowsThreadPool>false</UseWindowsThreadPool> 以在 Native AOT 應用程序中選擇退出,反之,在其他應用程序中使用 true 以選擇加入。當使用此 MSBuild 開關時,在 Native AOT 應用程序中,不使用的任何池都可以自動被剪除。爲了實驗,也可以設置 DOTNET_ThreadPool_UseWindowsThreadPool 環境變量爲 0 或 1,分別顯式選擇退出或加入。

目前還沒有硬性規定哪個池可能更好;這個選項已經添加,以便開發者進行實驗。我們已經看到,與線程池相比,Windows 池在更大的機器上的 I/O 擴展性不太好。然而,如果應用程序的其他地方已經大量使用了 Windows 線程池,那麼整合到同一個池中可以減少過度訂閱。此外,如果線程池線程經常被阻塞,Windows 線程池對這種阻塞有更多的信息,可能可以更有效地處理這些情況。我們可以通過一個簡單的例子來看這一點。編譯這段代碼:

// dotnet run -c Release -f net8.0

using System.Diagnostics;

var sw = Stopwatch.StartNew();

var barrier = new Barrier(Environment.ProcessorCount * 2 + 1);
for (int i = 0; i < barrier.ParticipantCount; i++)
{
    ThreadPool.QueueUserWorkItem(id =>
    {
        Console.WriteLine($"{sw.Elapsed}: {id}");
        barrier.SignalAndWait();
    }, i);
}

barrier.SignalAndWait();
Console.WriteLine($"Done: {sw.Elapsed}");

這是一個複雜的重現,它創建了一堆工作項,所有這些工作項都會阻塞,直到所有的工作項都被處理完畢:基本上,它接收線程池提供的每一個線程,並且永遠不會歸還(直到程序退出)。當我在我的機器上運行這個程序,其中 Environment.ProcessorCount 是 12,我得到的輸出如下:

00:00:00.0038906: 0
00:00:00.0038911: 1
00:00:00.0042401: 4
00:00:00.0054198: 9
00:00:00.0047249: 6
00:00:00.0040724: 3
00:00:00.0044894: 5
00:00:00.0052228: 8
00:00:00.0049638: 7
00:00:00.0056831: 10
00:00:00.0039327: 2
00:00:00.0057127: 11
00:00:01.0265278: 12
00:00:01.5325809: 13
00:00:02.0471848: 14
00:00:02.5628161: 15
00:00:03.5805581: 16
00:00:04.5960218: 17
00:00:05.1087192: 18
00:00:06.1142907: 19
00:00:07.1331915: 20
00:00:07.6467355: 21
00:00:08.1614072: 22
00:00:08.6749720: 23
00:00:08.6763938: 24
Done: 00:00:08.6768608

線程池速注入了 Environment.ProcessorCount 個線程,但在此之後,它只會每秒注入一到兩個額外的線程。現在,設置 DOTNET_ThreadPool_UseWindowsThreadPool 爲 1,然後再試一次:

00:00:00.0034909: 3
00:00:00.0036281: 4
00:00:00.0032404: 0
00:00:00.0032727: 1
00:00:00.0032703: 2
00:00:00.0447256: 5
00:00:00.0449398: 6
00:00:00.0451899: 7
00:00:00.0454245: 8
00:00:00.0456907: 9
00:00:00.0459155: 10
00:00:00.0461399: 11
00:00:00.0463612: 12
00:00:00.0465538: 13
00:00:00.0467497: 14
00:00:00.0469477: 15
00:00:00.0471055: 16
00:00:00.0472961: 17
00:00:00.0474888: 18
00:00:00.0477131: 19
00:00:00.0478795: 20
00:00:00.0480844: 21
00:00:00.0482900: 22
00:00:00.0485110: 23
00:00:00.0486981: 24
Done: 00:00:00.0498603

Windows 池在這裏注入線程的速度更快。這是好還是壞,取決於你的場景。如果你發現自己爲你的應用程序設置了一個非常高的最小線程池線程數,你可能會想嘗試這個選項。

Tasks

即使在之前的版本中對 async/await 進行了所有的改進,這個版本中的 async 方法仍然開銷更低,無論它們是同步完成還是異步完成。

當一個 async Task/Task 返回的方法同步完成時,它試圖返回一個緩存的任務對象,而不是創建一個新的併產生分配。在 Task 的情況下,這很容易,它可以簡單地使用 Task.CompletedTask。在 Task 的情況下,它使用一個緩存,該緩存存儲了一些 TResult 值的緩存任務。例如,當 TResult 是 Boolean 時,它可以成功地爲 true 和 false 緩存一個 Task,這樣它就可以始終成功地避免分配。對於 int,它爲常見值(例如,-1 到 8)緩存了一些任務。對於引用類型,它爲 null 緩存了一個任務。對於原始整數類型(sbyte, byte, short, ushort, char, int, uint, long, ulong, nint, 和 nuint),它爲 0 緩存了一個任務。這個邏輯過去都是專門用於 async 方法的,但在 .NET 6 中,這個邏輯移動到了 Task.FromResult 中,這樣所有使用 Task.FromResult 的地方現在都可以從這個緩存中受益。在 .NET 8 中,由於 dotnet/runtime#76349 和 dotnet/runtime#87541,緩存進一步改進。特別是,對於原始類型緩存一個任務爲 0 的優化擴展爲對於任何值類型 TResult 緩存一個任務爲 default(TResult),該值類型爲 1、2、4、8 或 16 字節。在這種情況下,我們可以進行一個不安全的轉換到這些原始類型中的一個,然後使用那個原始類型的等式來與 default 進行比較。如果這個比較是真的,那就意味着這個值完全是零,這就意味着我們可以使用一個從 default(TResult) 創建的 Task 的緩存任務,因爲它也完全是零。如果那個類型有一個自定義的等式比較器呢?實際上這並不重要,因爲原始值和存儲在緩存任務中的值有相同的位模式,這意味着它們是無法區分的。這樣做的結果是我們可以爲其他常用類型緩存任務。

// 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 async Task<TimeSpan> ZeroTimeSpan() => TimeSpan.Zero;
    [Benchmark] public async Task<DateTime> MinDateTime() => DateTime.MinValue;
    [Benchmark] public async Task<Guid> EmptyGuid() => Guid.Empty;
    [Benchmark] public async Task<DayOfWeek> Sunday() => DayOfWeek.Sunday;
    [Benchmark] public async Task<decimal> ZeroDecimal() => 0m;
    [Benchmark] public async Task<double> ZeroDouble() => 0;
    [Benchmark] public async Task<float> ZeroFloat() => 0;
    [Benchmark] public async Task<Half> ZeroHalf() => (Half)0f;
    [Benchmark] public async Task<(int, int)> ZeroZeroValueTuple() => (0, 0);
}
Method Runtime Mean Ratio Allocated Alloc Ratio
ZeroTimeSpan .NET 7.0 31.327 ns 1.00 72 B 1.00
ZeroTimeSpan .NET 8.0 8.851 ns 0.28 0.00
MinDateTime .NET 7.0 31.457 ns 1.00 72 B 1.00
MinDateTime .NET 8.0 8.277 ns 0.26 0.00
EmptyGuid .NET 7.0 32.233 ns 1.00 80 B 1.00
EmptyGuid .NET 8.0 9.013 ns 0.28 0.00
Sunday .NET 7.0 30.907 ns 1.00 72 B 1.00
Sunday .NET 8.0 8.235 ns 0.27 0.00
ZeroDecimal .NET 7.0 33.109 ns 1.00 80 B 1.00
ZeroDecimal .NET 8.0 13.110 ns 0.40 0.00
ZeroDouble .NET 7.0 30.863 ns 1.00 72 B 1.00
ZeroDouble .NET 8.0 8.568 ns 0.28 0.00
ZeroFloat .NET 7.0 31.025 ns 1.00 72 B 1.00
ZeroFloat .NET 8.0 8.531 ns 0.28 0.00
ZeroHalf .NET 7.0 33.906 ns 1.00 72 B 1.00
ZeroHalf .NET 8.0 9.008 ns 0.27 0.00
ZeroZeroValueTuple .NET 7.0 33.339 ns 1.00 72 B 1.00
ZeroZeroValueTuple .NET 8.0 11.274 ns 0.34 0.00

這些更改幫助一些異步方法在同步完成時變得緊湊。其他的更改幫助幾乎所有的異步方法在異步完成時變得緊湊。當一個異步方法第一次暫停,假設它返回的是 Task/Task/ValueTask/ValueTask 並且默認的異步方法構建器正在使用(即它們沒有被覆蓋使用 [AsyncMethodBuilder(...)] 在問題的方法上),一個單一的分配發生:要返回的任務對象。那個任務對象實際上是一個從 Task 派生的類型(在今天的實現中,內部類型被稱爲 AsyncStateMachineBox),它上面有一個強類型的字段,用於由 C# 編譯器生成的狀態機結構。實際上,從 .NET 7 開始,它比基礎的 Task 多出三個字段:

  • 一個用於保存 C# 編譯器生成的 TStateMachine 狀態機結構。
  • 一個用於緩存指向 MoveNext 的 Action 委託。
  • 一個用於存儲 ExecutionContext,以便流向下一個 MoveNext 調用。

如果我們可以減少所需的字段,我們可以通過分配更小的對象而不是更大的對象,使每個異步方法的成本降低。這正是 dotnet/runtime#83696 和 dotnet/runtime#83737 所完成的,它們一起從每個這樣的異步方法任務的大小中削減了 16 字節(在 64 位進程中)。如何實現呢?

C# 語言允許任何東西都可以被等待,只要它遵循正確的模式,暴露一個返回具有正確形狀的類型的 GetAwaiter() 方法。該模式包括一組接受 Action 委託的 “OnCompleted” 方法,使異步方法構建器能夠向等待器提供一個繼續操作,這樣當等待的操作完成時,它可以調用 Action 來恢復方法的處理。因此,AsyncStateMachineBox 類型上有一個字段,用於緩存一個懶加載創建的指向其 MoveNext 方法的 Action 委託;該 Action 在第一次需要它的暫停等待期間創建,然後可以用於所有後續的等待,這樣 Action 在異步方法的生命週期內最多分配一次,無論調用暫停多少次。然而,如果狀態機等待的東西不是已知的等待器,那麼只需要委託;運行時有快速路徑,避免在等待所有內置等待器時需要該 Action。有趣的是,Task 本身有一個用於存儲委託的字段,而該字段只在創建 Task 來調用委託時使用(例如,Task.Run,ContinueWith 等)。由於今天分配的大多數任務都來自異步方法,這意味着大多數任務都有一個浪費的字段。我們發現我們可以將這個基礎字段用於這個緩存的 MoveNext Action,使得這個字段對幾乎所有的任務都相關,並允許我們刪除狀態機箱上的額外 Action 字段。

在基礎 Task 上還有另一個在異步方法中未使用的現有字段:狀態對象字段。當你使用 StartNew 或 ContinueWith 方法創建一個 Task 時,你可以提供一個對象狀態,然後將其傳遞給 Task 的委託。然而,在異步方法中,這個字段就在那裏,未被使用,孤獨,被遺忘,悲傷。因此,我們可以將 ExecutionContext 存儲在這個現有的狀態字段中(小心不要讓它通過通常暴露對象狀態的 Task 的 AsyncState 屬性暴露出來)。

我們可以通過一個簡單的基準測試來看到去掉這兩個字段的效果:

// 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 async Task YieldOnce() => await Task.Yield();
}
Method Runtime Mean Ratio Allocated Alloc Ratio
YieldOnce .NET 7.0 918.6 ns 1.00 112 B 1.00
YieldOnce .NET 8.0 865.8 ns 0.94 96 B 0.86

正如我們預測的,減少了16字節。

異步方法的開銷也以其他方式減少。例如,dotnet/runtime#82181 縮小了用作自定義 IValueTaskSource/IValueTaskSource 實現的工作馬的 ManualResetValueTaskSourceCore 類型的大小;它利用了99.9%的情況,使用一個字段代替以前需要兩個字段的情況。但我最喜歡的添加是 dotnet/runtime#22144,它添加了新的 ConfigureAwait 重載。是的,我知道 ConfigureAwait 對某些人來說是一個痛點,但這些新的重載 a) 解決了許多人最終爲其編寫自定義等待器的非常有用的場景,b) 以比自定義解決方案更便宜的方式實現,c) 實際上幫助了 ConfigureAwait 的命名,因爲它實現了最初讓我們這樣命名 ConfigureAwait 的原始目的。當最初設計 ConfigureAwait 時,我們討論了許多名稱,並最終選擇了“ConfigureAwait”,因爲這就是它的作用:它允許你提供參數來配置 await 的行爲。當然,在過去的十年裏,你能做的唯一配置就是傳遞一個布爾值,以指示是否捕獲當前的上下文/調度器,這在一定程度上導致人們抱怨這個名稱過於冗長,對於一個單一的布爾值來說。現在在 .NET 8 中,有新的 ConfigureAwait 重載,它接受一個 ConfigureAwaitOptions 枚舉:

[Flags]
public enum ConfigureAwaitOptions
{
   None = 0,
   ContinueOnCapturedContext = 1,
   SuppressThrowing = 2,
   ForceYielding = 4,
}

ContinueOnCapturedContext 你知道;這就是今天的 ConfigureAwait(true)。ForceYielding 是在各種情況下不時出現的東西,但本質上你正在等待某件事,而不是在你等待它的時候如果它已經完成了就同步地繼續,你實際上希望系統假裝它沒有完成,即使它已經完成了。然後,而不是同步地繼續,延續總是會從調用者異步地運行。這在各種方式上都可以作爲優化。考慮一下在 .NET 7 的 SocketsHttpHandler 的 HTTP/2 實現中的這段代碼:

private void DisableHttp2Connection(Http2Connection connection)
{
    _ = Task.Run(async () => // fire-and-forget
    {
        bool usable = await connection.WaitForAvailableStreamsAsync().ConfigureAwait(false);
        ... // other stuff
    };
}

在 .NET 8 中使用 ForceYielding,代碼現在是:

private void DisableHttp2Connection(Http2Connection connection)
{
    _ = DisableHttp2ConnectionAsync(connection); // fire-and-forget

    async Task DisableHttp2ConnectionAsync(Http2Connection connection)
    {
        bool usable = await connection.WaitForAvailableStreamsAsync().ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
        .... // other stuff
    }
}

我們沒有單獨的 Task.Run,而是在 WaitForAvailableStreamsAsync 返回的任務的 await 上搭了個便車(我們知道它會快速返回任務給我們),確保在調用 DisableHttp2Connection 之後的工作不會同步運行。或者想象一下你的代碼是這樣做的:

return Task.Run(WorkAsync);

static async Task WorkAsync()
{
    while (...) await Something();
}

這是使用 Task.Run 來排隊一個異步方法的調用。這個異步方法導致一個任務被分配,加上 Task.Run 導致一個任務被分配,加上需要將一個工作項排隊到線程池,所以至少有三個分配。現在,這個相同的功能可以寫成:

return WorkAsync();

static async Task WorkAsync()
{
    await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
    while (...) await Something();
}

而不是三個分配,我們最終只有一個:異步任務的分配。這是因爲在之前的版本中引入的所有優化中,狀態機箱對象也將被排隊到線程池。

然而,這個支持中最有價值的添加可能是 SuppressThrowing。它的作用就像它聽起來的那樣:當你等待一個任務完成失敗或取消,這樣通常 await 會傳播異常,它不會。所以,例如,在 System.Text.Json 中,我們之前有這樣的代碼:

// Exceptions should only be propagated by the resuming converter
try
{
    await state.PendingTask.ConfigureAwait(false);
}
catch { }

現在我們有這樣的代碼:

// Exceptions should only be propagated by the resuming converter
await state.PendingTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);

或者在 SemaphoreSlim 中,我們有這樣的代碼:

await new ConfiguredNoThrowAwaiter<bool>(asyncWaiter.WaitAsync(TimeSpan.FromMilliseconds(millisecondsTimeout), cancellationToken));
if (cancellationToken.IsCancellationRequested)
{
    // If we might be running as part of a cancellation callback, force the completion to be asynchronous.
    await TaskScheduler.Default;
}

private readonly struct ConfiguredNoThrowAwaiter<T> : ICriticalNotifyCompletion, IStateMachineBoxAwareAwaiter
{
    private readonly Task<T> _task;
    public ConfiguredNoThrowAwaiter(Task<T> task) => _task = task;
    public ConfiguredNoThrowAwaiter<T> GetAwaiter() => this;
    public bool IsCompleted => _task.IsCompleted;
    public void GetResult() => _task.MarkExceptionsAsHandled();
    public void OnCompleted(Action continuation) => TaskAwaiter.OnCompletedInternal(_task, continuation, continueOnCapturedContext: false, flowExecutionContext: true);
    public void UnsafeOnCompleted(Action continuation) => TaskAwaiter.OnCompletedInternal(_task, continuation, continueOnCapturedContext: false, flowExecutionContext: false);
    public void AwaitUnsafeOnCompleted(IAsyncStateMachineBox box) => TaskAwaiter.UnsafeOnCompletedInternal(_task, box, continueOnCapturedContext: false);
}

internal readonly struct TaskSchedulerAwaiter : ICriticalNotifyCompletion
{
    private readonly TaskScheduler _scheduler;
    public TaskSchedulerAwaiter(TaskScheduler scheduler) => _scheduler = scheduler;
    public bool IsCompleted => false;
    public void GetResult() { }
    public void OnCompleted(Action continuation) => Task.Factory.StartNew(continuation, CancellationToken.None, TaskCreationOptions.DenyChildAttach, _scheduler);
    public void UnsafeOnCompleted(Action continuation)
    {
        if (ReferenceEquals(_scheduler, Default))
        {
            ThreadPool.UnsafeQueueUserWorkItem(s => s(), continuation, preferLocal: true);
        }
        else
        {
            OnCompleted(continuation);
        }
    }
}

現在我們只有這樣的代碼:

await ((Task)asyncWaiter.WaitAsync(TimeSpan.FromMilliseconds(millisecondsTimeout), cancellationToken)).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
if (cancellationToken.IsCancellationRequested)
{
    // If we might be running as part of a cancellation callback, force the completion to be asynchronous.
    await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
}

值得注意的是裏面的 (Task) 強制類型轉換。WaitAsync 返回一個 Task,但是這個 Task 被強制轉換爲基礎的 Task,因爲 SuppressThrowing 與 Task 不兼容。這是因爲,如果沒有異常傳播,await 將成功完成並返回一個 TResult,如果任務實際上出錯了,這可能是無效的。所以,如果你有一個你想用 SuppressThrowing 等待的 Task,將其強制轉換爲基礎的 Task 並等待它,然後你可以在 await 完成後立即檢查 Task。(如果你最終使用 ConfigureAwaitOptions.SuppressThrowing 與 Task,在 dotnet/roslyn-analyzers#6669 中引入的 CA2261 分析器會提醒你。)

上面的 SemaphoreSlim 示例也使用了新的 ConfigureAwaitOptions 來替換在 .NET 8 中添加的之前的優化。dotnet/runtime#83294 在 ConfiguredNoThrowAwaiter 中添加了對內部 IStateMachineBoxAwareAwaiter 接口的實現,這是使異步方法構建器能夠通過已知的 awaiter 進行後通道通信以避免 Action 委託分配的特殊醬汁。現在,這個 ConfiguredNoThrowAwaiter 提供的行爲已經內置,所以不再需要它,而內置的實現通過 IStateMachineBoxAwareAwaiter 享受同樣的特權。這些改變對 SemaphoreSlim 的淨效果是,它現在不僅有更簡單的代碼,而且代碼運行速度也更快。下面是一個基準測試,顯示了需要使用 CancellationToken 和/或超時等待的 SemaphoreAsync.WaitAsync 調用的執行時間和分配的減少:

// 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 CancellationToken _token = new CancellationTokenSource().Token;
    private readonly SemaphoreSlim _sem = new SemaphoreSlim(0);
    private readonly Task[] _tasks = new Task[100];

    [Benchmark]
    public Task WaitAsync()
    {
        for (int i = 0; i < _tasks.Length; i++)
        {
            _tasks[i] = _sem.WaitAsync(_token);
        }
        _sem.Release(_tasks.Length);
        return Task.WhenAll(_tasks);
    }
}
Method Runtime Mean Ratio Allocated Alloc Ratio
WaitAsync .NET 7.0 85.48 us 1.00 44.64 KB 1.00
WaitAsync .NET 8.0 69.37 us 0.82 36.02 KB 0.81

Task 上的其他操作也有其他改進。dotnet/runtime#81065 從 Task.WhenAll 中移除了一個防禦性的 Task[] 分配。它之前做了一個防禦性的複製,這樣它就可以在複製上驗證是否有任何元素是 null(一個複製,因爲另一個線程可能錯誤地併發地將元素置爲 null);這是在面對多線程誤用的情況下爲參數驗證付出的大代價。相反,該方法仍然會驗證輸入中是否有 null,如果一個 null 因爲輸入集合被錯誤地併發地與 WhenAll 的同步調用同時變異而滑過,那麼它在那個時候就會忽略 null。在做這些改變的時候,PR 也特別處理了 List 輸入,以避免做一個複製,因爲 List 也是我們看到的被輸入到 WhenAll 的主要類型之一(例如,有人構建了一個任務列表,然後等待所有的任務)。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Collections.ObjectModel;
using System.Runtime.CompilerServices;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
    [Benchmark]
    public void WhenAll_Array()
    {
        AsyncTaskMethodBuilder atmb1 = AsyncTaskMethodBuilder.Create();
        AsyncTaskMethodBuilder atmb2 = AsyncTaskMethodBuilder.Create();
        Task whenAll = Task.WhenAll(atmb1.Task, atmb2.Task);
        atmb1.SetResult();
        atmb2.SetResult();
        whenAll.Wait();
    }

    [Benchmark]
    public void WhenAll_List()
    {
        AsyncTaskMethodBuilder atmb1 = AsyncTaskMethodBuilder.Create();
        AsyncTaskMethodBuilder atmb2 = AsyncTaskMethodBuilder.Create();
        Task whenAll = Task.WhenAll(new List<Task>(2) { atmb1.Task, atmb2.Task });
        atmb1.SetResult();
        atmb2.SetResult();
        whenAll.Wait();
    }

    [Benchmark]
    public void WhenAll_Collection()
    {
        AsyncTaskMethodBuilder atmb1 = AsyncTaskMethodBuilder.Create();
        AsyncTaskMethodBuilder atmb2 = AsyncTaskMethodBuilder.Create();
        Task whenAll = Task.WhenAll(new ReadOnlyCollection<Task>(new[] { atmb1.Task, atmb2.Task }));
        atmb1.SetResult();
        atmb2.SetResult();
        whenAll.Wait();
    }

    [Benchmark]
    public void WhenAll_Enumerable()
    {
        AsyncTaskMethodBuilder atmb1 = AsyncTaskMethodBuilder.Create();
        AsyncTaskMethodBuilder atmb2 = AsyncTaskMethodBuilder.Create();
        var q = new Queue<Task>(2);
        q.Enqueue(atmb1.Task);
        q.Enqueue(atmb2.Task);
        Task whenAll = Task.WhenAll(q);
        atmb1.SetResult();
        atmb2.SetResult();
        whenAll.Wait();
    }
}
Method Runtime Mean Ratio Allocated Alloc Ratio
WhenAll_Array .NET 7.0 210.8 ns 1.00 304 B 1.00
WhenAll_Array .NET 8.0 160.9 ns 0.76 264 B 0.87
WhenAll_List .NET 7.0 296.4 ns 1.00 376 B 1.00
WhenAll_List .NET 8.0 185.5 ns 0.63 296 B 0.79
WhenAll_Collection .NET 7.0 271.3 ns 1.00 360 B 1.00
WhenAll_Collection .NET 8.0 199.7 ns 0.74 328 B 0.91
WhenAll_Enumerable .NET 7.0 328.2 ns 1.00 472 B 1.00
WhenAll_Enumerable .NET 8.0 230.0 ns 0.70 432 B 0.92

泛型 WhenAny 也在 dotnet/runtime#88154 的一部分中得到了改進,它從一個額外的繼續中移除了一個任務分配,這是一個實現細節。這是我最喜歡的 PR 類型之一:它不僅提高了性能,還使代碼更清晰,代碼量也更少。

Alt text

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
    [Benchmark]
    public Task<Task<int>> WhenAnyGeneric_ListNotCompleted()
    {
        AsyncTaskMethodBuilder<int> atmb1 = default;
        AsyncTaskMethodBuilder<int> atmb2 = default;
        AsyncTaskMethodBuilder<int> atmb3 = default;

        Task<Task<int>> wa = Task.WhenAny(new List<Task<int>>() { atmb1.Task, atmb2.Task, atmb3.Task });

        atmb3.SetResult(42);

        return wa;
    }
}
Method Runtime Mean Ratio Allocated Alloc Ratio
WhenAnyGeneric_ListNotCompleted .NET 7.0 555.0 ns 1.00 704 B 1.00
WhenAnyGeneric_ListNotCompleted .NET 8.0 260.3 ns 0.47 504 B 0.72

關於任務的最後一個例子,雖然這個例子有點不同,因爲它特別是關於提高測試性能(和測試可靠性)。假設你有一個像這樣的方法:

public static async Task LogAfterDelay(Action<string, TimeSpan> log)
{
    long startingTimestamp = Stopwatch.GetTimestamp();
    await Task.Delay(TimeSpan.FromSeconds(30));
    log("Completed", Stopwatch.GetElapsedTime(startingTimestamp));
}

這個方法的目的是等待30秒,然後記錄一個完成消息以及方法觀察到的過去的時間。這顯然是你在真實應用中會找到的功能的簡化,但你可以從中推斷出你可能寫過的代碼。你如何測試這呢?也許你已經寫了像這樣的測試:

[Fact]
public async Task LogAfterDelay_Success_CompletesAfterThirtySeconds()
{
    TimeSpan ts = default;

    Stopwatch sw = Stopwatch.StartNew();
    await LogAfterDelay((message, time) => ts = time);
    sw.Stop();

    Assert.InRange(ts, TimeSpan.FromSeconds(30), TimeSpan.MaxValue);
    Assert.InRange(sw.Elapsed, TimeSpan.FromSeconds(30), TimeSpan.MaxValue);
}

這驗證了方法在其日誌中包含了至少30秒的值,也驗證了至少過去了30秒。問題是什麼?從性能的角度來看,問題是這個測試必須等待30秒!這對於本來可以近乎瞬間完成的東西來說,是大量的開銷。現在想象一下延遲更長,比如10分鐘,或者我們有一堆測試都需要做同樣的事情。這使得良好和徹底的測試變得無法承受。

爲了解決這類情況,許多開發者引入了他們自己的時間流動的抽象。現在在 .NET 8 中,這已經不再需要了。從 dotnet/runtime#83604 開始,核心庫包括 System.TimeProvider。這個抽象基類抽象了時間的流動,有獲取當前 UTC 時間、獲取當前本地時間、獲取當前時區、獲取高頻時間戳和創建計時器(反過來返回新的 System.Threading.ITimer,支持改變計時器的滴答間隔)的成員。然後像 Task.Delay 和 CancellationTokenSource 的構造函數這樣的核心庫成員有新的接受 TimeProvider 的重載,並使用它進行時間相關的功能,而不是硬編碼到 DateTime.UtcNow、Stopwatch 或 System.Threading.Timer。有了這個,我們可以重寫我們之前的方法:

public static async Task LogAfterDelay(Action<string, TimeSpan> log, TimeProvider provider)
{
    long startingTimestamp = provider.GetTimestamp();
    await Task.Delay(TimeSpan.FromSeconds(30), provider);
    log("Completed", provider.GetElapsedTime(startingTimestamp));
}

它已經增加了接受 TimeProvider 參數的功能,雖然在使用依賴注入(DI)機制的系統中,它可能只是從 DI 中獲取一個 TimeProvider 單例。然後它使用 provider 上的對應成員,而不是使用 Stopwatch.GetTimestamp 或 Stopwatch.GetElapsedTime,而不是使用只接受持續時間的 Task.Delay 重載,它使用也接受 TimeProvider 的重載。在生產中使用時,可以傳遞 TimeProvider.System,這是基於系統時鐘實現的(如果不提供 TimeProvider,你會得到的就是這個),但在測試中,可以傳遞一個自定義實例,一個手動控制觀察到的時間流動的實例。在 Microsoft.Extensions.TimeProvider.Testing NuGet 包中就存在這樣一個自定義的 TimeProvider:FakeTimeProvider。下面是一個使用它和我們的 LogAfterDelay 方法的例子:

// dotnet run -c Release -f net8.0 --filter "*"

using Microsoft.Extensions.Time.Testing;
using System.Diagnostics;

Stopwatch sw = Stopwatch.StartNew();

var fake = new FakeTimeProvider();

Task t = LogAfterDelay((message, time) => Console.WriteLine($"{message}: {time}"), fake);

fake.Advance(TimeSpan.FromSeconds(29));
Console.WriteLine(t.IsCompleted);

fake.Advance(TimeSpan.FromSeconds(1));
Console.WriteLine(t.IsCompleted);

Console.WriteLine($"Actual execution time: {sw.Elapsed}");

static async Task LogAfterDelay(Action<string, TimeSpan> log, TimeProvider provider)
{
    long startingTimestamp = provider.GetTimestamp();
    await Task.Delay(TimeSpan.FromSeconds(30), provider);
    log("Completed", provider.GetElapsedTime(startingTimestamp));
}

當我運行這個時,它輸出了以下內容:

False
Completed: 00:00:30
True
Actual execution time: 00:00:00.0119943

換句話說,在手動推進時間29秒後,操作還沒有完成。然後我們手動推進了一秒鐘,操作完成了。它報告說過去了30秒,但實際上,整個操作只花了實際牆鍾時間的0.01秒。

有了這個,讓我們移動到 Parallel...

Parallel

.NET 6 在 Parallel 中引入了新的異步方法,形式爲 Parallel.ForEachAsync。在它的引入之後,我們開始收到對於 for 循環的等價物的請求,所以現在在 .NET 8 中,通過 dotnet/runtime#84804,這個類獲得了一組 Parallel.ForAsync 方法。這些以前可以通過傳入一個從像 Enumerable.Range 這樣的方法創建的 IEnumerable 來實現,例如:

await Parallel.ForEachAsync(Enumerable.Range(0, 1_000), async i =>
{
   ... 
});

但現在你可以更簡單、更便宜地實現同樣的功能:

await Parallel.ForAsync(0, 1_000, async i =>
{
   ... 
});

這最終會更便宜,因爲你不需要分配可枚舉的/枚舉器,而且多個工作器試圖剝離下一個迭代的同步可以以一種更不昂貴的方式完成,一個 Interlocked 而不是使用像 SemaphoreSlim 這樣的異步鎖。

// dotnet run -c Release -f net8.0 --filter "*"

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(Baseline = true)]
    public Task ForEachAsync() => Parallel.ForEachAsync(Enumerable.Range(0, 1_000_000), (i, ct) => ValueTask.CompletedTask);

    [Benchmark]
    public Task ForAsync() => Parallel.ForAsync(0, 1_000_000, (i, ct) => ValueTask.CompletedTask);
}
方法 平均 比率 分配 分配比率
ForEachAsync 589.5 ms 1.00 87925272 B 1.000
ForAsync 147.5 ms 0.25 792 B 0.000

這裏的分配列特別明顯,也有點誤導。爲什麼 ForEachAsync 在分配方面這麼糟糕?這是因爲同步機制。這裏的測試代理沒有執行任何工作,所以所有的時間都花在了源上。在 Parallel.ForAsync 的情況下,獲取下一個值是一個單獨的 Interlocked 指令。在 Parallel.ForEachAsync 的情況下,它是一個 WaitAsync,而在很多競爭下,許多 WaitAsync 調用將異步完成,導致分配。在一個真實的工作負載中,其中的主體代理正在執行真實的工作,同步或異步,那麼同步的影響就會小得多。這裏我把調用改爲了一個簡單的 Task.Delay,延遲1ms(並且也顯著降低了迭代次數):

// dotnet run -c Release -f net8.0 --filter "*"

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(Baseline = true)]
    public Task ForEachAsync() => Parallel.ForEachAsync(Enumerable.Range(0, 100), async (i, ct) => await Task.Delay(1));

    [Benchmark]
    public Task ForAsync() => Parallel.ForAsync(0, 100, async (i, ct) => await Task.Delay(1));
}

GitHub Copilot: 和這兩種方法實際上是一樣的:

方法 平均 比率 分配 分配比率
ForEachAsync 89.39 ms 1.00 27.96 KB 1.00
ForAsync 89.44 ms 1.00 27.84 KB 1.00

有趣的是,這個 Parallel.ForAsync 方法也是核心庫中第一個基於 .NET 7 中引入的泛型數學接口的公共方法之一:

public static Task ForAsync<T>(T fromInclusive, T toExclusive, Func<T, CancellationToken, ValueTask> body)
    where T : notnull, IBinaryInteger<T>

.net7 線程的性能優化部分

Threading 是一種橫切關注點,影響着每一個應用程序,因此線程空間的變化可能會產生廣泛的影響。這個版本看到了 ThreadPool 本身的兩個非常重大的變化;dotnet/runtime#64834 將 "IO pool" 完全切換到使用完全託管的實現(儘管之前工作池已完全切換到託管模式,但之前的 IO 池仍然使用原生代碼),而 dotnet/runtime#71864 同樣將計時器實現從基於原生的切換爲完全基於託管代碼。這兩個變化可能會影響性能,而且前者已經在更大規模硬件上進行了演示,但在很大程度上,這並不是它們的主要目標。相反,其他 PRs 一直專注於提高吞吐量。

特別值得一提的是 dotnet/runtime#69386。ThreadPool 擁有一個“全局隊列”,任何線程都可以將工作排入其中,然後池中的每個線程都有自己的“本地隊列”(任何線程都可以從中出列,但只有擁有它的線程才能將工作排入其中)。當工作線程需要另一個要處理的工作時,首先檢查自己的本地隊列,然後檢查全局隊列,僅當兩個地方都找不到工作時,它纔會去檢查所有其他線程的本地隊列,以查看是否可以幫助減輕它們的負載。隨着機器的核心數量和線程數量不斷增加,對這些共享隊列(特別是全局隊列)的爭用也越來越多。該 PR 針對這樣規模更大的機器進行了處理,一旦機器達到一定閾值(目前是32個處理器),就引入了額外的全局隊列。這有助於將訪問分配到多個隊列中,從而減少爭用。

另一個是 dotnet/runtime#57885。爲了協調線程,當工作項被入隊和出隊時,池會向其線程發出請求,讓它們知道有可用的工作要做。然而,這常常導致過度訂閱,即當系統未滿載時,會有更多的線程爭搶嘗試獲取工作項。這反過來會表現爲吞吐量下降。這個改變徹底改變了如何請求線程,這樣一次只請求一個額外的線程,當該線程出隊其第一個工作項後,如果還有剩餘的工作,它可以請求一個額外的線程,然後那個線程可以請求一個額外的線程,依此類推。這是我們性能測試套件中的一個性能測試(我已經將其簡化,去掉了測試中的一堆配置選項,但它仍然準確地是其中的一個配置)。乍一看你可能會想,“嘿,這是一個關於 ArrayPool 的性能測試,爲什麼它會出現在一個關於線程的討論中?”你是對的,這是一個專注於 ArrayPool 的性能測試。然而,如前所述,線程影響一切,在這種情況下,那個在中間的 await Task.Yield() 導致此方法的剩餘部分被排隊到 ThreadPool 中執行。並且,由於測試的結構,執行“真實的工作”與線程池線程爭搶獲取下一個任務的 CPU 週期競爭,當移動到 .NET 7 時,它顯示出可衡量的改進。

private readonly byte[][] _nestedArrays = new byte[8][];
private const int Iterations = 100_000;

private static byte IterateAll(byte[] arr)
{
    byte ret = default;
    foreach (byte item in arr) ret = item;
    return ret;
}

[Benchmark(OperationsPerInvoke = Iterations)]
public async Task MultipleSerial()
{
    for (int i = 0; i < Iterations; i++)
    {
        for (int j = 0; j < _nestedArrays.Length; j++)
        {
            _nestedArrays[j] = ArrayPool<byte>.Shared.Rent(4096);
            _nestedArrays[j].AsSpan().Clear();
        }

        await Task.Yield();

        for (int j = _nestedArrays.Length - 1; j >= 0; j--)
        {
            IterateAll(_nestedArrays[j]);
            ArrayPool<byte>.Shared.Return(_nestedArrays[j]);
        }
    }
}

方法 運行時 平均值 比率
MultipleSerial .NET 6.0 14.340 us 1.00
MultipleSerial .NET 7.0 9.262 us 0.65

ThreadPool 之外也有一些改進。一個顯著的變化是在 dotnet/runtime#68790 中處理 AsyncLocal 的方式。AsyncLocal 與 ExecutionContext 緊密集成;事實上,在 .NET Core 中,ExecutionContext 完全與流動 AsyncLocal 實例相關。一個 ExecutionContext 實例維護一個字段,即一個映射數據結構,用於存儲該上下文中存在的所有 AsyncLocal 的數據。每個 AsyncLocal 都有一個對象作爲其鍵,並且對該 AsyncLocal 的任何獲取或設置都表現爲獲取當前 ExecutionContext,在上下文的字典中查找該 AsyncLocal 的鍵,然後要麼返回找到的任何數據,要麼在設置器的情況下創建一個帶有更新字典的新 ExecutionContext 併發布回去。因此,這個字典在讀取和寫入方面需要非常高效,因爲開發人員希望 AsyncLocal 的訪問儘可能快,通常將其視爲任何其他本地變量一樣對待。因此,爲了優化這些查找,該字典的表示方式根據此上下文中表示的 AsyncLocal 的數量而改變。對於最多三個項目,使用了專門的實現,每個實現都有三個鍵和值的字段。超過三個項目,最多約 16 個元素,使用了一個鍵/值對數組。而超過這個數量,就使用了一個 Dictionary<,>。大多數情況下,這個方法效果很好,因爲大多數 ExecutionContext 能夠使用前三種類型來表示許多流程。但是,結果表明,有四個活躍的 AsyncLocal 實例非常常見,特別是在 ASP.NET 中,其中 ASP.NET 基礎設施本身使用了一些。因此,這個 PR 承擔了複雜性,添加了一個專門用於四個鍵/值對的類型,以便從一個到四個進行優化,而不是從一個到三。雖然這會稍微提高吞吐量,但它的主要目的是改善分配,相比於 .NET 6,改進了約 20%。

除了 ThreadPool 外,其他地方也有所改進。一個值得注意的變化是在處理 AsyncLocal 的方式上,見 dotnet/runtime#68790。AsyncLocal 與 ExecutionContext 緊密集成;實際上,在 .NET Core 中,ExecutionContext 完全是關於流動 AsyncLocal 實例的。一個 ExecutionContext 實例維護一個單一字段,一個映射數據結構,存儲該上下文中所有 AsyncLocal 的數據。每個 AsyncLocal 都有一個它用作鍵的對象,任何對該 AsyncLocal 的獲取或設置都表現爲獲取當前的 ExecutionContext,在上下文的字典中查找該 AsyncLocal 的鍵,然後返回它找到的任何數據,或者在設置器的情況下,創建一個帶有更新字典的新 ExecutionContext 併發布回去。因此,這個字典需要對讀取和寫入非常高效,因爲開發者期望 AsyncLocal 的訪問儘可能快,經常將其視爲其他任何本地變量。因此,爲了優化這些查找,該字典的表示方式會根據此上下文中表示的 AsyncLocal 的數量而變化。對於最多三個項目,使用了爲每個鍵和值的三個字段的專用實現。在此之上,最多約 16 個元素,使用了鍵/值對的數組。在此之上,使用了 Dictionary<,>。大部分情況下,這種方式運行良好,大多數 ExecutionContext 能夠用前三種類型中的一種表示許多流。然而,事實證明,四個活動的 AsyncLocal 實例非常常見,特別是在 ASP.NET 中,ASP.NET 的基礎設施本身就使用了幾個。因此,這個 PR 承擔了複雜性的打擊,添加了一個專用於四個鍵/值對的類型,以便優化它們中的一到四個,而不是一到三個。雖然這稍微提高了吞吐量,但其主要目的是改善分配,它在 .NET 6 上提高了約 20%。


private AsyncLocal<int> asyncLocal1 = new AsyncLocal<int>();
private AsyncLocal<int> asyncLocal2 = new AsyncLocal<int>();
private AsyncLocal<int> asyncLocal3 = new AsyncLocal<int>();
private AsyncLocal<int> asyncLocal4 = new AsyncLocal<int>();

[Benchmark(OperationsPerInvoke = 4000)]
public void Update()
{
    for (int i = 0; i < 1000; i++)
    {
        asyncLocal1.Value++;
        asyncLocal2.Value++;
        asyncLocal3.Value++;
        asyncLocal4.Value++;
    }
}

方法 運行時 平均值 比率 代碼大小 分配 分配比率
Update .NET 6.0 61.96 ns 1.00 1,272 B 176 B 1.00
Update .NET 7.0 61.92 ns 1.00 1,832 B 144 B 0.82

另一個有價值的修復是在 dotnet/runtime#70165 中對鎖定的處理。這個特定的改進有點難以用 benchmarkdotnet 來演示,所以只需嘗試運行這個程序,先在 .NET 6 上運行,然後在 .NET 7 上運行:

using System.Diagnostics;

var rwl = new ReaderWriterLockSlim();
var tasks = new Task[100];
int count = 0;

DateTime end = DateTime.UtcNow + TimeSpan.FromSeconds(10);
while (DateTime.UtcNow < end)
{
    for (int i = 0; i < 100; ++i)
    {
        tasks[i] = Task.Run(() =>
        {
            var sw = Stopwatch.StartNew();
            rwl.EnterReadLock();
            rwl.ExitReadLock();
            sw.Stop();
            if (sw.ElapsedMilliseconds >= 10)
            {
                Console.WriteLine(Interlocked.Increment(ref count));
            }
        });
    }

    Task.WaitAll(tasks);
}

這個程序簡單地啓動了100個任務,每個任務都進入和退出一個讀寫鎖,等待它們全部完成,然後再重複這個過程,持續10秒。它還計時進入和退出鎖所需的時間,並在必須等待至少15毫秒時寫入警告。當我在 .NET 6 上運行這個程序時,我得到了大約100次進入/退出鎖需要 >= 10 毫秒的情況。在 .NET 7 上,我得到了0次。爲什麼會有這種差異呢?ReaderWriterLockSlim 的實現有自己的旋轉循環實現,這個旋轉循環試圖在旋轉時混合各種操作,範圍從調用 Thread.SpinWait 到 Thread.Sleep(0) 到 Thread.Sleep(1)。問題在於 Thread.Sleep(1)。這表示“讓這個線程睡眠1毫秒”;然而,操作系統對這種時間有最終的決定權,而在 Windows 上,默認的睡眠時間會接近15毫秒(在 Linux 上稍低但仍然相當高)。因此,每次鎖的爭用足夠強烈以至於強制它調用 Thread.Sleep(1),我們就會至少延遲15毫秒,如果不是更多。上述 PR 通過消除對 Thread.Sleep(1) 的使用來解決這個問題。

最後要提到的與線程相關的變化是:dotnet/runtime#68639。這個是特定於 Windows 的。Windows 有處理器組的概念,每個處理器組可以有多達64個核心,且默認情況下,當一個進程運行時,它被分配一個特定的處理器組,並且只能使用該組中的核心。在 .NET 7 中,運行時將其默認值翻轉,以便默認情況下儘可能使用所有處理器組。

net6 線程部分的改進

我們來談談線程,從 ThreadPool 開始。

有時候,性能優化是關於消除不必要的工作,或者做出優化常見情況而稍微降低小衆情況的權衡,或者利用新的低級功能來更快地做某事,或者其他許多事情。但有時,性能優化是關於找到幫助糟糕但常見的代碼變得稍微不那麼糟糕的方法。

線程池的工作很簡單:運行工作項。爲了做到這一點,線程池在其核心需要兩件事:一個待處理的工作隊列,和一組處理它們的線程。我們可以輕易地編寫一個功能性的,簡單的線程池:

static class SimpleThreadPool
{
    private static BlockingCollection<Action> s_work = new();

    public static void QueueUserWorkItem(Action action) => s_work.Add(action);

    static SimpleThreadPool()
    {
        for (int i = 0; i < Environment.ProcessorCount; i++)
            new Thread(() =>
            {
                while (true) s_work.Take()();
            }) { IsBackground = true }.Start();
    }
}

嗯,這是一個功能性的線程池。但是...並不是一個很好的線程池。一個好的線程池最難的部分在於線程的管理,特別是在任何給定的時間點確定應該有多少線程在服務工作隊列。線程太多,你可能會讓系統停滯不前,因爲所有的線程都在爭奪系統的資源,通過上下文切換增加了巨大的開銷,並且由於緩存抖動而相互干擾。線程太少,你可能會讓系統停滯不前,因爲工作項沒有被快速處理,或者更糟糕的是,正在運行的工作項被阻塞等待其他工作項運行,但沒有足夠的額外線程來運行它們。.NET ThreadPool 有多種機制來確定在任何時間點應該有多少線程在運行。首先,它有一個飢餓檢測機制。這個機制是一個相當直接的門,每秒觸發一次或兩次,檢查是否有任何進展在從池的隊列中移除項目:如果沒有進展,意味着沒有被出隊,池假設系統是飢餓的並注入一個額外的線程。其次,它有一個爬山算法,這個算法通過操縱可用的線程數量,不斷尋求最大化工作項的吞吐量;每完成 N 個工作項後,它評估增加或減少一個線程到/從循環中是否有助於或損害工作項的吞吐量,從而使其適應系統當前的需求。然而,爬山機制有一個弱點:爲了正確地完成它的工作,工作項需要完成...如果工作項沒有完成,比如說,池中的所有線程都被阻塞,爬山就暫時無用,注入額外線程的唯一機制就是飢餓機制,這個機制(按設計)相當慢。

這種情況可能會出現在一個系統被“同步阻塞異步”工作淹沒的時候,這個術語是用來指啓動異步工作然後同步阻塞等待它完成的;在常見的情況下,這樣的反模式最終會阻塞一個線程池線程,這個線程依賴於另一個線程池線程做工作以便解除第一個線程的阻塞,這可能很快導致所有的線程池線程都被阻塞,直到注入足夠的線程使每個人都能向前進展。這樣的“同步阻塞異步”代碼,通常表現爲調用一個異步方法然後阻塞等待返回的任務(例如 int i = GetValueAsync().Result)在生產代碼中通常被認爲是不可接受的,這些代碼意味着要可擴展,但有時候它是無法避免的,例如你被迫實現一個同步的接口,而你手頭上唯一可以用來實現的功能只能通過異步方法來暴露。

我們可以通過一個糟糕的復現來看到這個影響:


using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;

var tcs = new TaskCompletionSource();
var tasks = new List<Task>();
for (int i = 0; i < Environment.ProcessorCount * 4; i++)
{
    int id = i;
    tasks.Add(Task.Run(() =>
    {
        Console.WriteLine($"{DateTime.UtcNow:MM:ss.ff}: {id}");
        tcs.Task.Wait();
    }));
}
tasks.Add(Task.Run(() => tcs.SetResult()));

var sw = Stopwatch.StartNew();
Task.WaitAll(tasks.ToArray());
Console.WriteLine($"Done: {sw.Elapsed}");

這將一堆工作項排隊到線程池,所有這些工作項都阻塞等待一個任務完成,但是那個任務不會完成,直到最後一個排隊的工作項完成它以解鎖所有其他的工作項。因此,我們最終阻塞了池中的每一個線程,等待線程池檢測到飢餓並注入另一個線程,然後復現就會盡職盡責地阻塞它,如此反覆,直到最後有足夠的線程,每個排隊的工作項都可以併發運行。在 .NET Framework 4.8 和 .NET 5 上,上述復現在我的12邏輯核心的機器上需要大約32秒才能完成。你可以在這裏看到輸出;注意每個工作項上的時間戳,你可以看到在非常快速地增加到與核心數量相等的線程數量後,它然後非常慢地引入額外的線程。

07:54.51: 4
07:54.51: 8
07:54.51: 1
07:54.51: 5
07:54.51: 9
07:54.51: 0
07:54.51: 10
07:54.51: 2
07:54.51: 11
07:54.51: 3
07:54.51: 6
07:54.51: 7
07:55.52: 12
07:56.52: 13
07:57.53: 14
07:58.52: 15
07:59.52: 16
07:00.02: 17
07:01.02: 18
07:01.52: 19
07:02.51: 20
07:03.52: 21
07:04.52: 22
07:05.03: 23
07:06.02: 24
07:07.03: 25
07:08.01: 26
07:09.03: 27
07:10.02: 28
07:11.02: 29
07:11.52: 30
07:12.52: 31
07:13.52: 32
07:14.02: 33
07:15.02: 34
07:15.53: 35
07:16.51: 36
07:17.02: 37
07:18.02: 38
07:18.52: 39
07:19.52: 40
07:20.52: 41
07:21.52: 42
07:22.55: 43
07:23.52: 44
07:24.53: 45
07:25.52: 46
07:26.02: 47
Done: 00:00:32.5128769

我很高興地說,對於.NET 6,這種情況有所改善。這並不是讓你開始編寫更多的同步阻塞異步代碼,而是承認有時這是無法避免的,特別是在現有的應用程序可能無法一次性轉移到異步模型,可能有一些遺留組件等情況下。dotnet/runtime#53471教會了線程池我們在這些情況下看到的最常見的阻塞形式,即等待一個尚未完成的任務。作爲迴應,只要阻塞持續,線程池就會變得更加積極地增加其目標線程數,然後在阻塞結束後立即再次降低目標數。在.NET 6上再次運行相同的控制檯應用程序,我們可以看到大約32秒的時間縮短到大約1.5秒,線程池對阻塞的反應更快地注入線程。

07:53.39: 5
07:53.39: 7
07:53.39: 6
07:53.39: 8
07:53.39: 9
07:53.39: 10
07:53.39: 1
07:53.39: 0
07:53.39: 4
07:53.39: 2
07:53.39: 3
07:53.47: 12
07:53.47: 11
07:53.47: 13
07:53.47: 14
07:53.47: 15
07:53.47: 22
07:53.47: 16
07:53.47: 17
07:53.47: 18
07:53.47: 19
07:53.47: 21
07:53.47: 20
07:53.50: 23
07:53.53: 24
07:53.56: 25
07:53.59: 26
07:53.63: 27
07:53.66: 28
07:53.69: 29
07:53.72: 30
07:53.75: 31
07:53.78: 32
07:53.81: 33
07:53.84: 34
07:53.91: 35
07:53.97: 36
07:54.03: 37
07:54.10: 38
07:54.16: 39
07:54.22: 40
07:54.28: 41
07:54.35: 42
07:54.41: 43
07:54.47: 44
07:54.54: 45
07:54.60: 46
07:54.68: 47
Done: 00:00:01.3649530

有趣的是,這個改進是由.NET 6中另一個大的線程池相關改變更容易實現的:現在的實現完全是用C#。在.NET的之前版本中,線程池的核心調度例程是在託管代碼中,但所有關於線程管理的邏輯都仍然在運行時的本地中。所有這些邏輯之前已經被移植到C#中,以支持CoreRT和mono,但它並沒有被用於coreclr。從.NET 6和dotnet/runtime#43841開始,它現在在所有地方都被使用。這應該使得進一步的改進和優化更容易,並在未來的版本中使池有更多的進步。

從線程池移開,.NET/runtime#55295 是一個有趣的改進。在多線程代碼中,您經常會遇到一些情況,無論是直接使用低鎖算法,還是間接使用併發原語(如鎖和信號量),都會有忙於等待某事發生的情況。基於這樣一個觀念:操作系統中阻塞等待某事發生對於較長的等待是非常高效的,但在等待操作的開始和結束時會帶來非同尋常的開銷;如果你等待的事情很可能很快就會發生,你可能最好直接循環嘗試再次發生,或者在非常短暫的暫停後嘗試。我在那裏使用的“PAUSE”一詞並非偶然,因爲x86指令集包括了“PAUSE”指令,它告訴處理器代碼正在執行忙等待,並幫助其進行相應的優化。然而,“PAUSE”指令所產生的延遲在不同的處理器架構上可能差異很大,例如,在英特爾 Core i5 上可能僅需 9 個週期,在 AMD Ryzen 7 上可能需要 65 個週期,在英特爾 Core i7 上可能需要 140 個週期。這使得調整使用 spin 循環編寫的更高層次代碼的行爲變得具有挑戰性,因爲運行時中的核心代碼和核心庫中的關鍵併發相關類型確實如此。爲了應對這種差異並提供一致的暫停視角,之前的 .NET 發行版嘗試在啓動時測量暫停的持續時間,然後使用這些指標在對角線上正常化使用多少暫停。然而,這種方法有幾個缺點。儘管在啓動路徑的主要線程上沒有進行測量,但它仍然爲每個進程貢獻了毫秒級的 CPU 時間,這些時間疊加在每天發生的數百萬或數十億次 .NET 進程調用上。此外,該測量僅對進程執行一次,但由於多種原因,進程壽命期間的開銷實際上可能會發生變化,例如,如果虛擬機被暫停並從一臺物理機移動到另一臺。爲了克服這個問題,上述 PR 改變了方案。與其在啓動時一次測量較長時間,不如定期進行短暫測量,並據此刷新對暫停時間認識的刷新。這應該會導致 CPU 利用率的整體下降,以及更準確地瞭解這些暫停的成本,從而使依賴它的應用程序和服務的行爲更加一致。

讓我們繼續談談 Task,在這裏有很多的改進。一個值得注意且早該改變的是使 Task.FromResult 能夠返回一個緩存的實例。當在 .NET Framework 4.5 中添加了異步方法時,我們添加了一個緩存,異步 Task 方法可以用於同步完成的操作(同步完成的異步方法反直覺地非常常見;考慮一個方法,第一次調用做 I/O 來填充一個緩衝區,但後續的操作只是從該緩衝區中消耗)。而不是爲這樣的方法的每次調用構造一個新的 Task,緩存會被查詢以查看是否可以使用單例 Task。顯然,緩存不能爲每個可能的 T 的每個可能的值存儲一個單例,但它可以特殊處理一些 T,併爲每個 T 緩存一些值。例如,它緩存了兩個 Task 實例,一個爲 true,一個爲 false,以及大約 10 個 Task 實例,每個實例對應 -1 到 8 之間的值,包含兩端。但是 Task.FromResult 從未使用過這個緩存,即使緩存中有一個任務,它也總是返回一個新的實例。這導致了兩種常見的情況:要麼使用 Task.FromResult 的開發者認識到這個缺陷並必須爲像 true 和 false 這樣的值維護他們自己的緩存,要麼使用 Task.FromResult 的開發者沒有認識到這一點,最終可能會支付不必要的分配。對於 .NET 6,dotnet/runtime#43894 改變了 Task.FromResult 以查詢緩存,所以創建一個 bool true 或 int 1 的任務,例如,不再分配。當 Task.FromResult 用於可以被緩存但特定值不是的類型時,這會增加一點點的開銷(一兩個分支);然而,考慮到對極其常見值的節省,總的來說這是值得的。

當然,任務與C#中的異步方法緊密相連,值得看一下C# 10和.NET 6中一個小而重要的特性,這可能會直接或間接影響很多.NET代碼。這需要一些背景知識。當C#編譯器去實現一個帶有簽名async SomeTaskLikeType的異步方法時,它會諮詢SomeTaskLikeType來看應該使用什麼“構建器”來幫助實現該方法。例如,ValueTask帶有[AsyncMethodBuilder(typeof(AsyncValueTaskMethodBuilder))]屬性,因此任何異步ValueTask方法都會導致編譯器使用AsyncValueTaskMethodBuilder作爲該方法的構建器。如果我們編譯一個簡單的異步方法,我們可以看到這一點:

public static async ValueTask ExampleAsync() { }
for which the compiler produces approximately the following as the implementation of ExampleAsync:

public static ValueTask ExampleAsync()
{
    <ExampleAsync>d__0 stateMachine = default;
    stateMachine.<>t__builder = AsyncValueTaskMethodBuilder.Create();
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
    return stateMachine.<>t__builder.Task;
}

這種構建器類型在生成的代碼中被用來創建構建器實例(通過一個靜態的 Create 方法),訪問構建的任務(通過一個 Task 實例屬性),完成那個構建的任務(通過 SetResult 和 SetException 實例方法),以及處理與那個構建的任務相關的狀態管理,當一個 await 產生(通過 AwaitOnCompleted 和 UnsafeAwaitOnCompleted 實例方法)。由於有四種類型內置在覈心庫中,它們被設計爲用作異步方法的返回類型(Task,Task,ValueTask,和 ValueTask),核心庫也包含四個構建器(AsyncTaskMethodBuilder,AsyncTaskMethodBuilder,AsyncValueTaskMethodBuilder,和 AsyncValueTaskMethodBuilder),所有這些都在 System.Runtime.CompilerServices 中。大多數開發者在他們閱讀或寫的任何代碼中都不應該看到這些類型。

然而,這種模型的一個缺點是,選擇哪個構建器與從異步方法返回的類型的定義有關。所以,如果你想定義你的異步方法返回 Task,Task,ValueTask,或 ValueTask,你無法控制使用的構建器:它由那個類型決定,只由那個類型決定。你爲什麼想改變構建器呢?有各種原因可能使人想控制任務生命週期的細節,但最突出的一個是池化。當一個異步 Task,異步 ValueTask 或異步 ValueTask 方法同步完成時,不需要分配任何東西:對於 Task,實現可以只返回 Task.CompletedTask,對於 ValueTask,它可以只返回 ValueTask.CompletedTask(這與 default(ValueTask) 相同),對於 ValueTask,它可以返回 ValueTask.FromResult,這會創建一個包裝 T 值的結構。然而,當方法異步完成時,實現需要分配一些對象(一個 Task 或 Task)來唯一標識這個異步操作,並提供一個通過它可以將完成信息傳回給等待返回實例的調用者的途徑。

ValueTask 不僅支持由 T 或 Task 支持,還支持由 IValueTaskSource 支持,這允許有進取心的開發者插入自定義實現,包括可能被池化的實現。如果我們可以編寫一個使用並池化自定義 IValueTaskSource 實例的構建器,而不是使用上述構建器,會怎樣呢?它可以使用這些實例來支持從異步完成的 async ValueTask 方法返回的 ValueTask,而不是 Task。如博客文章 .NET 5 中的 Async ValueTask Pooling 所概述的,.NET 5 包含了這樣一個可選實驗,其中 AsyncValueTaskMethodBuilder 和 AsyncValueTaskMethodBuilder 有一個自定義的 IValueTaskSource/IValueTaskSource 實現,它們可以實例化並池化,並用作 ValueTask 或 ValueTask 的後備對象。當一個異步方法第一次需要產生並將所有狀態從堆棧移動到堆時,這些構建器會諮詢池並嘗試使用已經存在的對象,只有當池中沒有可用的對象時纔會分配一個新的對象。然後,在通過等待產生的 ValueTask/ValueTask 調用 GetResult() 時,對象將被返回到池中。這個實驗已經完成,.NET 6 已經移除了環境變量。取而代之的是,這種能力在 .NET 6 和 C# 10 中以新的形式得到支持。

我們之前看到的 [AsyncMethodBuilder] 屬性現在可以放在方法上,除了類型之外,感謝 dotnet/roslyn#54033;當一個異步方法被 [AsyncMethodBuilder(typeof(SomeBuilderType))] 屬性標記時,C# 編譯器將會優先選擇那個構建器而不是默認的。並且,伴隨着 C# 10 語言/編譯器特性,.NET 6 包含了兩種新的構建器類型,PoolingAsyncValueTaskMethodBuilder 和 PoolingAsyncValueTaskMethodBuilder,感謝 dotnet/runtime#50116 和 dotnet/runtime#55955。如果我們改變我們之前的例子爲:

[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
public static async ValueTask ExampleAsync() { }
now the compiler generates:

public static ValueTask ExampleAsync()
{
    <ExampleAsync>d__0 stateMachine = default;
    stateMachine.<>t__builder = PoolingAsyncValueTaskMethodBuilder.Create();
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
    return stateMachine.<>t__builder.Task;
}

這意味着ExampleAsync現在可能使用池化的對象來支持返回的ValueTask實例。我們可以通過一個簡單的基準測試來看到這一點:

const int Iters = 100_000;

[Benchmark(OperationsPerInvoke = Iters, Baseline = true)]
public async Task WithoutPooling()
{
    for (int i = 0; i < Iters; i++)
        await YieldAsync();

    async ValueTask YieldAsync() => await Task.Yield();
}

[Benchmark(OperationsPerInvoke = Iters)]
public async Task WithPooling()
{
    for (int i = 0; i < Iters; i++)
        await YieldAsync();

    [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
    async ValueTask YieldAsync() => await Task.Yield();
}
Method Mean Ratio Allocated
WithoutPooling 763.9ns 1.00 112B
WithPooling 781.9ns 1.02

注意每次調用的分配從112字節降到0。那麼,爲什麼不直接將這種行爲設爲 AsyncValueTaskMethodBuilder 和 AsyncValueTaskMethodBuilder 的默認行爲呢?有兩個原因。首先,它確實創建了一個功能差異。任務比 ValueTasks 更有能力,支持併發使用,多個等待者,和同步阻塞。如果消費代碼,例如,正在執行:

ValueTask vt = SomeMethodAsync();
await vt;
await vt;

當 ValueTask 由 Task 支持時,這將“正常工作”,但是當啓用池化時,可能會以多種方式和不同的嚴重程度失敗。代碼分析規則 CA2012 旨在幫助避免此類代碼,但僅此一項是不足以防止此類中斷的。其次,如你從上面的基準測試中可以看到,雖然池化避免了分配,但它帶來了一點額外的開銷。這裏沒有顯示的是,維護池本身(每個異步方法都維護一個池)在內存和工作集中的額外開銷。這裏還有一些可能的開銷沒有顯示出來,這些是任何類型的池化的常見陷阱。例如,GC 優化爲使 gen0 收集非常快,它可以做到這一點的一種方式是不需要掃描 gen1 或 gen2 作爲 gen0 GC 的一部分。但是,如果有來自 gen1 或 gen2 的 gen0 對象的引用,那麼它確實需要掃描這些世代的部分(這就是爲什麼將引用存儲到字段中涉及“GC 寫屏障”,以查看是否將對 gen0 對象的引用存儲到來自更高世代的一箇中)。由於池化的整個目的是保持對象長時間存在,這些對象可能最終會在這些更高的世代中,它們存儲的任何引用可能最終會使 GC 更昂貴;這很容易在這些狀態機中出現,因爲在方法中使用的每個參數和局部變量可能都需要被跟蹤。因此,從性能的角度來看,最好只在可能重要並且性能測試證明它能夠朝正確方向推動指針的地方使用這種能力。當然,我們可以看到,除了節省分配外,還有一些場景實際上確實提高了吞吐量,這通常是人們在測量分配減少(即減少分配以減少在垃圾收集中花費的時間)時真正關注的改進點。

private const int Concurrency = 256;
private const int Iters = 100_000;

[Benchmark(Baseline = true)]
public Task NonPooling()
{
    return Task.WhenAll(from i in Enumerable.Range(0, Concurrency)
                        select Task.Run(async delegate
                        {
                            for (int i = 0; i < Iters; i++)
                                await A().ConfigureAwait(false);
                        }));

    static async ValueTask A() => await B().ConfigureAwait(false);

    static async ValueTask B() => await C().ConfigureAwait(false);

    static async ValueTask C() => await D().ConfigureAwait(false);

    static async ValueTask D() => await Task.Yield();
}

[Benchmark]
public Task Pooling()
{
    return Task.WhenAll(from i in Enumerable.Range(0, Concurrency)
                        select Task.Run(async delegate
                        {
                            for (int i = 0; i < Iters; i++)
                                await A().ConfigureAwait(false);
                        }));

    [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
    static async ValueTask A() => await B().ConfigureAwait(false);

    [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
    static async ValueTask B() => await C().ConfigureAwait(false);

    [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
    static async ValueTask C() => await D().ConfigureAwait(false);

    [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
    static async ValueTask D() => await Task.Yield();
}

Method Mean Ratio Allocated
NonPooling 3.271s 1.00 11,800,058 KB
Pooling 2.896s 0.88 214KB

除了這些新的構建器,.NET 6中還引入了其他與任務相關的新API。Task.WaitAsync在dotnet/runtime#48842中被引入,它提供了一個優化的實現,用於創建一個新的任務,該任務將在前一個任務完成或指定的超時時間已過或指定的CancellationToken已請求取消時完成。這對於替換一個相當常見的模式非常有用(不幸的是,開發者經常做錯),開發者希望等待一個任務完成,但是有超時和/或取消。例如,這樣:

Task t = ...;
using (var cts = new CancellationTokenSource())
{
    if (await Task.WhenAny(Task.Delay(timeout, cts.Token), t) != t)
    {
        throw new TimeoutException();
    }

    cts.Cancel();
    await t;
}

can now be replaced with just this:

Task t = ...;
await t.WaitAsync(timeout);

並且速度更快,開銷更小。一個很好的例子來自 dotnet/runtime#55262,它使用新的 Task.WaitAsync 替換了存在於 SemaphoreSlim.WaitAsync 內部的類似實現,使得後者現在更易於維護,速度更快,分配更少。

private SemaphoreSlim _sem = new SemaphoreSlim(0, 1);
private CancellationTokenSource _cts = new CancellationTokenSource();

[Benchmark]
public Task WithCancellationToken()
{
    Task t = _sem.WaitAsync(_cts.Token);
    _sem.Release();
    return t;
}

[Benchmark]
public Task WithTimeout()
{
    Task t = _sem.WaitAsync(TimeSpan.FromMinutes(1));
    _sem.Release();
    return t;
}

[Benchmark]
public Task WithCancellationTokenAndTimeout()
{
    Task t = _sem.WaitAsync(TimeSpan.FromMinutes(1), _cts.Token);
    _sem.Release();
    return t;
}
Method Runtime Mean Ratio Allocated
WithCancellationToken .NET Framework 4.8 2.993us 1.00 1,263B
WithCancellationToken .NET Core 3.1 1.327us 0.44 536B
WithCancellationToken .NET 5.0 1.337us 0.45 496B
WithCancellationToken .NET 6.0 1.056us 0.35 448B
WithTimeout .NET Framework 4.8 3.267us 1.00 1,304B
WithTimeout .NET Core 3.1 1.768us 0.54 1,064B
WithTimeout .NET 5.0 1.769us 0.54 1,056B
WithTimeout .NET 6.0 1.086us 0.33 544B
WithCancellationTokenAndTimeout .NET Framework 4.8 3.838us 1.00 1,409B
WithCancellationTokenAndTimeout .NET Core 3.1 1.901us 0.50 1,080B
WithCancellationTokenAndTimeout .NET 5.0 1.929us 0.50 1,072B
WithCancellationTokenAndTimeout .NET 6.0 1.186us 0.31 544B

.NET 6 還看到了長期以來要求添加的 Parallel.ForEachAsync (dotnet/runtime#46943),它使得異步枚舉 IEnumerable 或 IAsyncEnumerable 併爲每個產生的元素運行一個委託變得容易,這些委託並行執行,並對其執行方式有一些控制,例如,應使用哪個 TaskScheduler,應啓用的最大並行級別,以及應使用哪個 CancellationToken 來取消工作。

關於 CancellationToken,.NET 6 中的取消支持也有了性能改進,包括對現有功能和新 API 的改進,這些新 API 使應用程序能夠做得更好。一個有趣的改進是 dotnet/runtime#48251,這是一個很好的例子,說明了人們如何爲一個場景設計、實現和優化,只是發現它做出了錯誤的權衡。當 CancellationToken 和 CancellationTokenSource 在 .NET Framework 4.0 中引入時,當時的預期是,主要的使用場景將是許多線程並行地從同一個 CancellationToken 中註冊和註銷。這導致了一個非常整潔(但複雜)的無鎖實現,涉及到相當多的分配和開銷。如果你實際上是從許多並行線程的同一個令牌中註冊和註銷,那麼這個實現非常高效,結果是良好的吞吐量。但是,如果你沒有這樣做,你就會爲一些沒有提供相應利益的東西付出很多開銷。而且,幸運的是,現在幾乎從來沒有這種情況。更常見的是,CancellationToken 被串行使用,通常一次性註冊多個,但這些註冊大部分都是作爲串行執行流的一部分添加的,而不是全部併發添加的。這個 PR 認識到了這個現實,並將實現恢復到一個更簡單、更輕量、更快的版本,這個版本對絕大多數的使用場景表現更好(儘管如果它實際上被多個線程同時猛擊,會有所損失)。

private CancellationTokenSource _source = new CancellationTokenSource();

[Benchmark]
public void CreateTokenDispose()
{
    using (var cts = new CancellationTokenSource())
        _ = cts.Token;
}

[Benchmark]
public void CreateRegisterDispose()
{
    using (var cts = new CancellationTokenSource())
        cts.Token.Register(s => { }, null).Dispose();
}

[Benchmark]
public void CreateLinkedTokenDispose()
{
    using (var cts = CancellationTokenSource.CreateLinkedTokenSource(_source.Token))
        _ = cts.Token;
}

[Benchmark(OperationsPerInvoke = 1_000_000)]
public void CreateManyRegisterDispose()
{
    using (var cts = new CancellationTokenSource())
    {
        CancellationToken ct = cts.Token;
        for (int i = 0; i < 1_000_000; i++)
            ct.Register(s => { }, null).Dispose();
    }
}

[Benchmark(OperationsPerInvoke = 1_000_000)]
public void CreateManyRegisterMultipleDispose()
{
    using (var cts = new CancellationTokenSource())
    {
        CancellationToken ct = cts.Token;
        for (int i = 0; i < 1_000_000; i++)
        {
            var ctr1 = ct.Register(s => { }, null);
            var ctr2 = ct.Register(s => { }, null);
            var ctr3 = ct.Register(s => { }, null);
            var ctr4 = ct.Register(s => { }, null);
            var ctr5 = ct.Register(s => { }, null);
            ctr5.Dispose();
            ctr4.Dispose();
            ctr3.Dispose();
            ctr2.Dispose();
            ctr1.Dispose();
        }
    }
}

Method Runtime Mean Ratio Allocated
CreateTokenDispose .NET Framework 4.8 10.236 ns 1.00 72 B
CreateTokenDispose .NET Core 3.1 6.934 ns 0.68 64 B
CreateTokenDispose .NET 5.0 7.268 ns 0.71 64 B
CreateTokenDispose .NET 6.0 6.200 ns 0.61 48 B
CreateRegisterDispose .NET Framework 4.8 144.218 ns 1.00 385 B
CreateRegisterDispose .NET Core 3.1 79.392 ns 0.55 352 B
CreateRegisterDispose .NET 5.0 79.431 ns 0.55 352 B
CreateRegisterDispose .NET 6.0 56.715 ns 0.39 192 B
CreateLinkedTokenDispose .NET Framework 4.8 103.622 ns 1.00 209 B
CreateLinkedTokenDispose .NET Core 3.1 61.944 ns 0.60 112 B
CreateLinkedTokenDispose .NET 5.0 53.526 ns 0.52 80 B
CreateLinkedTokenDispose .NET 6.0 38.631 ns 0.37 64 B
CreateManyRegisterDispose .NET Framework 4.8 87.713 ns 1.00 56 B
CreateManyRegisterDispose .NET Core 3.1 43.491 ns 0.50
CreateManyRegisterDispose .NET 5.0 41.124 ns 0.47
CreateManyRegisterDispose .NET 6.0 35.437 ns 0.40
CreateManyRegisterMultipleDispose .NET Framework 4.8 439.874 ns 1.00 281 B
CreateManyRegisterMultipleDispose .NET Core 3.1 234.367 ns 0.53
CreateManyRegisterMultipleDispose .NET 5.0 229.483 ns 0.52
CreateManyRegisterMultipleDispose .NET 6.0 192.213 ns 0.44

CancellationToken 還有新的 API 來幫助提高性能。dotnet/runtime#43114 添加了 Register 和 Unregister 的新重載,它們接受一個 Action<object, CancellationToken> 委託,而不是 Action 委託。這使得委託能夠訪問負責調用回調的 CancellationToken,使得原本需要實例化一個新的委託並可能創建一個閉包以獲取該信息的代碼,現在可以使用緩存的委託實例(如編譯器爲不封閉任何狀態的 lambda 生成的)。而 dotnet/runtime#50346 使得對於想要池化它們的應用程序,重用 CancellationTokenSource 實例變得更容易。過去,有多次請求能夠重用任何 CancellationTokenSource,使其狀態從已請求取消變爲未請求取消。這不是我們已經做過的,也不是我們計劃做的,因爲很多代碼依賴於一旦 CancellationToken 的 IsCancellationRequested 爲 true,它就會永遠爲 true;如果不是這樣,就很難進行推理。然而,絕大多數 CancellationTokenSources 從未被取消,如果它們沒有被取消,就沒有什麼可以阻止它們繼續被使用,可能被存儲到一個池中,供將來其他人使用。然而,如果使用了 CancelAfter 或者使用了接受超時的構造函數,這就有點棘手,因爲這兩者都會創建一個計時器,並且在計時器觸發和有人檢查 IsCancellationRequested 是否爲 true(以確定是否重用實例)之間可能存在競態條件。新的 TryReset 方法避免了這種競態條件。如果你確實想重用這樣一個 CancellationTokenSource,調用 TryReset:如果它返回 true,那麼它沒有請求取消,任何底層的計時器也已經被重置,除非設置了新的超時,否則它不會觸發。如果它返回 false,那麼,不要試圖重用它,因爲不能保證它的狀態。你可以看到 Kestrel web 服務器是如何做到這一點的,通過 dotnet/aspnetcore#31528 和 dotnet/aspnetcore#34075。

這些是一些更大的關注性能的線程更改。還有許多較小的改變,例如新的 Thread.UnsafeStart dotnet/runtime#47056,PreAllocatedOverlapped.UnsafeCreate dotnet/runtime#53196,和 ThreadPoolBoundHandle.UnsafeAllocateNativeOverlapped API,它們使得避免捕獲 ExecutingContext 變得更容易和更便宜;dotnet/runtime#43891 和 dotnet/runtime#44199 避免了在線程類型中的多個易變訪問(這主要影響 ARM);dotnet/runtime#44853 來自 @LeaFrock,優化了 ElapsedEventArgs 構造函數,避免了一些不必要的 DateTime 通過 FILETIME 的往返;dotnet/runtime#38896 來自 @Bond-009,爲 Task.WhenAny(IEnumerable) 添加了一個快速路徑,用於輸入是 ICollection 的相對常見情況;以及 dotnet/runtime#47368,它通過使它們能夠重用現有的 int 和 long 的內在函數,改進了與 nint (IntPtr) 或 nuint (UIntPtr) 一起使用時 Interlocked.Exchange 和 Interlocked.CompareExchange 的代碼生成:

private nint _value;

[Benchmark]
public nint CompareExchange() => Interlocked.CompareExchange(ref _value, (nint)1, (nint)0) + (nint)1;

; .NET 5
; Program.CompareExchange()
       sub       rsp,28
       cmp       [rcx],ecx
       add       rcx,8
       mov       edx,1
       xor       r8d,r8d
       call      00007FFEC051F8B0
       inc       rax
       add       rsp,28
       ret
; Total bytes of code 31

; .NET 6
; Program.CompareExchange()
       cmp       [rcx],ecx
       add       rcx,8
       mov       edx,1
       xor       eax,eax
       lock cmpxchg [rcx],rdx
       inc       rax
       ret
; Total bytes of code 22

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章