線程
.NET 的最近版本在線程、並行、併發和異步等方面做出了巨大的改進,例如 ThreadPool 的完全重寫(在 .NET 6 和 .NET 7 中),異步方法基礎設施的完全重寫(在 .NET Core 2.1 中),ConcurrentQueue
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
// 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
- 一個用於保存 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
[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
上面的 SemaphoreSlim 示例也使用了新的 ConfigureAwaitOptions 來替換在 .NET 8 中添加的之前的優化。dotnet/runtime#83294 在 ConfiguredNoThrowAwaiter
// 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
// 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 類型之一:它不僅提高了性能,還使代碼更清晰,代碼量也更少。
// 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
除了 ThreadPool 外,其他地方也有所改進。一個值得注意的變化是在處理 AsyncLocal
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
當然,任務與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
然而,這種模型的一個缺點是,選擇哪個構建器與從異步方法返回的類型的定義有關。所以,如果你想定義你的異步方法返回 Task,Task
ValueTask
我們之前看到的 [AsyncMethodBuilder] 屬性現在可以放在方法上,除了類型之外,感謝 dotnet/roslyn#54033;當一個異步方法被 [AsyncMethodBuilder(typeof(SomeBuilderType))] 屬性標記時,C# 編譯器將會優先選擇那個構建器而不是默認的。並且,伴隨着 C# 10 語言/編譯器特性,.NET 6 包含了兩種新的構建器類型,PoolingAsyncValueTaskMethodBuilder 和 PoolingAsyncValueTaskMethodBuilder
[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
關於 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