پادشاهِ کُدنویسا شو!

تحلیل ریشه‌ای تداخل thread pool، SqlException و راه‌حل‌های عملی (مثال عملی: sitemap)

در توسعه وب‌اپلیکیشن‌های مبتنی بر ASP.NET Core، یکی از چالش‌های رایج اما پنهان، اجرای عملیات سنگین (مانند تولید فایل سایت‌مپ برای SEO) داخل جریان درخواست‌های اصلی (request pipeline) است. در پروژه‌ های با بیش از ۴۰۰۰ رکورد خبر، فراخوانی متد CreateOrUpdateSitemap() بعد از SaveChangesAsync() باعث می‌شد اپلیکیشن ناگهان کرش کند: لاگ‌ها پر از Microsoft.Data.SqlClient.SqlException، خروج ناگهانی threadهای worker از thread pool، و فایل سایت‌مپ خالی یا ناقص.
کینگتو - آموزش برنامه نویسی تخصصصی - دات نت - سی شارپ - بانک اطلاعاتی و امنیت

تحلیل ریشه‌ای تداخل thread pool، SqlException و راه‌حل‌های عملی (مثال عملی: sitemap)

7 بازدید 0 نظر ۱۴۰۴/۱۱/۲۰

این مقاله به‌طور تخصصی (تا حدود ۱۸۰۰ کلمه) به بررسی ریشه مشکل، دلایل فنی تداخل (thread pool starvation، فشار حافظه، connection leak)، علائم در لاگ‌ها و راه‌حل‌های عملی می‌پردازد. از تجربیات واقعی، مستندات مایکروسافت و بهترین شیوه‌های ۲۰۲۵–۲۰۲۶ استفاده شده است.

۱. توصیف مشکل (از علائم تا شکست کامل)

اپلیکیشن در به‌روزرسانی خبر (news update service) بعد از ذخیره تغییرات، سایت‌مپ را بازسازی می‌کرد:

await sitemapFacade.CreateOrUpdateSitemap();
var output = await dbContext.SaveChangesAsync();

ابتدا با .Take(10) کار می‌کرد، اما با داده کامل (۴۰۰۰+ رکورد خبر) کرش می‌کرد. حتی با Dapper + stored procedure، یک‌بار کار می‌کرد و بعد متوقف می‌شد.

لاگ‌های کلیدی (از Output window یا Application Insights):

  • اجرای موفق چند دستور EF (SELECT، UPDATE، INSERT روی News/NewsTags)
  • خروج ناگهانی threadهای .NET TP Worker با کد ۰
  • cascade از SqlException در System.Private.CoreLib.dll و DLL اپلیکیشن

فایل سایت‌مپ خالی می‌ماند () یا اصلاً ذخیره نمی‌شد. هیچ exception واضحی در try-catch اصلی دیده نمی‌شد، چون مشکل در worker thread رخ می‌داد.

 

تحلیل لاگ‌ها و علائم فنی

لاگ‌های EF نشان‌دهنده اجرای دستورات عادی بود (CommandTimeout=۳۰ ثانیه پیش‌فرض). سپس thread pool workerها ناگهان خارج می‌شدند. این الگو کلاسیک است:

  • Thread Pool Starvation: وقتی همه worker threadها مشغول (blocked) هستند، thread pool جدید ایجاد نمی‌کند (حداقل ۱ ثانیه تأخیر دارد). درخواست‌های جدید منتظر می‌مانند یا تایم‌اوت می‌خورند.
  • SqlException cascade: اغلب نشانه OOM، access violation در SqlClient، یا exception unhandled در background thread است که کل process را tear down می‌کند.
  • Thread exit with code 0: threadهای background pool وقتی exception unhandled داشته باشند، خاموش می‌شوند (مستندات ThreadPool مایکروسافت).

در مقاله Microsoft Learn (Debug ThreadPool Starvation) تأکید شده که sync-over-async یا عملیات طولانی synchronous روی thread pool، اصلی‌ترین دلیل است.

 

