عنوان:

‫بررسی عمیق Runtime Async در دات‌نت ۱۱: جهش بزرگ در کارایی کدهای غیرهمگام


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۵/۲۲ ۰۸:۱۵
آدرس: www.dntips.ir
چکیده: الگوی async/await در دات‌نت سال‌هاست که بستری استاندارد برای برنامه‌نویسی غیرهمگام (Asynchronous) فراهم کرده است. با این حال، پیاده‌سازی سنتی این الگو مبتنی بر ساخت ماشین حالت (State Machine) توسط کامپایلر سی‌شارپ (roslyn) بوده که هزینه‌های پردازشی و اختصاص حافظه (Allocation) قابل توجهی را - به‌ویژه در فراخوانی‌هایی که به‌صورت همگام (Synchronous) به پایان می‌رسند - به سیستم تحمیل می‌کرد. تیم دات‌نت در نسخه .NET 11 با معرفی چارچوب جدید Runtime Async، مسئولیت مدیریت و بهینه‌سازی کدهای غیرهمگام را از کامپایلر به JIT (Just-In-Time Compiler) منتقل کرده است. در این مقاله، ابتدا به محدودیتی‌های الگوی سنتی و تجربه ناموفق Green Threads می‌پردازیم؛ سپس قرارداد فراخوانی جدید (Async Calling Convention)، نحوه کدژانری JIT، جزییات سطوح پایین خط دستورات اسمبلی و نتایج بنچمارک‌های مقایسه‌ای بین .NET 10 و .NET 11 را بررسی می‌کنیم.

۱. مقدمه: الگوی سنتی Async/Await و محدودیت‌های آن
در الگوی سنتی، کامپایلر سی‌شارپ متدهای علامت‌گذاری‌شده با async را طی یک تبدیل بر پایه CPS (Continuation-Passing Style) به یک struct یا class ماشین حالت تبدیل می‌کند.

۱.۱. مکانیسم ماشین حالت
کد زیر یک نمونه ساده از الگوی سنتی است:
public async Task<int> GetDataAsync()
{
    await Task.Delay(1000);
    return 42;
}
کامپایلر، منطق فوق را شبیه به کد ساختاریافته زیر در قالب یک ماشین حالت پیاده‌سازی می‌کرد:
internal class GetDataAsync_StateMachine : IAsyncStateMachine
{
    public int state = 0;
    public AsyncTaskMethodBuilder<int> builder;
    private TaskAwaiter awaiter;

    public void MoveNext()
    {
        try
        {
            switch (state)
            {
                case 0:
                    awaiter = Task.Delay(1000).GetAwaiter();
                    if (!awaiter.IsCompleted)
                    {
                        state = 1;
                        // ثبت ادامه اجرا (Continuation)
                        builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                        return;
                    }
                    goto case 1;

                case 1:
                    state = -1;
                    awaiter.GetResult();
                    builder.SetResult(42);
                    return;
            }
        }
        catch (Exception ex)
        {
            builder.SetException(ex);
        }
    }
}
۱.۲. چرا این الگو محدودیت ایجاد می‌کرد؟
  • تکه‌تکه شدن جریان کنترل (Control Flow Destruction): کامپایلر پیش از رسیدن کد به JIT، ساختار متد را خرد می‌کرد. JIT به‌جای یک فراخوانی شفاف A -> B -> C با مجموعه‌ای از ماشین‌های حالت، شیءهای Task و متدهای MoveNext روبه‌رو می‌شد و توانایی انجام بهینه‌سازی‌های بین-متدی (Cross-method Optimizations) مانند Inline‌کردن را از دست می‌داد.
  • عدم انطباق با اجرای همگام: در سامانه‌های توزیع‌شده و معماری‌های لایه‌ای، درصد بالایی از متدهای async بدون تعلیق واقعی (Suspension) به‌صورت همگام مقدار را برمی‌گردانند (مثلاً خواندن از Cache). در الگوی سنتی، حتی اگر متدی تعلیق نمی‌شد، هزینه ساخت ماشین حالت و ایجاد شیء Task (در صورت عدم استفاده از ValueTask) پرداخت می‌شد.

