Levelwise
فارسی
پایه‌های C# و .NET

async/await و Thread Pool

کد async یک درخواست را سریع‌تر نمی‌کند. کارش این است که وقت انتظار برای شبکه و دیتابیس، thread را آزاد کند. اگر با Result یا Wait آن را بلاک کنی، در بار زیاد همه thread ها منتظر می‌مانند و سرور کند می‌شود.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۶ دقیقهمثال فروشگاه اینترنتی در روز کمپینکد C# و .NET 10

نویسنده: 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 اجرا می‌کند.

یک مثال ساده: thread ها مثل گارسون‌های رستوران هستند. گارسون خوب سفارش را به آشپزخانه می‌دهد و می‌رود سراغ میز بعدی. گارسون بد کنار آشپزخانه می‌ایستد تا غذا آماده شود. با ۸ گارسون بد، فقط ۸ میز سرویس می‌گیرند.

await دقیقاً چه می‌کند؟

بیشتر کار یک API، انتظار است: انتظار برای دیتابیس، برای سرویس دیگر، برای فایل. در این مدت CPU کاری ندارد. سؤال این است: آیا thread هم در این مدت گیر می‌افتد؟

.Resultبلاک‌کنندهالفمنتظر جواب شبکهthread is blockedالفدر این مدت هیچ درخواست دیگری جواب نمی‌گیردawaitغیرهمزمانالفبجآزاد، آماده کار بعدیالفدرخواست الف منتظر شبکه است، ولی کسی را معطل نکردهزمان
با Result، یک thread کل زمان انتظار را هدر می‌دهد. با await، همان thread در این مدت درخواست‌های دیگر را جلو می‌برد.

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

  1. کد تا اولین await اجرا می‌شود. مثلاً درخواست HTTP ساخته و فرستاده می‌شود.
  2. کار انتظار به سیستم‌عامل سپرده می‌شود. برای انتظار شبکه هیچ thread ای لازم نیست.
  3. متد برمی‌گردد. یک Task ناتمام به صدازننده می‌دهد و thread به pool برمی‌گردد.
  4. جواب شبکه می‌رسد. سیستم‌عامل خبر می‌دهد.
  5. بقیه متد روی یک thread آزاد اجرا می‌شود. شاید همان thread قبلی نباشد.
۱. اجرای کدتا اولین نقطه انتظار۲. شروع درخواست شبکهکار به سیستم‌عامل سپرده می‌شود۳. متد برمی‌گرددThread → Pool۴. جواب شبکه می‌رسدچند صد میلی‌ثانیه بعد۵. بقیه متد اجرا می‌شودon any free thread۶. کار تمام استجواب به کاربر می‌روددر این فاصله هیچ چیزی بلاک نیست
کامپایلر متد async را در محل هر await تکه می‌کند. به این ساختار State Machine می‌گوییم.
دو باور غلط: کد async یک درخواست را سریع‌تر نمی‌کند. کد async هم به معنای اجرا روی یک thread جدید نیست. سود async فقط این است: با thread کمتر، درخواست بیشتری را همزمان جواب بدهیم.

چرا Result سرور را کند می‌کند؟

حالا برگردیم به کد روز کمپین. دلیل کندی در چند قدم:

  1. هر درخواست یک thread از pool می‌گیرد.
  2. خاصیت Result آن thread را تا رسیدن جواب قیمت بلاک می‌کند. thread هیچ کار دیگری نمی‌کند.
  3. وقتی درخواست‌ها زیاد شوند، همه thread های pool بلاک می‌شوند. درخواست‌های تازه در صف می‌مانند.
  4. سیستم Thread Pool می‌بیند صف بزرگ شده است و thread جدید اضافه می‌کند. ولی این کار را عمداً آهسته انجام می‌دهد. برای همین تعداد thread ها آهسته بالا می‌رود.
  5. این thread ها فقط منتظرند و محاسبه نمی‌کنند. پس CPU پایین می‌ماند.

به این وضعیت Thread Pool Starvation (گرسنگی Thread Pool) می‌گوییم. نشانه‌اش همین است: CPU کم، ولی سیستم کند.

مثال زنده

یک بار با Result و یک بار با await اجرا کن. به تعداد thread ها، صف و بیشترین زمان پاسخ دقت کن:

مثال زنده: Thread Pool در روز کمپین

هشت ثانیه، هر ثانیه ۱۰ درخواست قیمت می‌رسد. هر درخواست ۱٫۵ ثانیه منتظر یک سرویس دیگر است. Pool با ۴ thread شروع می‌کند.

