لماذا يستحقّ الأمر إدخال Generic Host / BackgroundService إلى تطبيق سطح المكتب - يصبح تنظيم البدء والـ lifetime والـ graceful shutdown أسهل بكثير

· · C#, .NET, Generic Host, BackgroundService, WPF, WinForms, Windows Development, Design

عندما يكبر قليلاً أيّ أداة Windows أو تطبيق سطح مكتب مقيم، تبدأ الأعمال الواقعة خارج الواجهة بالازدياد تدريجيّاً. استطلاع دوريّ، ومراقبة ملفّات، وحلقات إعادة اتّصال، ومعالجة طوابير، وتهيئة عند البدء، وعمليّات flush عند الإيقاف. في البداية، يمكنك في الغالب الاكتفاء بـ Form_Load أو OnStartup أو استدعاء سريع لـ Task.Run، لكن إن استمرّ التطبيق بالنموّ على هذا النحو، يصبح من غير الواضح من يبدأ ماذا، ومن يوقف ماذا، ومن يتولّى مراقبة الاستثناءات.

ثمّة مواقف يكون فيها من المفيد أن تقرّر من يملك الـ lifetime لقطعة من العمل قبل حتّى أن تناقش تفاصيل async / await. هنا يصبح Generic Host و BackgroundService في .NET مفيدَين.

يرتبط جانب الـ UI thread من async / await ارتباطاً طبيعيّاً بـ:

يركّز هذا المقال على الطبقة التي تقع خارج ذلك بدرجة، أي تنظيم البدء والإيقاف للتطبيق ككلّ.

الأجزاء التي تميل إلى التحلّل البطيء في المشاريع الواقعيّة تكون عادةً من قبيل:

  • Task.Run يبدأ بالظهور في كلّ مكان داخل النماذج والـ view models
  • تُتحكّم الحلقات المقيمة بأعلام bool متناثرة
  • لا يزال بعض العمل يجري أثناء الإيقاف، فلا يُغلق التطبيق بنظافة من حين لآخر
  • ينتهي الـ logging والإعدادات والـ DI بنقاط دخول مستقلّة لكلّ منها
  • تبدأ Environment.Exit تبدو مغرية، فتُتجاوز كتل finally

يفترض هذا المقال إلى حدٍّ كبير .NET 6 وما بعد، وتطبيقات WPF / WinForms / تطبيقات Windows المقيمة، ويُنظّم لماذا يساعد Generic Host / BackgroundService، وإلى أيّ حدّ يستحقّ إدخالهما، وأين يصبح التصميم موحلاً إن استُخدما باستخفاف.

أوّلاً: المصطلحات

يصبح هذا الموضوع أصعب قراءةً بسرعة إن بقيت المصطلحات ضبابيّة، لذا يفيد تثبيت المفردات الأساسيّة أوّلاً.

  • Generic Host
    • الأساس الذي يتولّى بدء التطبيق والاعتماديّات والإعدادات والـ logging والإيقاف
    • ليس مخصّصاً لـ ASP.NET Core فحسب؛ إنّه يعمل أيضاً في تطبيقات console و workers وتطبيقات سطح المكتب
  • Host / IHost
    • مثيل وقت التشغيل المبنيّ
    • تبدؤه بـ StartAsync وتوقفه بـ StopAsync
  • Hosted Service
    • عمل مقيم يبدأ ويتوقّف مع lifetime الـ host
    • يمكنك تنفيذ IHostedService مباشرةً، لكن في معظم الأحيان ترث من BackgroundService
  • BackgroundService
    • تنفيذ مساعد يجعل كتابة IHostedService أسهل
    • بما أنّ الجسم طويل العمر يعيش داخل ExecuteAsync، فهو ملائم لتنظيم حلقات المراقبة والمعالجة الدوريّة
  • lifetime
    • في هذا المقال، يعني هذا متى يبدأ العمل ومتى ينتهي ومن يتحمّل مسؤوليّة إيقافه
    • إنّه أكثر من مجرّد “كم يعيش”؛ يشمل كلّاً من مسؤوليّة البدء ومسؤوليّة الإيقاف
  • graceful shutdown
    • الإيقاف عبر إرسال إشارة توقّف صحيحة والسماح للعمل الجاري بالاستقرار قدر الإمكان عمليّاً، بدلاً من إجبار العمليّة على الانتهاء فوراً
    • مثلاً: عدم بدء الدورة التالية، أو تحديد مقدار الطابور المراد تصريفه، أو انتظار close / flush
  • DI
    • اختصار لـ Dependency Injection
    • بدلاً من إنشاء الكائنات بشكل مباشر في كلّ موضع استدعاء، تُجمَع الاعتماديّات في نقطة الدخول وتُحقن عبر container