دلایل ریشه‌ای مشکل

الف) Thread Pool Starvation در ASP.NET Core

ASP.NET Core از thread pool مشترک برای مدیریت درخواست‌ها استفاده می‌کند (Kestrel + worker threads). هر درخواست یک thread می‌گیرد. اگر عملیاتی synchronous و طولانی (مانند لود ۴۰۰۰ رکورد + ساخت XDocument در حافظه) روی همان thread اجرا شود:

  • thread بلاک می‌شود → thread pool کوچک می‌ماند
  • تحت بار متوسط (حتی ۵۰ درخواست همزمان)، starvation رخ می‌دهد
  • نتیجه: پاسخ‌ها کند، تایم‌اوت، یا کرش (watchdog IIS/Azure/Docker process را kill می‌کند)

مستندات بهترین شیوه‌های ASP.NET Core (۲۰۲۵): «Many synchronous blocking calls lead to Thread Pool starvation and degraded response times.»

ب) فشار حافظه و buffering در EF Core + XDocument

  • GetAllNewsForSitemapService با .ToListAsync() تمام ۴۰۰۰ رکورد را در حافظه بارگذاری می‌کرد (DTO + entity tracking)
  • سپس در SitemapService با XDocument، ۴۰۰۰ × ۵ XElement ساخته می‌شد (پیک حافظه ۱۵۰–۵۰۰ مگابایت)
  • حتی با XmlWriter streaming، .ToList() اولیه buffer کامل می‌کرد

در دیتابیس‌های بزرگ، این کار باعث GC pressure طولانی، pauseهای ثانیه‌ای و در نهایت OOM یا exception در SqlClient می‌شود.

ج) Connection leak و pool exhaustion

در نسخه‌های اولیه با Dapper، SqlConnection بدون using درست مدیریت نمی‌شد → اتصالات برنمی‌گشتند به pool. اولین اجرا (pool تازه) موفق بود، اجرای بعدی timeout یا exhaustion.

CommandTimeout پیش‌فرض ۳۰ ثانیه برای کوئری‌های سنگین کافی نبود.

د) اجرای sync در مسیر درخواست

سایت‌مپ داخل request handler اجرا می‌شد → اگر ۱۰–۶۰ ثانیه طول می‌کشید، thread بلاک می‌ماند و کل اپلیکیشن تحت تأثیر قرار می‌گرفت.

 

راه‌حل‌های عملی و کدهای بهینه

گام ۱: انتقال به Background (مهم‌ترین تغییر)

هرگز عملیات سنگین را داخل request اصلی await نکنید. از fire-and-forget یا background job استفاده کنید:

// در سرویس به‌روزرسانی خبر، بعد از SaveChangesAsync()
_ = Task.Run(async () =>
{
    try
    {
        await sitemapFacade.CreateOrUpdateSitemap();
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Background sitemap generation failed");
    }
});

بهتر: استفاده از Hangfire (ساده، داشبورد، retry خودکار) یا Quartz.NET (زمان‌بندی پیچیده).

نصب Hangfire:

// Program.cs
builder.Services.AddHangfire(config => config.UseSqlServerStorage(connString));
builder.Services.AddHangfireServer();

// فراخوانی
BackgroundJob.Enqueue(() => sitemapService.CreateOrUpdateSitemap());

مزایا Hangfire نسبت به Quartz (بر اساس مقالات ۲۰۲۵): داشبرد retry، persistence در DB، آسان‌تر برای .NET.

گام ۲: Streaming با IAsyncEnumerable (حل فشار حافظه)

EF Core از C# 8 به بعد AsAsyncEnumerable() را پشتیبانی می‌کند → داده‌ها row-by-row stream می‌شوند بدون buffering کامل.

در سرویس خبر:

public async IAsyncEnumerable GetAllNewsStreamingAsync()
{
    var query = _context.News
        .AsNoTracking()
        .Where(n => n.Active && n.FutureDateTime <= DateTime.UtcNow)
        .Select(x => new GetAllNewsForSitemapServiceDto { ... })
        .OrderByDescending(x => x.FutureDateTime)
        .AsAsyncEnumerable();

    await foreach (var item in query)
    {
        yield return item;
    }
}

در SitemapService (با XmlWriter streaming):

await foreach (var item in newsFacade.GetAllNewsStreamingAsync())
{
    await WriteUrlAsync(writer, loc: $"https://.../{item.UniqueCode}", ...);
}

حافظه ثابت می‌ماند (چند مگابایت). مقالات متعدد (مانند Medium و elmah.io) نشان می‌دهند IAsyncEnumerable برای دیتاست‌های بزرگ، memory را تا ۹۰٪ کاهش می‌دهد.

گام ۳: بهبودهای جانبی

  • افزایش CommandTimeout و Max Pool Size در connection string: Connect Timeout=180;Max Pool Size=200
  • اضافه کردن logging دقیق + Stopwatch
  • استفاده از OPTION (RECOMPILE) در stored proc و ایندکس مناسب روی FutureDateTime
  • فایل سایت‌مپ را به sitemap.xml تغییر دهید (استاندارد گوگل)

۵. بهترین شیوه‌ها برای کارهای سنگین در ASP.NET Core (۲۰۲۵–۲۰۲۶)

  • همیشه async/await کامل استفاده کنید (sync-over-async ممنوع)
  • کارهای CPU-bound یا I/O-bound طولانی → به BackgroundService، Hangfire یا Quartz منتقل کنید
  • برای داده‌های بزرگ → IAsyncEnumerable + streaming (XmlWriter، StreamWriter)
  • نظارت: Application Insights، dotnet-counters، Prometheus برای thread pool usage
  • تست بار: با k6 یا JMeter، starvation را شبیه‌سازی کنید

 

مقایسه Hangfire و Quartz (از منابع جدید):

ویژگی Hangfire Quartz.NET
سادگی استفاده بسیار بالا (داشبورد آماده) متوسط (نیاز به داشبورد جدا)
Retry / Persistence - نیاز به پیاده‌سازی دستی
زمان‌بندی پیچیده خوب عالی (cron، calendar)
مناسب برای اکثر پروژه‌های وب سیستم‌های enterprise

 

نتیجه‌گیری و درس‌های آموخته‌شده

این مشکل نشان داد که «کار می‌کند با Take(10)» همیشه نشانه سلامت نیست — فقط نشانه buffer کوچک است. ریشه اصلی، ترکیب thread pool محدود، عملیات synchronous سنگین و اجرای داخل request path بود. با انتقال به background + streaming، اپلیکیشن پایدار شد، حافظه کنترل شد و سایت‌مپ با هزاران رکورد بدون مشکل تولید می‌شود.

درس کلیدی: در ASP.NET Core، thread pool را مثل یک منبع محدود ببینید. هر thread بلاک‌شده، ظرفیت کل اپلیکیشن را کاهش می‌دهد. همیشه فکر کنید: «اگر این عملیات ۳۰ ثانیه طول بکشد، چه اتفاقی برای ۱۰۰ درخواست همزمان می‌افتد؟»

با این رویکرد، پروژه‌های بزرگ نه‌تنها کرش نمی‌کنند، بلکه تحت بار واقعی هم scalable می‌مانند.

این مقاله بر اساس تجربه واقعی حل مشکل نوشته شده و امیدوارم برای توسعه‌دهندگان دیگر مفید باشد. اگر سؤال یا سناریوی مشابهی داشتید، خوشحال از قسمت کامنت ها درمیان بگذارید.

 
لینک استاندارد شده: EYO

0 نظر

    هنوز نظری برای این مقاله ثبت نشده است.
جستجوی مقاله و آموزش
دوره‌ها با تخفیفات ویژه