۲. چرا Green Threads پاسخ مناسبی برای دات‌نت نبود؟
تیم دات‌نت پیش از ایده Runtime Async، پروژه آزمایشی Green Threads (شبیه به Goroutines در Go یا Virtual Threads در جاوا) را بررسی کرد. اما این رویکرد به دلایل زیر در اکوسیستم دات‌نت کنار گذاشته شد:
  • هزینه حافظه Stack: هر نخ سبز حداقل به یک پشته اختصاصی در فضای کاربر (حدود 2KB) نیاز دارد. این میزان در مقایسه با حجم چند ده بایتی یک ماشین حالت بسیار سنگین است.
  • افت شدید کارایی System Call ها: به دلیل عدم تطابق نخ‌های فضای کاربر با OS Threads، نشت فراخوانی‌های سیستمی رخ می‌داد. در آزمایش‌های دات‌نت، ۱۰۰ میلیون فراخوانی سیستمی از ۳۰۰ میلی‌ثانیه به ۱۸۰۰ میلی‌ثانیه (بیش از ۵ برابر کندتر) رسید.
  • سازگاری با امنیت سخت‌افزاری: ویژگی‌هایی مانند Intel CET Shadow Stack در سوئیچ پشته‌های کاربر در سطح runtime با چالش‌های امنیتی و پیچیدگی‌های تعاملی مواجه می‌شدند.
  • وابستگی به Thread Affinity: برنامه‌های گرافیکی (GUI) و بخش‌هایی از سیستم‌عامل که به Thread-Local Storage وابسته هستند، با تغییر نخ اجرا دچار اختلال شده یا نیازمند سوئیچ‌های گران‌قیمت می‌شدند.

در نهایت، آزمایش Green Threads در ASP.NET Core نه تنها نرخ RPS (Request Per Second) را افزایش نداد، بلکه باعث افت کارایی شد.

۳. معماری Runtime Async در .NET 11
راهکار دات‌نت ۱۱ ساده و در عین حال بنیادی است: ساخت ماشین حالت را در کامپایلر متوقف کرده و جریان کنترل غیرهمگام را مستقیماً به JIT بسپارید.

۳.۱. قرارداد فراخوانی جدید (Async Calling Convention)
در Runtime Async، متدها با خصوصیت داخلی MethodImplOptions.Async نشانه‌گذاری می‌شوند. JIT متد را به‌گونه‌ای مدیریت می‌کند که علاوه بر آرگومان‌های ورودی معمولی، یک پارامتر اضافه به نام Continuation دریافت و دو مقدار را برمی‌گرداند: مقدار بازگشتی اصلی + شیء Continuation.

مفهوم این مدل فراخوانی را می‌توان به شکل زیر تصوری کرد:
(Result, Continuation) = Method(Continuation, Args)
  • اجرای همگام (Fast Path): در اولین فراخوانی، مقدار Continuation برابر null است. اگر متد بدون تعلیق اجرا شود، مقدار پاسخ همراه با Continuation = null مستقیماً از طریق ثبات‌های پردازنده (Registers) بازگردانده می‌شود. در این حالت، هیچ شیء Task یا ماشین حالتی در Heap اختصاص داده نمی‌شود (Zero Allocation).
  • اجرای غیرهمگام (Slow Path): اگر متد به نقطه تعلیق (await کامل‌نشده) برسد، JIT در همان لحظه یک شیء سبک Continuation شامل متغیرهای محلی و نقطه بازگشت ساخته و آن را برمی‌گرداند.

۴. بررسی اسمبلی و کد تولیدی JIT
برای درک ملموس موضوع، متد محاسبه فیبوناچی غیرهمگام زیر را در نظر بگیرید:
class Program
{
    async Task<int> Fib(int n)
    {
        if (n <= 1)
            return n;
        return await Fib(n - 1) + await Fib(n - 2);
    }
}

۴.۱. خروجی IL
در خروجی کد واسط (IL)، هیچ اثری از ساختار ماشین حالت یا IAsyncStateMachine دیده نمی‌شود:
[MethodImpl(MethodImplOptions.Async)]
public Task<int> Fib(int n)
{
    if (n > 1)
    {
        int num = AsyncHelpers.Await(Fib(n - 1));
        int num2 = AsyncHelpers.Await(Fib(n - 2));
        return (Task<int>)(num + num2);
    }
    return (Task<int>)n;
}

۴.۲. تحلیل ماشین اسمبلی JIT (معماری x64)
JIT برای متد فوق کدی شبیه به ساختار زیر در سطح اسمبلی تولید می‌کند:
Program:Fib(int):int:this
    ; آماده‌سازی فراخوانی اول: await Fib(n - 1)
    lea      edx, [rbx-0x01]          ; n - 1
    mov      rdi, r14                 ; this
    xor      rsi, rsi                 ; Continuation = null
    call     [Program:Fib(int):int:this]

    mov      r12d, eax                ; دریافت پاسخ در ثبات eax
    test     rcx, rcx                 ; آیا Continuation null است؟
    jne      SHORT SUSPEND_FIRST      ; اگر null نبود، وارد مسیر تعلیق بشو

    ; آماده‌سازی فراخوانی دوم: await Fib(n - 2)
    lea      edx, [rbx-0x02]          ; n - 2
    mov      rdi, r14 
    xor      rsi, rsi                 ; Continuation = null
    call     [Program:Fib(int):int:this]

    mov      ebx, eax                 ; دریافت پاسخ دوم در ثبات eax
    test     rcx, rcx                 ; آیا Continuation null است؟
    jne      SHORT SUSPEND_SECOND

    ; هر دو فراخوانی به‌صورت همگام تمام شدند
    add      ebx, r12d                ; ebx = result1 + result2
    mov      eax, ebx                 ; قرار دادن نتیجه در eax
    xor      ecx, ecx                 ; null کردن rcx (یعنی Continuation = null)
    ret