بعبارة أخرى، هذا ليس مجرّد تعريف بصنف ملائم اسمه BackgroundService.
إنّه فعلاً عن جمع بدء وإيقاف التطبيق داخل الـ host، ومعاملة lifetime المعالجة المقيمة كجزء من العماريّة.

المحتويات

  1. النسخة المختصرة
  2. أوّلاً: استعرض الصورة الكاملة في صفحة واحدة
  3. لماذا يعمل هذا جيّداً في تطبيقات سطح المكتب
  4. الحالات التي تلائمه
  5. مثال إعداد بحدّه الأدنى (WPF)
  6. كيفيّة تقسيم StartAsync / ExecuteAsync / StopAsync
  7. أنماط مضادّة شائعة
  8. قائمة تحقّق للمراجعة
  9. دليل تقريبيّ كقواعد إبهام
  10. الخلاصة
  11. مراجع

1. النسخة المختصرة

  • Generic Host أساس قويّ جدّاً لـ إدارة البدء والـ lifetime، حتّى في تطبيقات سطح المكتب
  • BackgroundService هو وعاء يضع العمل طويل العمر داخل lifetime مُدار، بدلاً من رميه في Task.Run ونسيانه
  • ما يساعد أكثر من غيره عمليّاً هو القدرة على جمع مسؤوليّة البدء، ومسؤوليّة الإيقاف، ومراقبة الاستثناءات، والـ logging، والـ DI، والإعدادات داخل مركز تصميم واحد
  • إذا بقي StartAsync قصيراً، وعاش الجسم طويل العمر داخل ExecuteAsync، ووُضع تنظيف الإيقاف في StopAsync، يصبح الكود أسهل قراءةً بكثير
  • تطبيقات الـ tray، وتطبيقات مراقبة الأجهزة، والمزامنة الدوريّة، والمعالجة الخلفيّة المرتّبة، وحلقات إعادة الاتّصال هي حالات ملائمة بشكل خاصّ
  • في المقابل، إن نقلت إلى BackgroundService كلّ عمل لمرّة واحدة يبدأ بضغطة زرّ، فقد يصبح التصميم ثقيلاً بلا داعٍ
  • StopAsync مفيد، لكنّه ليس تأميناً ضدّ تعطّل العمليّة أو إنهائها قسراً. من المهمّ كذلك ألّا تُحمّله بكلّ شكل من أشكال التنظيف

إذاً، السبب الذي يجعل Generic Host / BackgroundService يساعد في تطبيقات سطح المكتب ليس مجرّد “وجود عمل خلفيّ”.
إنّه يساعد لأنّك تريد معاملة lifetime ذلك العمل الخلفيّ كعماريّة، بدلاً من معاملته كأثر جانبيّ للواجهة.

2. أوّلاً: استعرض الصورة الكاملة في صفحة واحدة

2.1. الصورة الإجماليّة

عادةً ما يجعل هذا المخطّط الفكرة أسهل استيعاباً بكثير.

flowchart LR
    A["Desktop app startup<br/>(WPF / WinForms)"] --> B["Build / StartAsync the host"]
    B --> C["Prepare DI / Logging / Configuration"]
    B --> D["HostedService.StartAsync"]
    D --> E["BackgroundService.ExecuteAsync"]
    E --> F["PeriodicTimer / Queue / Reconnect / Monitoring loop"]

    C --> G["Show MainWindow / MainForm"]
    F --> H["State updates / Logging / External I/O"]
    H --> I["Touch UI only where needed via Dispatcher / Invoke"]

    J["User exit / Fatal error / StopApplication"] --> K["IHost.StopAsync"]
    K --> L["Signal CancellationToken"]
    L --> M["HostedService.StopAsync"]
    M --> N["Connection close / flush / graceful shutdown"]

