مقالات › ASP.NET Core

کارهای پس‌زمینه در ASP.NET Core؛ BackgroundService، صف و زمان‌بندی

کار را بیرون از Pipeline درخواست اجرا کنید: IHostedService و BackgroundService، سرویس‌های Scoped در Worker، کارهای دوره‌ای با PeriodicTimer، صف مبتنی بر Channel، توقف امن و Windows Service.

هر کاری جایش داخل یک درخواست HTTP نیست. ارسال ایمیل، ساخت گزارش، پاک کردن داده‌های منقضی، همگام‌سازی با سیستم‌های خارجی و ارسال اعلان تلگرام باید در پس‌زمینه اجرا شوند تا کاربران پاسخ سریع بگیرند و کار حتی با کندی یک API خارجی ادامه پیدا کند. ASP.NET Core دقیقاً برای همین یک مدل میزبانی داخلی دارد.

IHostedService و BackgroundService

هر کلاسی که به‌عنوان IHostedService ثبت شود، با شروع برنامه اجرا و با خاموش شدن آن متوقف می‌شود. BackgroundService کلاس پایه‌ی راحتی است که فقط کافی است یک متد، یعنی ExecuteAsync، را پیاده کنید.

public class HeartbeatWorker(ILogger<HeartbeatWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            logger.LogInformation("Worker alive at {Time}", DateTimeOffset.UtcNow);
            await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
        }
    }
}

builder.Services.AddHostedService<HeartbeatWorker>();

stoppingToken هنگام خاموش شدن برنامه لغو می‌شود؛ همیشه آن را به Delayها و فراخوانی‌های I/O پاس بدهید تا Worker سریع متوقف شود.

کارهای زمان‌بندی‌شده با PeriodicTimer

PeriodicTimer (از ‎.NET 6) تمیزترین روش برای اجرای دوره‌ای یک کار است؛ بدون انحراف زمانی و بدون اجرای هم‌پوشان:

public class ExpiredSessionCleanup(IServiceScopeFactory scopeFactory, ILogger<ExpiredSessionCleanup> logger)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromHours(1));

        do
        {
            try
            {
                await using var scope = scopeFactory.CreateAsyncScope();
                var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

                var deleted = await db.Sessions
                    .Where(s => s.ExpiresAt < DateTime.UtcNow)
                    .ExecuteDeleteAsync(stoppingToken);

                logger.LogInformation("Removed {Count} expired sessions", deleted);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                logger.LogError(ex, "Session cleanup failed");   // نگذارید یک خطا Worker را از کار بیندازد
            }
        }
        while (await timer.WaitForNextTickAsync(stoppingToken));
    }
}

دو نکته‌ی حیاتی در این کد:

  1. سرویس‌های Scoped به Scope نیاز دارند. سرویس‌های Hosted از نوع Singleton هستند. DbContext Scoped است؛ پس IServiceScopeFactory را تزریق کنید و برای هر واحد کار یک Scope بسازید. هرگز DbContext را مستقیم در سازنده‌ی Worker تزریق نکنید.
  2. Exception را داخل حلقه بگیرید. یک Exception مدیریت‌نشده در ExecuteAsync Worker را متوقف می‌کند و از ‎.NET 6 به‌طور پیش‌فرض کل برنامه را هم متوقف می‌کند. لاگ کنید و ادامه دهید.

اجرا در یک ساعت مشخص

static TimeSpan DelayUntil(TimeOnly runAt, TimeZoneInfo zone)
{
    var now = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, zone);
    var next = now.Date + runAt.ToTimeSpan();
    if (next <= now.DateTime) next = next.AddDays(1);
    return next - now.DateTime;
}

// داخل ExecuteAsync: گزارش روزانه ساعت ۲ بامداد به وقت تهران
var tehran = TimeZoneInfo.FindSystemTimeZoneById("Iran Standard Time");
while (!stoppingToken.IsCancellationRequested)
{
    await Task.Delay(DelayUntil(new TimeOnly(2, 0), tehran), stoppingToken);
    await GenerateDailyReportAsync(stoppingToken);
}

برای زمان‌بندی‌های پیچیده (عبارت‌های Cron، تلاش مجدد، داشبورد و ماندگاری بعد از Restart) از کتابخانه‌ای مثل Hangfire یا Quartz.NET استفاده کنید.

صف کار پس‌زمینه با Channel

گاهی یک درخواست باید کاری را آغاز کند بدون اینکه منتظرش بماند؛ مثلاً «ارسال ایمیل خوش‌آمد بعد از ثبت‌نام». یک Channel<T> محدود، صفی سریع و Thread-Safe داخل حافظه است:

public interface IBackgroundQueue
{
    ValueTask EnqueueAsync(Func<IServiceProvider, CancellationToken, Task> job, CancellationToken ct = default);
}

public class BackgroundQueue : IBackgroundQueue
{
    private readonly Channel<Func<IServiceProvider, CancellationToken, Task>> _channel =
        Channel.CreateBounded<Func<IServiceProvider, CancellationToken, Task>>(new BoundedChannelOptions(1000)
        {
            FullMode = BoundedChannelFullMode.Wait
        });

