عنوان:

‫Runtime Async در دات‌نت ۱۱: تحولی بنیادین در برنامه‌نویسی غیرهمگام (Async)


نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۱۱/۲۶ ۰۹:۱۵
آدرس: www.dntips.ir
از زمان معرفی async/await در سی‌شارپ ۵، نحوه مدیریت کدهای غیرهمگام (Asynchronous) بر دوش کامپایلر (Roslyn) بوده است. اما با عرضه .NET 11 Preview 1، شاهد یک تغییر پارادایم بزرگ هستیم: Runtime Async. در این رویکرد جدید، به جای اینکه کامپایلر مسئول بازنویسی کدهای شما باشد، خودِ Runtime (محیط اجرا) دات‌نت به صورت بومی (Native) مفهوم متدهای async را درک و مدیریت می‌کند. در این مقاله بررسی می‌کنیم که Runtime Async چیست، چرا اهمیت دارد و چگونه می‌توانید همین امروز در دات‌نت ۱۱ با آن کار کنید.

مشکل مدل فعلی (Compiler-Generated Async) چیست؟
تا قبل از دات‌نت ۱۱، وقتی شما یک متد async می‌نوشتید، کامپایلر سی‌شارپ در پشت صحنه آن را کاملاً تغییر می‌داد و به یک ماشین وضعیت (State Machine) تبدیل می‌کرد. این ماشین وضعیت، یک struct است که پیاده‌سازی IAsyncStateMachine را بر عهده دارد و وظیفه‌اش حفظ وضعیت متد در نقاط توقف (await) است.
اگرچه این مدل سال‌هاست که کار می‌کند، اما هزینه‌هایی دارد:
  • سربار ماشین وضعیت (Overhead): برای هر متد async، یک ساختار (Struct) تولید می‌شود تا متغیرهای محلی را در خود نگه دارد. اگر متد به صورت همگام (Synchronous) کامل نشود، این ساختار باید به حافظه Heap منتقل (Box) شود که فشار بر Garbage Collector (GC) را افزایش می‌دهد.
  • پیچیدگی در دیباگ: هنگام دیباگ کردن، Stack Trace پر از متدهای مصنوعی مانند MoveNext می‌شود و دنبال کردن جریان اصلی کد دشوار است.
  • نقاط کور برای Profilerها: ابزارهای تحلیل کارایی (Profilers) نمی‌توانند زمان صرف شده را به درستی بین نقاط توقف (Suspension Points) تقسیم کنند، زیرا Runtime درک خاصی از async ندارد و صرفاً فراخوانی متدهای معمولی را می‌بیند.
  • از دست رفتن فرصت‌های بهینه‌سازی: چون Runtime الگوی async را نمی‌شناسد، نمی‌تواند زنجیره‌ای از فراخوانی‌های async را بهینه کند. هر await یک چرخه کامل توقف/بازگشت (Suspend/Resume) را تحمیل می‌کند.

راه حل: Runtime Async چیست؟
قابلیت Runtime Async مسئولیت مدیریت متدهای غیرهمگام را از کامپایلر گرفته و به خودِ دات‌نت (Runtime) می‌سپارد. در این مدل، کامپایلر به جای تولید کدهای پیچیده و ماشین وضعیت، یک کد IL (Intermediate Language) بسیار ساده‌تر تولید می‌کند که با ویژگی [MethodImpl(MethodImplOptions.Async)] نشانه‌گذاری شده است.
مزایای این تغییر معماری عبارتند از:
  • مدیریت تعلیق و بازگشت توسط Runtime: دیگر خبری از Structهای تولید شده توسط کامپایلر نیست. خود Runtime وضعیت متد را ذخیره و بازیابی می‌کند.
  • Hoisting هوشمند متغیرها: تنها متغیرهایی که واقعاً بین نقاط await زنده می‌مانند، در حافظه ذخیره (Hoist) می‌شوند.
  • بهینه‌سازی زنجیره‌ای (Async Chaining): این یکی از مهم‌ترین بهبودهاست. وقتی یک متد Runtime-Async متد دیگری از همین نوع را صدا می‌زند، Runtime می‌تواند انتقال اجرا (Handoff) را بهینه کند و در بسیاری از موارد، تخصیص حافظه برای Task را به کلی حذف کند.