في تطبيقات الواجهة، تتشتّت المسؤوليّات في الغالب عبر Program.cs و App.xaml.cs و Form_Load و Closing واستدعاءات عشوائيّة لـ Task.Run ومؤقّتات و static singletons.

بمجرّد إدخال الـ host، يمكنك عادةً فصل الصورة إلى:

  • UI: الشاشات والإدخال والعرض
  • HostedService / BackgroundService: العمل المقيم والمراقبة ومعالجة الطوابير والمعالجة الدوريّة
  • خدمات DI: المنطق التجاريّ الفعليّ، والاتّصالات الخارجيّة، والإعدادات، والـ logging

ذلك الفصل وحده يجعل مراجعة التصميم أسهل بكثير.

2.2. جدول قرار التموضع

ما تريد فعله المرشّح الأوّل للتموضع السبب
تهيئة خفيفة فور البدء StartAsync تشارك بوضوح في البدء كعمل قصير
سلوك مراقبة / استطلاع / إعادة اتّصال طويل العمر ExecuteAsync يعمل طبيعيّاً طوال lifetime الخدمة
إشعارات الإيقاف / flush / close أثناء الإغلاق StopAsync يقترن طبيعيّاً مع CancellationToken و graceful shutdown
إعداد الاعتماديّات والإعدادات والـ logging Host.CreateApplicationBuilder يجمع نقطة الدخول في مكان واحد
تحديثات الواجهة جانب الـ UI الأكثر أماناً ألّا تجعل الـ workers تلمس الواجهة مباشرةً
عمل لمرّة واحدة بضغطة زرّ دالّة async عاديّة لا يحتاج عادةً أن يصبح hosted service
معالجة خلفيّة لاحقة مرتّبة Channel<T> + BackgroundService يُدير الـ lifetime والحدود بشكل أفضل من fire-and-forget

قيمة إدخال الـ host ليست فقط في أنّه يسمح لشيء أن يصبح “غير متزامن”.
بل في أنّه يجعل قرارات التموضع أوضح بكثير.

3. لماذا يعمل هذا جيّداً في تطبيقات سطح المكتب

3.1. يصبح فصل مسؤوليّة الواجهة عن المعالجة المقيمة أسهل

تبدو تطبيقات سطح المكتب متمحورةً حول الواجهة، لكنّ الأجزاء التي تصبح ثقيلةً في العمل الفعليّ تكون عادةً خارج الواجهة.

مثلاً:

  • مزامنة الحالة كلّ 10 ثوانٍ
  • إعادة الاتّصال بأجهزة أو خوادم
  • مراقبة الملفّات واستيعابها
  • معالجة لاحقة مُدرَجة في طابور
  • شحن الـ logs أو الإبلاغ عن المقاييس
  • إحماء الـ cache عند البدء

هذه ليست في الحقيقة “أحداث شاشة”. إنّها أعمال متعلّقة بـ lifetime التطبيق ككلّ.

إن جعلتها تعيش في النماذج أو في code-behind النوافذ، تبدأ مسؤوليّة إيقافها عند إغلاق الشاشة، ومراقبة الاستثناءات، وتقرير الإعادة أو الـ backoff بالاختلاط مع شؤون الواجهة.

مع BackgroundService، يظهر التصريح “هذا العمل يعيش طالما يعيش التطبيق” مباشرةً في شكل الكود.
هذا أمر قويّ بهدوء.

3.2. يمكن جمع نقاط دخول البدء والإيقاف والاستثناءات في مكان واحد

حتّى من دون الـ host، يمكن لتطبيق سطح المكتب أن يصفّ ServiceCollection و ConfigurationBuilder و LoggerFactory يدويّاً.

لكن ذلك الشكل يتشتّت عادةً شيئاً فشيئاً:

  • DI داخل Program.cs
  • الإعدادات داخل حامل static خاصّ
  • الـ logging داخل factory آخر
  • معالجة الإيقاف داخل ApplicationExit
  • المعالجة المقيمة داخل Task.Run

قد يستمرّ هذا في العمل أوّلاً. لكن حين تعود إليه بعد بضعة أشهر، يصبح من الصعب رؤية من يملك فعلاً lifetime التطبيق.