    public ValueTask EnqueueAsync(Func<IServiceProvider, CancellationToken, Task> job, CancellationToken ct = default) =>
        _channel.Writer.WriteAsync(job, ct);

    public IAsyncEnumerable<Func<IServiceProvider, CancellationToken, Task>> ReadAllAsync(CancellationToken ct) =>
        _channel.Reader.ReadAllAsync(ct);
}

public class QueueWorker(BackgroundQueue queue, IServiceScopeFactory scopes, ILogger<QueueWorker> logger)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var job in queue.ReadAllAsync(stoppingToken))
        {
            try
            {
                await using var scope = scopes.CreateAsyncScope();
                await job(scope.ServiceProvider, stoppingToken);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                logger.LogError(ex, "Background job failed");
            }
        }
    }
}

// ثبت
builder.Services.AddSingleton<BackgroundQueue>();
builder.Services.AddSingleton<IBackgroundQueue>(sp => sp.GetRequiredService<BackgroundQueue>());
builder.Services.AddHostedService<QueueWorker>();

// استفاده در یک Endpoint
await queue.EnqueueAsync((sp, ct) =>
    sp.GetRequiredService<IEmailSender>().SendAsync(user.Email, "Welcome!", ct));
return Results.Accepted();

هشدار: صف داخل حافظه با Restart پروسه کارها را از دست می‌دهد. برای کارهایی که نباید گم شوند (پرداخت، فاکتور) از یک صف پایدار استفاده کنید: جدول Outbox در دیتابیس، Hangfire، RabbitMQ یا Azure Service Bus.

توقف امن (Graceful Shutdown)

وقتی Host متوقف می‌شود (انتشار نسخه‌ی جدید، Recycle شدن IIS، توقف کانتینر)، stoppingToken را لغو می‌کند و منتظر تمام شدن Workerها می‌ماند؛ از ‎.NET 6 به‌طور پیش‌فرض ۳۰ ثانیه:

builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(45));

کارها را Idempotent (اجرای دوباره‌شان بی‌خطر باشد) و قابل ادامه طراحی کنید، چون یک توقف همیشه می‌تواند آن‌ها را نیمه‌کاره بگذارد.

ملاحظات میزبانی

  • IIS — Application Poolها ممکن است Recycle شوند یا در زمان بیکاری خاموش شوند و Workerهای شما را متوقف کنند. Idle Timeout را غیرفعال، Start Mode را روی AlwaysRunning تنظیم و Preload را فعال کنید؛ یا Workerها را به یک سرویس جداگانه منتقل کنید.
  • چند نمونه — هر نمونه همه‌ی سرویس‌های Hosted را اجرا می‌کند. یک کار پاک‌سازی که هم‌زمان روی سه سرور اجرا شود ممکن است تداخل ایجاد کند. از قفل توزیع‌شده یا انتخاب رهبر (Leader Election) استفاده کنید یا کارهای زمان‌بندی‌شده را در یک Worker اختصاصی واحد اجرا کنید.
  • قالب Worker Service — برای پروسه‌هایی که فقط کار پس‌زمینه دارند، dotnet new worker یک Host بدون وب‌سرور می‌سازد. با builder.Services.AddWindowsService() (یا AddSystemd()) به‌صورت سرویس ویندوز یا Daemon لینوکس اجرا می‌شود؛ گزینه‌ای عالی برای ربات‌ها و کارهای یکپارچه‌سازی.
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddWindowsService(o => o.ServiceName = "Portfolio Bot");
builder.Services.AddHostedService<TelegramBotWorker>();
builder.Build().Run();

جمع‌بندی

برای کارهای طولانی یا دوره‌ای از BackgroundService، برای اجرای دوره‌ای از PeriodicTimer، برای صف کارهای داخل پروسه از Channel<T> و برای هر واحد کاری که به DbContext یا سرویس‌های Scoped دیگر نیاز دارد از Scope استفاده کنید. Exceptionها را داخل حلقه بگیرید، به توکن توقف احترام بگذارید، کارها را Idempotent کنید و وقتی کارها باید بعد از Restart باقی بمانند یا فقط روی یک سرور اجرا شوند، صف پایدار یا زمان‌بند اختصاصی انتخاب کنید. اگر درست انجام شود، پردازش پس‌زمینه API شما را سریع و سیستم را مقاوم نگه می‌دارد.

پرسش‌های متداول

چرا DbContext خطای «Cannot consume scoped service» می‌دهد؟

سرویس‌های Hosted از نوع Singleton هستند. IServiceScopeFactory را تزریق و DbContext را داخل Scopeی که خودتان می‌سازید دریافت کنید.

آیا برای کار پس‌زمینه در کنترلر از Task.Run استفاده کنم؟

نه. کار ردیابی نمی‌شود، Exceptionها گم می‌شوند و ممکن است با پایان درخواست یا Recycle شدن برنامه از بین برود. از یک صف که توسط Hosted Service پردازش می‌شود استفاده کنید.

Hangfire یا BackgroundService دست‌نویس؟

Workerهای دست‌نویس برای حلقه‌های ساده عالی‌اند. Hangfire یا Quartz برای پردازش جدی کارها، ماندگاری، تلاش مجدد، زمان‌بندی Cron و داشبورد اضافه می‌کنند.