عنوان:

‫فرسایش خاموش منابع: تحلیل اشتباهات رایج در طراحی BackgroundServiceها


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۶/۰۳ ۱۰:۵۰
آدرس: www.dntips.ir
در اکوسیستم دات‌نت، پیاده‌سازی پردازش‌های پس‌زمینه با BackgroundService در نگاه اول بسیار سرراست و بی‌دردسر به نظر می‌رسد: کافی است از این کلاس ارث‌بری کنید، متد ExecuteAsync را بازنویسی (Override) کرده و یک حلقه ساده همراه با Task.Delay بنویسید تا هاست دات‌نت چرخه حیات آن را مدیریت کند.
public sealed class NotificationWorker : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await ProcessNotificationsAsync(stoppingToken);
            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
    }
}
ظاهر این کد هیچ نشانه‌ای از خطر ندارد و دقیقاً به همین دلیل منشأ بسیاری از ناپایداری‌های خاموش و فرسایشی در محیط‌های عملیاتی (Production) می‌شود. خطرناک‌ترین سرویس‌های پس‌زمینه آن‌هایی نیستند که بلافاصله با خطا از کار می‌افتند (Crash می‌کنند)، بلکه آن‌هایی هستند که ظاهراً بدون مشکل به کار خود ادامه می‌دهند؛ در حالی که صف‌ها در سکوت رشد می‌کنند، اتصالات اشباع می‌شوند، طول عمر اسکوپ‌ها بیش از حد کش می‌آید و ظرفیت عملیاتی کل برنامه افت می‌کند.

توهم استقلال منابع در پردازش‌های پس‌زمینه

ریشه اصلی این مشکلات یک خطای شناختی رایج میان توسعه‌دهندگان است: تلقی کردن واژه «پس‌زمینه» (Background) به معنای «مستقل از منابع» (Resource-Independent). یک BackgroundService یک پیاده‌سازی پایه از اینترفیس IHostedService است. زمانی که این سرویس درون یک وب‌اپلیکیشن یا سرویس ASP.NET Core اجرا می‌شود، در همان Process قرار دارد و دقیقاً از همان منابع مشترکی استفاده می‌کند که درخواست‌های حساس HTTP به آن‌ها وابسته‌اند:
  • ظرفیت نخ‌های پردازشی در ThreadPool
  • حافظه رم مشترک و بار کاری ناشی از Garbage Collector
  • Socketهای سیستم‌عامل و اتصالات خروجی شبکه
  • مخزن اتصالات دیتابیس (Database Connection Pool)
  • اتصالات سوکت به زیرساخت‌هایی نظیر Redis یا سرویس‌های Downstream

وقتی یک پردازش پس‌زمینه به دلیل ماهیت ناهمگام و پنهان خود با وسواس و استاندارد کدنویسی روتین بازبینی نمی‌شود، داشبوردهای مانیتورینگ ممکن است وضعیت APIها را کاملاً سبز و با کدهای 200 OK نشان دهند، اما همان پردازش در لایه‌های زیرین در حال بلعیدن کل کانکشن‌های دیتابیس یا نشت تدریجی حافظه باشد. در نتیجه، این سرویس می‌تواند کارایی اندپوینت‌هایی را تخریب کند که از نظر منطقی هیچ ارتباط مستقیمی با آن ندارند.

خلأهای ذاتی BackgroundService و مسئولیت‌های توسعه‌دهنده

هاست دات‌نت صرفاً آغاز و پایان تسک بازگردانده‌شده از ExecuteAsync را در زمان بالا آمدن و خاموش شدن برنامه همگام می‌کند؛ این هاست به‌صورت خودکار امکانات زیر را فراهم نمی‌کند:
  • Backpressure و کنترل نرخ تولید/مصرف: اگر سرعت ورود تسک‌ها یا پیام‌ها بیشتر از سرعت پردازش باشد، صف‌های نامحدود (Unbounded) حافظه را تا مرز رخ دادن OutOfMemoryException اشباع می‌کنند.
  • مدیریت همزمانی و Throttling: استفاده ناصحیح از Task.WhenAll یا پردازش موازی کنترل‌نشده، می‌تواند صدها درخواست همزمان به سرویس‌های خروجی ارسال کرده و منجر به خطای Socket Exhaustion یا مسدود شدن تردها شود.
  • ایزولاسیون طول عمر سرویس‌ها (Scope Management): یک BackgroundService یک سرویس Singleton است. هرگونه استفاده مستقیم از سرویس‌های Scoped (مانند DbContext در EF Core) بدون ایجاد صریح IServiceScope طول عمر آبجکت‌ها و ردیابی تغییرات (Change Tracker) را بی‌نهایت طولانی کرده و حافظه را اشغال نگه می‌دارد.
  • انتشار صحیح سیگنال لغو (Cancellation Propagation): نادیده گرفتن stoppingToken در متدهای ناهمگام یا کارهای مسدودکننده (Blocking)، پروسه Graceful Shutdown را متوقف کرده و دیپلوی‌های کانتینری (مانند Kubernetes) را تا مرز دریافت سیگنال سخت SIGKILL معطل نگه می‌دارد.

یک توسعه‌دهنده ارشد دات‌نت با BackgroundService صرفاً به عنوان یک حلقه تکرارشونده برخورد نمی‌کند، بلکه آن را یک زمان‌بند جریان کار (Workload Scheduler) می‌بیند؛ مکانیزمی که باید مرز مشخصی برای حافظه، سقف همزمانی، رفتار مشخص در زمان افت سرعت مصرف‌کننده، و انتشار یکپارچه سیگنال‌های لغو داشته باشد.