مع Generic Host، تنضوي كلّ هذه ضمن إطار واحد:

  • تسجيل الخدمات
  • تحميل الإعدادات
  • إعداد الـ logging
  • بدء الـ hosted service
  • إشعارات الإيقاف
  • إيقاف التطبيق ككلّ عبر IHostApplicationLifetime

فيصبح من الأسهل جمع نقطة الدخول لـ “كيف يبدأ هذا التطبيق وكيف يتوقّف” في مكان واحد.
ويُؤتي ذلك ثماره لاحقاً في التطبيقات المقيمة.

3.3. يصبح من الأسهل جعل الـ graceful shutdown جزءاً من التصميم

بالنسبة إلى العمل المقيم، الإيقاف أصعب من البدء. حقّاً. يمكن أن يستغرق البدء ثلاثة أسطر. أمّا الإيقاف فطعمه طينيّ في الغالب.

عند الإيقاف، تحتاج عادةً إلى:

  • إلغاء الـ I/O الجاري
  • تجنّب بدء الدورة التالية
  • تقرير مقدار العمل المُدرَج في الطابور المراد تصريفه
  • إغلاق الـ sockets أو كائنات الـ COM
  • انتظار عمليّات flush للـ logging أو مثابرة الحالة

إن حشرتَ ذلك في FormClosing، فسيصبح مؤلماً سريعاً لأنّه يختلط مع شؤون خاصّة بالشاشة.

مع Host / BackgroundService، يتوفّر لديك بالفعل CancellationToken و StopAsync، لذا يكون مسار الإيقاف موجوداً منذ البداية.

طبعاً، الأمر ليس سحراً. قد لا يُستدعى StopAsync عند التعطّلات أو الإنهاء القسريّ. ومع ذلك، فإنّ مجرّد امتلاك تصميم يقول “عند الإيقاف العاديّ، نتوقّف عبر هذا المسار” يجعل النظام كلّه أهدأ بكثير.

3.4. DI و logging والإعدادات تأتي مُحاذاةً مسبقاً

الجزء الجميل في Generic Host ليس فقط BackgroundService.

  • يمنحك Host.CreateApplicationBuilder أساساً لـ DI والإعدادات والـ logging
  • يلائمك appsettings.json ومتغيّرات البيئة بشكل طبيعيّ
  • يمكن استخدام ILogger<T> في كود الواجهة وفي الـ workers بالأسلوب نفسه
  • عند الحاجة، يكون تجميع الإعدادات بأسلوب IOptions<T> متاحاً سلفاً

يهمّ هذا خصوصاً في مشاريع أدوات Windows، حيث “بدأ التطبيق صغيراً، فعاشت الإعدادات والـ loggers في statics عشوائيّة” يصبح غالباً مؤلماً لاحقاً.

إن وضعتَ تلك الشؤون على الـ host منذ البداية، فسيظلّ التطبيق أقلّ لهاثاً حين يكبر قليلاً.

4. الحالات التي تلائمه

يكون Generic Host / BackgroundService فعّالاً بشكل خاصّ في حالات مثل:

  • تطبيقات الـ tray المقيمة
    توجد مزامنة دوريّة ومراقبة وإشعارات وسلوك إعادة اتّصال
  • تطبيقات الاتّصال بالأجهزة / الكاميرات / الـ sockets
    يوجد إبقاء الاتّصال والمراقبة وإعادة المحاولة وجمع الحالة
  • أدوات تكامل الملفّات
    توجد المراقبة وطوابير الاستيعاب والمعالجة المرتّبة
  • منع الأدوات الداخليّة من التضخّم بهدوء
    يبدأ التطبيق صغيراً، لكن من المرجّح أن تنمو الإعدادات والـ logging والـ I/O الخارجيّ
  • التطبيقات التي تهمّ فيها جودة الإيقاف
    لا تريد ترك حالة نصف مكتملة وراءك عند الخروج

في المقابل، قد لا تحتاج إلى إدخال الـ host فوراً في حالات مثل:

  • أداة صغيرة تبدأ وتجري عمليّة واحدة وتخرج
  • شاشة تكاد تكون مدفوعةً بالكامل بأحداث الواجهة وتحوي عملاً خلفيّاً قليلاً
  • أداة مساعدة داخليّة صغيرة فعلاً، لا يُرجَّح أن تنمو فيها الاعتماديّات والإعدادات

