مقالات › C#

آموزش کامل async و await در C#؛ برنامه‌نویسی ناهمگام از صفر تا حرفه‌ای

async و await در سی‌شارپ واقعاً چطور کار می‌کنند؟ Task، ماشین حالت، ConfigureAwait، لغو عملیات، اجرای موازی و اشتباهات رایجی که باعث Deadlock و کندی API می‌شوند.

برنامه‌نویسی ناهمگام (Asynchronous) یکی از مهم‌ترین مهارت‌های یک توسعه‌دهنده‌ی دات‌نت است. هر بار که کد شما با دیتابیس حرف می‌زند، یک API را صدا می‌زند یا فایلی را می‌خواند، در واقع منتظر چیزی بیرون از CPU است. با async و await این زمانِ انتظار هدر نمی‌رود: Thread به Thread Pool برمی‌گردد و در همین فاصله به درخواست‌های دیگر رسیدگی می‌کند.

در این آموزش از مفاهیم پایه شروع می‌کنیم و به جزئیاتی می‌رسیم که در پروژه‌های واقعی اهمیت دارند: Task چیست، کامپایلر برای یک متد async چه کدی تولید می‌کند، چطور کارها را موازی اجرا کنیم، لغو عملیات چطور کار می‌کند و چه اشتباهاتی باعث Deadlock و کمبود Thread می‌شوند.

چرا کد ناهمگام مهم است؟

فرض کنید یک API در ASP.NET Core در هر ثانیه ۱۰۰۰ درخواست دریافت می‌کند و هر درخواست ۱۰۰ میلی‌ثانیه منتظر SQL Server می‌ماند. با کد همگام، هر درخواست در این ۱۰۰ میلی‌ثانیه یک Thread را قفل می‌کند. Thread Pool مجبور به رشد می‌شود، مصرف حافظه بالا می‌رود و در نهایت درخواست‌ها در صف می‌مانند.

با کد ناهمگام، تا وقتی دیتابیس مشغول کار است Thread آزاد می‌شود. وقتی نتیجه رسید، هر Thread آزادی می‌تواند ادامه‌ی متد را اجرا کند. یعنی همان سرور با Threadهای خیلی کمتر، درخواست‌های هم‌زمان بسیار بیشتری را پاسخ می‌دهد.

نکته: هدف async مقیاس‌پذیری است، نه سرعت خام. یک فراخوانی async به‌تنهایی از نسخه‌ی همگامش سریع‌تر نیست؛ اما برنامه‌ی شما می‌تواند تعداد خیلی بیشتری از آن‌ها را هم‌زمان مدیریت کند.

Task و Task<T>

یک Task نماینده‌ی عملیاتی است که در آینده تمام می‌شود. Task<T> همان است، با این تفاوت که در پایان یک مقدار از نوع T برمی‌گرداند.

public async Task<User?> GetUserAsync(int id)
{
    // تا وقتی کوئری دیتابیس اجرا می‌شود، Thread آزاد است
    return await _db.Users.FirstOrDefaultAsync(u => u.Id == id);
}

سه قانون مهم:

  • متدی که async است باید Task، Task<T>، ValueTask<T> یا (فقط برای Event Handlerها) void برگرداند.
  • از await فقط داخل متدهای async می‌توان استفاده کرد.
  • طبق قرارداد، نام متدهای ناهمگام با پسوند Async تمام می‌شود.

کامپایلر پشت صحنه چه می‌کند؟

وقتی یک متد async می‌نویسید، کامپایلر C# آن را به یک ماشین حالت (State Machine) تبدیل می‌کند. هر await یک نقطه‌ی توقف احتمالی است:

  1. متد تا اولین await به‌صورت همگام اجرا می‌شود.
  2. اگر Task مورد انتظار از قبل کامل شده باشد، اجرا بدون توقف ادامه پیدا می‌کند.
  3. اگر کامل نشده باشد، متد یک ادامه (Continuation) ثبت می‌کند و یک Task ناتمام به فراخواننده برمی‌گرداند.
  4. وقتی عملیات تمام شد، Continuation متد را از همان نقطه‌ای که متوقف شده بود ادامه می‌دهد.

به همین دلیل است که await چیزی را قفل نمی‌کند: متد واقعاً برمی‌گردد و باقی آن برای بعد زمان‌بندی می‌شود.

اجرای موازی با Task.WhenAll

یک اشتباه بسیار رایج، await کردن عملیات مستقل به‌صورت پشت‌سرهم است:

// ❌ ترتیبی: زمان کل = زمان A + زمان B + زمان C
var user = await GetUserAsync(id);
var orders = await GetOrdersAsync(id);
var messages = await GetMessagesAsync(id);

اگر این عملیات به هم وابسته نیستند، همه را شروع کنید و بعد با هم منتظر بمانید:

// ✅ هم‌زمان: زمان کل ≈ کندترین عملیات
var userTask = GetUserAsync(id);
var ordersTask = GetOrdersAsync(id);
var messagesTask = GetMessagesAsync(id);

await Task.WhenAll(userTask, ordersTask, messagesTask);

var user = userTask.Result;       // امن است: Task قبلاً کامل شده
var orders = ordersTask.Result;
var messages = messagesTask.Result;

هشدار: DbContext در Entity Framework Core Thread-Safe نیست. چند کوئری را هم‌زمان روی یک نمونه‌ی DbContext اجرا نکنید؛ اگر واقعاً به موازی‌سازی نیاز دارید، از Contextهای جداگانه (مثلاً با IDbContextFactory) استفاده کنید.

Task.WhenAny خواهر WhenAll است: به‌محض تمام شدن یکی از Taskها کامل می‌شود و برای پیاده‌سازی Timeout یا سناریوهای «اولین پاسخ برنده است» کاربرد دارد.

لغو عملیات با CancellationToken

عملیات طولانی باید قابل لغو باشند. در ASP.NET Core هر درخواست از قبل یک Token دارد که با قطع شدن اتصال کاربر لغو می‌شود:

[HttpGet("{id:int}")]
public async Task<IActionResult> Get(int id, CancellationToken cancellationToken)
{
    var report = await _reports.BuildAsync(id, cancellationToken);
    return Ok(report);
}

public async Task<Report> BuildAsync(int id, CancellationToken ct)
{
    var rows = await _db.Sales.Where(s => s.ReportId == id).ToListAsync(ct);
    ct.ThrowIfCancellationRequested();
    return Aggregate(rows);
}

همیشه Token را به همه‌ی متدهای ناهمگامی که آن را می‌پذیرند پاس بدهید. می‌توانید Timeout اختصاصی هم بسازید:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var response = await _http.GetAsync(url, cts.Token);

ConfigureAwait(false)؛ کِی لازم است؟

به‌طور پیش‌فرض، await Synchronization Context فعلی را ذخیره می‌کند و ادامه‌ی کار را روی همان اجرا می‌کند. در فریم‌ورک‌های رابط کاربری مثل WPF، یعنی «برگرد روی Thread رابط کاربری» — دقیقاً همان چیزی که برای به‌روزرسانی کنترل‌ها لازم دارید.

در کدهای کتابخانه‌ای که با UI کاری ندارند، می‌توانید این سربار را حذف کنید:

var json = await _http.GetStringAsync(url).ConfigureAwait(false);

ASP.NET Core اصلاً Synchronization Context ندارد؛ بنابراین در کد اپلیکیشن، ConfigureAwait(false) تفاوت عملکردی ایجاد نمی‌کند. با این حال در کتابخانه‌های NuGet که ممکن است از WPF یا WinForms صدا زده شوند، عادت خوبی است.

گناهان کبیره‌ی کد ناهمگام

۱. قفل کردن با .Result یا .Wait()

// ❌ در برنامه‌های دسکتاپ Deadlock می‌سازد و در ASP.NET Core باعث کمبود Thread می‌شود
var user = GetUserAsync(id).Result;

قفل کردن روی کد ناهمگام (Sync over Async) یک Thread را درگیر انتظار برای کاری می‌کند که خودش برای تمام شدن به Thread نیاز دارد. به‌جای آن، متد فراخواننده را هم async کنید — async از ابتدا تا انتها.

۲. استفاده از async void

// ❌ Exceptionها کل پروسه را از کار می‌اندازند و فراخواننده نمی‌تواند منتظر بماند
public async void SaveAsync() { ... }

// ✅
public async Task SaveAsync() { ... }

async void فقط برای Event Handlerها وجود دارد. در هر جای دیگر Task برگردانید.

۳. «صدا بزن و فراموش کن» بدون احتیاط

صدا زدن یک متد async بدون await یعنی Exceptionها بی‌صدا گم می‌شوند و ممکن است با پایان درخواست، کار نیمه‌تمام بماند. در ASP.NET Core برای کارهای پس‌زمینه‌ی واقعی از BackgroundService یا یک صف استفاده کنید.

۴. پیچیدن کد همگام در Task.Run روی سرور

