Task، async و await: مسیر کار غیرهمگام از شروع تا نتیجه

منتشر شده در · 11 دقیقه مطالعه

مهندسی توسعه نرم‌افزار #csharp #dotnet #async

مقدمهٔ مجموعه: برنامه‌نویسی غیرهمگام: پیش از انتخاب ابزار، نوع انتظار را بشناسید

بیایید مسیر متدی را دنبال کنیم که به نتیجه‌ی یک عملیات I/O نیاز دارد. درخواست HTTP می‌فرستد، متن پاسخ را می‌گیرد و آن را تجزیه می‌کند. تا پاسخ نرسد، تجزیه هم شروع نمی‌شود. اما فراخواننده می‌تواند تسک این متد را نگه دارد و در این فاصله سراغ کاری برود که به نتیجه‌ی تجزیه وابسته نیست.

اینجا باید دو کار را از هم جدا کنیم: درخواست HTTP که متن پاسخ را فراهم می‌کند و متد ما که آن متن را به یک Response تبدیل می‌کند. Task، async و await کمک می‌کنند نتیجه‌ی هرکدام را جداگانه دنبال کنیم.

اول، تسک چیست؟

تسک (Task) به فراخواننده امکان می‌دهد از پایان یک عملیات باخبر شود. تسک خودش thread نیست و وجودش هم به این معنی نیست که کاری حتماً جای دیگری در حال اجراست. عملیات ممکن است موفق شود، خطا بدهد یا لغو شود. اگر نتیجه‌ای داشته باشد، نوع آن Task<T> است: مثلاً Task<string> در نهایت یک رشته می‌دهد، اما Task ساده مقدار بازگشتی ندارد. حتی ممکن است تسک پیش از آنکه به دست فراخواننده برسد، کامل شده باشد.[1]

در مثال ما، تسکی که API مربوط به HTTP برمی‌گرداند، نتیجه‌ی درخواست را نشان می‌دهد. تسک متد خودمان همه‌ی کارهای باقی‌مانده‌ی آن متد را هم در بر می‌گیرد، از جمله استفاده از پاسخ پس از رسیدنش. تسک اول متن پاسخ را در اختیار می‌گذارد و تسک دوم نتیجه‌ی تجزیه‌شده را برمی‌گرداند.

async چه چیزی را تغییر می‌دهد؟

با async می‌توانیم در یک متد از await استفاده کنیم. متدی که قرار است مقداری از نوع T برگرداند، معمولاً امضای Task<T> دارد؛ اگر مقدار بازگشتی نداشته باشد، معمولاً Task برمی‌گرداند. داخل متد مقدار را با return value برمی‌گردانیم و فراخواننده آن را از طریق تسک متد دریافت می‌کند.[1]

وقتی یک متد async را فراخوانی می‌کنیم، اجرای کدش شروع می‌شود. متد تا پایان کار یا تا رسیدن به awaitای که عملیاتش هنوز کامل نشده، پیش می‌رود. در حالت دوم می‌تواند یک تسک ناتمام به فراخواننده برگرداند و بعداً ادامه‌ی کارش را انجام دهد. در [نوشته‌ی قبلی درباره‌ی دلیل استفاده از async]1 توضیح دادیم که async متد را روی thread پس‌زمینه اجرا نمی‌کند.

await کجای این مسیر قرار می‌گیرد؟

متد در await نتیجه‌ی یک عملیات را می‌خواهد. اگر تسک کامل شده باشد، اجرا بی‌درنگ از همان‌جا ادامه پیدا می‌کند. اگر تسک هنوز کامل نشده باشد، متد موقتاً ادامه‌ی کارش را کنار می‌گذارد و کنترل را به فراخواننده برمی‌گرداند؛ لازم نیست thread را برای این انتظار مسدود نگه دارد. پس از کامل شدن عملیات، متد با نتیجه‌ی آن از بعدِ await ادامه می‌دهد.[1]

خطا یا لغو هم در همین نقطه برای متد آشکار می‌شود. متد می‌تواند خودش به آن رسیدگی کند یا رسیدگی را به فراخواننده‌ای بسپارد که تسک متد را await می‌کند. رسیدن به await لزوماً به معنی توقف نیست؛ شاید عملیات از قبل تمام شده باشد.[1]

مسیر یک درخواست HTTP در کد

این مسیر را با HttpClient.GetStringAsync ببینیم:

static async Task<Response> FetchAsync(
    HttpClient httpClient,
    string url,
    CancellationToken cancellationToken)
{
    Task<string> responseTask =
        httpClient.GetStringAsync(url, cancellationToken);

    string json = await responseTask;
    return Parse(json);
}

در این مثال، Response و Parse فقط برای نشان دادن مسیر کار آمده‌اند؛ تمرکز مثال روی مسیر تسک‌هاست، نه شکل پاسخ. GetStringAsync درخواست GET را شروع می‌کند و یک Task<string> برمی‌گرداند. این تسک وقتی کامل می‌شود که بدنه‌ی پاسخ به‌صورت متن خوانده شده باشد. نوع بازگشتی FetchAsync اما Task<Response> است، چون کار آن متد به گرفتن متن محدود نمی‌شود و تجزیه‌ی آن را هم شامل می‌شود.[2]