بعبارة أخرى، الـ Host ليس إلزاميّاً.
لكن بمجرّد أن ترى اثنين أو أكثر من سير العمل المقيم، فإنّ الأمر يستحقّ النظر فيه بإيجابيّة شديدة. إنّه أرخص بكثير من تنظيف مستعمرة Task.Run لاحقاً.

5. مثال إعداد بحدّه الأدنى (WPF)

كمثال، إليك إعداد WPF بحدّه الأدنى يبدأ host ويُشغّل BackgroundService يقرأ حالةً خارجيّةً كلّ خمس ثوانٍ. في WinForms، تتغيّر نقطة الدخول إلى Main / ApplicationContext، لكنّ الفكرة تكاد تكون مماثلة.

5.1. App.xaml.cs

using System.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace DesktopHostSample;

public partial class App : Application
{
    private IHost? _host;

    protected override async void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);

        HostApplicationBuilder builder = Host.CreateApplicationBuilder(e.Args);

        builder.Services.Configure<HostOptions>(options =>
        {
            options.ShutdownTimeout = TimeSpan.FromSeconds(15);
        });

        builder.Services.AddSingleton<MainWindow>();
        builder.Services.AddSingleton<StatusStore>();
        builder.Services.AddScoped<IDeviceStatusReader, DeviceStatusReader>();
        builder.Services.AddHostedService<DevicePollingBackgroundService>();

        _host = builder.Build();

        await _host.StartAsync();

        MainWindow mainWindow = _host.Services.GetRequiredService<MainWindow>();
        mainWindow.Show();
    }

    protected override async void OnExit(ExitEventArgs e)
    {
        if (_host is not null)
        {
            await _host.StopAsync();
            _host.Dispose();
        }

        base.OnExit(e);
    }
}

ثلاث نقاط تهمّ في هذا الشكل:

  1. ابدأ الـ host قبل عرض الواجهة
  2. انتظر StopAsync بشكل صريح عند الإيقاف
  3. اجمع DI والـ hosted services ومهلة الإيقاف عند نقطة الدخول

جعل OnExit غير متزامن بحدّ ذاته يحتاج إلى بعض الحذر بسبب سلوك إطار عمل الواجهة، لكن يبقى مهمّاً جعل مسار إيقاف الـ host صريحاً.

5.2. BackgroundService

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace DesktopHostSample;

public sealed class DevicePollingBackgroundService(
    IServiceScopeFactory scopeFactory,
    StatusStore statusStore,
    ILogger<DevicePollingBackgroundService> logger) : BackgroundService
{
    public override async Task StartAsync(CancellationToken cancellationToken)
    {
        logger.LogInformation("Device polling service is starting.");
        await base.StartAsync(cancellationToken);
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        logger.LogInformation("Device polling loop started.");

        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                using IServiceScope scope = scopeFactory.CreateScope();
                IDeviceStatusReader reader =
                    scope.ServiceProvider.GetRequiredService<IDeviceStatusReader>();

                DeviceStatus status = await reader.ReadAsync(stoppingToken);
                statusStore.Update(status);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                break;
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Device polling failed.");
            }
        }

        logger.LogInformation("Device polling loop finished.");
    }

    public override async Task StopAsync(CancellationToken cancellationToken)
    {
        logger.LogInformation("Device polling service is stopping.");
        await base.StopAsync(cancellationToken);
        logger.LogInformation("Device polling service stopped.");
    }
}

المهمّ هنا هو كتابة ExecuteAsync ببساطة بوصفه حلقة while مُدارة.

  • PeriodicTimer يتحكّم بالدورة
  • stoppingToken يتحكّم بالإيقاف
  • تُسجَّل الاستثناءات في الـ logs
  • إذا احتجت إلى اعتماديّات scoped، فأنشئ scope في كلّ مرّة

ذلك الشكل يجعل من الأسهل بكثير قراءة أين يبدأ العمل المقيم، وأين يتوقّف، وأين يصبح الفشل مرئيّاً.

5.3. لا تربط مشاركة الحالة بالواجهة مباشرةً

