فرسایش خاموش منابع: تحلیل اشتباهات رایج در طراحی 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);
}
}
}BackgroundService یک پیادهسازی پایه از اینترفیس IHostedService است. زمانی که این سرویس درون یک وباپلیکیشن یا سرویس ASP.NET Core اجرا میشود، در همان Process قرار دارد و دقیقاً از همان منابع مشترکی استفاده میکند که درخواستهای حساس HTTP به آنها وابستهاند:200 OK نشان دهند، اما همان پردازش در لایههای زیرین در حال بلعیدن کل کانکشنهای دیتابیس یا نشت تدریجی حافظه باشد. در نتیجه، این سرویس میتواند کارایی اندپوینتهایی را تخریب کند که از نظر منطقی هیچ ارتباط مستقیمی با آن ندارند.ExecuteAsync را در زمان بالا آمدن و خاموش شدن برنامه همگام میکند؛ این هاست بهصورت خودکار امکانات زیر را فراهم نمیکند:OutOfMemoryException اشباع میکنند.Task.WhenAll یا پردازش موازی کنترلنشده، میتواند صدها درخواست همزمان به سرویسهای خروجی ارسال کرده و منجر به خطای Socket Exhaustion یا مسدود شدن تردها شود.BackgroundService یک سرویس Singleton است. هرگونه استفاده مستقیم از سرویسهای Scoped (مانند DbContext در EF Core) بدون ایجاد صریح IServiceScope طول عمر آبجکتها و ردیابی تغییرات (Change Tracker) را بینهایت طولانی کرده و حافظه را اشغال نگه میدارد.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(...) نهفته است.TimeoutException رخ میدهد.protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var item = await _queue.ReadAsync(stoppingToken);
await ProcessAsync(item, stoppingToken); // سقف دقیق: دقیقاً یک کار همزمان
}
}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();
}
}
}SemaphoreSlim، میتوان از ساختارهای دادهای استانداردتری برای اعمال همزمانی و Backpressure استفاده کرد:System.Threading.Channels با ظرفیت محدود (Bounded Channel): اگر بخش تولیدکننده (Producer) و مصرفکننده (Consumer) جدا هستند، تعریف یک Channel با ظرفیت ثابت، تولیدکننده را هنگام پر شدن بافر متوقف (Suspend) کرده و از نشت حافظه پیشگیری میکند.Parallel.ForEachAsync: اگر کارهایی به صورت دستهای دریافت میشوند، این متد کنترل دقیقی روی سقف همزمانی از طریق MaxDegreeOfParallelism فراهم میکند و مدیریت چرخه حیات و استثناها را به صورت کاملاً ساختاریافته در دست میگیرد.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);
}
}Unbounded) است، برنامه در عمل به سیستمعامل اعلام میکند: «من حاضرم حجم نامحدودی از تسکهای آینده را درون RAM نگه دارم.» چنین رویکردی در محیط عملیاتی به معنای تبدیل یک کندی موقت زیرساختی به بحران نشت حافظه و وقوع خطای غیرقابل جبران OutOfMemoryException است.Func<...>) به جای دادههای خام است؛ کپسولهسازی منطق در قالب Delegateها اشارهگرها و متغیرهای Scope را در قالب Closureها در حافظه حبس میکند و بار بسیار سنگینی به Garbage Collector تحمیل مینماید.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 استراتژیهای متفاوتی را در زمان تکمیل ظرفیت ارائه میدهد که باید متناسب با بیزینس انتخاب شوند:Wait (حالت پیشفرض): تولیدکننده را متوقف میکند تا فضا آزاد شود. این حالت تضمین میکند هیچ دادهای گم نشود و در عین حال مصرف حافظه کنترل شود (مناسب برای تراکنشهای مهم).DropOldest / DropNewest: دادههای قدیمی یا جدید را رها میکند (مناسب برای لاگها، تلمتری یا پیامهای دورهای مانند وضعیت سنسورها).DropWrite: پیام را نمیپذیرد و به صورت صریح شکست عملیات ثبت را مشخص میکند تا بتوان به کلاینت پاسخ 429 Too Many Requests یا 503 Service Unavailable بازگرداند.Queue Depth)Oldest Item Age)Enqueue Wait Duration)Dequeue Throughput)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);
}
}
}ChangeTracker ردیابی کند. وقتی طول عمر DbContext معادل طول عمر کل پروسس باشد، این لیست ردیابی در هر تکرار حلقه بزرگتر شده، آبجکتها در حافظه زنده میمانند و نشت تدریجی حافظه (Memory Leak) رخ میدهد.DbContext به هیچ عنوان Thread-Safe نیستند. اگر بخشهای مختلف ورکر یا تسکهای همزمان به یک نمونه مشترک دسترسی پیدا کنند، خطای مشهور InvalidOperationException مبنی بر همزمانی دسترسی رخ میدهد.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 راهکاری بسیار سبکتر و بهینهتر برای ایجاد کانتکستهای مجزا است.RemoveRange پردازشی سنگین است. در EF Core 7+ میتوان با یک کوئری مستقیم مانند زیر، کارایی را به شدت ارتقا داد بدون اینکه نیاز به لود حتی یک رکورد در حافظه باشد:await db.Sessions
.Where(x => x.ExpiresAt < DateTimeOffset.UtcNow)
.ExecuteDeleteAsync(cancellationToken);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 ذلتاً نمایانگر چرخه حیات یک پردازش پسزمینه و ناهمگام است.Task.Run نه تنها کارایی را افزایش نمیدهد، بلکه سربار زمانبندی (Context Switching و Overhead تخصیص کار) به ThreadPool تحمیل میکند:// الگوی ضدبهینه: سربار زمانبندی بیهوده روی تردهای ThreadPool
await Task.Run(async () =>
{
await db.SaveChangesAsync(stoppingToken);
await httpClient.PostAsync(url, content, stoppingToken);
}, stoppingToken);Task.Run، یک ترد از ThreadPool فقط برای شروع کاری رزرو میشود که خودش نخ را رها میکرد.await db.SaveChangesAsync(stoppingToken); await httpClient.PostAsync(url, content, stoppingToken);
.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);
}
}Task.Run تنها زمانی منطقی است که عملیات CPU-Bound واقعی باشد (مانند محاسبات سنگین ریاضی، رمزنگاری، پردازش تصویر یا فشردهسازی فایلهای حجیم). در این شرایط نیز باید به این سوال معماری پاسخ داد:آیا این پردازش سنگین محاسباتی اساساً باید درون همان پروسسی اجرا شود که در حال پاسخدهی به درخواستهای حساس API است؟
Task.Run مشکل را حل نمیکند؛ بلکه سیستم از نبود ایزولاسیون بار کاری (Workload Isolation) رنج میبرد. در چنین مواردی باید پردازشهای سنگین را به پروسسهای مجزا، Worker Serviceهای تفکیکشده یا صفهای توزیعشده (Message Broker) منتقل کرد.System.Threading.Timer معمولاً شبیه به این است:_timer = new Timer(
async _ => await ExecuteCleanupAsync(),
null,
TimeSpan.Zero,
TimeSpan.FromSeconds(10));ExecuteCleanupAsync در شرایط عادی ظرف ۳ ثانیه به پایان میرسد؛ همه چیز در داشبوردها عالی به نظر میرسد. اما تصور کنید به دلیل قفل شدن جدولها یا کندی موقت شبکه، زمان اجرای یک اجرا به ۳۰ ثانیه افزایش یابد. تایمر بدون توجه به وضعیت اجرای قبلی، هر ۱۰ ثانیه تیک میزند و اجرای جدیدی را استارت میزند.async void اجرا میشوند که مدیریت خطا و لغو تمیز پردازش را به شدت دشوار میسازد.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 تیکهای تایمر را تا زمان بازگشت تسک قبلی معطل نگه میدارد. اگر اجرای متد ۳۰ ثانیه طول بکشد، تیکهای از دست رفته تلمبار نمیشوند و اجرای بعدی بلافاصله پس از اتمام قبلی آغاز میشود (بدون هیچگونه همپوشانی).PeriodicTimer با هدف حفظ فرکانس تیکها بدون اجرای همزمان.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 صریحاً تعیین شده باشد.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 شده و برنامه دیگر قادر به برقراری هیچ ارتباط خروجی جدیدی نخواهد بود.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 استفاده میکنند، در برابر خطاهای شبکه مصون هستند؛ اما فکتوری سقف همزمانی ایجاد نمیکند. HttpClient به صورت پیشفرض هیچ محدودیتی روی تعداد درخواستهای ارسالی همزمان اعمال نمیکند.// الگوی پرریسک: Fan-out همزمان بدون سقف
await Task.WhenAll(
messages.Select(x => notificationClient.SendAsync(x, cancellationToken)));messages شامل ۲۰,۰۰۰ آیتم باشد، سیستم در همان لحظه تلاش میکند ۲۰,۰۰۰ درخواست ناهمگام همزمان ثبت کند. در پروتکل HTTP/1.1 این رفتار موجب تلاش برای باز کردن صدها کانکشن موازی، جهش مصرف حافظه برای بافرهای I/O، وقوع خطاهای سرریز سوکت یا بن شدن توسط فایروال و Rate Limiter سرور مقصد میشود.Parallel.ForEachAsync این کار را به سادهترین و پایدارترین شکل ممکن انجام میدهد:await Parallel.ForEachAsync(
messages,
new ParallelOptions
{
MaxDegreeOfParallelism = 16,
CancellationToken = cancellationToken
},
async (message, ct) =>
{
await notificationClient.SendAsync(message, ct);
});MaxDegreeOfParallelism (در اینجا ۱۶) باید بر اساس ظرفیت شبکه، سقف مجاز کلاینت مقصد (Rate Limits)، زمان پاسخدهی (Latency) و منابع سرور تنظیم شود.SocketsHttpHandler یا فکتوری، چرخه حیات اتصال را با PooledConnectionLifetime (مثلاً ۱۵ دقیقه) محدود کنید تا در صورت تغییر DNS سرور مقصد، ورکرها به آدرسهای منسوخ متصل نمانند.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 برابر با ۵۰۰ رکورد باشد، برنامه تلاش میکند در کسری از میلیثانیه ۵۰۰ عملیات همزمان روی دیتابیس باز کند.Max Pool Size) عدد محدودی (غالباً ۱۰۰ یا ۱۲۸ کانکشن) است.Connection Pool Wait Time).TimeoutException نیست؛ بلکه جهش شدید در تأخیر دمدراز (p99 / Tail Latency) در APIهای اصلی سیستم است. اندپوینتهای وب که در حالت عادی زیر ۱۰ میلیثانیه پاسخ میدادند، حالا برای گرفتن یک کانکشن خالی ثانیهها معطل میمانند.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);
});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);Contains (عملگر IN در SQL) میتواند به تولید کوئریهای عظیم منجر شود. تقسیم دادهها به تکههای کوچکتر (مثلاً بستههای ۱۰۰ تا ۲۵۰ تایی با متد .Chunk()) رفتاری پایدار و پیشبینیپذیر ایجاد میکند.ChangeTracker، متد ExecuteUpdateAsync کل عملیات را به صورت یک دستور بهینه SQL ترجمه کرده و فشار روی رم و کانکشن را به حداقل میرساند.Connection Acquisition Wait Duration، تعداد کوئریهای همزمان و مدتزمان باز ماندن تراکنشها، تنها راه تضمین پایداری در مقیاس بالا است.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);
}
}// کوئری دیتابیس با پشتیبانی از لغو
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);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);
}
}stoppingToken قطع شوند؟LinkedTokenSource) یا بخش نهایی Commit را با یک سقف زمانی کوتاه (Grace Period) مستقل اجرا کرد تا از ثبت دادههای ناقص (Incomplete Writes) جلوگیری شود.HttpClient بیشتر از بودجه کل زمان خاموش شدن هاست است؟BackgroundService بهندرت ناشی از یک متد معیوب یا فراخوانی اشتباه یک API است؛ عامل اصلی، نبود مرزها و محدودیتهای صریح (Missing Bounds) در معماری سرویس است:BackgroundService نیست؛ این کلاس یکی از بهترین و استانداردترین انتزاعهای داتنت برای اجرای پردازشهای بلندمدت تحت نظارت Generic Host است. راهکار واقعی، طراحی ورکرها با استانداردهای مهندسی سیستمهای پروداکشن است:Parallel.ForEachAsync یا SemaphoreSlim) در نظر بگیرید.IServiceScope را به واحد کار معنادار عملیاتی محدود کرده و پس از اتمام کار، منابع را بلافاصله آزاد کنید.Task.Run محصور نکنید و از مسدود کردن همگام متدها (Sync-over-Async) بپرهیزید.PeriodicTimer مانع اجرای همزمان و تصادفی کارهای زمانبندیشده شوید.IHttpClientFactory چرخه حیات سوکتها را ایمن کنید و با کنترل همزمانی، شکل بار ارسالی به شبکه را مهار نمایید.stoppingToken را تا عمیقترین سطوح I/O دیتابیس و شبکه هدایت کنید تا فرآیند Graceful Shutdown زیرساختهای ابری و کانتینری بدون مانع انجام شود.Queue Depth & Age)GC Allocations)Connection Acquisition Wait)BackgroundService ایدهآل و پایدار، سرویسی نیست که تحت هر شرایطی به پردازش بیرویه ادامه دهد؛ بلکه سرویسی است که دقیقاً میداند سیستم در هر لحظه گنجایش و ظرفیت پردازش چه میزانی از کار را دارد.