SUSPEND_FIRST:
    ; ایجاد Continuation تنها در صورت تعلیق واقعی
    mov      rdi, rcx
    call     [CORINFO_HELP_ALLOC_CONTINUATION]
    mov      r12, rax
    mov      dword ptr [r12+0x48], ebx ; ذخیره وضعیت محلی n
    mov      rcx, r12                 ; بازگرداندن Continuation در rcx
    ret

۴.۳. نقش Async Thunk
از آنجا که کدهای خارج از این بنچمارک انتظار دریافت Task دارند، JIT یک لایه واسط سبک به نام Async Thunk ایجاد می‌کند. این Thunk متد اصلی را فراخوانی کرده، در صورت تکمیل همگام، نتیجه را در یک Task.FromResult می‌پوشاند (که با Inline شدن و Escape Analysis حتی همین تخصیص حافظه نیز می‌تواند حذف شود) و در صورت تعلیق، شیء Task واقعی را به تعویق می‌اندازد.

۵. ارزیابی کارایی و نتایج بنچمارک (Benchmarks)
در مقایسه سناریوهای مختلف روی ۱۰ process run با ۱۰۰ میلیون بار اجرا بین .NET 10 (Async1) و .NET 11 Runtime Async (Async2) نتایج زیر به دست آمده است:

سناریوی بنچمارکزمان اجرای .NET 10زمان اجرای .NET 11میزان بهبود کاراییتخصیص حافظه (Allocations)
Synchronous Baseline~120 ms~120 msبرابر0 B
Async (بدون تعلیق - Fast Path)~2,400 ms~125 ms~۱۹.۲ برابر سریع‌تر0 B (در برابر 64B در دات‌نت ۱۰)
ThreadPool Continuation~8,500 ms~2,200 ms~۳.۸ برابر سریع‌ترکاهش ~۶۰ درصدی
TaskCompletionSource~9,100 ms~2,700 ms~۳.۳ برابر سریع‌ترکاهش ~۵۰ درصدی
Deep Async Chain (زنجیره عمیق)~18,200 ms~2,450 ms~۷.۴ برابر سریع‌ترکاهش چشمگیر اختصاص Gen0
تحلیل نتایج:
  • پیروی از الگوی Pay-for-Play: در مسایلی که تعلیق رخ نمی‌دهد، زمان اجرای کدهای async تقریباً با کدهای همگام (Synchronous Baseline) یکسان شده است.
  • کاهش تخصیص حافظه به صفر: در مسیر اجراهای سریع (Fast Path)، هیچ شیء در حافظه heap ساخته نشده و فشار روی Garbage Collector به حد صفر می‌رسد.
  • بهبود زنجیره‌های عمیق: هرچه عمق فراخوانی‌های غیرهمگام بیشتر باشد، تأثیر حذف ماشین‌های حالت لایه به لایه بیشتر نمایان می‌شود.

۶. نتیجه‌گیری
معماری Runtime Async در دات‌نت ۱۱ یکی از بنیادی‌ترین بهینه‌سازی‌های سیستم Runtime در چند سال اخیر محسوب می‌شود. با انتقال مسئولیت پردازش غیرهمگام به کامپایلر JIT:
  • الگوی async/await بدون فرض پیش‌فرض تعلیق، رفتاری بسیار شبیه به کدهای همگام را در اجرای سریع نشان می‌دهد.
  • هزینه‌های پردازشی و تخصیص حافظه تابع اصل Pay-for-Play می‌شوند؛ یعنی تنها در صورت تعلیق واقعی برنامه، هزینه ساخت Continuation پرداخت می‌شود.
  • فرصت‌های بهینه‌سازی بسیار بیشتری (مانند Inlining عمیق و Escape Analysis) برای JIT فراهم گردیده است.

این تغییر، برنامه‌نویسان دات‌نت را قادر می‌سازد تا بدون دغدغه از هزینه‌های جانبی الگوی async/await، کدهای خوانا، تمیز و در عین حال با بالاترین کارایی ممکن (High Performance) بنویسند.