إذا لمس worker كائنات الواجهة مباشرةً، فإنّ مشكلة الـ UI thread ستعود ببساطة هناك.

لذا، الفصل الأكثر أماناً أوّلاً هو:

  • يحدّث الـ worker مخزن حالة أو طبقة messaging
  • تقرأ الواجهة وتعكس تلك الحالة في سياقها الخاصّ

مثلاً، يمكن أن يكون StatusStore طبقة مشتركة رقيقة كهذه:

namespace DesktopHostSample;

public sealed class StatusStore
{
    private readonly object _gate = new();
    private DeviceStatus _current = DeviceStatus.Empty;

    public DeviceStatus Current
    {
        get
        {
            lock (_gate)
            {
                return _current;
            }
        }
    }

    public void Update(DeviceStatus next)
    {
        lock (_gate)
        {
            _current = next;
        }
    }
}

public sealed record DeviceStatus(string Message)
{
    public static readonly DeviceStatus Empty = new("No Data");
}

إن احتجت إلى إشعارات فوريّة للواجهة، فيمكنك استخدام Dispatcher أو BeginInvoke أو الأحداث أو messenger. لكنّ الأمر أقلّ ارتباكاً حين تعيش تلك المسؤوليّة عند حدود الواجهة.

6. كيفيّة تقسيم StartAsync / ExecuteAsync / StopAsync

بمجرّد اختلاط هذه الثلاثة، تتعكّر صورة القارئ بسرعة كبيرة. هذا التقسيم نقطة بداية مستقرّة.

6.1. StartAsync

StartAsync هو حيث تضع العمل القصير الذي يشارك في البدء.

ملائم لـ:

  • logs البدء
  • بدء اشتراك خفيف
  • تحضير حالة أوّليّة بسرعة
  • الحدّ الأدنى من التسلسل قبل أو بعد base.StartAsync

غير ملائم لـ:

  • warm-up يستغرق عشرات الثواني
  • حلقات لا نهائيّة
  • الجسم الرئيسيّ الذي يصفّ I/O ثقيلاً

إذا أصبح StartAsync ثقيلاً، يبدأ بدء التطبيق بأكمله بالشعور بالبطء. الأكثر أماناً أن تفكّر فيه بوصفه “المكان لكتابة إشارة البدء”.

6.2. ExecuteAsync

ExecuteAsync هو الجسم الرئيسيّ لـ lifetime الخدمة.

ملائم لـ:

  • الاستطلاع
  • حلقات المراقبة
  • حلقات إعادة الاتّصال
  • المستهلكون الذين يقرؤون Channel<T>
  • العمل الدوريّ
  • أيّ عمل يجب أن يعيش حتّى الإيقاف

ثلاث عادات تهمّ هنا:

  1. مرّر CancellationToken على طول الطريق
  2. تجنّب أن تموت الحلقة بأكملها بصمت عند الاستثناءات
  3. لا تستمرّ بتكديس عمليّات إعادة المحاولة والـ backoff في الموضع نفسه بشكل ارتجاليّ

BackgroundService ملائم، لكن إن تركتَه، يمكن أن يتحوّل إلى “الحلقة العملاقة التي تمتصّ كلّ شيء”. عادةً ما يكون من الأسهل قراءته حين يعيش منطق الأعمال الحقيقيّ في خدمات منفصلة، ويبقى ExecuteAsync مركّزاً على إدارة الـ lifetime والتنسيق.

6.3. StopAsync

StopAsync هو حيث تجري تنظيف الإيقاف الطبيعيّ.

ملائم لـ:

  • logs الإيقاف
  • إلغاء تسجيل المؤقّتات والاشتراكات والمراقبة
  • تنظيف الموارد التي تريد إغلاقها أو flush لها صراحةً
  • انتظار الاكتمال عبر base.StopAsync

لكن من المهمّ أيضاً ألّا تتوقّع الكثير من StopAsync.

  • تعطّلت العمليّة
  • أُنهيت العمليّة قسراً
  • أنهاها نظام التشغيل

في تلك الحالات، قد لا يجري هذا المسار أبداً.

لذا يهمّ:

  • إبقاء المثابرة صغيرةً ومنتظمةً أثناء التشغيل العاديّ
  • تجنّب التصاميم التي تصبح متّسقةً عند الإيقاف فقط
  • جعل التنظيف idempotent