پیش از await، متغیر responseTask را داریم، اما json هنوز وجود ندارد. اگر پاسخ آماده نباشد، FetchAsync با تسک ناتمام خودش کنترل را به فراخواننده برمی‌گرداند. وقتی تسک HTTP کامل شد، متد در await responseTask متن را می‌گیرد و سپس Parse(json) را فراخوانی می‌کند. Parse همچنان کد همگام است؛ بخش غیرهمگام این مثال، انتظار برای پاسخ HTTP است.

تسک Task<Response> فقط پس از بازگشت Parse با موفقیت کامل می‌شود. اگر پاسخ HTTP هنگام رسیدن به await آماده باشد، متد بدون واگذار کردن کنترل ادامه می‌دهد و حتی ممکن است فراخواننده تسکی را دریافت کند که همان موقع کامل شده است. کد در هر دو حالت یکی است؛ فراخواننده نباید منطقش را به این وابسته کند که متد این بار مکث کرده یا نه.

حالا فراخواننده می‌تواند منتظر تسک متد بماند:

Response response = await FetchAsync(
    httpClient,
    url,
    cancellationToken);

await اول داخل FetchAsync متن پاسخ را می‌گیرد؛ await دوم، در فراخواننده، Response تجزیه‌شده را دریافت می‌کند. هر متد منتظر نتیجه‌ای می‌ماند که خودش به آن نیاز دارد.

اگر درخواست موفق نشود

فرض کنید درخواست HTTP خطا بدهد. تسک GetStringAsync با خطا کامل می‌شود و در نتیجه await responseTask داخل FetchAsync استثنا پرتاب می‌کند. چون FetchAsync این استثنا را نمی‌گیرد، تسک Task<Response> خودش هم با خطا کامل می‌شود. فراخواننده هنگام await کردن FetchAsync خطا را می‌بیند. GetStringAsync وضعیت پاسخی را که خارج از محدوده‌ی موفقیت باشد هم خطای درخواست در نظر می‌گیرد.[2]

تجزیه‌ی متن می‌تواند جداگانه خطا بدهد. ممکن است درخواست موفق باشد، اما Parse نتواند از متن برگشتی استفاده کند. در این حالت تسک HTTP موفق شده، ولی استثنای Parse تسک برگشتی FetchAsync را با خطا کامل می‌کند. فراخواننده‌ای که فقط HttpRequestException را می‌گیرد، لزوماً خطای تجزیه را مدیریت نمی‌کند. تسک بیرونی نتیجه‌ی کل متد را نشان می‌دهد، نه فقط درخواست شبکه را.

لغو از یک درخواست برای توقف کار شروع می‌شود، نه از متوقف کردن اجباری آن. این نسخه از GetStringAsync، CancellationToken فراخواننده را می‌پذیرد. اگر عملیات HTTP در حالی که هنوز فعال است لغو را تشخیص دهد، فراخواننده ممکن است هنگام await کردن FetchAsync یک OperationCanceledException ببیند. در این کد، توکن به فراخوانی جداگانه‌ی Parse داده نمی‌شود؛ لغو پس از پایان درخواست هم نمی‌تواند نتیجه‌ای را که قبلاً تولید شده برگرداند. پایان مهلت انتظار نیز ممکن است با OperationCanceledException گزارش شود. بنابراین فقط از روی نوع استثنا نمی‌توان فهمید چرا عملیات متوقف شده است.[2]

در این مثال، فراخواننده خطای درخواست یا لغوی را که خودش خواسته مدیریت می‌کند و می‌گذارد خطاهای دیگر به لایه‌ی بالاتر برسند:

try
{
    Response response = await FetchAsync(httpClient, url, cancellationToken);
    Console.WriteLine(response);
}
catch (HttpRequestException exception)
{
    Console.Error.WriteLine(exception.Message);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
    Console.WriteLine("Request cancelled.");
}

شرط catch بررسی می‌کند که توکن فراخواننده لغو شده یا نه؛ علت استثنا را تشخیص نمی‌دهد. اگر پایان مهلت انتظار و لغو از طرف فراخواننده تقریباً هم‌زمان رخ دهند، ممکن است همین شاخه پایان مهلت را هم با پیام "Request cancelled." گزارش کند. اگر توکن لغو نشده باشد، خطای پایان مهلت به لایه‌ی بالاتر می‌رود. خطاهای تجزیه هم به همین شکل منتقل می‌شوند.

متد کجا ادامه پیدا می‌کند؟

وقتی درخواست HTTP کامل می‌شود، کد بعد از await در محیطی که برنامه فراهم کرده ادامه می‌یابد. بعضی فریم‌ورک‌ها یک SynchronizationContext سفارشی فراهم می‌کنند. در برنامه‌ی دارای رابط کاربری، رفتار پیش‌فرض await می‌تواند ادامه‌ی متد را به همان context برگرداند تا کد بتواند صفحه را به‌روزرسانی کند. ASP.NET Core معمولاً چنین زمینه‌ی همگام‌سازی سفارشی‌ای نصب نمی‌کند.[3]