نحوه عملکرد مدل جدید
در مدل جدید، متدها از کلاسی به نام AsyncHelpers برای اعلام تعلیق استفاده می‌کنند. وقتی کامپایلر کد شما را ترجمه می‌کند، به جای ساخت ماشین وضعیت، فراخوانی‌هایی به AsyncHelpers.Await(...) درج می‌کند. Runtime این فراخوانی‌ها را رهگیری کرده و اگر Task هنوز کامل نشده باشد، متد را به بهینه‌ترین شکل ممکن معلق (Suspend) می‌کند.
namespace System.Runtime.CompilerServices
{
    public static class AsyncHelpers
    {
        // نمونه‌ای از متدهای کمکی که Runtime از آن‌ها استفاده می‌کند
        [MethodImpl(MethodImplOptions.Async)]
        public static void Await(Task task);
        
        [MethodImpl(MethodImplOptions.Async)]
        public static T Await<T>(Task<T> task);
        
        // پشتیبانی از ValueTask و سایر Awaitableها نیز وجود دارد
    }
}

تغییرات در .NET 11 Preview 1
اگرچه در دات‌نت ۱۰ امکان تست اولیه وجود داشت، اما در دات‌نت ۱۱ پیش‌نمایش ۱، تغییرات بزرگی رخ داده است:
  • فعال بودن پیش‌فرض در CoreCLR: دیگر نیازی به تنظیم متغیر محیطی DOTNET_RuntimeAsync=1 نیست. Runtime به صورت پیش‌فرض آماده اجرای این متدهاست.
  • پشتیبانی از Native AOT: کدهایی که با runtime-async کامپایل می‌شوند، اکنون می‌توانند به صورت Ahead-of-Time (AOT) کامپایل شوند. این یعنی برنامه‌هایی بسیار سبک‌تر و سریع‌تر که از مزایای Runtime Async نیز بهره می‌برند.

راهنمای عملی: تجربه Runtime Async
برای اینکه همین حالا این قابلیت را در پروژه‌های خود تست کنید، مراحل زیر را انجام دهید:
۱. هدف‌گذاری دات‌نت ۱۱:
فایل .csproj خود را باز کنید و تارگت را تغییر دهید:
<TargetFramework>net11.0</TargetFramework>

۲. فعال‌سازی ویژگی در کامپایلر:
باید به کامپایلر بگویید که از حالت جدید استفاده کند:
<PropertyGroup>
    <EnablePreviewFeatures>true</EnablePreviewFeatures>
    <Features>$(Features);runtime-async=on</Features>
</PropertyGroup>

۳. نوشتن کد Async معمولی:
هیچ تغییری در کد سی‌شارپ شما لازم نیست!
async Task<string> FetchDataAsync(HttpClient client, string url)
{
    var response = await client.GetAsync(url);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsStringAsync();
}

مقایسه کدهای کامپایل شده (Decompiled)
برای درک عمق فاجعه در مدل قدیم و زیبایی مدل جدید، بیایید نگاهی به کدهای تولید شده بیندازیم.

مدل جدید (Runtime Async):
همانطور که می‌بینید، کد IL بسیار تمیز است و خبری از کلاس‌های تو در تو نیست:
[MethodImpl(MethodImplOptions.Async)] // مشخصه جدید
internal static Task<string> FetchDataAsync(HttpClient client, string url)
{
   // استفاده مستقیم از AsyncHelpers بدون ماشین وضعیت
   HttpResponseMessage response = AsyncHelpers.Await<HttpResponseMessage>(client.GetAsync(url));
   response.EnsureSuccessStatusCode();
   return AsyncHelpers.Await<string>(response.Content.ReadAsStringAsync());
}