نظرات

  • وحید نصیری در ۱۴۰۵/۰۶/۰۳ ۱۰:۵۳
    یکی از خطرناک‌ترین الگوها در معماری پردازش‌های پس‌زمینه، با موازی‌سازی به‌ظاهر بی‌ضرر آغاز می‌شود. به قطعه‌کد زیر دقت کنید:
    public sealed class ImportWorker : BackgroundService
    {
        private readonly IImportQueue _queue;
    
        public ImportWorker(IImportQueue queue)
        {
            _queue = queue;
        }
    
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                var item = await _queue.ReadAsync(stoppingToken);
                _ = ProcessAsync(item, stoppingToken); // الگوی فاجعه‌بار Fire-and-Forget
            }
        }
    
        private async Task ProcessAsync(ImportItem item, CancellationToken cancellationToken)
        {
            // اجرای کوئری دیتابیس + درخواست HTTP + عملیات Serialization
            await Task.Delay(100, cancellationToken);
        }
    }
    مشکل این ساختار متوجه BackgroundService نیست؛ ریشه فاجعه در خط _ = ProcessAsync(...) نهفته است.

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

    پیامدهای اجرای نامحدود (Unbounded Execution):

    • فشار سنگین بر حافظه و GC: هر تسک باز اشاره‌گرهایی (References) به بافرهای داده، DTOها، وضعیت‌های لاگین، کلاینت‌های HTTP و موجودیت‌های دیتابیس را زنده نگه می‌دارد و آن‌ها را به نسل‌های بالاتر Garbage Collector منتقل می‌کند.
    • اشباع Connection Pool: اگر در هر تسک ارتباطی با پایگاه داده برقرار شود، به سرعت ظرفیت مخزن اتصالات به اتمام می‌رسد و خطاهایی مانند TimeoutException رخ می‌دهد.
    • تحمیل بار مخرب به سرویس‌های خارجی (Downstream Overload): ارسال همزمان صدها درخواست HTTP می‌تواند کلاینت‌های دیگر را دچار Socket Exhaustion کرده یا توسط سرویس مقصد مسدود (Rate-limited) شود.
    • از دست رفتن خطاهای سیستمی: متدهای ناهمگامی که به صورت Fire-and-Forget اجرا می‌شوند، در صورت مواجهه با Exception خطای خود را در فضای پیش‌فرض Process رها می‌کنند (Unobserved Task Exception) که ردیابی و عیب‌یابی آن بسیار دشوار خواهد بود.

    گام اول: پردازش ترتیبی و مقید

    ساده‌ترین و امن‌ترین نقطه شروع، پردازش متوالی (Sequential) است:
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var item = await _queue.ReadAsync(stoppingToken);
            await ProcessAsync(item, stoppingToken); // سقف دقیق: دقیقاً یک کار همزمان
        }
    }
    پردازش ترتیبی شاید همیشه توان عملیاتی (Throughput) مورد نیاز را تأمین نکند، اما یک مزیت اساسی دارد: سقف قطعی و پایدار ایجاد می‌کند و اجازه انباشت حافظه در سطح Taskها را نمی‌دهد.

    گام دوم: ایجاد سقف همزمانی صریح و شفاف

    اگر ماهیت کار نیازمند همزمانی است، این همزمانی باید به صورت یک سیاست معماری ملموس و دارای سقف مشخص تعریف شود، نه یک اثر جانبی تصادفی در کد:
    public sealed class ImportWorker : BackgroundService
    {
        private const int MaxConcurrency = 8;
        private readonly IImportQueue _queue;
    
        public ImportWorker(IImportQueue queue)
        {
            _queue = queue;
        }
    
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            using var semaphore = new SemaphoreSlim(MaxConcurrency);
            var runningTasks = new HashSet<Task>();
    
            while (!stoppingToken.IsCancellationRequested)
            {
                var item = await _queue.ReadAsync(stoppingToken);
    
                await semaphore.WaitAsync(stoppingToken);
    
                var task = ProcessWithReleaseAsync(item, semaphore, stoppingToken);
                runningTasks.Add(task);
                
                // پاک‌سازی تسک‌های تکمیل‌شده برای جلوگیری از رشد مجموعه
                runningTasks.RemoveWhere(static t => t.IsCompleted);
            }
    
            // تضمین تکمیل کارهای در حال پردازش پیش از خروج کامل سرویس
            await Task.WhenAll(runningTasks);
        }
    
        private async Task ProcessWithReleaseAsync(
            ImportItem item,
            SemaphoreSlim semaphore,
            CancellationToken cancellationToken)
        {
            try
            {
                await ProcessAsync(item, cancellationToken);
            }
            finally
            {
                semaphore.Release();
            }
        }
    }

    رویکرد مدرن‌تر در دات‌نت: استفاده از خط لوله Channel یا Parallel.ForEachAsync

    در نسخه‌های مدرن دات‌نت، علاوه بر مدیریت دستی با SemaphoreSlim، می‌توان از ساختارهای داده‌ای استانداردتری برای اعمال همزمانی و Backpressure استفاده کرد:
    ۱. System.Threading.Channels با ظرفیت محدود (Bounded Channel): اگر بخش تولیدکننده (Producer) و مصرف‌کننده (Consumer) جدا هستند، تعریف یک Channel با ظرفیت ثابت، تولیدکننده را هنگام پر شدن بافر متوقف (Suspend) کرده و از نشت حافظه پیشگیری می‌کند.
    ۲. Parallel.ForEachAsync: اگر کارهایی به صورت دسته‌ای دریافت می‌شوند، این متد کنترل دقیقی روی سقف همزمانی از طریق MaxDegreeOfParallelism فراهم می‌کند و مدیریت چرخه حیات و استثناها را به صورت کاملاً ساختاریافته در دست می‌گیرد.

    سقف همزمانی (مانند مقدار ۸ در مثال فوق) نباید عددی شهودی یا تصادفی باشد؛ این عدد باید بر اساس ظرفیت اتصالات دیتابیس، محدودیت نرخ سرویس‌های مقصد، میزان مصرف CPU و پایش زمان‌های پاسخگویی (معیارهای p95 و p99) در بارهای کاری شبیه‌سازی‌شده انتخاب و کالیبره شود. اصل بنیادین در پردازش‌های پس‌زمینه همواره یک چیز است: ظرفیت کار باید مقید و محدود باشد (Bounded Work).
  • وحید نصیری در ۱۴۰۵/۰۶/۰۳ ۱۰:۵۵
    اعمال محدودیت روی همزمانی پردازش (Concurrency Limits) تنها نیمی از معادله پایداری سرویس‌های پس‌زمینه را حل می‌کند. نیمه دیگر و حیاتی‌تر، مدیریت حجم کارهایی است که تولیدکننده‌ها (مانند کنترلرهای API یا رویدادهای سیستمی) وارد صف می‌کنند.

    به این پیاده‌سازی رایج دقت کنید:
    public sealed class BackgroundTaskQueue
    {
        private readonly Channel<Func<CancellationToken, ValueTask>> _channel =
            Channel.CreateUnbounded<Func<CancellationToken, ValueTask>>();
    
        public ValueTask QueueAsync(Func<CancellationToken, ValueTask> workItem)
        {
            return _channel.Writer.WriteAsync(workItem);
        }
    
        public ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(
            CancellationToken cancellationToken)
        {
            return _channel.Reader.ReadAsync(cancellationToken);
        }
    }
    در شرایط عادی، این صف بی‌نقص کار می‌کند. اما فرض کنید پایگاه داده یا یک سرویس خارجی برای ۱۰ دقیقه با کندی یا افت کارایی مواجه شود:
    • تولیدکننده‌ها با نرخ عادی به افزودن آیتم‌ها ادامه می‌دهند.
    • هیچ مکانیزمی کار جدید را رد نمی‌کند و جلوی تولیدکننده‌ها را نمی‌گیرد.
    • هیچ سقفی برای مصرف حافظه RAM تعریف نشده است.

    صف در اینجا تبدیل به بافری میان «نرخ ورود تسک‌ها» و «ظرفیت مصرف» می‌شود. از آنجا که این صف نامحدود (Unbounded) است، برنامه در عمل به سیستم‌عامل اعلام می‌کند: «من حاضرم حجم نامحدودی از تسک‌های آینده را درون RAM نگه دارم.» چنین رویکردی در محیط عملیاتی به معنای تبدیل یک کندی موقت زیرساختی به بحران نشت حافظه و وقوع خطای غیرقابل جبران OutOfMemoryException است.

    نکته منفی دیگر در کد اول، صف‌بندی Delegateها (Func<...>) به جای داده‌های خام است؛ کپسوله‌سازی منطق در قالب Delegateها اشاره‌گرها و متغیرهای Scope را در قالب Closureها در حافظه حبس می‌کند و بار بسیار سنگینی به Garbage Collector تحمیل می‌نماید.

    حل مسئله با Bounded Channel و اعمال Backpressure

    در دات‌نت، پیاده‌سازی رسمی صف‌های درون‌حافظه‌ای باید مبتنی بر Channelهای دارای ظرفیت محدود (Bounded) باشد تا اضافه بار (Overload) به‌صورت شفاف در معماری مهار شود:
    public sealed class BackgroundTaskQueue
    {
        private readonly Channel<WorkItem> _channel;
    
        public BackgroundTaskQueue(int capacity)
        {
            var options = new BoundedChannelOptions(capacity)
            {
                FullMode = BoundedChannelFullMode.Wait,
                SingleReader = true,
                SingleWriter = false
            };
    
            _channel = Channel.CreateBounded<WorkItem>(options);
        }
    
        public ValueTask EnqueueAsync(
            WorkItem item,
            CancellationToken cancellationToken = default)
        {
            return _channel.Writer.WriteAsync(item, cancellationToken);
        }
    
        public ValueTask<WorkItem> DequeueAsync(
            CancellationToken cancellationToken)
        {
            return _channel.Reader.ReadAsync(cancellationToken);
        }
    }
    با این تغییر، صف دارای یک بودجه ظرفیتی مشخص می‌شود. وقتی مصرف‌کننده (BackgroundService) عقب می‌افتد و صف پر می‌شود، متد EnqueueAsync به شکل ناهمگام تولیدکننده را معطل می‌کند تا جا باز شود؛ این مفهوم دقیق Backpressure است.

    انتخاب رفتار مناسب در زمان پر شدن ظرفیت (BoundedChannelFullMode)

    دات‌نت از طریق BoundedChannelFullMode استراتژی‌های متفاوتی را در زمان تکمیل ظرفیت ارائه می‌دهد که باید متناسب با بیزینس انتخاب شوند:
    • Wait (حالت پیش‌فرض): تولیدکننده را متوقف می‌کند تا فضا آزاد شود. این حالت تضمین می‌کند هیچ داده‌ای گم نشود و در عین حال مصرف حافظه کنترل شود (مناسب برای تراکنش‌های مهم).
    • DropOldest / DropNewest: داده‌های قدیمی یا جدید را رها می‌کند (مناسب برای لاگ‌ها، تلمتری یا پیام‌های دوره‌ای مانند وضعیت سنسورها).
    • DropWrite: پیام را نمی‌پذیرد و به صورت صریح شکست عملیات ثبت را مشخص می‌کند تا بتوان به کلاینت پاسخ 429 Too Many Requests یا 503 Service Unavailable بازگرداند.

    شاخص‌های حیاتی برای مانیتورینگ صف

    یک صف درون‌حافظه‌ای نامحدود، مدیریت اضافه بار را به مکانیزم مرگ برنامه بر اثر کمبود حافظه واگذار می‌کند؛ در حالی که یک صف مقید شما را وادار به تصمیم‌گیری مهندسی درباره رفتار سیستم در شرایط بحرانی می‌نماید.
    برای اطمینان از سلامت این خط لوله، همواره باید شاخص‌های زیر را پایش (Telemetry/Metrics) کرد:
    • عمق فعلی صف (Queue Depth)
    • سن قدیمی‌ترین آیتم منتظر (Oldest Item Age)
    • مدت‌زمان توقف تولیدکننده هنگام درج (Enqueue Wait Duration)
    • نرخ خروج و پردازش آیتم‌ها (Dequeue Throughput)

    اگر سن صف به طور پیوسته رشد کند اما توان عملیاتی ثابت بماند، افزایش کورکورانه همزمانی ورکرها ممکن است فقط گلوگاه را با شدت بیشتری به سمت دیتابیس یا سرویس‌های Downstream منتقل کند. مکانیزم Backpressure کل سیستم را در برابر این خطای زنجیره‌ای محافظت می‌کند.
  • وحید نصیری در ۱۴۰۵/۰۶/۰۳ ۱۰:۵۷
    یکی از خطاهای رایج در طراحی سرویس‌های پس‌زمینه در دات‌نت، نادیده گرفتن تداخل چرخه حیات سرویس‌ها (Lifetime Mismatch) در سیستم Dependency Injection است. سرویس‌های مشتق‌شده از BackgroundService به عنوان Singleton رجیستر می‌شوند و هیچ اسکوپ DI به‌صورت خودکار برای آن‌ها ساخته نمی‌شود. از طرف دیگر، در معماری‌های متداول ASP.NET Core و EF Core، کلاس DbContext به صورت Scoped ثبت می‌گردد. تزریق مستقیم یک وابستگی Scoped به درون یک سرویس Singleton (شناخته‌شده به عنوان Captive Dependency)، چرخه‌ی حیات آن را مخدوش می‌کند.

    به کد وسوسه‌انگیز اما پرریسک زیر توجه کنید:
    public sealed class CleanupWorker : BackgroundService
    {
        private readonly AppDbContext _db;
    
        public CleanupWorker(AppDbContext db)
        {
            _db = db; // خطای Captive Dependency: آبجکت Scoped در یک Singleton به دام افتاده است
        }
    
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                var expired = await _db.Sessions
                    .Where(x => x.ExpiresAt < DateTimeOffset.UtcNow)
                    .ToListAsync(stoppingToken);
    
                _db.Sessions.RemoveRange(expired);
                await _db.SaveChangesAsync(stoppingToken);
    
                await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
            }
        }
    }

    چرا تزریق مستقیم DbContext به سرویس پس‌زمینه بحران‌آفرین است؟
    • انباشت حافظه در Change Tracker: پیش‌فرض EF Core این است که موجودیت‌های کوئری‌شده را در ChangeTracker ردیابی کند. وقتی طول عمر DbContext معادل طول عمر کل پروسس باشد، این لیست ردیابی در هر تکرار حلقه بزرگ‌تر شده، آبجکت‌ها در حافظه زنده می‌مانند و نشت تدریجی حافظه (Memory Leak) رخ می‌دهد.
    • عدم بازخوانی داده‌های به‌روز (Stale Data): کانتکست دیتابیس داده‌های لود شده را در کش لایه‌اول (First-level Cache) نگه می‌دارد. اگر رکوردی توسط بخش دیگری از اپلیکیشن تغییر کند، این ورکر متوجه آن نخواهد شد و با داده‌های قدیمی کار می‌کند.
    • خطای همزمانی و Thread Safety: نمونه‌های DbContext به هیچ عنوان Thread-Safe نیستند. اگر بخش‌های مختلف ورکر یا تسک‌های همزمان به یک نمونه مشترک دسترسی پیدا کنند، خطای مشهور InvalidOperationException مبنی بر همزمانی دسترسی رخ می‌دهد.

    الگوی صحیح: ایجاد صریح اسکوپ به ازای هر واحد کار (Unit of Work)

    راهکار استاندارد و توصیه‌شده مایکروسافت، تزریق IServiceScopeFactory و ساخت صریح اسکوپ به ازای هر واحد کاری معنادار است:
    public sealed class CleanupWorker : BackgroundService
    {
        private readonly IServiceScopeFactory _scopeFactory;
    
        public CleanupWorker(IServiceScopeFactory scopeFactory)
        {
            _scopeFactory = scopeFactory;
        }
    
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                // ساخت اسکوپ ناهمگام به ازای هر اجرای منطقی
                await using (var scope = _scopeFactory.CreateAsyncScope())
                {
                    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
                    await DeleteExpiredSessionsAsync(db, stoppingToken);
                }
    
                await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
            }
        }
    
        private static async Task DeleteExpiredSessionsAsync(
            AppDbContext db,
            CancellationToken cancellationToken)
        {
            var expired = await db.Sessions
                .Where(x => x.ExpiresAt < DateTimeOffset.UtcNow)
                .ToListAsync(cancellationToken);
    
            if (expired.Count > 0)
            {
                db.Sessions.RemoveRange(expired);
                await db.SaveChangesAsync(cancellationToken);
            }
        }
    }

    نکات تکمیلی برای بهینه‌سازی بیشتر:
    • استفاده از CreateAsyncScope و await using: در دات‌نت مدرن، همیشه از CreateAsyncScope استفاده کنید تا وابستگی‌هایی که اینترفیس IAsyncDisposable را پیاده‌سازی کرده‌اند، بدون بلاک کردن ترد و به صورت کاملاً غیرهمگام آزاد شوند.
    • استفاده از IDbContextFactory: اگر در ورکر خود همزمانی بالا دارید و نمی‌خواهید برای هر کار کوچک اسکوپ کامل DI بسازید، رجیستر کردن و تزریق IDbContextFactory راهکاری بسیار سبک‌تر و بهینه‌تر برای ایجاد کانتکست‌های مجزا است.
    • بهره‌گیری از ExecuteDeleteAsync در سناریوهای پاک‌سازی: در متد پاک‌سازی بالا، لود کردن کل داده‌ها در رم و فراخوانی RemoveRange پردازشی سنگین است. در EF Core 7+ می‌توان با یک کوئری مستقیم مانند زیر، کارایی را به شدت ارتقا داد بدون اینکه نیاز به لود حتی یک رکورد در حافظه باشد:
    await db.Sessions
        .Where(x => x.ExpiresAt < DateTimeOffset.UtcNow)
        .ExecuteDeleteAsync(cancellationToken);
    مرز اسکوپ باید همیشه منطبق بر «واحد کار معنادار» عملیاتی باشد. ساخت اسکوپ پیش از آغاز کار، استفاده از منابع Scoped و آزادسازی فوری آن‌ها (Dispose) پس از اتمام، تضمین می‌کند که منابع دیتابیس و حافظه همگام با عملیات واقعی مدیریت شوند، نه همراه با چرخه حیات برنامه.
  • وحید نصیری در ۱۴۰۵/۰۶/۰۳ ۱۱:۲۳
    یکی از الگوهای ضدبهینه‌سازی رایج در پیاده‌سازی سرویس‌های پس‌زمینه، استفاده بی‌مورد از Task.Run است:
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await Task.Run(() => ProcessBatch(), stoppingToken);
            await Task.Delay(1000, stoppingToken);
        }
    }
    بسیاری از توسعه‌دهندگان بر این باورند که چون این کار «پردازش پس‌زمینه» است، حتماً باید آن را صریحاً روی یک «نخ پس‌زمینه» با Task.Run منتقل کنند. اما متد ExecuteAsync در BackgroundService ذلتاً نمایانگر چرخه حیات یک پردازش پس‌زمینه و ناهمگام است.
    وقتی عملیات ذاتا I/O-Bound (مانند تعامل با دیتابیس یا فراخوانی‌های HTTP) باشد، بسته‌بندی آن درون Task.Run نه تنها کارایی را افزایش نمی‌دهد، بلکه سربار زمان‌بندی (Context Switching و Overhead تخصیص کار) به ThreadPool تحمیل می‌کند:
    // الگوی ضدبهینه: سربار زمان‌بندی بیهوده روی تردهای ThreadPool
    await Task.Run(async () =>
    {
        await db.SaveChangesAsync(stoppingToken);
        await httpClient.PostAsync(url, content, stoppingToken);
    }, stoppingToken);
    این کد سرعت اجرای دستورات SQL یا تأخیر شبکه را کاهش نمی‌دهد. عملیات ناهمگام واقعی مبتنی بر Completion Portها در سیستم‌عامل هستند و در زمان انتظار برای پاسخ شبکه یا دیسک، هیچ نخی را بلاک نمی‌کنند. با استفاده از Task.Run، یک ترد از ThreadPool فقط برای شروع کاری رزرو می‌شود که خودش نخ را رها می‌کرد.
    رویکرد مستقیم و صحیح، فراخوانی مستقیم متدهای ناهمگام است:
    await db.SaveChangesAsync(stoppingToken);
    await httpClient.PostAsync(url, content, stoppingToken);

    خطر بزرگ‌تر: مسدود کردن نخ‌ها با Sync-over-Async

    ضرر دیگر استفاده نادرست از تردها زمانی رخ می‌دهد که متدهای ناهمگام با .Result یا .Wait() یا .GetAwaiter().GetResult() به شکل همگام مسدود شوند:
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // مسدود کردن قطعی یک نخ از ThreadPool تا اتمام عملیات
            var result = SomeAsyncOperation(stoppingToken).Result; 
            Process(result);
            await Task.Delay(100, stoppingToken);
        }
    }
    در این حالت، یک نخ باارزش از ThreadPool تا زمان بازگشت پاسخ کاملاً قفل و بلاک می‌شود. با بالا رفتن بار، پدیده‌ای موسوم به ThreadPool Starvation رخ می‌دهد؛ تردهای آزاد تمام می‌شوند، ایجاد تردهای جدید توسط دات‌نت زمان‌بر خواهد بود و در نتیجه درخواست‌های ورودی حساس HTTP در صف انتظار معطل می‌مانند تا سیستم دچار تأخیر شدید (Latency Spikes) شود.

    چه زمانی استفاده از Task.Run مجاز است؟

    استفاده از Task.Run تنها زمانی منطقی است که عملیات CPU-Bound واقعی باشد (مانند محاسبات سنگین ریاضی، رمزنگاری، پردازش تصویر یا فشرده‌سازی فایل‌های حجیم). در این شرایط نیز باید به این سوال معماری پاسخ داد:
    آیا این پردازش سنگین محاسباتی اساساً باید درون همان پروسسی اجرا شود که در حال پاسخ‌دهی به درخواست‌های حساس API است؟
    اگر ورکر پردازش تصویر تمام هسته‌های CPU را درگیر کند، بهینه‌سازی نحوه نوشتن Task.Run مشکل را حل نمی‌کند؛ بلکه سیستم از نبود ایزولاسیون بار کاری (Workload Isolation) رنج می‌برد. در چنین مواردی باید پردازش‌های سنگین را به پروسس‌های مجزا، Worker Serviceهای تفکیک‌شده یا صف‌های توزیع‌شده (Message Broker) منتقل کرد.

    برای سنجش سلامت ThreadPool و اطمینان از عدم رقابت مخرب ورکر با درخواست‌های برنامه، شاخص‌های زیر را مانیتور کنید:
    • ThreadPool Queue Length (طول صف کارهای منتظر نخ)
    • Thread Count / Active Worker Threads (تعداد تردهای فعال)
    • CPU Saturation (میزان اشباع پردازنده)
    • API Latency (p95 / p99) در زمان اجرای بارهای پس‌زمینه

    معیار موفقیت در پروداکشن صرفاً «مدت زمان اجرای پردازش پس‌زمینه» نیست، بلکه این است که آیا اجرای آن، منابع و ظرفیت در دسترس سایر بخش‌های برنامه را محدود کرده است یا خیر.
  • وحید نصیری در ۱۴۰۵/۰۶/۰۳ ۱۱:۲۴
    اجرای دوره‌ای و زمان‌بندی‌شده‌ی عملیات (Periodic Workloads) یکی دیگر از نقاطی است که نشتی منابع نه به خاطر یک باگ آشکار در منطق بیزینس، بلکه به دلیل ضعف در کنترل جریان اجرای برنامه (Control Flow) رخ می‌دهد.
    یک پیاده‌سازی ساده‌لوحانه مبتنی بر System.Threading.Timer معمولاً شبیه به این است:
    _timer = new Timer(
        async _ => await ExecuteCleanupAsync(),
        null,
        TimeSpan.Zero,
        TimeSpan.FromSeconds(10));
    فرض کنید متد ExecuteCleanupAsync در شرایط عادی ظرف ۳ ثانیه به پایان می‌رسد؛ همه چیز در داشبوردها عالی به نظر می‌رسد. اما تصور کنید به دلیل قفل شدن جدول‌ها یا کندی موقت شبکه، زمان اجرای یک اجرا به ۳۰ ثانیه افزایش یابد. تایمر بدون توجه به وضعیت اجرای قبلی، هر ۱۰ ثانیه تیک می‌زند و اجرای جدیدی را استارت می‌زند.

    پیامدهای همپوشانی ناخواسته (Execution Overlap):
    • انفجار همزمانی (Concurrency Multiplication): کندی یک وابستگی خارجی به‌جای مهار شدن، باعث راه‌اندازی چندین نمونه همزمان از همان جاب می‌شود.
    • رقابت بر سر منابع و دیتابیس: هر اجرا اسکوپ جدیدی می‌سازد، اتصالات دیتابیس جداگانه‌ای می‌گیرد و احتمالاً رکوردهای یکسانی را واکشی و قفل می‌کند. این تداخل می‌تواند منجر به Deadlock یا رقابت مخرب (Race Condition) شود.
    • فقدان انتشار CancellationToken: در ساختار تایمر سنتی، متدهای ناهمگام به صورت async void اجرا می‌شوند که مدیریت خطا و لغو تمیز پردازش را به شدت دشوار می‌سازد.

    راهکار مدرن دات‌نت: استفاده از PeriodicTimer
    از دات‌نت ۶ به بعد، کلاس PeriodicTimer دقیقاً برای حل این مسئله و مدیریت جریان‌های دوره‌ای ناهمگام بدون خطر همپوشانی تصادفی طراحی شده است:
    public sealed class ReconciliationWorker : BackgroundService
    {
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
    
            // تا زمانی که متد ReconcileAsync به پایان نرسد، اجرای بعدی آغاز نمی‌شود
            while (await timer.WaitForNextTickAsync(stoppingToken))
            {
                await ReconcileAsync(stoppingToken);
            }
        }
    
        private static async Task ReconcileAsync(CancellationToken cancellationToken)
        {
            // اجرای پردازش به صورت ترتیبی و کنترل‌شده
            await Task.Delay(100, cancellationToken);
        }
    }
    متد WaitForNextTickAsync تیک‌های تایمر را تا زمان بازگشت تسک قبلی معطل نگه می‌دارد. اگر اجرای متد ۳۰ ثانیه طول بکشد، تیک‌های از دست رفته تلمبار نمی‌شوند و اجرای بعدی بلافاصله پس از اتمام قبلی آغاز می‌شود (بدون هیچ‌گونه همپوشانی).

    تفکیک دو مفهوم: «اجرا در فواصل معین» در برابر «تأخیر پس از اتمام»

    توسعه‌دهنده باید تفاوت معنایی این دو نیاز را تفکیک کند:
    ۱. تلاش برای اجرا در بازه‌های ثابت (Periodic Execution): استفاده از PeriodicTimer با هدف حفظ فرکانس تیک‌ها بدون اجرای همزمان.
    ۲. تأخیر مشخص پس از پایان هر اجرا (Delay After Completion): استفاده از Task.Delay و اندازه‌گیری زمان واقعی با TimeProvider (در دات‌نت ۸ به بعد):
    while (!stoppingToken.IsCancellationRequested)
    {
        var started = TimeProvider.System.GetTimestamp();
    
        await ExecuteBatchAsync(stoppingToken);
    
        var elapsed = TimeProvider.System.GetElapsedTime(started);
        logger.LogInformation("Batch completed in {Elapsed}", elapsed);
    
        // دقیقاً ۱۰ ثانیه پس از اتمام اجرای قبلی صبر کن
        await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
    }
    پرسش اساسی در طراحی هر سیستم زمان‌بندی این است: «آیا امکان دارد اجرای جدیدی پیش از آزادسازی منابع توسط اجرای قبلی آغاز شود؟» اگر پاسخ مثبت است، باید حداکثر سقف همپوشانی با ابزارهایی مانند SemaphoreSlim صریحاً تعیین شده باشد.
    اگر مدت زمان اجرای یک پردازش دوره‌ای به طور مکرر از بازه زمانی تایمر بیشتر می‌شود، این پردازش صرفاً «دیر نکرده است»؛ بلکه هشداری جدی است که فرضیات زمان‌بندی و ظرفیت سیستم در محیط عملیاتی دیگر با واقعیت همخوانی ندارند.
  • وحید نصیری در ۱۴۰۵/۰۶/۰۳ ۱۱:۲۶
    پردازش‌های پس‌زمینه غالباً وظایف ارتباطی سنگینی مانند Polling از وب‌سرویس‌ها، ارسال وب‌هوک و اعلان‌ها، دانلود فایل‌ها یا فراخوانی سرویس‌های Third-party را بر عهده دارند. این وابستگی مداوم باعث می‌شود مدیریت اتصالات شبکه و چرخه حیات پروتکل HTTP به یکی از ارکان حیاتی معماری ورکرها تبدیل شود.
    یک خطای کلاسیک، ساخت و نابودی مکرر نمونه‌های HttpClient به ازای هر درخواست است:
    private async Task SendAsync(Message message, CancellationToken cancellationToken)
    {
        // خطای کلاسیک: چرخه حیات اشتباه کلاینت
        using var client = new HttpClient();
        await client.PostAsJsonAsync("https://example.com/messages", message, cancellationToken);
    }
    با اجرای این کد، نمونه HttpClient در ظاهر Dispose می‌شود؛ اما لایه زیرین سوکت‌های TCP بلافاصله بسته نمی‌شوند و برای مدتی در وضعیت TIME_WAIT سیستم‌عامل باقی می‌مانند. با بالا رفتن بار، این پدیده به سرعت منجر به خطای Socket Exhaustion شده و برنامه دیگر قادر به برقراری هیچ ارتباط خروجی جدیدی نخواهد بود.

    گام اول: استفاده از IHttpClientFactory و Typed Clients

    رویکرد استاندارد مایکروسافت، استفاده از نمونه‌های طولانی‌مدت مبتنی بر SocketsHttpHandler یا بهره‌گیری از IHttpClientFactory است. فکتوری با مدیریت مخزن هندلرهای زیرین (HttpMessageHandler Pooling)، مشکل باز و بسته شدن بیهوده اتصالات را برطرف می‌سازد:
    // ثبت Typed Client در Program.cs با تنظیمات مشخص
    builder.Services.AddHttpClient<NotificationClient>(client =>
    {
        client.BaseAddress = new Uri("https://notifications.internal");
        client.Timeout = TimeSpan.FromSeconds(5);
    });
    سپس کلاینت اختصاصی را به شکل زیر تعریف و استفاده می‌کنیم:
    public sealed class NotificationClient
    {
        private readonly HttpClient _httpClient;
    
        public NotificationClient(HttpClient httpClient)
        {
            _httpClient = httpClient;
        }
    
        public async Task SendAsync(
            Notification notification,
            CancellationToken cancellationToken)
        {
            using var response = await _httpClient.PostAsJsonAsync(
                "/api/notifications",
                notification,
                cancellationToken);
    
            response.EnsureSuccessStatusCode();
        }
    }

    گام دوم: توهم همزمانی نامحدود با IHttpClientFactory

    توسعه‌دهندگان اغلب تصور می‌کنند وقتی از IHttpClientFactory استفاده می‌کنند، در برابر خطاهای شبکه مصون هستند؛ اما فکتوری سقف همزمانی ایجاد نمی‌کند. HttpClient به صورت پیش‌فرض هیچ محدودیتی روی تعداد درخواست‌های ارسالی همزمان اعمال نمی‌کند.
    الگوی زیر در بارهای پس‌زمینه فوق‌العاده مخرب است:
    // الگوی پرریسک: Fan-out همزمان بدون سقف
    await Task.WhenAll(
        messages.Select(x => notificationClient.SendAsync(x, cancellationToken)));
    اگر مجموعه messages شامل ۲۰,۰۰۰ آیتم باشد، سیستم در همان لحظه تلاش می‌کند ۲۰,۰۰۰ درخواست ناهمگام همزمان ثبت کند. در پروتکل HTTP/1.1 این رفتار موجب تلاش برای باز کردن صدها کانکشن موازی، جهش مصرف حافظه برای بافرهای I/O، وقوع خطاهای سرریز سوکت یا بن شدن توسط فایروال و Rate Limiter سرور مقصد می‌شود.

    راهکار: مهار نرخ همزمانی با Parallel.ForEachAsync

    راهکار اصولی، تعیین سقف همزمانی صریح برای ارسال درخواست‌هاست. متد مدرن Parallel.ForEachAsync این کار را به ساده‌ترین و پایدارترین شکل ممکن انجام می‌دهد:
    await Parallel.ForEachAsync(
        messages,
        new ParallelOptions
        {
            MaxDegreeOfParallelism = 16,
            CancellationToken = cancellationToken
        },
        async (message, ct) =>
        {
            await notificationClient.SendAsync(message, ct);
        });
    مقدار MaxDegreeOfParallelism (در اینجا ۱۶) باید بر اساس ظرفیت شبکه، سقف مجاز کلاینت مقصد (Rate Limits)، زمان پاسخ‌دهی (Latency) و منابع سرور تنظیم شود.

    نکات تکمیلی برای پایداری در سطح پروداکشن:
    • تنظیمات PooledConnectionLifetime: هنگام استفاده از SocketsHttpHandler یا فکتوری، چرخه حیات اتصال را با PooledConnectionLifetime (مثلاً ۱۵ دقیقه) محدود کنید تا در صورت تغییر DNS سرور مقصد، ورکرها به آدرس‌های منسوخ متصل نمانند.
    • استفاده از Resilient Handlers: ترکیب کلاینت با کتابخانه‌های تاب‌آوری مانند Microsoft.Extensions.Http.Resilience (یا Polly) برای اعمال خودکار الگوهای Circuit Breaker، Retry و Timeoutهای هوشمند الزامی است تا کندی یا قطعی مقطعی سرور مقصد کل ورکر را قفل نکند.

    استفاده مجدد از اتصالات شبکه (HTTP Reuse) چرخه حیات کانکشن‌ها را مدیریت می‌کند؛ اما کنترل همزمانی (Concurrency Control) شکل و حجم بار ورودی را متناسب می‌سازد. پایداری سیستم نیازمند اعمال همزمان هر دوی این اصول است.
  • وحید نصیری در ۱۴۰۵/۰۶/۰۳ ۱۱:۲۸
    یکی از پنهان‌ترین و مخرب‌ترین بحران‌های کارایی در محیط‌های عملیاتی، اشباع مخزن اتصالات دیتابیس (Database Connection Pool Exhaustion) توسط ورکرها است؛ وضعیتی که در آن ممکن است تک‌تک کوئری‌ها از نظر ساختار SQL کاملاً درست، بهینه و دارای ایندکس مناسب باشند، اما معماری اجرای آن‌ها کل پایگاه داده را فلج کند.
    به کد زیر دقت کنید:
    var tasks = batch.Select(async item =>
    {
        await using var scope = scopeFactory.CreateAsyncScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    
        var entity = await db.Items
            .SingleAsync(x => x.Id == item.Id, cancellationToken);
    
        entity.Process();
        await db.SaveChangesAsync(cancellationToken);
    });
    
    await Task.WhenAll(tasks);
    در این نمونه‌کد، توسعه‌دهنده به درستی از IServiceScopeFactory استفاده کرده تا DbContext به‌صورت مجزا اسکوپ‌بندی شود؛ اما خطای اصلی در معماری همزمانی است. اگر اندازه batch برابر با ۵۰۰ رکورد باشد، برنامه تلاش می‌کند در کسری از میلی‌ثانیه ۵۰۰ عملیات همزمان روی دیتابیس باز کند.

    چرا خرابی استخر اتصالات قبل از ثبت Exception آغاز می‌شود؟

    به طور پیش‌فرض در دات‌نت (مثلاً با Npgsql یا Microsoft.Data.SqlClient)، سقف مخزن اتصالات دیتابیس (Max Pool Size) عدد محدودی (غالباً ۱۰۰ یا ۱۲۸ کانکشن) است.
    هنگامی که یک ورکر صدها کانکشن را به یکباره تقاضا می‌کند:
    • مخزن اتصالات درجا اشباع می‌شود.
    • اتصالات بعدی مجبورند در صف قفل شوند تا کانکشن‌های قبلی آزاد شوند (Connection Pool Wait Time).
    • اولین نشانه این بحران رخ دادن TimeoutException نیست؛ بلکه جهش شدید در تأخیر دم‌دراز (p99 / Tail Latency) در APIهای اصلی سیستم است. اندپوینت‌های وب که در حالت عادی زیر ۱۰ میلی‌ثانیه پاسخ می‌دادند، حالا برای گرفتن یک کانکشن خالی ثانیه‌ها معطل می‌مانند.
    • رقابت بر سر تراکنش‌ها و بروز Lock Contention در سطح جداول دیتابیس بالا می‌گیرد.

    گام اول: مهار همزمانی با سقف قطعی

    اولین گام برای بازگرداندن تعادل، تعیین سقف همزمانی صریح برای تراکنش‌های دیتابیس است:
    await Parallel.ForEachAsync(
        batch,
        new ParallelOptions
        {
            MaxDegreeOfParallelism = 8, // سقف مشخص برای همزمانی استفاده از کانکشن‌ها
            CancellationToken = cancellationToken
        },
        async (item, ct) =>
        {
            await using var scope = scopeFactory.CreateAsyncScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    
            await ProcessItemAsync(db, item, ct);
        });

    گام دوم: حذف رفت‌وبرگشت‌های اضافه شبکه با بارگذاری دسته‌ای (Batching)

    یک اشتباه شایع دیگر، تحمیل الگوی N+1 در پردازش‌های پس‌زمینه است؛ اجرای یک کوئری خواندن و یک کوئری نوشتن به ازای تک‌تک آیتم‌های یک دسته (Batch).
    در صورتی که منطق بیزینس اجازه دهد، باید بارگذاری و ذخیره‌سازی را تجمیع کرد:
    await using var scope = scopeFactory.CreateAsyncScope();
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    
    var ids = batch.Select(x => x.Id).ToArray();
    
    // واکشی دسته‌ای همه رکوردها در قالب یک کوئری به جای N کوئری مجزا
    var entities = await db.Items
        .Where(x => ids.Contains(x.Id))
        .ToListAsync(cancellationToken);
    
    foreach (var entity in entities)
    {
        entity.Process();
    }
    
    // ارسال تغییرات در قالب یک تراکنش واحد و بهینه
    await db.SaveChangesAsync(cancellationToken);

    نکات تکمیلی برای محافظت از دیتابیس:

    • بهینه‌سازی سایز دسته‌ها (Chunking): ارسال لیست‌های بسیار بزرگ به متد Contains (عملگر IN در SQL) می‌تواند به تولید کوئری‌های عظیم منجر شود. تقسیم داده‌ها به تکه‌های کوچک‌تر (مثلاً بسته‌های ۱۰۰ تا ۲۵۰ تایی با متد .Chunk()) رفتاری پایدار و پیش‌بینی‌پذیر ایجاد می‌کند.
    • استفاده از ExecuteUpdateAsync در EF Core: برای به‌روزرسانی‌های ساختاریافته بدون نیاز به لود موجودیت‌ها در ChangeTracker، متد ExecuteUpdateAsync کل عملیات را به صورت یک دستور بهینه SQL ترجمه کرده و فشار روی رم و کانکشن را به حداقل می‌رساند.
    • جداسازی دیتابیس تحلیلی/گزارشی (Read Replicas): اگر ورکرها وظایف سنگین تحلیلی یا گزارش‌گیری دارند، باید کانکشن‌استرینگ آن‌ها به سمت Read Replicaهای پایگاه داده هدایت شود تا ترافیک خواندن آن‌ها ظرفیت تراکنش‌های اصلی سیستم (OLTP) را کاهش ندهد.

    همواره باید این واقعیت معماری را به خاطر داشت: سرویس‌های پس‌زمینه مالک پایگاه داده نیستند، بلکه فقط بخشی از منابع اشتراکی آن را مصرف می‌کنند. مانیتورینگ منظم شاخص‌هایی مثل Connection Acquisition Wait Duration، تعداد کوئری‌های همزمان و مدت‌زمان باز ماندن تراکنش‌ها، تنها راه تضمین پایداری در مقیاس بالا است.
  • وحید نصیری در ۱۴۰۵/۰۶/۰۳ ۱۱:۳۰
    مدیریت لغو عملیات (Cancellation) و خاتمه آرام و ساختاریافته (Graceful Shutdown) صرفاً یک تشریفات ساده برای پاک‌سازی منابع در پایان اجرای برنامه نیست؛ بلکه بخش جدایی‌ناپذیر از مدیریت منابع و پایداری پروسس است. سرویسی که فرآیند لغو را نادیده بگیرد، می‌تواند باعث معلق ماندن پایپ‌لاین‌های استقرار (Deployment)، ثبت داده‌های ناقص (Partial Writes)، پردازش‌های تکراری، و دریافت سیگنال خشن و ناگهانی SIGKILL از سمت زیرساخت‌هایی مانند Kubernetes یا Docker شود. در دات‌نت، متد ExecuteAsync پارامتری تحت عنوان stoppingToken دریافت می‌کند که بازتاب‌دهنده درخواست هاست (IHost) برای توقف ایمن سرویس است. بر اساس مستندات پیش‌فرض ASP.NET Core Generic Host، سیستم در زمان خاموش شدن حداکثر ۳۰ ثانیه (HostOptions.ShutdownTimeout) منتظر اتمام کار سرویس‌های پس‌زمینه می‌ماند.

    نادیده گرفتن این توکن، چرخه حیات هاست را به طور کامل مختل می‌کند:
    // الگوی ضدبهینه و مخرب: نادیده گرفتن توکن لغو هاست
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (true)
        {
            await ProcessBatchAsync(CancellationToken.None); // ایزوله کردن متد از سیگنال خروج
            await Task.Delay(TimeSpan.FromSeconds(5));       // توقف ناپذیر در زمان خاموشی
        }
    }
    الگوی صحیح و استاندارد، بررسی مداوم وضعیت توکن و انتقال مستقیم آن به تمام فراخوانی‌ها است:
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await ProcessBatchAsync(stoppingToken);
            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
    }

    ۱. انتشار یکپارچه توکن در تمام مرزهای I/O (Cancellation Propagation)

    توکن لغو باید بدون استثنا به تمام متدهای ناهمگام دیتابیس، فراخوانی‌های شبکه، خواندن/نوشتن فایل و عملیات کانال‌ها منتقل شود:
    // کوئری دیتابیس با پشتیبانی از لغو
    var pendingJobs = await db.PendingJobs
        .Where(x => x.Status == JobStatus.Pending)
        .Take(100)
        .ToListAsync(stoppingToken);
    
    // درخواست خروجی HTTP با پشتیبانی از لغو
    using var response = await httpClient.SendAsync(
        request,
        HttpCompletionOption.ResponseHeadersRead,
        stoppingToken);
    اگر عملیاتی متوقف نشود یا توکن را نپذیرد، هاست ناچاراً تا پایان مهلت ۳۰ ثانیه‌ای منتظر مانده و دیپلوی برنامه را با تأخیر شدید یا خطای تایم‌اوت مواجه می‌سازد.

    ۲. مدیریت کارهای فعال و پرهیز از تسک‌های رها شده (Detached Tasks)

    اجرای تسک‌های جداشده (Detached یا Fire-and-Forget) در زمان Shutdown یک ریسک عملیاتی جدی است؛ چون ورکر خارج می‌شود در حالی که این تسک‌ها در نقاط پیش‌بینی‌نشده‌ای قطع شده یا داده‌ها را در وضعیتی ناپایدار ذخیره می‌کنند.
    اگر پردازش کارهای فعال پیش از خروج کامل الزامی است، باید آن‌ها را صریحاً ردیابی کرد:
    public sealed class TrackedWorker : BackgroundService
    {
        private readonly ConcurrentDictionary<Guid, Task> _activeTasks = new();
    
        private void TrackTask(Guid id, Task task)
        {
            _activeTasks[id] = task;
            _ = task.ContinueWith(
                _ => _activeTasks.TryRemove(id, out _),
                CancellationToken.None,
                TaskContinuationOptions.ExecuteSynchronously,
                TaskScheduler.Default);
        }
    
        public override async Task StopAsync(CancellationToken cancellationToken)
        {
            // منتظر اتمام کارهای جاری تا سقف مجاز زمان Shutdown بمانید
            await Task.WhenAll(_activeTasks.Values);
            await base.StopAsync(cancellationToken);
        }
    }

    ۳. جلوگیری از خراب شدن تراکنش‌های حیاتی با CancellationTokenSource.CreateLinkedTokenSource

    یک نکته حیاتی که کمتر به آن توجه می‌شود این است: آیا تمام مراحل یک تراکنش باید بلافاصله با stoppingToken قطع شوند؟
    گاهی اوقات قطع شدن ناگهانی ذخیره‌سازی داده در میانه یک تراکنش مهم، دیتابیس را دچار ناسازگاری می‌کند. در این سناریوها می‌توان توکن لغو هاست را با یک Timeout مشخص پیوند زد (LinkedTokenSource) یا بخش نهایی Commit را با یک سقف زمانی کوتاه (Grace Period) مستقل اجرا کرد تا از ثبت داده‌های ناقص (Incomplete Writes) جلوگیری شود.

    چرا افزایش کورکورانه Shutdown Timeout راه‌حل درستی نیست؟

    افزایش مهلت زمان خاموش شدن برنامه (مثلاً از ۳۰ ثانیه به ۲ دقیقه) برای رفع خطای دیپلوی، صرفاً پاک کردن صورت مسئله است. در مواجهه با کندی فرآیند خاموشی، باید ریشه‌های اصلی را بررسی کرد:
    • آیا ورکر تراکنش‌های طولانی‌مدت و سنگینی روی دیتابیس باز نگه داشته است؟
    • آیا زمان تایم‌اوت در HttpClient بیشتر از بودجه کل زمان خاموش شدن هاست است؟
    • آیا در لایه‌های عمیق سرویس‌ها توکن لغو نادیده گرفته شده است؟
    • آیا صف‌های درون‌حافظه‌ای آن‌قدر انباشته شده‌اند که تخلیه آن‌ها در بازه Shutdown ناممکن است؟

    خاتمه ساختاریافته (Graceful Shutdown) بخشی از معماری کل سیستم است؛ زیرا هر انتشار و دیپلوی جدید در پروداکشن، یک آزمون عملی برای بررسی وجود مرزهای تعریف‌شده در توقف کارهای پس‌زمینه خواهد بود.
  • وحید نصیری در ۱۴۰۵/۰۶/۰۳ ۱۱:۳۲
    ریشه خطاهای بحرانی و فرسایشی در BackgroundService به‌ندرت ناشی از یک متد معیوب یا فراخوانی اشتباه یک API است؛ عامل اصلی، نبود مرزها و محدودیت‌های صریح (Missing Bounds) در معماری سرویس است:
    • نبود سقف برای حجم صف‌های کاری
    • نبود سقف برای همزمانی اجرای تسک‌ها
    • نبود سقف برای اتصالات همزمان به پایگاه داده
    • نبود سقف برای درخواست‌های موازی خروجی (HTTP Fan-out)
    • نبود تعریف شفاف برای طول عمر وابستگی‌های Scoped
    • نبود مرز مشخص برای توقف و لغو عملیات (Cancellation)
    • نبود سیاست ساختاریافته برای رفتار سیستم در زمان اضافه بار (Overload Policy)

    سرویس پس‌زمینه تا زمانی که نرخ ورود کار کمتر از ظرفیت پردازش باشد، کاملاً بی‌نقص عمل می‌کند. اما به محض اینکه یک وابستگی بیرونی با افت سرعت موقت مواجه شود، یک واکنش زنجیره‌ای خاموش آغاز می‌شود:
    عمق صف‌ها بالا می‌رود، تسک‌های همزمان تکثیر می‌شوند، آبجکت‌های بیشتری در حافظه رم حبس می‌مانند، درخواست برای اتصالات دیتابیس و سوکت‌های شبکه جهش پیدا می‌کند، تلاش‌های مجدد (Retries) بار کار را مضاعف می‌کنند و در نهایت ورکر تلاش می‌کند با موازی‌سازی بیشتر عقب‌ماندگی را جبران کند. نتیجه این چرخه معیوب، قفل شدن منابع مشترک سرور و افت شدید کارایی در اندپوینت‌های اصلی وب‌اپلیکیشن است. در چنین نقطه‌ای، این سرویس دیگر یک «پردازش پس‌زمینه» نیست، بلکه به عامل اصلی تخریب پرفورمنس کل سیستم تبدیل شده است.
    راهکار این بحران‌ها، اجتناب از BackgroundService نیست؛ این کلاس یکی از بهترین و استانداردترین انتزاع‌های دات‌نت برای اجرای پردازش‌های بلندمدت تحت نظارت Generic Host است. راهکار واقعی، طراحی ورکرها با استانداردهای مهندسی سیستم‌های پروداکشن است:

    اصول طلایی پایداری در سرویس‌های پس‌زمینه:
    1. استفاده از صف‌های مقید: برای انباشت کار، همواره از Bounded Channel همراه با استراتژی مشخص اعمال Backpressure استفاده کنید.
    2. شفاف‌سازی سقف همزمانی: همزمانی را نه یک اثر جانبی، بلکه یک سیاست معماری ملموس با سقف مشخص (مثلاً از طریق Parallel.ForEachAsync یا SemaphoreSlim) در نظر بگیرید.
    3. تعریف دقیق مرزهای اسکوپ: ساخت IServiceScope را به واحد کار معنادار عملیاتی محدود کرده و پس از اتمام کار، منابع را بلافاصله آزاد کنید.
    4. پرهیز از سربار ترد: عملیات ذاتا I/O-Bound را بیهوده در Task.Run محصور نکنید و از مسدود کردن همگام متدها (Sync-over-Async) بپرهیزید.
    5. جلوگیری از همپوشانی اجرای دوره‌ای: با ابزارهایی مانند PeriodicTimer مانع اجرای همزمان و تصادفی کارهای زمان‌بندی‌شده شوید.
    6. مدیریت دولایه اتصالات HTTP: با IHttpClientFactory چرخه حیات سوکت‌ها را ایمن کنید و با کنترل همزمانی، شکل بار ارسالی به شبکه را مهار نمایید.
    7. احترام به سهم مشترک دیتابیس: پایگاه داده را یک زیرساخت اشتراکی ببینید و با Batching و محدود کردن همزمانی، ظرفیت Connection Pool را حفظ کنید.
    8. انتشار کامل توکن لغو: پارامتر stoppingToken را تا عمیق‌ترین سطوح I/O دیتابیس و شبکه هدایت کنید تا فرآیند Graceful Shutdown زیرساخت‌های ابری و کانتینری بدون مانع انجام شود.

    معیار نهایی سلامت سیستم

    پایش مستمر شاخص‌های کلیدی زیر، ضامن پایداری پردازش‌ها در محیط عملیاتی است:
    • عمق و سن آیتم‌های صف (Queue Depth & Age)
    • تعداد کارهای فعال همزمان و نرخ تخصیص حافظه (GC Allocations)
    • مدت‌زمان معطلی در صف دریافت کانکشن دیتابیس (Connection Acquisition Wait)
    • زمان‌های پاسخگویی p50، p95 و به ویژه p99 در فراخوانی‌های شبکه و کوئری‌ها
    • نرخ خطاها، تایم‌اوت‌ها و مدت‌زمان تکمیل خاموشی سیستم

    مهم‌ترین هشدار در محیط عملیاتی لزوماً کند شدن خود پردازش پس‌زمینه نیست؛ بلکه این است که با پرمشغله شدن ورکر، شاخص p99 اندپوینت‌های به ظاهر نامرتبط سیستم افت کند.
    یک BackgroundService ایده‌آل و پایدار، سرویسی نیست که تحت هر شرایطی به پردازش بی‌رویه ادامه دهد؛ بلکه سرویسی است که دقیقاً می‌داند سیستم در هر لحظه گنجایش و ظرفیت پردازش چه میزانی از کار را دارد.