محاولة إنقاذ العالم بأسره عند الإيقاف فقط تتعكّر عادةً.

6.4. ملاحظة لـ .NET 10 وما بعد

كتغيير اعتباراً من 2025 وما بعد، في .NET 10 يُنفَّذ كامل جسم BackgroundService.ExecuteAsync بوصفه مهمّةً خلفيّةً.

سابقاً، كان ثمّة سلوك مربك بعض الشيء، إذ كان الجزء المتزامن قبل أوّل await يستطيع أن يحجب بدء خدمات أخرى. يقلّل هذا التغيير من احتمال أن “تجعل الأسطر الأولى من ExecuteAsync بدء التشغيل ثقيلاً بهدوء”.

ومع ذلك، من منظور التصميم لا يزال من الأسهل القراءة إن أبقيتَ التقسيم:

  • العمل القصير الذي يشارك في البدء -> StartAsync
  • الجسم الرئيسيّ طويل العمر -> ExecuteAsync

إن احتجت إلى تحكّم أدقّ بتوقيت الـ lifecycle، فإنّ IHostedLifecycleService يستحقّ النظر فيه. هذه نقطة هادئة لكن مفيدة بمجرّد أن تبدأ التطبيقات المقيمة بالكبر.

7. أنماط مضادّة شائعة

7.1. بدء حلقة لا نهائيّة في Window_Loaded / Form_Shown

يبدو الأمر سهلاً في البداية. لكن مسؤوليّة الإيقاف ومسؤوليّة الاستثناءات ستلتصق مباشرةً بالواجهة.

بمجرّد أن تبدأ شروط مثل هذه بالتراكم، يصبح الأمر مؤلماً:

“توقّف عند إغلاق الشاشة”
“لا تتوقّف عند التصغير إلى الـ tray”
“أعد البدء عند تغيّر الإعدادات”

7.2. استخدام Task.Run بأسلوب fire-and-forget

Task.Run بحدّ ذاته ليس شرّاً. ما يؤذي هو عدم وجود من يملك الـ lifetime والاستثناءات.

خصوصاً حين يبدأ العمل المقيم بـ Task.Run(async () => { while (...) { ... } })، تصبح هذه ضبابيّة:

  • متى ينتهي؟
  • من ينتظره؟
  • من يراقب الاستثناءات؟
  • كم ننتظر أثناء الإيقاف؟

بمجرّد انتقال ذلك العمل إلى BackgroundService، تصبح الصورة أسهل تنظيماً بكثير.

7.3. لمس الواجهة مباشرةً من BackgroundService

هذا لغم. تتشابك مشكلة الـ UI thread ومشكلة الـ lifetime دفعةً واحدة.

الأكثر أماناً ألّا يتلاعب الـ worker بالواجهة مباشرةً، وأن يضع حدّاً حول واحدة من:

  • الحالة
  • الأحداث
  • الرسائل
  • الطوابير

7.4. وضع المثابرة الحرجة في StopAsync فقط

يساعد StopAsync في الإيقاف العاديّ، لكنّه ليس القاضي النهائيّ لكلّ شيء.

إن قال التصميم:

  • احفظ عند الإيقاف فقط
  • flush عند الإيقاف فقط
  • اكسب الاتّساق عند الإيقاف فقط

فإنّ التعطّلات ستكسره.

7.5. استخدام Environment.Exit رغم أنّك تستخدم الـ host بالفعل

يحدث هذا أيضاً كثيراً.

بمجرّد أن تقول “هذا مزعج، فلنخرج فحسب” وتستدعي Environment.Exit، فأنت تقطع مسار الـ graceful shutdown الذي يوفّره الـ host بالفعل.

إن أردتَ إنهاء التطبيق بأكمله عند خطأ مُميت، فالأنظف بكثير استخدام IHostApplicationLifetime.StopApplication() أوّلاً والمرور عبر المسار الرسميّ للإيقاف.

8. قائمة تحقّق للمراجعة

