بررسی عمیق نقش async در اجرا ناهمزمان کدها در سی شارپ (مقاله جامع)
کالبدشکافی مدرن Async در #C: پارادایم قدرت و مسئولیت
در اکوسیستم داتنت، برنامهنویسی غیرهمزمان (Asynchronous Programming) دیگر یک «آپشن» نیست، بلکه به ستون فقرات توسعه نرمافزار تبدیل شده است. اما استفاده نابجا از آن میتواند به اندازه استفاده نکردن از آن، فاجعهبار باشد.
چرا باید از Async استفاده کنیم؟ (فراتر از پاسخگویی UI)
برخلاف تصور رایج، هدف اصلی async/await در سمت سرور (ASP.NET Core)، افزایش سرعت اجرای یک درخواست واحد نیست؛ بلکه افزایش ظرفیت تحمل بار (Throughput) است.
مدیریت Thread Pool و جادوی I/O Completion Ports
وقتی یک کد سنکرون (Synchronous) یک درخواست I/O (مانند کوئری دیتابیس یا فراخوانی API خارجی) ارسال میکند، ترد (Thread) جاری مسدود (Block) میشود. این ترد در حالت WaitSleepJoin قرار گرفته و در حالی که هیچ پردازشی انجام نمیدهد، حافظه (حدود ۱ مگابایت برای Stack) را اشغال کرده و هزینهی Context Switching را به سیستم تحمیل میکند.
در مقابل، async از مکانیزم IOCP (I/O Completion Ports) استفاده میکند. وقتی شما await میکنید:
-
ترد فعلی به Thread Pool بازمیگردد تا درخواستهای دیگر را مدیریت کند.
-
هیچ تریدی منتظر پاسخ I/O نمیماند.
-
پس از اتمام عملیات سختافزاری، سیستمعامل یک سیگنال به داتنت میفرستد و یک ترد (شاید همان ترد قبلی، شاید یک ترد جدید) ادامه کار را از جایی که رها شده بود، از طریق State Machine ایجاد شده توسط کامپایلر، از سر میگیرد.
مقیاسپذیری الاستیک
در سیستمهای High-Traffic، تفاوت بین کد Async و Sync، تفاوت بین زنده ماندن سرور و سقوط آن تحت فشار (Thread Starvation) است. با Async، شما میتوانید با تعداد تردهای بسیار کمتر، هزاران درخواست همزمان را مدیریت کنید.
چرا نباید از Async استفاده کنیم؟ (تاریکیهای پشت صحنه)
استفاده از Async هزینهبر است. این هزینهها در اپلیکیشنهای معمولی ناچیزند، اما در سیستمهای High-Performance یا Real-time، میتوانند به گلوگاه تبدیل شوند.
الف) هزینه تخصیص حافظه (Allocation Overhead)
هر بار که یک متد async فراخوانی میشود، چندین تخصیص حافظه رخ میدهد:
-
Task Object: شیء Task یا Task در Heap ایجاد میشود.
-
Async Method Builder: ساختاری که مدیریت اجرای متد را بر عهده دارد.
-
StateMachine Class: کامپایلر کدهای شما را به یک کلاس تبدیل میکند تا بتواند وضعیت متغیرهای محلی را در هنگام await حفظ کند.
-
Delegates & Contexts: نگهداری ExecutionContext و SynchronizationContext.
پس؛ اگر متدی دارید که در هر ثانیه میلیونها بار فراخوانی میشود و عملیات آن بسیار سریع (مثل دسترسی به یک دیکشنری در حافظه) است، async کردن آن فقط باعث فشار شدید به Garbage Collector (GC) میشود.
ب) سربار پردازشی (CPU Overhead)
- مدیریت ماشین وضعیت (State Machine) و جابجایی بین تردها، سیکلهای CPU مصرف میکند. در عملیاتهای CPU-Bound (مانند محاسبات سنگین ریاضی یا پردازش تصویر)، استفاده از async/await نه تنها کمکی نمیکند، بلکه به دلیل همین سربار، سرعت را کاهش میدهد. در این موارد باید از Task.Run برای انتقال کار به ترد دیگر استفاده کرد، نه اینکه خودِ متد را ذاتا async طراحی کرد.
ج) آلودگی کد (Async Infection)
-
خاصیت «ویروسی» Async باعث میشود که اگر پایینترین لایه (مثلاً Repository) را Async کنید، این تغییر تا بالاترین لایه (Controller) سرایت کند. در کدهای قدیمی (Legacy)، این موضوع میتواند منجر به استفاده از Anti-patternهایی مثل .Result یا .Wait() شود که عامل اصلی Deadlock هستند.
مثال (1): بررسی موشکافانه با مثال عملی
فرض کنید وظیفهای داریم که دادهای را از کش (InMemory) میخواند؛ اگر نبود، از دیتابیس میگیرد.
رویکرد اشتباه: Async برای همه چیز
public async Task GetDataAsync(int id)
{
// این بخش بسیار سریع است و نیازی به async ندارد
var cachedData = _cache.Get(id);
if (cachedData != null) return cachedData;
// این بخش I/O-Bound است و باید async باشد
return await _db.Data.FindAsync(id);
}
تحلیل: حتی اگر داده در کش باشد، ما هزینه ساخت Task و StateMachine را پرداخت کردهایم.
رویکرد بهینه: استفاده از ValueTask
در داتنت مدرن، برای رفع این مشکل از ValueTask استفاده میکنیم. ValueTask یک struct است و اگر عملیات به صورت همزمان (Synchronous) به پایان برسد (مثلاً داده در کش باشد)، هیچ تخصیص حافظهای در Heap صورت نمیگیرد.
public ValueTask GetDataOptimizedAsync(int id)
{
var cachedData = _cache.Get(id);
if (cachedData != null)
return new ValueTask(cachedData); // بدون تخصیص حافظه در Heap
return new ValueTask(_db.Data.FindAsync(id)); // فقط در صورت نیاز به I/O واقعی
}
کالبدشکافی State Machine (آنچه کامپایلر پنهان میکند)
وقتی کلمه async را مینویسید، کامپایلر کد شما را به چیزی شبیه به این تبدیل میکند:
-
یک struct به نام d__1 ایجاد میشود که اینترفیس IAsyncStateMachine را پیادهسازی میکند.
-
کد شما به بخشهای مختلف تقسیم شده و در یک متد MoveNext() قرار میگیرد.
-
یک فیلد __state وضعیت فعلی را نگه میدارد.
-
در هر await برسی میشود: آیا عملیات تمام شده؟
-
اگر بله: مقدار را بگیر و به مرحله بعد برو.
-
اگر خیر: یک Awaiter ثبت کن، وضعیت را ذخیره کن و از متد خارج شو (Return Task).
-
این فرآیند پیچیده، دلیلی است بر اینکه نباید در متدهای بسیار کوچک و پرتکرار که عملیات غیر-I/O انجام میدهند، از async استفاده کرد.
چه زمانی قطعاً نباید از Async استفاده کرد؟
-
Property Accessors: پراپرتیها نباید async باشند. این یک خطای طراحی معماری است.
-
Constructors: سازندهها نمیتوانند async باشند. به جای آن از Factory Methodهای استاتیک استفاده کنید.
-
Simple Wrappers: اگر متدی فقط یک متد async دیگر را فراخوانی میکند و کاری با نتیجه ندارد، نباید خودش async باشد.
-
بد: public async Task Do() => await _service.Do(); (ساخت State Machine اضافه)
-
خوب: public Task Do() => _service.Do(); (پاس دادن مستقیم Task)
-
-
CPU-Bound Work: کارهایی که فقط پردازنده را درگیر میکنند (مثل مرتبسازی یک لیست بزرگ در حافظه).
مثال (2): تجمیع دادههای توزیع شده
در این مثال، دو عملیات I/O داریم که به هم وابسته نیستند:
-
GetProfileAsync: فراخوانی HTTP (زمان تقریبی: ۵۰۰ میلیثانیه)
-
GetPointsAsync: کوئری دیتابیس (زمان تقریبی: ۳۰۰ میلیثانیه)
۱. پیادهسازی همزمان (Synchronous) - فاجعه در مقیاس بالا
public UserCompositeData GetFullUserData(int userId)
{
// ترد جاری مسدود میشود تا پاسخ HTTP دریافت شود
var profile = _profileService.GetProfile(userId);
// ترد جاری باز هم مسدود میشود تا کوئری دیتابیس تمام شود
var points = _database.GetPoints(userId);
return new UserCompositeData(profile, points);
}
اگر از Async استفاده نمیکردیم چه میشد؟
-
هدر رفت زمان (Latency): زمان کل پاسخگویی برابر است با مجموع زمانها: $T_{total} = T_{profile} + T_{points} = 800ms$. در حالی که این دو کار میتوانستند همزمان انجام شوند.
-
خفگی تردها (Thread Starvation): اگر ۱۰۰۰ درخواست همزمان داشته باشید، شما به ۱۰۰۰ ترد واقعی سیستمعامل نیاز دارید که هر کدام ۱ مگابایت حافظه اشغال کردهاند و هیچ کاری جز «منتظر ماندن» انجام نمیدهند. وقتی ظرفیت Thread Pool تمام شود، درخواستهای ۱۰۰۱ به بعد در صف میمانند و Latency به شدت بالا میرود، حتی اگر CPU بیکار باشد.
۲. پیادهسازی غیرهمزمان (Asynchronous) - مهندسی بهینه
در اینجا ما از پتانسیل Task.WhenAll برای اجرای موازی و await برای آزادسازی ترد استفاده میکنیم:
public async Task GetFullUserDataAsync(int userId)
{
// شروع عملیاتها بدون مسدود کردن ترد جاری
Task profileTask = _profileService.GetProfileAsync(userId);
Task pointsTask = _database.GetPointsAsync(userId);
// ترد فعلی آزاد شده و به Thread Pool بازمیگردد
// وقتی هر دو عملیات تمام شدند، یک ترد دیگر ادامه کار را میگیرد
await Task.WhenAll(profileTask, pointsTask);
return new UserCompositeData(await profileTask, await pointsTask);
}
چرا لزوماً باید از این روش استفاده کرد؟
۱. کاهش زمان پاسخگویی: زمان کل به جای مجموع، به «طولانیترین عملیات» محدود میشود: $T_{total} \approx \max(T_{profile}, T_{points}) = 500ms$. شما ۳۰۰ میلیثانیه ذخیره کردید.
۲. بهرهوری I/O Completion Ports (IOCP): در لحظهای که کد به Task.WhenAll میرسد، هیچ تریدی در سرور شما در حال مصرف شدن برای این درخواست نیست. کارت شبکه (NIC) و درایورهای سیستمعامل مدیریت انتظار برای بستههای TCP را بر عهده دارند.
۳. تراکمپذیری (Density): در این مدل، یک سرور با ۱۶ هسته و مقدار کمی رم میتواند به راحتی ۱۰,۰۰۰ درخواست همزمان را مدیریت کند، زیرا فقط برای محاسبات کوتاه از تردها استفاده میکند، نه برای انتظارهای طولانی.
بررسی موشکافانه تفاوت رفتار سیستم
|
ویژگی |
حالت Synchronous |
حالت Asynchronous |
|
وضعیت ترد هنگام I/O |
WaitSleepJoin (مسدود) |
آزاد (در دسترس برای درخواستهای دیگر) |
|
هزینه Context Switch |
بسیار بالا (جابجایی مدام بین تردهای مسدود) |
بسیار پایین |
|
مصرف حافظه |
بالا (به دلیل تعداد زیاد تردهای فعال) |
بهینه (تعداد تردها به CPU وابسته است) |
|
رفتار در بار سنگین |
خطای 503 Service Unavailable |
پاسخگویی پایدار با تاخیر کنترل شده |
تحلیل عمیق در لایه سیستمعامل
وقتی شما از await در داتنت استفاده میکنید، اتفاقی که در لایه پایین میافتد این است:
درایور دیتابیس یا HTTP Client یک درخواستی را به پشته شبکه (Network Stack) میفرستد و یک I/O Request Packet (IRP) در ویندوز ایجاد میکند. سپس به داتنت میگوید: «من کار را به سختافزار سپردم، به محض اینکه داده آمد، من یک وقفه (Interrupt) ایجاد میکنم».
در این مرحله، داتنت State Machine را ذخیره کرده و ترد را کاملاً آزاد میکند. وقتی داده از کابل شبکه رسید، کارت شبکه یک وقفه سختافزاری ایجاد میکند، سیستمعامل IOCP را باخبر میکند و ترد پول داتنت یک ترد آزاد را برمیدارد تا کد شما را از نقطه await به بعد اجرا کند.
اگر این کار را نمیکردیم: ترد مجبور بود در یک حلقه while یا در حالت تعلیق توسط سیستمعامل، مدام چک کند که آیا داده رسیده یا خیر، که این یعنی اتلاف گرانبهاترین منبع سرور (Threads).
مثال (3): ارسال SMS و یا EMAIL
تصویر کنید که میخواهیم برای 3000 نفر اس ام اس بزنیم. آیا اینجا باید از async استفاده کنیم چون میخواهیم بغیر از «انتظار جواب اتمام رسیدن پیام ها» به این 3000 نفر کار دیگه ای انجام دهیم؟ و یا دلیل دیگه ای دارد؟ شاید اصلا نباشد از async استفاده کنیم؟
در این سناریو، پاسخ کوتاه این است: بله، حتماً باید از Async استفاده کنید، اما نه به تنهایی.
تحلیل زیرساختی: چرا Sync در اینجا یک فاجعه است؟
اگر بخواهید به صورت سنکرون (Synchronous) عمل کنید، کد شما چیزی شبیه به این خواهد بود:
foreach (var user in users)
{
_smsService.SendSms(user.Phone, "سلام"); // هر فراخوانی مثلاً ۵۰۰ میلیثانیه طول میکشد
}
فاجعه اول (زمان): ۵۰۰ میلیثانیه ضربدر ۳۰۰۰ نفر میشود ۱۵۰۰ ثانیه (۲۵ دقیقه!). اپلیکیشن شما ۲۵ دقیقه کاملاً قفل میشود تا این حلقه تمام شود. فاجعه دوم (منابع): در تمام این ۲۵ دقیقه، تریدی که این کد را اجرا میکند در حالت Blocked میماند. اگر این کد در یک وبسرور (مثل ASP.NET Core) اجرا شود، شما یک ترد با ارزش را ۲۵ دقیقه از دست دادهاید.
چرا Async؟ (هدف: آزادسازی منبع)
استفاده از async/await در اینجا به شما اجازه میدهد که در حین انتظار برای پاسخ سرور پیامک، ترد را آزاد کنید.
foreach (var user in users)
{
await _smsService.SendSmsAsync(user.Phone, "سلام");
}
نکته ظریف: این کد هنوز ۲۵ دقیقه طول میکشد! چون شما با await داخل حلقه، برنامه را مجبور میکنید صبر کند تا پیامک اول تاییدیه بگیرد، سپس سراغ دومین پیامک برود. تنها مزیت این کد نسبت به قبلی این است که در آن ۲۵ دقیقه، تردِ سرور شما آزاد است و میتواند به درخواستهای دیگر کاربران پاسخ دهد.
فراتر از انتظار: انجام کارهای دیگر (Concurrency) - ترکیب Async و Concurrency
پرسیدیم: «چون میخواهیم غیر از انتظار، کار دیگری انجام دهیم، باید از Async استفاده کنیم؟»
پاسخ: بله. در مدل Async، شما میتوانید عملیات را "شلیک" کنید (Fire) و لزوماً همان لحظه منتظر نمانید.
برای اینکه ۳۰۰۰ پیامک را سریع بفرستید و کارهای دیگر را هم انجام دهید، باید از Task-based Parallelism استفاده کنید:
public async Task ProcessMassSms()
{
// ۱. لیست تسکها را میسازیم (بدون انتظار در لحظه)
List smsTasks = users.Select(u => _smsService.SendSmsAsync(u.Phone, "سلام")).ToList();
// ۲. حالا "در حین" ارسال پیامکها، میتوانید کارهای دیگر انجام دهید
DoSomeOtherWork(); // این متد بلافاصله اجرا میشود چون پیامکها در پسزمینه در حال ارسال هستند
// ۳. در نهایت، هر جا که نیاز بود، منتظر پایان همه پیامکها میمانیم
await Task.WhenAll(smsTasks);
}
تحلیل: در این حالت، زمان اجرای شما از ۲۵ دقیقه به چند ثانیه کاهش مییابد (بسته به پهنای باند شبکه و سرور پیامک).
سوال؟ آیا await Task.WhenAll الزامی است؟
از نظر کامپایلر، خیر؛ اما از نظر منطق برنامه، بله (در ۹۹٪ مواقع).
اگر شما await Task.WhenAll(smsTasks) را ننویسید، اتفاقات زیر میافتد:
-
Fire and Forget (بزن و برو): متد ProcessMassSms بلافاصله به پایان میرسد و به فراخواننده (Caller) اعلام میکند که «من کارم تمام شد»، در حالی که تسکهای ارسال پیامک هنوز در پسزمینه در حال اجرا هستند.
-
بلعیده شدن استثناها (Exceptions): اگر در حین ارسال پیامکها خطایی رخ دهد (مثلاً پنل پیامک قطع شود)، چون شما منتظر تسکها نماندهاید، هیچ راهی برای گرفتن (Catch) آن خطا ندارید. این خطاها اصطلاحاً "Unobserved" میشوند که در نسخههای قدیمی داتنت میتوانست باعث کرش کردن کل اپلیکیشن شود.
-
اتمام زودرس: اگر این کد بخشی از یک کنسول اپلیکیشن باشد، برنامه بسته میشود و تمام تسکهای نیمهتمام متوقف میشوند.
چند نکته دیگر برای این سناریو:
آیا همیشه باید ۳۰۰۰ Task را همزمان با Task.WhenAll شلیک کرد؟ خیر.
اگر ۳۰۰۰ درخواست HTTP همزمان به سمت پنل پیامک بفرستید، احتمالاً با مشکلات زیر روبرو میشوید:
- Socket Exhaustion: تمام پورتهای خروجی سرور شما اشغال میشود.
- Rate Limiting: سرور پیامک شما را به عنوان حملهی DOS شناسایی کرده و IP شما را مسدود میکند.
- DNS Bottleneck: وضوح ۳۰۰۰ آدرس DNS در لحظه ممکن است سیستم را دچار اختلال کند.
راه حل حرفهای (Throttling): به جای ارسال همزمان ۳۰۰۰ تا، یا ارسال یکییکی (Sync)، از یک SemaphoreSlim برای کنترل جریان استفاده میکنیم. این یعنی: «Async باش، اما فقط ۵۰ تا ۵۰ تا بفرست».
using var semaphore = new SemaphoreSlim(50); // حداکثر ۵۰ درخواست همزمان
var tasks = users.Select(async user =>
{
await semaphore.WaitAsync();
try {
await _smsService.SendSmsAsync(user.Phone, "سلام");
}
finally {
semaphore.Release();
}
});
await Task.WhenAll(tasks);
معماری پیشنهادی: چرا اصلاً در Request-Response نباید باشد؟
اگر ادمین روی دکمه «ارسال به ۳۰۰۰ نفر» کلیک میکند، شما نباید او را حتی برای ۱ ثانیه معطل نگه دارید. در سیستمهای بزرگ، ارسال ۳۰۰۰ پیامک اصلاً نباید بخشی از جریان async/await مستقیم در Controller باشد.
دلیل: اگر وسط کار، اپلیکیشن ریستارت شود یا IIS بازیافت (Recycle) شود، لیست تسکهای در حال انتظار (In-memory) از بین میرود.
راهکار اصولی: 1. ادمین درخواست را ثبت میکند. 2. شما درخواست را در یک Queue (مثل RabbitMQ یا دیتابیس) قرار میدهید. 3. یک Background Service (مثل Worker Service یا Hangfire) به صورت async دادهها را از صف برداشته و ارسال میکند.
مثال (4): تغییر صفحه و بررسی درخواست دستور قبل
اینجاست که تفاوت بین یک برنامهنویس جونیور و سنیور مشخص میشود. وقتی ادمین صفحه را میبندد یا به صفحه دیگری میرود، چه اتفاقی میافتد؟
الف) اگر در محیط وب (ASP.NET Core) باشید:
درخواست HTTP ادمین یک طول عمر (Lifetime) دارد. وقتی ادمین صفحه را ترک میکند یا مرورگر را میبندد:
-
مرورگر اتصال TCP را قطع میکند.
-
یک CancellationToken در سمت سرور فعال میشود.
-
با توجه به مثال 3، اگر شما کد ارسال را مستقیماً در اکشنِ Controller نوشته باشید، با بسته شدن درخواست، آن Thread یا Task که مسئول مدیریت ۳۰۰۰ پیامک بود، ممکن است توسط سیستم متوقف شود (یا در بهترین حالت، بدون صاحب در حافظه رها شود).
ب) مشکل گم شدن وضعیت (State Loss):
اگر ادمین به صفحه دیگری برود، چطور میخواهد بفهمد که از آن ۳۰۰۰ تا، ۲۵۰۰ تا ارسال شده؟ در کدی که شما نوشتید، لیست smsTasks در حافظه موقت (RAM) همان درخواست است. با رفتن ادمین، آن متغیر از بین میرود.

