بررسی عمیق 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 را طی یک تبدیل بر پایه 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);
}
}
}A -> B -> C با مجموعهای از ماشینهای حالت، شیءهای Task و متدهای MoveNext روبهرو میشد و توانایی انجام بهینهسازیهای بین-متدی (Cross-method Optimizations) مانند Inlineکردن را از دست میداد.async بدون تعلیق واقعی (Suspension) بهصورت همگام مقدار را برمیگردانند (مثلاً خواندن از Cache). در الگوی سنتی، حتی اگر متدی تعلیق نمیشد، هزینه ساخت ماشین حالت و ایجاد شیء Task (در صورت عدم استفاده از ValueTask) پرداخت میشد.MethodImplOptions.Async نشانهگذاری میشوند. JIT متد را بهگونهای مدیریت میکند که علاوه بر آرگومانهای ورودی معمولی، یک پارامتر اضافه به نام Continuation دریافت و دو مقدار را برمیگرداند: مقدار بازگشتی اصلی + شیء Continuation.(Result, Continuation) = Method(Continuation, Args)Continuation برابر null است. اگر متد بدون تعلیق اجرا شود، مقدار پاسخ همراه با Continuation = null مستقیماً از طریق ثباتهای پردازنده (Registers) بازگردانده میشود. در این حالت، هیچ شیء Task یا ماشین حالتی در Heap اختصاص داده نمیشود (Zero Allocation).await کاملنشده) برسد، JIT در همان لحظه یک شیء سبک Continuation شامل متغیرهای محلی و نقطه بازگشت ساخته و آن را برمیگرداند.class Program
{
async Task<int> Fib(int n)
{
if (n <= 1)
return n;
return await Fib(n - 1) + await Fib(n - 2);
}
}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;
}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
retTask دارند، JIT یک لایه واسط سبک به نام Async Thunk ایجاد میکند. این Thunk متد اصلی را فراخوانی کرده، در صورت تکمیل همگام، نتیجه را در یک Task.FromResult میپوشاند (که با Inline شدن و Escape Analysis حتی همین تخصیص حافظه نیز میتواند حذف شود) و در صورت تعلیق، شیء Task واقعی را به تعویق میاندازد.| سناریوی بنچمارک | زمان اجرای .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 |
async تقریباً با کدهای همگام (Synchronous Baseline) یکسان شده است.async/await بدون فرض پیشفرض تعلیق، رفتاری بسیار شبیه به کدهای همگام را در اجرای سریع نشان میدهد.Continuation پرداخت میشود.async/await، کدهای خوانا، تمیز و در عین حال با بالاترین کارایی ممکن (High Performance) بنویسند.