Task.Run کار را به Thread دیگری از Thread Pool منتقل می‌کند — عملیات I/O را ناهمگام نمی‌کند. در یک Web API، نوشتن await Task.Run(() => CpuWork()) فقط یک Thread را با Thread دیگری عوض می‌کند. از Task.Run برای کارهای سنگین CPU در برنامه‌های دسکتاپ (برای روان ماندن UI) استفاده کنید، نه برای «async کردن» کد روی سرور.

ValueTask؛ یک بهینه‌سازی، نه پیش‌فرض

ValueTask<T> وقتی نتیجه اغلب به‌صورت همگام در دسترس است — مثلاً در کش — از ساخت شیء Task جلوگیری می‌کند:

public ValueTask<Product?> GetProductAsync(int id)
{
    if (_cache.TryGetValue(id, out Product? product))
        return ValueTask.FromResult(product);      // بدون تخصیص حافظه

    return new ValueTask<Product?>(LoadFromDbAsync(id));
}

یک ValueTask را فقط یک بار می‌توان await کرد. تا وقتی مسیر پرتکراری را اندازه‌گیری نکرده‌اید، همان Task را استفاده کنید.

استریم‌های ناهمگام با IAsyncEnumerable

وقتی داده تکه‌تکه می‌رسد — صفحه‌های یک API یا ردیف‌های یک کوئری بزرگ — IAsyncEnumerable<T> اجازه می‌دهد هر آیتم را به‌محض آماده شدن پردازش کنید:

public async IAsyncEnumerable<Order> StreamOrdersAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    await foreach (var order in _db.Orders.AsAsyncEnumerable().WithCancellation(ct))
        yield return order;
}

await foreach (var order in StreamOrdersAsync())
    Console.WriteLine(order.Id);

مدیریت خطا

Exceptionی که داخل متد async رخ می‌دهد در Task برگشتی ذخیره می‌شود و هنگام await دوباره پرتاب می‌شود؛ پس try/catch معمولی به‌درستی کار می‌کند:

try
{
    await _payment.ChargeAsync(order);
}
catch (HttpRequestException ex)
{
    _logger.LogError(ex, "Payment gateway failed for order {OrderId}", order.Id);
    throw;
}

در Task.WhenAll، دستور await فقط اولین Exception را پرتاب می‌کند. اگر همه‌ی خطاها را لازم دارید، ویژگی Exception در Task برگشتی از WhenAll را بررسی کنید.

چک‌لیست سریع

موقعیت کار درست
دیتابیس، HTTP، فایل متد async را await کنید
عملیات مستقل از هم همه را شروع کنید، بعد await Task.WhenAll(...)
عملیات طولانی CancellationToken بگیرید و پاس بدهید
کد کتابخانه‌ای ConfigureAwait(false) را در نظر بگیرید
Event Handler async void مجاز است — فقط همین‌جا
مسیر پرتکرار با نتایج همگام بعد از اندازه‌گیری، ValueTask<T>
هرگز .Result، .Wait() و GetAwaiter().GetResult() در مسیر درخواست‌ها

جمع‌بندی

async و await به Threadها اجازه می‌دهند در زمان انتظار برای I/O کار مفیدی انجام دهند. کامپایلر متد شما را به ماشین حالتی تبدیل می‌کند که در هر await متوقف می‌شود و با آماده شدن نتیجه ادامه می‌دهد. کد را از ابتدا تا انتها async بنویسید، کارهای مستقل را با Task.WhenAll هم‌زمان اجرا کنید، همیشه CancellationToken را منتقل کنید و از قفل کردن و async void دوری کنید. با رعایت همین چند قانون، برنامه‌های دات‌نت شما زیر بار واقعی هم روان و مقیاس‌پذیر می‌مانند.

پرسش‌های متداول

آیا async کد من را سریع‌تر می‌کند؟

یک عملیات تکی را نه. async برنامه را مقیاس‌پذیرتر می‌کند، چون Threadها در زمان انتظار برای I/O قفل نمی‌شوند و سرور می‌تواند درخواست‌های هم‌زمان بیشتری را پاسخ دهد.

آیا در ASP.NET Core باید ConfigureAwait(false) بنویسم؟

در کد اپلیکیشن تأثیر عملکردی ندارد، چون ASP.NET Core فاقد Synchronization Context است. در کتابخانه‌های مشترک همچنان توصیه می‌شود.

تفاوت Task.Run با async/await چیست؟

Task.Run کد را روی یک Thread از Thread Pool اجرا می‌کند و برای کارهای سنگین CPU مناسب است. اما async/await روشی برای منتظر ماندن بدون قفل کردن Thread است که برای عملیات I/O ایده‌آل است.