عنوان:

‫خدمات پس‌زمینه در .NET 10: چگونه ExecuteAsync بالاخره روی نخ جداگانه‌ای اجرا می‌شود


نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۹/۰۲ ۱۰:۰۵
آدرس: www.dntips.ir
اگر تا به حال با ساخت برنامه‌های .NET مدرن سروکار داشته‌اید، حتماً به این نیاز برخورده‌اید که وظایف پس‌زمینه‌ای مانند کارهای دوره‌ای، پردازش صف پیام‌ها، نظارت بر سیستم یا عملیات پاکسازی را بدون اختلال در جریان اصلی برنامه اجرا کنید. فریم‌ورک .NET دو ابزار قدرتمند برای این کار ارائه می‌دهد: رابط IHostedService و کلاس پایه انتزاعی BackgroundService. اما اگر در حال ارتقا به .NET 10 هستید، یک بهبود کلیدی در زمینه نخ‌ها (Threading) وجود دارد که زندگی شما را به عنوان توسعه‌دهنده آسان‌تر می‌کند.

توضیح: پایه‌ها، تله‌ها و انقلاب .NET 10

پایه‌ها: رابطIHostedServiceو الگوهای صحیح
رابط IHostedService، که بخشی از میزبان عمومی (Generic Host) در .NET است، پایه‌ای‌ترین ابزار برای مدیریت خدمات پس‌زمینه محسوب می‌شود. این رابط دو متد کلیدی تعریف می‌کند: StartAsync(CancellationToken) که هنگام راه‌اندازی برنامه فراخوانی می‌شود، و StopAsync(CancellationToken) که هنگام توقف برنامه اجرا می‌گردد. نکته حیاتی این است که StartAsync باید فوراً تکمیل شود تا راه‌اندازی برنامه بلاک نشود.

مثال ۱: رویکرد صحیح با استفاده از تایمر (توصیه‌شده)
این الگو، با استفاده از کلاس Timer، کار را به نخ‌های استخر نخ (Thread Pool) واگذار می‌کند و راه‌اندازی را بلاک نمی‌کند:
public class MyBackgroundService : IHostedService
{
    private Timer? _timer;

    public Task StartAsync(CancellationToken cancellationToken)
    {
        // تایمر کار را راه‌اندازی می‌کند و فوراً برمی‌گردد
        _timer = new Timer(DoWork, null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        _timer?.Dispose();
        return Task.CompletedTask;
    }

    private void DoWork(object? state)
    {
        Console.WriteLine($"وظیفه پس‌زمینه در حال اجرا در: {DateTime.Now}");
    }
}
چرا این کار می‌کند؟ متد StartAsync بلافاصله Task.CompletedTask برمی‌گرداند، در حالی که کار واقعی روی نخ‌های جداگانه‌ای از استخر نخ‌ها اجرا می‌شود. این الگو برای وظایف دوره‌ای ایده‌آل است و از بلاک شدن جلوگیری می‌کند.

مثال ۲: اشتباه رایج — بلاک کردنStartAsync(هشدار!)
هرگز این کار را نکنید! حلقه while متد StartAsync را بلاک می‌کند و برنامه را در حین راه‌اندازی معلق نگه می‌دارد:
public class MyBackgroundService : IHostedService
{
    public Task StartAsync(CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            Console.WriteLine($"وظیفه پس‌زمینه در حال اجرا در: {DateTime.Now}");
        }
        return Task.CompletedTask; // هرگز به اینجا نمی‌رسد!
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        return Task.CompletedTask;
    }
}
درس کلیدی: هرگز کارهای طولانی‌مدت را مستقیماً در StartAsync قرار ندهید. همیشه آن‌ها را به نخ‌های جداگانه واگذار کنید. این اشتباه نه تنها راه‌اندازی را کند می‌کند، بلکه در سناریوهای واقعی مانند کانتینرهای Docker یا Azure App Service، می‌تواند منجر به شکست‌های زمانی (Timeouts) شود. برای جلوگیری، همیشه از ابزارهایی مانند Task.Run یا تایمرها استفاده کنید.

معرفیBackgroundService: سادگی با یک تله پنهان در .NET 9
کلاس BackgroundService یک کلاس پایه انتزاعی است که IHostedService را برای شما پیاده‌سازی می‌کند. شما فقط نیاز به بازنویسی متد ExecuteAsync دارید، که این کار را برای وظایف مداوم مانند پردازش صف‌ها یا نظرسنجی‌های دوره‌ای عالی می‌کند.

