Levelwise
فارسی
ASP.NET Core

کار پس‌زمینه با Background Service

بعضی کارها نباید منتظر یک درخواست HTTP بمانند، مثل پاک کردن سبدهای قدیمی یا ارسال ایمیل. برای این کارها یک Background Service می‌نویسیم. سه چیز در آن مهم است. یک Scope جدا برای هر دور کار، مدیریت خطا و خاموش شدن امن.

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

نویسنده: bezzad

مشکل: کارهایی که درخواست ندارند

فروشگاه ما چند کار دارد که هیچ کاربری منتظرش نیست:

  1. پاک کردن سبدهای منقضی. هر چند دقیقه یک بار.
  2. ارسال ایمیل تأیید سفارش. کاربر نباید سه ثانیه منتظر سرور ایمیل بماند.
  3. فرستادن پیام‌های Outbox. هر چند ثانیه، پیام‌های ذخیره‌شده را به صف پیام بفرست.

این کارها را داخل endpoint نمی‌گذاریم. یک سرویس می‌سازیم که همراه برنامه شروع می‌شود، در پس‌زمینه کار می‌کند و همراه برنامه خاموش می‌شود. به آن Background Service می‌گوییم.

ایده: یک سرویس با شروع و پایان

در .NET، میزبان (Host) برنامه را اجرا می‌کند. هر سرویسی که رابط IHostedService را پیاده کند، دو متد دارد:

  • متد StartAsync: وقتی برنامه بالا می‌آید، صدا زده می‌شود.
  • متد StopAsync: وقتی برنامه خاموش می‌شود، صدا زده می‌شود.

کلاس آماده BackgroundService این کار را ساده‌تر می‌کند. فقط یک متد را می‌نویسی: ExecuteAsync. این متد یک توکن به اسم stoppingToken می‌گیرد. وقتی برنامه می‌خواهد خاموش شود، این توکن لغو می‌شود.

زمان ←برنامه بالا می‌آیدStartAsyncحلقه کار می‌کندExecuteAsyncخاموش شدنStopAsyncSIGTERMتوکن توقف هنوز لغو نشده: ادامه بدهتوکن لغو شد: تمام کن
شروع برنامه، اجرای حلقه و خاموش شدن. توکن توقف، پل بین خاموش شدن برنامه و کد تو است.
تغییر در .NET 10: از .NET 10، کل متد ExecuteAsync روی یک Thread پس‌زمینه اجرا می‌شود. قبلاً بخش اول متد (تا اولین await) شروع برنامه را معطل می‌کرد. اگر کاری باید حتماً قبل از شروع بقیه سرویس‌ها انجام شود، آن را در متد StartAsync بگذار.

کد: پاک کردن سبدهای منقضی

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

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                // A new scope (and a new DbContext) for every round.
                await using var scope = scopeFactory.CreateAsyncScope();
                var db = scope.ServiceProvider.GetRequiredService<ShopDb>();

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

                logger.LogInformation("Deleted {Count} expired carts", deleted);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                // One bad round must not kill the service. Try again next tick.
                logger.LogError(ex, "Cart cleanup failed");
            }
        }
    }
}

// Program.cs
builder.Services.AddHostedService<ExpiredCartCleaner>();

سه نکته در این کد است. هر کدام یک بخش جدا در ادامه دارد:

  1. برای هر دور، یک Scope تازه.
  2. خطا داخل حلقه گرفته می‌شود.
  3. توکن stoppingToken همه جا پاس داده می‌شود.

نکته اول: Scope برای هر دور

سرویس پس‌زمینه یک بار ساخته می‌شود و تا آخر عمر برنامه زنده است. پس رفتارش مثل Singleton است. ولی DbContext از نوع Scoped است. اگر DbContext را مستقیم در سازنده بگیری، در محیط Development برنامه هنگام شروع خطا می‌دهد. در Production این چک به طور پیش‌فرض خاموش است، پس خطایی نمی‌بینی و همان مشکل پایین پیش می‌آید. اگر یک Scope را در اول متد بسازی و تا آخر نگه داری، مشکل بدتری داری:

  1. یک DbContext برای همیشه زنده می‌ماند.
  2. هر چیزی که با آن خوانده شود، در Change Tracker آن می‌ماند. حتی بعد از ذخیره.
  3. حافظه آهسته بالا می‌رود. بعد از چند روز، pod با خطای کمبود حافظه ری‌استارت می‌شود.
  4. سرعت هم کم می‌شود. هر بار ذخیره، همه آن چیزها را دوباره چک می‌کند.
سرویس پس‌زمینه: یک نمونه برای کل عمر برنامهExpiredCartCleanerدور ۱Scope + DbContextدر پایان دور آزاد شددور ۲Scope + DbContextدر پایان دور آزاد شددور ۳Scope + DbContextدر پایان دور آزاد شدحافظه هر دور آزاد می‌شود و بالا نمی‌رود
سرویس پس‌زمینه یک بار ساخته می‌شود. ولی برای هر دور، یک Scope تازه می‌سازد و در پایان دور آن را دور می‌اندازد.
قانون ساده: در سرویس پس‌زمینه، هر «واحد کار» یک Scope خودش را دارد. یک دور حلقه، یا یک پیام از صف.