مدل قدیمی (کامپایلر دات‌نت ۱۰ و قبل‌تر):
(به دلیل طولانی بودن این نوع کدها (!) خلاصه شده است)
در مدل قدیمی، یک کلاس stateMachine پیچیده ساخته می‌شود که شامل فیلدهای state، builder و awaiter است. متد اصلی فقط این ماشین را می‌سازد و استارت می‌زند. منطق اصلی در یک متد MoveNext طولانی و پیچیده با چندین دستور goto و try/catch پیاده‌سازی می‌شود که خوانایی و دیباگ را دشوار می‌کند.

آینده و نقشه راه
هنوز کار تمام نشده است. تیم دات‌نت موارد زیر را در اپیک (Epic) مربوط به دات‌نت ۱۱ دنبال می‌کند:
  • بازکامپایل کتابخانه‌ها (Recompilation): برای اینکه تأثیر واقعی بر پرفورمنس را ببینید، کتابخانه‌های پایه دات‌نت (System.*) و ASP.NET Core باید با قابلیت runtime-async مجدداً کامپایل شوند. تا آن زمان، کد شما سریع است اما وقتی وارد متدهای فریم‌ورک می‌شوید، به مدل قدیمی برمی‌گردد.
  • بهبود Diagnostics: نمایش بهتر Stack Trace برای متدهای جدید.
  • تست گسترده: اطمینان از سازگاری کامل در تمام اکوسیستم.

نتیجه‌گیری
قابلیت Runtime Async یکی از مهم‌ترین تغییرات سطح پایین (Low-level) در تاریخ دات‌نت است. با انتقال درکِ مفهوم async از کامپایلر به Runtime، دات‌نت ۱۱ پایه‌ای را بنا می‌نهد که منجر به کاهش چشمگیر مصرف حافظه، دیباگ راحت‌تر و بهینه‌سازی‌هایی می‌شود که در مدل قبلی غیرممکن بود. اگرچه در Preview 1 هنوز تمام کتابخانه‌ها به‌روز نشده‌اند، اما این بهترین زمان برای توسعه‌دهندگان پیشرو است تا با این معماری جدید آشنا شوند و خود را برای انقلابِ async در نسخه نهایی دات‌نت ۱۱ آماده کنند.

