مقدمه

تاکنون ابزارهای مختلفی را برای همزمانی و موازی‌سازی بررسی کرده‌ایم. دیدیم که کلاس Parallel و PLINQ برای کارهای محاسباتی سنگین (CPU-bound) عالی هستند. اما رایج‌ترین سناریوی همزمانی در برنامه‌های مدرن، کار با عملیات ورودی/خروجی (I/O-bound) است؛ عملیاتی مانند درخواست از یک وب‌سرویس، کوئری زدن به یک پایگاه داده، یا خواندن از یک فایل بزرگ. در این سناریوها، برنامه بیشتر وقت خود را صرف انتظار برای پاسخ از یک منبع خارجی می‌کند. اشغال کردن یک نخ کامل برای این انتظار، هدر دادن منابع سیستم است. برای حل این مشکل، C# 5.0 دو کلمه‌ی کلیدی انقلابی را معرفی کرد: async و await. این دو با هم، یک الگوی برنامه‌نویسی سطح بالا به نام برنامه‌نویسی ناهمزمان (Asynchronous Programming) را فراهم می‌کنند که به ما اجازه می‌دهد کدهای غیرمسدودکننده و پاسخگو را با سینتکسی که تقریباً شبیه به کد همزمان و عادی است، بنویسیم.

برنامه‌نویسی ناهمزمان چیست؟

در مدل همزمان سنتی، وقتی شما یک متد برای خواندن یک فایل فراخوانی می‌کنید، نخ شما تا زمانی که کل فایل خوانده شود، "مسدود" (block) می‌شود. در یک برنامه‌ی دسکتاپ یا موبایل، این به معنای یخ زدن کامل رابط کاربری است.

در مدل ناهمزمان، وقتی شما یک عملیات I/O را شروع می‌کنید، نخ شما بلافاصله آزاد می‌شود تا به کارهای دیگر رسیدگی کند. شما یک "ادامه" (continuation) یا "callback" را برای سیستم تعریف می‌کنید که پس از اتمام عملیات I/O، به طور خودکار فراخوانی شود. الگوی async/await این فرآیند پیچیده‌ی مدیریت callback ها را از دید ما پنهان کرده و آن را بسیار ساده می‌کند.

کلمات کلیدی async و await

این دو کلمه کلیدی همیشه با هم به کار می‌روند:

  • async: این کلمه کلیدی را به امضای یک متد اضافه می‌کنیم تا به کامپایلر اعلام کنیم که این متد ممکن است حاوی یک یا چند عملیات ناهمزمان باشد. یک متد async می‌تواند مقادیری از نوع void، Task یا Task<TResult> را برگرداند.
  • await: این عملگر را قبل از فراخوانی یک متد ناهمزمان (متدی که یک Task یا Task<TResult> برمی‌گرداند) قرار می‌دهیم. این عملگر به کامپایلر می‌گوید: "این عملیات را شروع کن. اگر هنوز تمام نشده، کنترل را به کد فراخواننده‌ی من برگردان و نخ را آزاد کن. وقتی عملیات تمام شد، اجرای این متد را از همین نقطه ادامه بده."

یک مثال ساده

بیایید یک متد ناهمزمان برای شبیه‌سازی دانلود یک فایل بنویسیم.

Copy Icon Program.cs
// An async method. Its return type is Task or Task.
static async Task<string> DownloadFileAsync(string url)
{
    Console.WriteLine($"Starting download from {url}...");
    // Simulate a network delay without blocking the thread.
    await Task.Delay(3000);
    Console.WriteLine("Download complete.");
    return "File content";
}

// --- In an async Main method ---
Console.WriteLine("Before download call.");

// We 'await' the result of the async method.
string content = await DownloadFileAsync("example.com");

Console.WriteLine($"File content received: {content}");
Console.WriteLine("After download call.");

وقتی await DownloadFileAsync(...) فراخوانی می‌شود، DownloadFileAsync شروع به اجرا می‌کند. به محض رسیدن به await Task.Delay(3000) این متد متوقف شده و کنترل به متد Main بازمی‌گردد. چون متد Main نیز منتظر (await) نتیجه است، نخ اصلی آزاد می‌شود. پس از ۳ ثانیه، عملیات Delay تمام شده و اجرای متد DownloadFileAsync از خط بعد از await ادامه می‌یابد.

اجرای چندین وظیفه‌ی ناهمزمان

یکی از مزایای بزرگ برنامه‌نویسی ناهمزمان، قابلیت اجرای چندین عملیات I/O به صورت همزمان است. شما می‌توانید چندین وظیفه را شروع کرده و سپس منتظر بمانید تا همه‌ی آن‌ها به پایان برسند. این کار با استفاده از Task.WhenAll انجام می‌شود.

Copy Icon Program.cs
async static Task DownloadMultipleFilesAsync()
{
    // Start multiple tasks without awaiting them immediately.
    Task<string> task1 = DownloadFileAsync("site1.com");
    Task<string> task2 = DownloadFileAsync("site2.com");

    Console.WriteLine("Both downloads started concurrently.");

    // Wait for ALL tasks to complete.
    await Task.WhenAll(task1, task2);

    Console.WriteLine("All downloads have finished.");

    // Now we can safely get the results.
    string content1 = task1.Result; // Note: Use .Result only AFTER the task is complete.
    string content2 = task2.Result;
}

در این مثال، هر دو دانلود به صورت همزمان شروع می‌شوند. متد Task.WhenAll یک Task جدید برمی‌گرداند که تنها زمانی کامل می‌شود که تمام تسک‌های ورودی آن کامل شده باشند. این روش بسیار کارآمدتر از منتظر ماندن برای هر دانلود به صورت جداگانه است.

"Async All the Way"

یک قانون مهم در برنامه‌نویسی ناهمزمان وجود دارد: "async all the way up". این یعنی اگر شما یک متد async را فراخوانی می‌کنید، متد فراخواننده‌ی شما نیز باید async باشد (و به همین ترتیب تا بالای پشته‌ی فراخوانی). ترکیب کردن کد همزمان و ناهمزمان (مثلاً با فراخوانی Wait() یا دسترسی به .Result بر روی یک تسک که هنوز کامل نشده) یک ضد-الگوی رایج است که می‌تواند منجر به بن‌بست (deadlock) و مشکلات عملکردی شود. همیشه سعی کنید ناهمزمان باقی بمانید.

خلاصه: چه زمانی از کدام رویکرد استفاده کنیم؟

  • برنامه‌نویسی موازی (TPL, PLINQ): برای کارهای محاسباتی سنگین (CPU-Bound) که می‌خواهید با استفاده از تمام هسته‌های CPU آن‌ها را سریع‌تر انجام دهید.
  • برنامه‌نویسی ناهمزمان (async/await): برای کارهای وابسته به ورودی/خروجی (I/O-Bound) که شامل انتظار برای منابع خارجی هستند. هدف اصلی، آزاد کردن نخ‌ها و افزایش پاسخگویی برنامه است، نه لزوماً سریع‌تر کردن خود عملیات.