عند مراجعة تطبيق سطح مكتب يستخدم Generic Host / BackgroundService، يفيد فحص هذه بالترتيب:

  • هل هذا العمل فعلاً عمل بـ lifetime تطبيق، أم أنّه مجرّد معالج حدث للواجهة؟
  • هل تنقسم مسؤوليّات البدء بشكل ملائم عبر StartAsync / ExecuteAsync / StopAsync؟
  • هل أصبح StartAsync ثقيلاً أكثر من اللزوم؟
  • هل يمرّر ExecuteAsync الـ CancellationToken على طول الطريق؟
  • هل يمسك hosted service باعتماديّات scoped مباشرةً بدلاً من إنشاء scopes؟
  • هل يلمس worker كائنات الواجهة مباشرةً؟
  • هل تُبتلَع الاستثناءات بصمت؟
  • هل تحوّلت حلقة إعادة المحاولة إلى حلقة عاليّة التردّد بلا حدّ أعلى؟
  • هل ثمّة حدّ أعلى لزمن انتظار الإيقاف؟
  • هل ثمّة مسارات إيقاف تفترض Environment.Exit أو إنهاء العمليّة؟

تجعل هذه القائمة من الأسهل بكثير رؤية الفرق بين “أدخلنا Host” و “نظّمنا الـ lifetime فعلاً كعماريّة”.

9. دليل تقريبيّ كقواعد إبهام

ما تريد فعله الخيار الأوّل
محاذاة DI / logging / إعدادات للتطبيق ككلّ Host.CreateApplicationBuilder
تشغيل حلقة مقيمة BackgroundService
التشغيل بفترة ثابتة PeriodicTimer + BackgroundService
معالجة عمل لاحق خلفيّ مرتّب Channel<T> + BackgroundService
استخدام خدمات scoped IServiceScopeFactory.CreateScope()
إشارة الإيقاف العاديّ للتطبيق ككلّ IHostApplicationLifetime.StopApplication()
تحديث الواجهة Dispatcher / Invoke على جانب الواجهة
تشغيل إجراء شاشة لمرّة واحدة دالّة async عاديّة
التحكّم بـ lifecycle البدء بصرامة أكبر تأمّل في IHostedLifecycleService

10. الخلاصة

السبب لإدخال Generic Host / BackgroundService إلى تطبيق سطح المكتب ليس “لأنّنا نريد أن نكتب كوداً يبدو كأنّه كود ويب”.

ما يساعد فعلاً هو هذه الثلاثة:

  1. يمكن جمع مسؤوليّة البدء والإيقاف في مكان واحد
  2. يصبح lifetime العمل طويل العمر جزءاً من التصميم
  3. يمكن معاملة الـ graceful shutdown من نقطة الدخول بدلاً من إضافته لاحقاً

قد تبدأ أدوات Windows والتطبيقات المقيمة صغيرةً، لكنّ المراقبة والمزامنة ومنطق إعادة الاتّصال والطوابير والـ logging والإعدادات تميل إلى الازدياد تدريجيّاً. حين تُشغَّل تلك بوصفها أثراً جانبيّاً لكود الواجهة، يصبح المشروع مؤلماً بهدوء لاحقاً.

في المقابل، التقسيم التالي يُنظّف الكثير سلفاً:

  • الواجهة هي الواجهة
  • العمل المقيم هو hosted services
  • منطق الأعمال الفعليّ يعيش في خدمات DI
  • يجري الإيقاف عبر StopAsync و CancellationToken

ليس هذا تصميماً مبهرجاً. لكن هذا النوع من التصميم الهادئ يعمل جيّداً جدّاً عمليّاً. يقلّل من اللزوجة المزعجة وراء مشكلات مثل:

“يتصرّف الإيقاف بشكل غريب أحياناً”
“لا أحد يعرف أين توقَّف العمليّة فعلاً”

إن كنتَ عالقاً في معالجة خلفيّة لتطبيق سطح المكتب، أو تصميم البدء / الإيقاف، أو حلقات المراقبة، أو تصميم lifetime لـ COM / sockets / مراقبة الملفّات، أو التحقّق من عيوب الإيقاف، فلا تتردّد في التواصل مع شركة كومورا سوفت ذ.م.م. لمراجعة التصميم أو تحديد الاتّجاه.

11. مراجع

مقالات ذات صلة

أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.

أين يتصل هذا الموضوع

ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.

العودة إلى المدونة