نظرات

  • وحید نصیری در ۱۴۰۴/۱۱/۲۷ ۰۹:۱۲
    چند نکته‌ی تکمیلی

    مدیریت استثناها و تغییرات در Stack Trace
    یکی از تغییرات ملموس در مدل Runtime Async، نحوه‌ی نمایش پشته فراخوانی (Call Stack) هنگام بروز خطا است. از آنجایی که دیگر ماشین وضعیتی (State Machine) توسط کامپایلر تولید نمی‌شود، ردپای خطا در Stack Trace تغییر می‌کند. بیایید یک مثال ساده را بررسی کنیم که در آن یک متد async دچار خطا می‌شود:
    await MyTask();
    
    async Task<string> MyTask()
    {
        await Task.Delay(1);
        throw new InvalidOperationException("This is an exception.");
        return "";
    }

    تفاوت در خروجی Stack Trace
    اگر این کد را در دو حالت قدیمی و جدید اجرا کنیم، تفاوت جالبی را مشاهده خواهیم کرد:
    ۱. در مدل جدید (Runtime Async):
    در اینجا ردپای متدهای داخلی Runtime به چشم می‌خورد:
    Unhandled exception. System.InvalidOperationException: This is an exception.
       at Program.MyTask() in .../Program.cs:line 13
       at System.Runtime.CompilerServices.AsyncHelpers.RuntimeAsyncTask`1.DispatchContinuations()
    --- End of stack trace from previous location ---
       at Program.<Main>()
    نکته‌ی قابل توجه، وجود متد DispatchContinuations است. این نشان می‌دهد که Runtime مستقیماً مسئولیت "توزیع" و مدیریت ادامه اجرای کد (Continuation) پس از await را بر عهده گرفته است.

    ۲. در مدل قدیمی (Compiler-Generated):
    Unhandled exception. System.InvalidOperationException: This is an exception.
       at Program.MyTask() in .../Program.cs:line 13
       at Program.Main() in .../Program.cs:line 7
       at Program.<Main>()
    در مدل قدیمی، ساختار استانداردتری را می‌بینیم که حاصل کدهای تولید شده توسط کامپایلر است.

    چه نتیجه‌ای می‌گیریم؟
    در حال حاضر، مدل جدید جزئیات داخلی Runtime (AsyncHelpers) را در Stack Trace نشان می‌دهد. هرچند این ممکن است در نگاه اول کمی متفاوت به نظر برسد، اما هدف نهایی این است که با حذف لایه‌های اضافیِ ماشین وضعیت، دیباگ کردن برای توسعه‌دهنده شفاف‌تر شود. البته انتظار می‌رود در نسخه‌های نهایی، نمایش این اطلاعات باز هم بهینه‌تر شود.


    وضعیت SynchronizationContext: دوست قدیمی و چالش‌های جدید
    آه، SynchronizationContext! همان دوست (و گاهی دشمن) قدیمی که با ConfigureAwait(true) (یا به صورت پیش‌فرض) همراهمان است! وظیفه‌ی اصلی SynchronizationContext این است که "زمینه" فعلی اجرا (مثلاً ترد UI در برنامه‌های WPF یا WinForms) را حفظ کند و مطمئن شود که پس از پایان عملیات await، ادامه‌ی کد دقیقاً روی همان زمینه اجرا می‌شود.

    ابهام در رفتار Runtime Async
    در مدل Runtime Async، ممکن است شاهد تغییر رفتار در این حوزه باشیم.
    • در مدل قدیمی: ماشین وضعیتِ تولید شده‌ی توسط کامپایلر، وظیفه داشت SynchronizationContext را ضبط (Capture) کند و پس از اتمام کار، ادامه متد را روی آن Post کند.
    • در مدل جدید: از آنجایی که Runtime مدیریت همه چیز را بر عهده دارد، این احتمال وجود دارد که در پیاده‌سازی‌های فعلی، SynchronizationContext نادیده گرفته شده یا حذف شود (Drop).

    یک نکته‌ی مهم برای نویسندگان کتابخانه (Library Authors):
    در حال حاضر، چون کتابخانه‌های پایه دات‌نت (Core Libraries) هنوز به طور کامل با مدل جدید بازنویسی نشده‌اند، هر تلاشی برای تست دقیق این موضوع ممکن است به رفتار قدیمی (Fallback) منجر شود. بنابراین، هنوز زود است که با قطعیت بگوییم AsyncLocal، ExecutionContext و SynchronizationContext دقیقاً چه رفتاری خواهند داشت. اما این ناحیه‌ای است که نیاز به مراقبت ویژه دارد؛ زیرا اگر Runtime این زمینه‌ها را به درستی منتقل نکند، کدهای وابسته به ترد UI یا داده‌های محلی (Local Data) ممکن است دچار مشکل شوند.
    باید منتظر آپدیت‌های بعدی و بازنویسی کتابخانه‌های اصلی باشیم تا بتوانیم رفتار دقیق این بخش را تحلیل کنیم.
  • وحید نصیری در ۱۴۰۵/۰۳/۱۲ ۰۷:۵۷
    بهینه‌سازی‌های کتابخانه‌های زمان اجرا در .NET 11 Preview 4: حرکت به سمت Runtime-Async

    NET 11. در حال حرکت به سمت تغییرات اساسی در نحوه مدیریت عملیات ناهمگام (async) است. یکی از مهم‌ترین تغییرات معرفی‌شده در Preview 4، کامپایل کتابخانه‌های زمان اجرا (Runtime Libraries) با گزینهٔ runtime-async=on است. این تغییر، گامی بزرگ به سوی حذف ماشین‌های حالت (State Machines) تولیدشده توسط کامپایلر و استفاده از قابلیت async فراهم‌شده توسط خود runtime محسوب می‌شود. این به‌روزرسانی نه تنها عملکرد و اندازهٔ کتابخانه‌ها را بهبود می‌بخشد، بلکه زمینه را برای مهاجرت آسان‌تر برنامه‌ها به مدل جدید فراهم می‌کند. در ادامه به بررسی دقیق این تغییرات و ویژگی‌های همراه آن می‌پردازیم.

    توضیح تغییرات
    ۱. کامپایل کتابخانه‌های زمان اجرا با Runtime-Async
    تا پیش از این، متدهای async در کتابخانه‌های .NET توسط کامپایلر #C به ماشین‌های حالت (State Machine) تبدیل می‌شدند. این ماشین‌ها کد اضافی تولید می‌کردند که هم بر اندازهٔ باینری تأثیر منفی داشت و هم در مسیرهای اجرایی overhead ایجاد می‌کرد.

    با فعال‌سازی runtime-async برای تمام کتابخانه‌های runtime:
    • دیگر ماشین‌های حالت تولیدشده توسط کامپایلر در کتابخانه‌ها وجود نخواهد داشت.
    • runtime خود مسئولیت مدیریت حالت‌های async را بر عهده می‌گیرد.

    این رویکرد مزایای قابل توجهی دارد:
    • بهبود اندازهٔ کتابخانه: کاهش کد اضافی تولیدشده.
    • بهبود توان عملیاتی (Throughput): به‌ویژه در سناریوهایی که استفاده از async زیاد است.
    • تست جامع: فعال‌سازی این ویژگی برای تمام کتابخانه‌ها، تست عملکرد و صحت عملکردی بسیار گسترده‌ای را ممکن ساخته است.
    • مهاجرت آسان: توسعه‌دهندگان می‌توانند برنامه‌های خود (که فقط وابستگی به کتابخانه‌های runtime دارند) را به مدل جدید منتقل کنند.
    این تغییر بر پایهٔ Preview 3 بنا شده که الزام استفاده از [RequiresPreviewFeatures] برای فعال‌سازی runtime-async را حذف کرد.

    نکته مهم: میزان بهبود عملکرد و اندازه مستقیماً به میزان استفادهٔ برنامه از الگوهای async بستگی دارد. تیم .NET از بازخوردهای مثبت و منفی توسعه‌دهندگان استقبال می‌کند تا این ویژگی را هرچه بیشتر بهبود بخشد.

    ۲. پشتیبانی از Covariant Task : Task Overrides
    یکی از مشکلات رایج در برنامه‌نویسی شیءگرا، هنگام اورراید کردن متدی که در کلاس پایه نوع Task برمی‌گرداند و در کلاس مشتق‌شده Task برمی‌گرداند، وجود داشت. این تفاوت در نوع بازگشت، باعث مشکل در فراخوانی مجازی (Virtual Dispatch) می‌شد.
    در .NET 11 Preview 4، runtime به‌طور خودکار یک thunk (تابع واسط) بدون مقدار بازگشت تولید می‌کند که این اختلاف در قرارداد فراخوانی (Calling Convention) را برطرف می‌کند. بدین ترتیب، فراخوانی مجازی برای هر دو نوع Task و Task به درستی کار خواهد کرد.

    ۳. قابلیت Inlining متدهای Runtime-Async در Crossgen2
    در نسخه‌های قبلی، محدودیت‌هایی برای درون‌خطی کردن (Inlining) متدهای runtime-async در زمان تولید ReadyToRun وجود داشت. این محدودیت‌ها اکنون برداشته شده‌اند.
    نتایج:
    • تمام ۶۹ تست async با موفقیت اجرا می‌شوند (هم با crossgen2 و هم با Composite R2R).
    • مسیر سریع همگام (Sync Fast-Path) برای متدهای async بدون await، به‌طور کامل و end-to-end پشتیبانی می‌شود (Pull Request #125472).
    این تغییر باعث می‌شود متدهای async در زمان اجرا بهینه‌تر کامپایل شوند و overhead کمتری داشته باشند.

    نتیجه‌گیری
    تغییرات Preview 4 در حوزهٔ runtime-async نشان‌دهندهٔ تعهد تیم .NET به بهبود عمیق عملکرد و سادگی مدل برنامه‌نویسی ناهمگام است. با حذف ماشین‌های حالت کامپایلر از کتابخانه‌های پایه و انتقال مسئولیت به runtime، نه تنها اندازهٔ باینری کاهش می‌یابد، بلکه پتانسیل بهینه‌سازی‌های بیشتر در آینده نیز فراهم می‌شود.