مثال ۳: مشکل .NET 9 — خواب همزمان بلاک‌کننده (هشدار!)
در .NET 9 و نسخه‌های قبلی، ExecuteAsync روی نخ اصلی (Main Thread) اجرا می‌شود تا زمانی که کنترل را با await واگذار کند. استفاده از متدهای همزمان مانند Thread.Sleep راه‌اندازی را بلاک می‌کند:
public class MyBackgroundService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            Console.WriteLine($"وظیفه پس‌زمینه در حال اجرا در: {DateTime.Now}");
            Thread.Sleep(500); // در .NET 9، این نخ اصلی را بلاک می‌کند!
        }
    }
}

مثال ۴: راه‌حل .NET 9 — استفاده ازawait Task.Delay(توصیه‌شده)
این الگو با await کنترل را فوراً واگذار می‌کند:
public class MyBackgroundService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            Console.WriteLine($"وظیفه پس‌زمینه در حال اجرا در: {DateTime.Now}");
            await Task.Delay(500, stoppingToken); // کنترل را واگذار می‌کند
        }
    }
}

مثال ۵: راه‌حل جایگزین دردات‌نت 9 Task.Yield()
برای کدهای قدیمی همزمان، می‌توانید فوراً کنترل را واگذار کنید:
public class MyBackgroundService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await Task.Yield(); // واگذاری فوری به نخ استخر
        while (!stoppingToken.IsCancellationRequested)
        {
            Console.WriteLine($"وظیفه پس‌زمینه در حال اجرا در: {DateTime.Now}");
            Thread.Sleep(5000); // حالا ایمن است
        }
    }
}

نکته: در .NET 9، عدم رعایت الگوهای ناهمگام (Async) نه تنها راه‌اندازی را بلاک می‌کند، بلکه می‌تواند منجر به مصرف بیش از حد منابع شود، زیرا نخ اصلی اشغال می‌ماند. همیشه از CancellationToken برای مدیریت توقف graceful استفاده کنید — مثلاً در سناریوهای shutdown، این توکن به شما اجازه می‌دهد کارهای نیمه‌تمام را به درستی تمام کنید.

انقلاب .NET 10: نخ جداگانه به طور خودکار
در دات‌نت 10، متد ExecuteAsync بلافاصله روی یک نخ پس‌زمینه‌ی جداگانه شروع به اجرا می‌شود. این تغییر، نیاز به هک‌های واگذاری را از بین می‌برد و تجربه‌ی توسعه را شهودی‌تر می‌کند.

مثال ۶: کد همزمان در .NET 10 بدون مشکل
حالا می‌توانید بدون نگرانی از بلاک، از کدهای همزمان استفاده کنید:
public class MyBackgroundService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            Console.WriteLine($"وظیفه پس‌زمینه در حال اجرا در: {DateTime.Now}");
            Thread.Sleep(5000); // در .NET 10، هیچ مشکلی ندارد!
        }
    }
}

بهبود .NET 10: کدهای همزمان مانند Thread.Sleep دیگر راه‌اندازی را بلاک نمی‌کنند. با این حال، الگوهای ناهمگام همچنان برای بهره‌وری بهتر منابع (مانند جلوگیری از بلاک نخ‌ها در سناریوهای پربار) توصیه می‌شود. این تغییر بخشی از تلاش‌های مایکروسافت برای بهبود میزبان عمومی است و در ترکیب با ویژگی‌های جدید مانند AOT (Ahead-of-Time Compilation) در .NET 10، عملکرد کلی برنامه‌های پس‌زمینه را افزایش می‌دهد.

الگوهای مقداردهی اولیه: از سازنده دوری کنید
برای کارهای اولیه، هرگز از سازنده (Constructor) استفاده نکنید، زیرا در حین ثبت خدمات (DI Container) اجرا می‌شود و می‌تواند کل فرآیند را بلاک کند.

مثال ۷: مقداردهی در سازنده (توصیه نمی‌شود!)
public class MyBackgroundService : BackgroundService
{
    public MyBackgroundService()
    {
        // این DI Container را بلاک می‌کند
        Console.WriteLine("سرویس پس‌زمینه در حال مقداردهی اولیه...");
        Thread.Sleep(2000); // کار اولیه
        Console.WriteLine("مقداردهی اولیه تمام شد.");
    }
    // ... پیاده‌سازی ExecuteAsync
}