مثلاً در یک هندلر رویداد رابط کاربری، رفتار پیش‌فرض await اجازه می‌دهد کد پس از آن یک کنترل را به‌روزرسانی کند:

await FetchAsync(httpClient, url, cancellationToken);
statusLabel.Text = "Response loaded";

هندلر برای تغییر این برچسب به زمینه‌ی رابط کاربری نیاز دارد. باید تصمیم بگیرد خطای احتمالی FetchAsync را هم چطور به کاربر نشان دهد.

در کد کتابخانه‌ایِ عمومی که ادامه‌ی متد به زمینه‌ی فراخواننده نیاز ندارد، ConfigureAwait(false) می‌تواند الزامِ بازگشت به زمینه‌ی ثبت‌شده را بردارد. این کار اجرای ادامه‌ی متد روی thread pool را تحمیل نمی‌کند: اگر عملیات از قبل کامل شده باشد، کد ممکن است همان‌جا ادامه پیدا کند. کد برنامه‌ای که به زمینه‌ی رابط کاربری نیاز دارد باید رفتار پیش‌فرض را نگه دارد. انتخاب به نیازِ ادامه‌ی متد بستگی دارد، نه به قاعده‌ای که بگوید همه‌ی awaitها باید یکسان نوشته شوند.[3]

در نسخه‌ای از FetchAsync که برای کد کتابخانه‌ای نوشته شده و به زمینه‌ی فراخواننده وابسته نیست، فقط خط await عوض می‌شود:

string json = await responseTask.ConfigureAwait(false);
return Parse(json);

Parse به متن نیاز دارد، نه به زمینه‌ی رابط کاربری. این تنظیم روی ادامه‌ی اجرای همین متد اثر می‌گذارد؛ نحوه‌ی ادامه پیدا کردن await در متد فراخواننده را تغییر نمی‌دهد.

تسک را همراه کار نگه داریم

FetchAsync یک تسک برمی‌گرداند تا فراخواننده تصمیم بگیرد چه زمانی به نتیجه نیاز دارد و کجا به خطا رسیدگی کند. اگر متدی میان فراخواننده و درخواست باشد، می‌تواند همان تسک FetchAsync را برگرداند یا آن را به‌عنوان بخشی از کار خودش await کند. در هر دو صورت، نتیجه در زنجیره‌ی فراخوانی قابل مشاهده می‌ماند.

اگر متد میانی کار دیگری ندارد، می‌تواند همان تسک را برگرداند:

static Task<Response> GetResponseAsync(
    HttpClient httpClient, string url, CancellationToken cancellationToken)
{
    return FetchAsync(httpClient, url, cancellationToken);
}

GetResponseAsync نه async اضافه دارد و نه await، چون خودش از نتیجه استفاده نمی‌کند. فراخواننده‌ی آن همچنان پایان کار، خطا یا لغو FetchAsync را دریافت می‌کند.

در مقابل، .Result یا .Wait() تا زمانی که تسک ناتمام است thread فعلی را مسدود نگه می‌دارند. در برنامه‌ی دارای رابط کاربری، اگر ادامه‌ی متد به همان زمینه‌ی مسدودشده نیاز داشته باشد، حتی ممکن است بن‌بست رخ دهد. برای این درخواست، نگه داشتن تسک و await کردن آن مسیرِ رسیدن از پاسخ HTTP به نتیجه‌ی تجزیه‌شده را حفظ می‌کند.[3]

وقتی درخواست‌ها مستقل‌اند

در FetchAsync، تجزیه به متن پاسخ HTTP وابسته است؛ پس این دو مرحله نمی‌توانند هم‌پوشانی داشته باشند. اما اگر فراخواننده دو درخواست مستقل داشته باشد، می‌تواند هر دو را پیش از انتظار برای نتیجه شروع کند تا زمان انتظار I/O آن‌ها روی هم بیفتد.

Task<Response> first = FetchAsync(httpClient, firstUrl, cancellationToken);
Task<Response> second = FetchAsync(httpClient, secondUrl, cancellationToken);

Response[] responses = await Task.WhenAll(first, second);

هر دو درخواست پیش از await شروع شده‌اند. WhenAll تسکی برای دریافت نتیجه‌ی هر دو به فراخواننده می‌دهد؛ خودش درخواست‌ها را شروع نمی‌کند. این کار زمانی مفید است که درخواست‌ها مستقل باشند و سرویس راه دور بتواند این هم‌پوشانی را پاسخ دهد. برای تعداد زیادی درخواست باید محدودیت هم در نظر گرفت؛ آن تصمیم جداگانه‌ای است.

منابع

  1. Task asynchronous programming model - C# - Microsoft Learn
  2. HttpClient.GetStringAsync Method - Microsoft Learn
  3. ConfigureAwait FAQ - .NET Blog

پانویس‌ها

  1. چرا برنامه‌نویسی async وجود دارد؟
اشتراک‌گذاری در LinkedIn