.NET 7 NativeAOT의 ThreadPool은 Windows 스레드 풀을 사용합니다. | Austin Wise

본 글은 Austin Wise님의 Austin Wise - The ThreadPool in .NET 7 NativeAOT uses the Windows thread pool을 번역한 것입니다.

.NET 6 및 .NET 7 릴리스 과정에서 .NET 팀은 스레드 풀의 공통 C# 구현으로 수렴하기 위해 노력해 왔습니다. ThreadPool.QueueUserWorkItemTask로 시작된 스케줄링 작업을 처리하는 기본 스레드 풀은 .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

image

NativeAOT 버전은 더 적은 메모리(프라이빗 바이트로 측정)를 사용했지만, 절대적인 용량은 10 MiB에 불과했습니다. 물론 1000개의 스레드를 생성하면 모든 스택을 생성할 때 약간의 가상 메모리가 사용되지만, 이러한 스레드에 대해 더 작은 256KiB 스택을 사용하는 .NET을 사용하면 이 문제가 어느 정도 완화됩니다.

디버거에서 프로그램을 살펴볼 때 1000개의 스레드를 파헤치지 않아도 되니 삶의 질이 향상됩니다.

결론

이 마이크로 벤치마크를 기반으로 어떤 결론을 내려야 한다고 주장하지는 않습니다. Windows의 .NET 7 NativeAOT에는 스레드 풀의 고유한 구현이 있으므로 애플리케이션을 테스트할 때 이 점을 염두에 두어야 합니다.

각주

1

왜 64개가 아닌가요? 64개의 대기 핸들 중 마지막 대기 핸들은 스레드 풀 구현에서 스레드를 깨우는 데 사용되므로 대기 핸들을 대기에서 추가하거나 제거할 수 있습니다. C#에서 구현을 참조하세요.

2

문서화되지 않은 관련 시스템 호출은 NtCreateWaitCompletionPacket, NtAssociateWaitCompletionPacket 및 NtCancelWaitCompletionPacket입니다.

3

구체적으로 AMD 스레드리퍼 프로 3955WX.

4

기타 소프트웨어 버전: Windows 11 22000.978, 최신 .NET Framework 4.8 패치, .NET 6.0.9.


4개의 좋아요

음… .NET7 NativeAOT가 Windows Thread Pool을 이용한다면 Linux나 MacOS에도 당연히 같은 내용이 있겠죠…? Windows만 되는건가…

1개의 좋아요

윈도우만으로 알고 있어요

2개의 좋아요