نکته دوم: خطا سرویس را نکشد

اگر خطایی از متد ExecuteAsync بیرون بیاید، به طور پیش‌فرض کل برنامه متوقف می‌شود (از .NET 6 این رفتار پیش‌فرض است). این خوب است، چون خطا پنهان نمی‌ماند. ولی یعنی یک قطعی کوتاه دیتابیس، کل سرویس را خاموش می‌کند.

پس:

  1. خطای هر دور را داخل حلقه بگیر. لاگ کن و دور بعد دوباره تلاش کن.
  2. خطای لغو را نگیر. خطای OperationCanceledException یعنی برنامه دارد خاموش می‌شود. بگذار حلقه تمام شود.
  3. اگر خطا مدام تکرار شد، هشدار بساز. یک سرویس پس‌زمینه که بی‌صدا هر بار شکست می‌خورد، از سرویس خاموش بدتر است.

صف داخل حافظه: ارسال ایمیل

برای ایمیل تأیید سفارش، endpoint نباید منتظر سرور ایمیل بماند. پس endpoint فقط کار را در یک صف می‌گذارد و فوراً جواب می‌دهد. یک سرویس پس‌زمینه از صف برمی‌دارد و ایمیل را می‌فرستد. کلاس Channel برای همین کار ساخته شده است.

ثبت سفارش ۱ثبت سفارش ۲ثبت سفارش ۳Channelصف با سقف ظرفیتارسال ایمیلسرویس پس‌زمینهجواب فوری به کاربریکی‌یکی، با سرعت خودش
درخواست‌ها کار را در صف می‌گذارند. یک سرویس پس‌زمینه یکی‌یکی آن‌ها را برمی‌دارد.
public sealed class EmailQueue
{
    // Bounded: if the queue is full, writers wait instead of using all memory.
    private readonly Channel<OrderEmail> _channel = Channel.CreateBounded<OrderEmail>(1_000);

    public ValueTask EnqueueAsync(OrderEmail email, CancellationToken ct) =>
        _channel.Writer.WriteAsync(email, ct);

    public IAsyncEnumerable<OrderEmail> ReadAllAsync(CancellationToken ct) =>
        _channel.Reader.ReadAllAsync(ct);
}

public sealed class EmailSender(
    EmailQueue queue, IServiceScopeFactory scopeFactory, ILogger<EmailSender> logger)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var email in queue.ReadAllAsync(stoppingToken))
        {
            try
            {
                await using var scope = scopeFactory.CreateAsyncScope();
                var client = scope.ServiceProvider.GetRequiredService<IEmailClient>();
                await client.SendAsync(email, stoppingToken);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                logger.LogError(ex, "Sending email for order {OrderId} failed", email.OrderId);
            }
        }
    }
}

// Program.cs
builder.Services.AddSingleton<EmailQueue>();
builder.Services.AddHostedService<EmailSender>();
صف حافظه با ری‌استارت پاک می‌شود. اگر pod ری‌استارت شود، ایمیل‌های داخل صف گم می‌شوند. برای ایمیل تأیید شاید قابل قبول باشد. برای کار مهم، مثل پرداخت، از صف ماندگار (مثل RabbitMQ یا Kafka) یا جدول Outbox در دیتابیس استفاده کن.

نکته سوم: خاموش شدن امن

در Kubernetes، هر deploy یعنی pod های قدیمی خاموش می‌شوند. ترتیب کار این است:

  1. سیگنال SIGTERM می‌رسد. میزبان .NET شروع به خاموش شدن می‌کند.
  2. درخواست جدید قبول نمی‌شود. درخواست‌های در حال اجرا فرصت دارند تمام شوند.
  3. توکن stoppingToken لغو می‌شود. حلقه سرویس پس‌زمینه باید این را ببیند و تمام شود.
  4. میزبان تا یک سقف زمانی صبر می‌کند. این سقف با تنظیم ShutdownTimeout در HostOptions مشخص می‌شود. مقدار پیش‌فرض آن ۳۰ ثانیه است.
  5. اگر Kubernetes بیشتر صبر نکند، SIGKILL می‌فرستد. مهلت Kubernetes هم به طور پیش‌فرض ۳۰ ثانیه است. بعد از آن، process فوراً کشته می‌شود.
builder.Services.Configure<HostOptions>(options =>
{
    // Must be shorter than the Kubernetes grace period.
    options.ShutdownTimeout = TimeSpan.FromSeconds(20);
});
اگر توکن را پاس ندهی: حلقه نمی‌فهمد برنامه دارد خاموش می‌شود. یک ایمیل یا یک دسته کار وسط راه با SIGKILL قطع می‌شود. بعد از ری‌استارت، همان کار دوباره انجام می‌شود یا گم می‌شود.

حتی با همه این‌ها، pod گاهی ناگهانی می‌میرد. پس هر کار پس‌زمینه باید Idempotent باشد. یعنی اگر دو بار اجرا شد، نتیجه خراب نشود.