راه حل استاندارد: معماری "Job-Based"
برای ارسال ۳۰۰۰ پیامک، نباید به "ماندن ادمین در صفحه" متکی باشید. راه حل حرفهای شامل سه ضلع است:
۱. ذخیره در دیتابیس (Persistence)
-
به محض اینکه ادمین دکمه ارسال را زد، ۳۰۰۰ رکورد در جدول SmsLogs با وضعیت Pending ثبت کنید.
۲. پردازش در پسزمینه (Background Worker)
- از ابزارهایی مثل Hangfire، Quartz.NET یا خودِ BackgroundService در داتنت استفاده کنید. این سرویسها مستقل از ادمین و درخواستهای HTTP کار میکنند.
۳. اطلاعرسانی زنده (Real-time Notification)
- برای اینکه ادمین در هر صفحهای بود متوجه پیشرفت کار شود، از SignalR استفاده کنید.
پیادهسازی اصلاح شده (نمای کلی):
// ۱. ادمین فقط دستور را صادر میکند و سریعاً پاسخ 202 (Accepted) میگیرد
public async Task TriggerSms(SmsRequest request)
{
// ثبت در دیتابیس برای پیگیری بعدی
var jobId = await _smsStore.CreateBatchSmsJob(request);
// ارسال کار به پسزمینه (مثلاً با Hangfire)
_backgroundJobClient.Enqueue(() => _smsProcessor.SendMassSms(jobId));
return Accepted(new { JobId = jobId, Message = "ارسال در پیشزمینه شروع شد." });
}
// ۲. متدی که در پسزمینه اجرا میشود
public async Task SendMassSms(int jobId)
{
var tasks = users.Select(async u => {
var result = await _smsService.SendSmsAsync(u.Phone, "...");
// اطلاعرسانی به ادمین از طریق SignalR (اگر آنلاین بود)
await _hubContext.Clients.User(adminId).SendAsync("SmsProgress", ...);
});
await Task.WhenAll(tasks);
}
نتیجهگیری و «قانون طلایی»
استفاده از Async یک معامله است: شما کمی از Latency (تاخیر در یک درخواست به دلیل سربار State Machine) و Memory را فدا میکنید تا Scalability (توانایی پاسخگویی به هزاران درخواست همزمان) را به دست آورید.
قانون طلایی:
-
برای عملیات I/O-Bound (شبکه، دیتابیس، فایل سیستم): همیشه از Async استفاده کنید، اما با نگاهی به ValueTask برای بهینهسازی.
-
برای عملیات CPU-Bound: از کدهای سنکرون استفاده کنید و در صورت نیاز به جلوگیری از فریز شدن ترد اصلی، از Task.Run بهره ببرید.
-
در مسیرهای داغ (Hot Paths) که میلیثانیهها اهمیت دارند، از خیر Async بگذرید.
0 نظر
هنوز نظری برای این مقاله ثبت نشده است.