본 글은 Austin Wise님의 Austin Wise - The ThreadPool in .NET 7 NativeAOT uses the Windows thread pool을 번역한 것입니다.
.NET 6 및 .NET 7 릴리스 과정에서 .NET 팀은 스레드 풀의 공통 C# 구현으로 수렴하기 위해 노력해 왔습니다. ThreadPool.QueueUserWorkItem
및 Task
로 시작된 스케줄링 작업을 처리하는 기본 스레드 풀은 .NET 6에서 C#으로 변환되었습니다. Windows에서 비동기 I/O에 사용되는 I/O 스레드풀과 ThreadPool.RegisterWaitForSingleObject
는 .NET 7에서 변환되었습니다. .NET 8에서는 CoreCLR의 C++ 버전 스레드 풀이 더 이상 대체 옵션으로 존재하지 않습니다.
.NET 7부터는 두 가지 플랫폼을 제외한 모든 닷넷 플랫폼에서 스레드풀의 공통 C# 구현을 사용할 수 있습니다.
- 단일 스레드 WASM
- Windows의 NativeAOT
이 게시물에서는 두 가지 중 두 번째 예외에 대해 설명합니다.
업데이트 2022-09-19: 수년에 걸쳐 NativeAOT의 많은 부분을 구현하고 그 사용을 널리 알린 .NET 팀의 소프트웨어 엔지니어인 Michal Strehovský는 향후 Windows에서 .NET NativeAOT의 버전이 일반적인 C# 스레드 풀 구현을 사용하도록 변경될 수도 있고 변경되지 않을 수도 있다고 명확히 밝혔습니다. 현재 구현은 의도적인 선택이 아니라 출시 마감 시점의 코드베이스 상태에 따른 것입니다.
NativeAOT
NativeAOT(Native Ahead-of-time의 줄임말)는 네이티브 코드로 미리 컴파일하는 새로운 형식의 .NET입니다. 이를 통해 시작 시간과 바이너리 크기를 개선할 수 있습니다. 자세한 내용은 설명서를 참조하세요.
Windows에서 실행할 때 NativeAOT의 흥미로운 속성 중 하나는 Windows에 내장된 스레드 풀 API를 사용한다는 것입니다. 이는 .NET이 출시된 지 몇 년이 지난 후 Windows Vista에 추가되었으며, .NET 스레드 풀 API에서 영감을 받은 것으로 보입니다. 따라서 Windows에 이미 포함된 스레드 풀을 사용함으로써 NativeAOT 앱은 약간의 바이너리 크기를 절약할 수 있습니다.
대기 핸들 대기 중
다양한 스레드 풀이 전체 애플리케이션 성능에 미치는 영향을 벤치마킹하는 것은 이번 일요일 오후 블로그 게시물의 범위를 벗어납니다. 그러나 스레드 풀의 설계가 개선되어 마이크로 벤치마크를 작성하기 쉬운 한 가지 측면이 있습니다: ThreadPool.RegisterWaitForSingleObject
이 함수는 대기 핸들이 신호를 받거나 시간 초과가 만료되면 콜백을 트리거합니다. .NET 스레드 풀과 Windows 8 이전의 Windows 스레드 풀에서는 이 API가 Win32 API WaitForMultipleObjects
위에 구현되었습니다. WaitForMultipleObjects
는 최대 64개 항목까지 대기할 수 있습니다. 이 제한을 우회하기 위해 WaitForMultipleObjects
를 사용하는 스레드 풀은 63개의 대기 1당 1개의 스레드를 생성해야 했습니다.1
Windows 8부터는 Windows 스레드 풀이 내부적으로2 I/O 완료 포트를 사용하여 대기를 구현하도록 변경되어 더 이상 63개의 대기당 1개의 스레드가 필요하지 않게 되었습니다. 올해 초 레이몬드 첸은 자신의 블로그 The Old New Thing에서 이 개선된 Windows 스레드 풀에 대한 글을 썼습니다.
레이몬드와 유사한 벤치마크를 작성하여 NativeAOT를 사용할 때의 성능 차이를 관찰할 수 있습니다.
벤치마크
아래 벤치마크는 63,000개의 대기 핸들을 설정하고 ThreadPool.RegisterWaitForSingleObject
를 사용하여 대기하기 시작합니다. 벤치마크에서 실제로 측정되는 부분은 63번째마다 신호를 보내는 것입니다. 따라서 여기에 표시되는 워크로드는 많은 대기 핸들이 대기 중이고 그중 몇 개만 신호를 보내는 경우입니다.
Windows 스레드 풀을 사용하지 않는 런타임에서 이 벤치마크는 1000개의 스레드를 생성합니다. 벤치마크의 핵심 부분은 1000개의 스레드를 깨우는 것입니다.
Windows 스레드 풀을 사용하는 NativeAOT에서는 프로세스가 생성하는 스레드가 1000개보다 훨씬 적습니다. I/O 스레드 풀이 완료 포트에서 읽은 I/O 완료 패킷을 스레딩하고 콜백을 실행하는 것만 있습니다.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using System.Threading;
[SimpleJob(RuntimeMoniker.Net48)]
[SimpleJob(RuntimeMoniker.Net60)]
[SimpleJob(RuntimeMoniker.Net70)]
[SimpleJob(RuntimeMoniker.NativeAot70)]
[RPlotExporter]
public class Program
{
public static void Main(string[] args) => BenchmarkRunner.Run<Program>(null, args);
private const int WAITS_PER_THREAD = 63;
[Params(1000)]
public int N;
private AutoResetEvent[] HandlesToRegister;
private RegisteredWaitHandle[] RegisteredWaits;
private WaitOrTimerCallback CompleteDel;
private AutoResetEvent AllCompleted;
private volatile int CompleteCount;
[GlobalSetup]
public void GlobalSetup()
{
CompleteDel = new WaitOrTimerCallback(CompleteFunc);
RegisteredWaits = new RegisteredWaitHandle[N * WAITS_PER_THREAD];
HandlesToRegister = new AutoResetEvent[N * WAITS_PER_THREAD];
AllCompleted = new AutoResetEvent(false);
for (int i = 0; i < N * WAITS_PER_THREAD; i++)
{
HandlesToRegister[i] = new AutoResetEvent(false);
}
for (int i = 0; i < N * WAITS_PER_THREAD; i++)
{
RegisteredWaits[i] = ThreadPool.RegisterWaitForSingleObject(
HandlesToRegister[i],
CompleteDel,
null,
-1,
executeOnlyOnce: false);
}
}
[GlobalCleanup]
public void GlobalCleanup()
{
foreach (var rw in RegisteredWaits)
{
rw.Unregister(null);
}
foreach (var wh in HandlesToRegister)
{
wh.Close();
}
}
private void CompleteFunc(object state, bool timedOut)
{
if (Interlocked.Add(ref CompleteCount, 1) == N)
{
AllCompleted.Set();
}
}
[Benchmark]
public void BenchWait()
{
CompleteCount = 0;
for (int i = 0; i < N; i++)
{
HandlesToRegister[i * WAITS_PER_THREAD].Set();
}
AllCompleted.WaitOne();
}
}
참고: 게시 시점 현재, 이 벤치마크는 게시된 어떤 버전의 Benchmark.NET에서도 작동하지 않습니다. 출시 전 버전에서 실행했습니다. 0.13.3 버전에는 NativeAOT에 대한 벤치마크 실행에 대한 수정 사항이 포함되어야 합니다.
성능 개선
이 벤치마크는 16코어 32스레드 머신3에서 실행했습니다. .NET 7 RC14를 사용했습니다. NativeAOT 버전이 .NET Framework, .NET 6 및 .NET 7보다 처리량이 2배 더 높다는 것을 분명히 알 수 있습니다.
Method | Job | Runtime | N | Mean | Error | StdDev | Median |
---|---|---|---|---|---|---|---|
BenchWait | .NET Framework 4.8 | .NET Framework 4.8 | 1000 | 4.034 ms | 0.0177 ms | 0.0166 ms | 4.031 ms |
BenchWait | .NET 6.0 | .NET 6.0 | 1000 | 4.093 ms | 0.0157 ms | 0.0147 ms | 4.093 ms |
BenchWait | .NET 7.0 | .NET 7.0 | 1000 | 3.914 ms | 0.0091 ms | 0.0085 ms | 3.914 ms |
BenchWait | NativeAOT 7.0 | NativeAOT 7.0 | 1000 | 2.060 ms | 0.0500 ms | 0.1474 ms | 2.132 ms |
NativeAOT 버전은 더 적은 메모리(프라이빗 바이트로 측정)를 사용했지만, 절대적인 용량은 10 MiB에 불과했습니다. 물론 1000개의 스레드를 생성하면 모든 스택을 생성할 때 약간의 가상 메모리가 사용되지만, 이러한 스레드에 대해 더 작은 256KiB 스택을 사용하는 .NET을 사용하면 이 문제가 어느 정도 완화됩니다.
디버거에서 프로그램을 살펴볼 때 1000개의 스레드를 파헤치지 않아도 되니 삶의 질이 향상됩니다.
결론
이 마이크로 벤치마크를 기반으로 어떤 결론을 내려야 한다고 주장하지는 않습니다. Windows의 .NET 7 NativeAOT에는 스레드 풀의 고유한 구현이 있으므로 애플리케이션을 테스트할 때 이 점을 염두에 두어야 합니다.
각주
왜 64개가 아닌가요? 64개의 대기 핸들 중 마지막 대기 핸들은 스레드 풀 구현에서 스레드를 깨우는 데 사용되므로 대기 핸들을 대기에서 추가하거나 제거할 수 있습니다. C#에서 구현을 참조하세요.
문서화되지 않은 관련 시스템 호출은 NtCreateWaitCompletionPacket, NtAssociateWaitCompletionPacket 및 NtCancelWaitCompletionPacket입니다.
구체적으로 AMD 스레드리퍼 프로 3955WX.
기타 소프트웨어 버전: Windows 11 22000.978, 최신 .NET Framework 4.8 패치, .NET 6.0.9.