در حال کاربلاک، منتظر شبکهآزاد
زمان۰
در صف۰
منتظر شبکه بدون thread۰
جواب گرفته۰
بیشترین زمان پاسخ۰
یک حالت را انتخاب کن.
این یک مدل ساده است. عددها برای آموزش انتخاب شده‌اند. Thread Pool واقعی الگوریتم پیچیده‌تری دارد. ولی رفتار کلی همین است.

کد درست: 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);

حالا زمان کل تقریباً برابر کندترین کار است، نه جمع هر دو.

با DbContext این کار را نکن. یک DbContext در یک لحظه فقط یک کوئری را قبول می‌کند. دو کوئری همزمان روی همان DbContext خطا می‌دهد. این روش برای فراخوانی سرویس‌های جدا است.

سقف همزمانی

فرض کن باید قیمت ۱۰۰۰ محصول را از سرویس قیمت بگیریم و در کش بگذاریم. اگر همه را با هم بفرستیم، سرویس مقابل زیر بار می‌رود. با متد 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

کاربر صفحه گزارش فروش را باز می‌کند. کوئری دو دقیقه طول می‌کشد. کاربر صبر نمی‌کند و صفحه را می‌بندد. چه اتفاقی می‌افتد؟

  1. فریم‌ورک ASP.NET Core می‌فهمد اتصال قطع شده است.
  2. توکن لغو آن درخواست را فعال می‌کند. این توکن فقط یک سیگنال است.
  3. اگر کد ما توکن را به EF Core نداده باشد، کسی به دیتابیس نمی‌گوید «بس کن». کوئری تا آخر اجرا می‌شود و نتیجه‌اش دور ریخته می‌شود.
کاربر صفحه رامی‌بنددASP.NET CoreRequestAbortedکد ماCancellationToken ctEF Core / HttpClientکوئری متوقف می‌شوداگر یک لایه توکن را پاس ندهد، زنجیر همان‌جا پاره می‌شود
توکن لغو باید از اول تا آخر، از همه لایه‌ها عبور کند.
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));
یک استثنا: وقتی وسط کاری هستیم که نیمه‌کاره ماندنش بد است، لغو را نادیده بگیر. مثلاً پول از درگاه کم شده و فقط ذخیره نتیجه مانده است. برای این قدم توکن خالی، یعنی CancellationToken.None، بده.

کار سنگین CPU

اگر کار واقعاً محاسبه است (مثلاً ساختن یک فایل PDF بزرگ)، async کمکی نمی‌کند. thread واقعاً مشغول است، نه منتظر.

  • در ASP.NET Core، متد Run در کلاس Task سودی ندارد. کار را از یک thread pool به همان thread pool منتقل می‌کند. فقط هزینه اضافه می‌سازد.
  • در برنامه دسکتاپ مفید است. کار سنگین را از thread صفحه (UI) جدا می‌کند تا صفحه قفل نشود.
  • برای کار سنگین در سرور، کار را به صف و یک worker پس‌زمینه بسپار.

قانون‌های مهم

  1. هیچ وقت Result یا Wait روی Task نزن. در کد سرور یعنی thread بلاک. در برنامه‌هایی که SynchronizationContext دارند (مثل WinForms)، حتی ممکن است deadlock بدهد.
  2. متد async void ننویس. خطای داخل آن را هیچ کس نمی‌گیرد و می‌تواند کل process را از کار بیندازد. تنها استثنا event handler در برنامه‌های دسکتاپ است.
  3. توکن لغو را همیشه پاس بده. هر متد async که I/O دارد، یک پارامتر CancellationToken بگیرد.
  4. کار را رها نکن (Fire and Forget). اگر یک Task را بدون await رها کنی، خطایش گم می‌شود. وقتی درخواست تمام شود، سرویس‌های Scoped مثل DbContext هم dispose می‌شوند. برای کار پس‌زمینه، از صف و BackgroundService استفاده کن.
  5. در کتابخانه‌ها از ConfigureAwait با مقدار false استفاده کن. در ASP.NET Core لازم نیست، چون SynchronizationContext ندارد. ولی کتابخانه نمی‌داند در چه برنامه‌ای اجرا می‌شود.
  6. نوع 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 به نظر برسد.

خلاصه در شش خط

  1. هدف async آزاد کردن thread در وقت انتظار است، نه سریع‌تر کردن کد.
  2. با هر await، متد تکه می‌شود و thread به pool برمی‌گردد.
  3. خواندن Result یا صدا زدن Wait، thread را بلاک می‌کند و در بار زیاد Thread Pool Starvation می‌سازد.
  4. نشانه گرسنگی Thread Pool: CPU کم، ولی سیستم کند.
  5. کارهای مستقل را با هم شروع کن، ولی برای تعداد زیاد سقف بگذار.
  6. توکن لغو را از endpoint تا دیتابیس پاس بده.