مثال ۸: بازنویسیStartAsync(توصیه‌شده)
این الگو اجازه می‌دهد مقداردهی ناهمگام و قابل لغو قبل از شروع کار مداوم انجام شود:
public class MyBackgroundService : BackgroundService
{
    public override async Task StartAsync(CancellationToken cancellationToken)
    {
        Console.WriteLine("سرویس پس‌زمینه در حال مقداردهی اولیه...");
        await Task.Delay(2000, cancellationToken); // برای بهره‌وری بهتر
        Console.WriteLine("مقداردهی اولیه تمام شد.");

        // این فراخوانی ExecuteAsync را شروع می‌کند
        await base.StartAsync(cancellationToken);
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            Console.WriteLine($"وظیفه پس‌زمینه در حال اجرا در: {DateTime.Now}");
            Thread.Sleep(5000);
        }
    }
}
توضیح بیشتر: بازنویسی StartAsync نه تنها بلاک را جلوگیری می‌کند، بلکه اجازه می‌دهد وابستگی‌های تزریقی (مانند اتصال به دیتابیس) را با پشتیبانی از لغو مدیریت کنید. در عمل، این الگو در برنامه‌های واقعی مانند APIهای وب، جایی که خدمات پس‌زمینه با Hangfire یا Quartz ترکیب می‌شوند، حیاتی است.

راهنمای تصمیم‌گیری سریع

برای کمک به انتخاب، جدول زیر را ببینید:
نوع سرویساستفاده کنید وقتی...مزیت کلیدی
IHostedServiceنیاز به کنترل دقیق بر راه‌اندازی/توقف، تایمرها یا مدیریت نخ صریح داریدجریان کنترل صریح
BackgroundServiceوظایف مداوم طولانی‌مدت (صف‌ها، نظرسنجی) دارید یا boilerplate کم می‌خواهیدسادگی (فقط ExecuteAsync را بازنویسی کنید)
مقایسه کامل .NET 9 در برابر .NET 10
جنبه دات‌نت 9دات‌نت 10
اجرایExecuteAsyncروی نخ اصلی تا اولین awaitبلافاصله روی نخ جداگانه
تأثیرThread.Sleepبلاک راه‌اندازیبدون بلاک
نیاز به workaroundبله (Task.Yield یا await)خیر
بهترین الگوهمیشه async/awaitasync توصیه‌شده اما الزامی نیست

نکات کلیدی برای به خاطر سپردن:
  • در .NET 9: همیشه در ExecuteAsync، زود کنترل را واگذار کنید تا بلاک نشوید.
  • در .NET 10: کدهای همزمان در ExecuteAsync راه‌اندازی را بلاک نمی‌کنند.
  • برای بیشتر پردازش‌های مداوم، BackgroundService را انتخاب کنید به دلیل سادگی‌اش.
  • برای منطق مقداردهی اولیه، StartAsync را بازنویسی کنید (نه سازنده).



مشاهده مطلب اصلی

