async/await و Thread Pool
کد async یک درخواست را سریعتر نمیکند. کارش این است که وقت انتظار برای شبکه و دیتابیس، thread را آزاد کند. اگر با Result یا Wait آن را بلاک کنی، در بار زیاد همه thread ها منتظر میمانند و سرور کند میشود.
نویسنده: bezzad
مشکل: روز کمپین و سرور کند
فروشگاه اینترنتی ما یک صفحه محصول دارد. قیمت هر محصول را از یک سرویس دیگر میگیریم. یک نفر این کد را نوشته است:
app.MapGet("/products/{id}/price", (int id, PriceClient prices) =>
{
var price = prices.GetPriceAsync(id).Result; // waits here
return Results.Ok(price);
});
در روزهای عادی همه چیز خوب است. ولی در روز کمپین این اتفاقها میافتد:
- زمان پاسخ از چند ده میلیثانیه به چند ثانیه میرسد.
- مصرف CPU سرور پایین است. سرور «بیکار» به نظر میرسد.
- تعداد thread های process آهسته و مدام بالا میرود.
برای فهمیدن دلیل، اول باید بدانیم thread چیست و async/await دقیقاً چه کاری میکند.
Thread و Thread Pool به زبان ساده
یک Thread کارگری است که کد را اجرا میکند. سیستمعامل آن را میسازد. هر thread حافظه stack جدا دارد و ساختنش هزینه دارد.
برای همین .NET برای هر درخواست thread تازه نمیسازد. یک Thread Pool دارد: چند thread آماده که کارها را از یک صف برمیدارند. خود ASP.NET Core هر درخواست را روی یک thread از همین pool اجرا میکند.
await دقیقاً چه میکند؟
بیشتر کار یک API، انتظار است: انتظار برای دیتابیس، برای سرویس دیگر، برای فایل. در این مدت CPU کاری ندارد. سؤال این است: آیا thread هم در این مدت گیر میافتد؟
وقتی به یک await میرسیم، این قدمها انجام میشود:
- کد تا اولین await اجرا میشود. مثلاً درخواست HTTP ساخته و فرستاده میشود.
- کار انتظار به سیستمعامل سپرده میشود. برای انتظار شبکه هیچ thread ای لازم نیست.
- متد برمیگردد. یک Task ناتمام به صدازننده میدهد و thread به pool برمیگردد.
- جواب شبکه میرسد. سیستمعامل خبر میدهد.
- بقیه متد روی یک thread آزاد اجرا میشود. شاید همان thread قبلی نباشد.
چرا Result سرور را کند میکند؟
حالا برگردیم به کد روز کمپین. دلیل کندی در چند قدم:
- هر درخواست یک thread از pool میگیرد.
- خاصیت Result آن thread را تا رسیدن جواب قیمت بلاک میکند. thread هیچ کار دیگری نمیکند.
- وقتی درخواستها زیاد شوند، همه thread های pool بلاک میشوند. درخواستهای تازه در صف میمانند.
- سیستم Thread Pool میبیند صف بزرگ شده است و thread جدید اضافه میکند. ولی این کار را عمداً آهسته انجام میدهد. برای همین تعداد thread ها آهسته بالا میرود.
- این thread ها فقط منتظرند و محاسبه نمیکنند. پس CPU پایین میماند.
به این وضعیت Thread Pool Starvation (گرسنگی Thread Pool) میگوییم. نشانهاش همین است: CPU کم، ولی سیستم کند.
مثال زنده
یک بار با Result و یک بار با await اجرا کن. به تعداد thread ها، صف و بیشترین زمان پاسخ دقت کن:
هشت ثانیه، هر ثانیه ۱۰ درخواست قیمت میرسد. هر درخواست ۱٫۵ ثانیه منتظر یک سرویس دیگر است. Pool با ۴ thread شروع میکند.
کد درست: async از اول تا آخر
راه حل این است که از endpoint تا پایینترین لایه، همه چیز async باشد. به این قانون async all the way میگوییم.
app.MapGet("/products/{id}/price",
async (int id, PriceClient prices, CancellationToken ct) =>
{
var price = await prices.GetPriceAsync(id, ct);
return Results.Ok(price);
});
public sealed class PriceClient(HttpClient http)
{
public async Task<decimal> GetPriceAsync(int productId, CancellationToken ct)
{
var text = await http.GetStringAsync($"prices/{productId}", ct);
return decimal.Parse(text, CultureInfo.InvariantCulture);
}
}
چند کار با هم
صفحه محصول به قیمت و موجودی نیاز دارد. این دو به هم وابسته نیستند. پس میتوانیم هر دو را با هم شروع کنیم و بعد منتظر هر دو بمانیم:
var priceTask = prices.GetPriceAsync(id, ct);
var stockTask = stock.GetStockAsync(id, ct);
await Task.WhenAll(priceTask, stockTask);
var page = new ProductPage(id, await priceTask, await stockTask);
حالا زمان کل تقریباً برابر کندترین کار است، نه جمع هر دو.
سقف همزمانی
فرض کن باید قیمت ۱۰۰۰ محصول را از سرویس قیمت بگیریم و در کش بگذاریم. اگر همه را با هم بفرستیم، سرویس مقابل زیر بار میرود. با متد ForEachAsync در کلاس Parallel یک سقف میگذاریم:
await Parallel.ForEachAsync(productIds,
new ParallelOptions { MaxDegreeOfParallelism = 10, CancellationToken = ct },
async (id, token) =>
{
var price = await prices.GetPriceAsync(id, token);
latestPrices[id] = price; // a ConcurrentDictionary<int, decimal>
});
Task با Thread فرق دارد
Thread
- یک منبع سیستمعامل است.
- حافظه stack جدا دارد و ساختنش گران است.
- وقتی بلاک است، همچنان جا میگیرد.
Task
- یک «کار» است که بعداً تمام میشود.
- روی thread های pool اجرا میشود.
- وقتی منتظر I/O است، هیچ thread ای اشغال نمیکند.
برای هزاران درخواست همزمان، thread جدا برای هر درخواست راه درستی نیست. ولی یک استثنا داریم: اگر یک کار پسزمینه همیشه با یک کتابخانه blocking کار میکند، بهتر است thread اختصاصی بگیرد. اینطوری یک thread از pool را برای همیشه اشغال نمیکند. برای این کار گزینه LongRunning را به متد StartNew میدهیم. این گزینه فقط برای کد blocking معنی دارد. کد async بعد از اولین await دوباره به pool برمیگردد.
لغو کار با CancellationToken
کاربر صفحه گزارش فروش را باز میکند. کوئری دو دقیقه طول میکشد. کاربر صبر نمیکند و صفحه را میبندد. چه اتفاقی میافتد؟
- فریمورک ASP.NET Core میفهمد اتصال قطع شده است.
- توکن لغو آن درخواست را فعال میکند. این توکن فقط یک سیگنال است.
- اگر کد ما توکن را به EF Core نداده باشد، کسی به دیتابیس نمیگوید «بس کن». کوئری تا آخر اجرا میشود و نتیجهاش دور ریخته میشود.
app.MapGet("/reports/sales", async (ShopDb db, CancellationToken ct) =>
await db.Orders
.Where(o => o.CreatedAt >= DateTime.UtcNow.AddDays(-30))
.GroupBy(o => o.CreatedAt.Date)
.Select(g => new { Day = g.Key, Total = g.Sum(o => o.Total) })
.ToListAsync(ct));
کار سنگین CPU
اگر کار واقعاً محاسبه است (مثلاً ساختن یک فایل PDF بزرگ)، async کمکی نمیکند. thread واقعاً مشغول است، نه منتظر.
- در ASP.NET Core، متد Run در کلاس Task سودی ندارد. کار را از یک thread pool به همان thread pool منتقل میکند. فقط هزینه اضافه میسازد.
- در برنامه دسکتاپ مفید است. کار سنگین را از thread صفحه (UI) جدا میکند تا صفحه قفل نشود.
- برای کار سنگین در سرور، کار را به صف و یک worker پسزمینه بسپار.
قانونهای مهم
- هیچ وقت Result یا Wait روی Task نزن. در کد سرور یعنی thread بلاک. در برنامههایی که SynchronizationContext دارند (مثل WinForms)، حتی ممکن است deadlock بدهد.
- متد async void ننویس. خطای داخل آن را هیچ کس نمیگیرد و میتواند کل process را از کار بیندازد. تنها استثنا event handler در برنامههای دسکتاپ است.
- توکن لغو را همیشه پاس بده. هر متد async که I/O دارد، یک پارامتر CancellationToken بگیرد.
- کار را رها نکن (Fire and Forget). اگر یک Task را بدون await رها کنی، خطایش گم میشود. وقتی درخواست تمام شود، سرویسهای Scoped مثل DbContext هم dispose میشوند. برای کار پسزمینه، از صف و BackgroundService استفاده کن.
- در کتابخانهها از ConfigureAwait با مقدار false استفاده کن. در ASP.NET Core لازم نیست، چون SynchronizationContext ندارد. ولی کتابخانه نمیداند در چه برنامهای اجرا میشود.
- نوع ValueTask را فقط وقتی لازم است انتخاب کن. برای متدی که بیشتر وقتها بدون انتظار جواب میدهد (مثلاً از کش) و در کد پرفشار است. فقط یک بار await میشود. پیشفرض همچنان Task است.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| خواندن Result یا صدا زدن Wait | Thread Pool Starvation در بار زیاد. | از اول تا آخر await. |
| متد async void | خطا گم میشود یا process از کار میافتد. | برگرداندن Task. |
| پاس ندادن CancellationToken | کوئریهای بیفایده بعد از رفتن کاربر اجرا میشوند. | پارامتر توکن در همه لایهها. |
| استفاده از متد Run در کلاس Task در API | هزینه اضافه، بدون هیچ سودی. | مستقیم await. |
| شروع کار بدون await و رها کردن آن | خطا گم میشود. DbContext وسط کار dispose میشود. | صف و BackgroundService. |
| ارسال هزار درخواست با هم | سرویس مقابل زیر بار میرود. | سقف همزمانی. |
چه وقت async؟
مناسب
- کار با شبکه، دیتابیس، فایل و صف.
- هر endpoint در ASP.NET Core که I/O دارد.
- کار پسزمینهای که با کتابخانه async کار میکند.
بیفایده
- محاسبه خالص CPU. thread واقعاً مشغول است.
- متد کوچکی که هیچ I/O ندارد. async فقط هزینه اضافه میسازد.
- پیچیدن یک متد sync داخل متد Run فقط برای اینکه async به نظر برسد.
خلاصه در شش خط
- هدف async آزاد کردن thread در وقت انتظار است، نه سریعتر کردن کد.
- با هر await، متد تکه میشود و thread به pool برمیگردد.
- خواندن Result یا صدا زدن Wait، thread را بلاک میکند و در بار زیاد Thread Pool Starvation میسازد.
- نشانه گرسنگی Thread Pool: CPU کم، ولی سیستم کند.
- کارهای مستقل را با هم شروع کن، ولی برای تعداد زیاد سقف بگذار.
- توکن لغو را از endpoint تا دیتابیس پاس بده.