چند pod، چند نسخه از همان کار

یک گزارش مالی هر شب ساعت ۲ ساخته و برای مشتری‌ها ایمیل می‌شود. برای تحمل بار، تعداد pod ها را از ۱ به ۳ می‌رسانیم. حالا چه می‌شود؟

  1. هر pod یک نسخه کامل از برنامه است. پس هر pod سرویس پس‌زمینه خودش را دارد.
  2. هر سه، ساعت ۲ بیدار می‌شوند. هیچ کدام از بقیه خبر ندارند.
  3. هر مشتری سه ایمیل یکسان می‌گیرد.
بدون هماهنگیpod 1pod 2pod 3سه ایمیل یکسانبا قفل مشترکpod 1pod 2pod 3قفل یا ردیف یکتایک ایمیلدو تای دیگر می‌بینند قفل گرفته شده و کاری نمی‌کنند
بدون هماهنگی، هر pod کار را جدا انجام می‌دهد. با یک قفل مشترک، فقط یکی برنده می‌شود.

راه‌ها، از ساده به پیچیده:

  1. کار را از سرویس جدا کن. یک CronJob در Kubernetes که هر شب یک pod کوتاه‌عمر اجرا می‌کند. دیگر چند نسخه نداریم.
  2. ثبت اجرا در دیتابیس با Unique Constraint. هر pod قبل از شروع، یک ردیف «گزارش امروز» اضافه می‌کند. فقط اولین نوشتن موفق می‌شود.
  3. قفل توزیع‌شده. با قفل دیتابیس یا Redis. یا ابزار آماده‌ای مثل Hangfire یا Quartz.NET در حالت cluster.
قفل Redis تاریخ انقضا دارد. اگر pod وسط کار مدتی متوقف شود (مثلاً یک GC طولانی)، قفل منقضی می‌شود و pod دیگری شروع می‌کند. پس خود کار هم باید Idempotent باشد. مثلاً برای هر مشتری و هر تاریخ ثبت کن «ایمیل رفت».

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

  1. برای هر واحد کار، یک Scope تازه بساز. با متد CreateAsyncScope روی IServiceScopeFactory.
  2. توکن stoppingToken را همه جا پاس بده. به دیتابیس، HttpClient و تأخیرها.
  3. خطای هر دور را بگیر و لاگ کن. ولی خطای لغو را نگیر.
  4. سقف خاموش شدن را کمتر از مهلت Kubernetes بگذار.
  5. کار را Idempotent بنویس. ری‌استارت و اجرای دوباره همیشه ممکن است.
  6. به تعداد pod ها فکر کن. کار زمان‌بندی‌شده روی چند pod، چند بار اجرا می‌شود.

اشتباه‌های رایج

اشتباه نتیجه راه درست
یک Scope و یک DbContext برای کل عمر سرویس حافظه مدام بالا می‌رود و سرعت کم می‌شود. یک Scope تازه برای هر دور.
نگرفتن خطا داخل حلقه یک خطای موقت، کل برنامه را متوقف می‌کند. گرفتن خطا، لاگ و تلاش در دور بعد.
حلقه با Task.Delay و بدون توکن برنامه دیر خاموش می‌شود و کار وسط راه قطع می‌شود. پاس دادن stoppingToken.
صف حافظه برای کار مهم با ری‌استارت، کارها گم می‌شوند. صف ماندگار یا Outbox.
زمان‌بند داخلی روی چند pod کار چند بار اجرا می‌شود. CronJob، Unique Constraint یا قفل.
کار سنگین CPU در سرویس وب درخواست‌های کاربران کند می‌شوند. سرویس Worker جدا.

چه وقت Background Service؟

مناسب

  • کارهای کوتاه و دوره‌ای، مثل پاکسازی.
  • خواندن از صف پیام (Kafka یا RabbitMQ).
  • فرستادن پیام‌های Outbox.
  • کار سبکی که نباید کاربر را منتظر بگذارد.

نامناسب

  • کار شبانه‌ای که فقط یک بار باید اجرا شود و چند pod داریم. یک CronJob ساده‌تر است.
  • کار طولانی و مهمی که نباید با ری‌استارت گم شود، ولی فقط در حافظه است.
  • کار سنگینی که منابع سرویس وب را می‌گیرد. آن را به یک سرویس جدا ببر.

خلاصه در شش خط

  1. سرویس پس‌زمینه همراه برنامه شروع و خاموش می‌شود. متد ExecuteAsync کار اصلی است.
  2. سرویس پس‌زمینه مثل Singleton است. برای هر دور کار، یک Scope تازه بساز.
  3. خطای هر دور را بگیر. وگرنه به طور پیش‌فرض کل برنامه متوقف می‌شود.
  4. توکن توقف را همه جا پاس بده تا خاموش شدن امن باشد.
  5. صف داخل حافظه با ری‌استارت پاک می‌شود. برای کار مهم صف ماندگار لازم است.
  6. روی چند pod، هر pod کار را جدا اجرا می‌کند. هماهنگی و Idempotency لازم است.