نظرات

  • امیر هاشم زاده در ۱۴۰۴/۰۹/۰۳ ۲۱:۰۱
    حالا نقطه مقابل این وضعیت: اگه قرار باشه شروع برنامه وابسته به بارگذاری یک گواهی از Vault و مقدار دهی ش به یک شی سینگلتون باشه که HttpClient از اون استفاده کنه چ راهکاری اصولی وجود داره؟
    کاری که خودم انجام دادم نوشتن یه متد اکستنشن بود که قبل از app.Run اجرا میشد و با استفاده از الگوی تاب آوری اون رو تا 5 بار تکرار می کنم و در صورت شکست از راه اندازی برنامه جلوگیری می کردم.

    public static class WebApplicationExtensions
    {
      public static async Task LoadCertificatesFromVaultOrFail(this WebApplication app)
      {
          // Load the certificate from the vault, with retry logic in case of failure
      }
    }
    
    program.cs
    var app = builder.Build();
    await app.LoadCertificatesFromVaultOrFail();
    app.Run();
    • وحید نصیری در ۱۴۰۴/۰۹/۰۳ ۲۳:۳۲
      بله. در پروژه‌های واقعی (به‌خصوص میکروسرویس‌ها در Kubernetes یا Azure/AWS) دقیقاً همین مشکل وجود داره:
      برنامه نباید بالا بیاید مگر اینکه گواهی از HashiCorp Vault (یا Key Vault، Secrets Manager و …) با موفقیت بارگذاری شده باشد و یک IHttpClientFactory یا HttpClient سینگلتون با آن گواهی تنظیم شده باشد.
      کاری که انجام دادید (متد اکستنشن + Retry + Fail-Fast) در عمل کاملاً درست و حتی بهترین راه‌حل موجود در .NET 6–9 بود، اما حالا در .NET 8 و به‌خصوص .NET 10 می‌تونیم این کار رو خیلی تمیزتر، استانداردتر و کاملاً هم‌راستا با چرخه حیات میزبان (Host Lifetime) انجام بدیم.

      راه‌حل اصولی و توصیه‌شده از .NET 8 به بعد
      استفاده از IHostApplicationLifetime + یک IHostedLifecycleService (در .NET 8+) یا ساده‌تر: یک BackgroundService که فقط کارش «مقداردهی اولیه بحرانی» است و تا موفق نشود، اجازه ادامه راه‌اندازی را نمی‌دهد.

      بهترین الگو در سال 2025 (.NET 8/9/10)
      // 1. اول سرویس مقداردهی اولیه را تعریف می‌کنیم
      public class CertificateInitializationService : BackgroundService
      {
          private readonly ILogger<CertificateInitializationService> _logger;
          private readonly VaultClient _vaultClient; // یا هر کلاینتی که داری
          private readonly CertificateSingleton _certificateSingleton; // سینگلتون شما
      
          public CertificateInitializationService(
              ILogger<CertificateInitializationService> logger,
              VaultClient vaultClient,
              CertificateSingleton certificateSingleton)
          {
              _logger = logger;
              _vaultClient = vaultClient;
              _certificateSingleton = certificateSingleton;
          }
      
          protected override async Task ExecuteAsync(CancellationToken stoppingToken)
          {
              const int maxRetries = 10;
              var delay = TimeSpan.FromSeconds(3);
      
              for (int i = 0; i < maxRetries; i++)
              {
                  try
                  {
                      _logger.LogWarning("در حال بارگذاری گواهی از Vault... تلاش {Attempt}/{Max}", i + 1, maxRetries);
      
                      var certificate = await _vaultClient.GetCertificateAsync(stoppingToken);
      
                      _certificateSingleton.SetCertificate(certificate); // تنظیم سینگلتون
      
                      _logger.LogInformation("گواهی با موفقیت از Vault بارگذاری و تنظیم شد.");
                      return; // موفقیت → خروج از ExecuteAsync → برنامه ادامه پیدا می‌کند
                  }
                  catch (Exception ex) when (i < maxRetries - 1)
                  {
                      _logger.LogError(ex, "بارگذاری گواهی ناموفق بود. تلاش مجدد پس از {Delay} ثانیه...", delay);
                      await Task.Delay(delay, stoppingToken);
                  }
              }
      
              // اگر اینجا رسیدیم یعنی همه تلاش‌ها شکست خورده
              _logger.LogCritical("بارگذاری گواهی از Vault پس از {MaxRetries} تلاش ناموفق بود. برنامه متوقف می‌شود.", maxRetries);
      
              // بسیار مهم: درخواست توقف برنامه
              // این باعث می‌شود Host به طور کنترل‌شده shutdown شود و کد خروج غیرصفر برگردد
              Environment.ExitCode = 1;
              // یا اگر از IHostApplicationLifetime تزریق کرده باشی:
              // _lifetime.StopApplication();
              throw new InvalidOperationException("Failed to load certificate from Vault after multiple retries.");
          }
      }

      var app = builder.Build();
      
      // اینجا برنامه تا CertificateInitializationService کامل نشود، به app.Run() نمی‌رسد
      app.Run();

      روش جایگزین خیلی تمیز (اگر فقط یک کار اولیه داری)
      اگر فقط یک کار اولیه دارید و نمی‌خواهید BackgroundService کامل بسازید، از IStartupFilter هم می‌تونید استفاده کنید ولی توصیه نمی‌شود؛ چون قابلیت Retry و CancellationToken ندارد. بهترین گزینه همچنان BackgroundService + خروج کنترل‌شده است.

      خلاصه توصیه نهایی برای سال 2025
      اگر برنامه‌ات بدون گواهی/سکرت/تنظیمات بحرانی نباید بالا بیاید:
      به جای نوشتن متد اکستنشن دستی (که هنوز هم قابل قبوله)، از یک BackgroundService اختصاصی برای مقداردهی اولیه بحرانی استفاده کن و در انتها اگر شکست خورد، Environment.Exit(1) بزن یا IHostApplicationLifetime.StopApplication() فراخوانی کن.
      این دقیقاً همان چیزیه که تیم‌های بزرگ (از جمله خود مایکروسافت در نمونه‌های AKS/EKS) الان استفاده می‌کنند و کاملاً production-ready و observable است.