把 Generic Host / BackgroundService 帶進桌面應用程式的理由 - 啟動・壽命・graceful shutdown 的整理會輕鬆很多

· · C#, .NET, Generic Host, BackgroundService, WPF, WinForms, Windows 開發, 設計

把 Windows 工具或常駐系應用程式稍微養大,UI 外側的處理會逐漸增加。 定期輪詢、檔案監視、重連、佇列處理、啟動時初始化、終止時 flush。 最初用 Form_LoadOnStartupTask.Run 可以撐,但那樣養下去,會變得誰開始、誰停止、誰看例外都曖昧。

async / await 寫法本身更早,該決定處理的壽命由誰持有的場面。 這時有效的是 .NET 的 Generic Host 與 BackgroundService

UI 執行緒側的 async / await 請參考 以一頁整理 WPF / WinForms 的 async/await 與 UI 執行緒 - await 後的回歸處、Dispatcher、ConfigureAwait、.Result / .Wait() 的卡點C# async/await 的最佳實踐 - Task.Run 與 ConfigureAwait 的判斷表 這些相連的話題。 本文限定在那個更外側的「整個應用程式的啟動與停止」的整理。

在實務上慢慢腐爛的大致是這一帶。

  • 表單或 ViewModel 到處長出 Task.Run
  • 常駐迴圈的停止條件用 bool 旗標散落
  • 終止時還在動作的處理還在,偶爾關不掉
  • 日誌 / 設定 / DI 的入口依技術分散
  • 想用 Environment.Exit 收尾,finally 飛掉

本文以 .NET 6 以後的 WPF / WinForms / 常駐系 Windows 應用程式為前提,整理為何 Generic Host / BackgroundService 樸素但有效,帶進多少划算,在哪粗略做會變泥沼。

先對齊用語

這類話題,名詞的意思含糊會突然難讀。 所以本文使用的用語先大致固定。

  • Generic Host
    • 統一照料 .NET 應用程式「啟動」「相依」「設定」「日誌」「停止」的基礎。
    • 不是 ASP.NET Core 專屬,主控台、worker、桌面應用程式也能用。
  • Host / IHost
    • build 後的實體。
    • StartAsync 啟動,用 StopAsync 停止。
  • Hosted Service
    • 掛在 host 壽命上被開始・停止的常駐處理。
    • 實作 IHostedService,或通常繼承 BackgroundService 寫。
  • BackgroundService
    • IHostedService 的易寫實作輔助。
    • 能把長時間跑的本體寫在 ExecuteAsync,監視迴圈或定期處理較易整理。
  • lifetime
    • 本文指「該處理何時開始、何時結束、誰負責停止」。
    • 不只是生存時間,包含開始職責與停止職責的壽命管理。
  • graceful shutdown
    • 不是強制終止,而是放出停止訊號,盡量整好進行中的處理後結束。
    • 例如「不開始下個週期」「決定佇列要流到哪」「等 close 或 flush」都在這裡。
  • DI
    • Dependency Injection 的縮寫,相依物件的組裝不直接寫在呼叫端,透過容器接收的方式。
    • 本文中以「不要對 logger 或設定或 reader 開 new 大會,在入口一次構成」的理解就夠。

簡言之,本文 不只是「方便的 BackgroundService 類別介紹」,而是 把整個應用程式的啟動與停止集中到 host,把常駐處理的 lifetime 當作設計來持有的話題,這樣讀比較容易跟。

1. 先講結論(一句話)

  • Generic Host 在桌面應用程式中也是 啟動與 lifetime 管理的基礎 相當有力。
  • BackgroundService 是把「長期生存的處理」從 Task.Run 拋出去變成 受管理的壽命 的容器。
  • 實務上最有效的是能把 開始職責 / 停止職責 / 例外監視 / 日誌 / DI / 設定 集中到一處設計。
  • StartAsync 做短,長時間跑的本體在 ExecuteAsync,終止的善後在 StopAsync 分開,會相當好讀。
  • 常駐應用程式、托盤應用程式、裝置監視、定期同步、有順序的後處理、重連迴圈特別合適。
  • 反之,按一次按鈕只跑一次的處理全都做成 BackgroundService,會稍微誇張。
  • StopAsync 方便,但 不是行程崩潰或強制終止的保險。不要把善後過度放那裡也很重要。

簡言之,桌面應用程式中 Generic Host / BackgroundService 有效不是因為「有背景處理」,而是「不想把那個背景處理的壽命當 UI 的附帶,而是當設計持有」。

2. 先用一頁整理

2.1. 整體圖

先用這張圖看話題會很快。

flowchart LR
    A["桌面應用程式啟動<br/>(WPF / WinForms)"] --> B["Host 做 Build / StartAsync"]
    B --> C["DI / Logging / Configuration 準備"]
    B --> D["HostedService.StartAsync"]
    D --> E["BackgroundService.ExecuteAsync"]
    E --> F["PeriodicTimer / Queue / 重連 / 監視迴圈"]

    C --> G["顯示 MainWindow / MainForm"]
    F --> H["狀態更新 / 日誌 / 外部 I/O"]
    H --> I["UI 只在必要處用 Dispatcher / Invoke"]

    J["使用者終止 / Fatal error / StopApplication"] --> K["IHost.StopAsync"]
    K --> L["通知 CancellationToken"]
    L --> M["HostedService.StopAsync"]
    M --> N["關閉連線 / flush / graceful shutdown"]

UI 應用程式常見的是 Program.cs / App.xaml.cs / Form_Load / Closing / Task.Run / Timer / static singleton 上職責逐步散落的形式。

放入 Host 就能大致分擔如下。

  • UI:畫面、輸入、顯示
  • HostedService / BackgroundService:常駐處理、監視、佇列處理、定期處理
  • DI 服務:實際的業務邏輯、外部連線、設定、日誌

光能這樣切,審查的容易度就會大幅不同。

2.2. 放置位置的判斷表

想做的事 放置位置的第一候補 理由
啟動直後的輕量初始化 StartAsync 以參與啟動的短處理有明確意義
長期生存的監視 / 輪詢 / 重連 ExecuteAsync 易與服務壽命一起跑
終止時的停止通知 / flush / close StopAsync CancellationToken 搭配寫 graceful shutdown 容易
相依關係構成、設定、日誌 Host.CreateApplicationBuilder 能把入口集中到一處
畫面更新 UI 側 不從 worker 直接碰 UI,事故較少
每次按鈕按下的 1 次處理 一般 async 方法 常常不用做成 HostedService
有順序的背景後處理 Channel<T> + BackgroundService 比拋出去易管理壽命和上限

放入 Host 的價值不是能讓某事「非同步」,是讓 該放哪的判斷變明確

3. 為何在桌面應用程式有效

3.1. 容易分開 UI 與常駐處理的職責

桌面應用程式看起來 UI 是主角,但實務上變重的通常是 UI 外側。

例如:

  • 每 10 秒的狀態同步
  • 與裝置或伺服器的重連
  • 檔案監視與匯入
  • 佇列堆積的後處理
  • 日誌轉送或指標傳送
  • 啟動時的快取 warm-up

這些不是「畫面的事件」,是 掛在整個應用程式壽命上的處理

把這些放在表單或視窗的程式碼隱藏中,關閉畫面時停止的責任、抓例外的責任、決定重試或 backoff 的責任都會與 UI 方便攪在一起。

BackgroundService,「這個處理在應用程式動作期間一直活著」這個宣告會以程式碼形式出現。 這個樸素地強。

3.2. 能把啟動・停止・例外的入口集中到一處

不用 Host 的 desktop app,把 ServiceCollectionConfigurationBuilderLoggerFactory 個別排也能做類似的事。

但那個形式通常會一點點散。

  • DI 在 Program.cs
  • 設定是獨自 static
  • 日誌是別的 factory
  • 終止處理是 ApplicationExit
  • 常駐處理是 Task.Run

這狀態最初能動。 但幾個月後回看,誰持有應用程式的壽命 變難看見。

用 Generic Host,

  • 服務註冊
  • 構成讀取
  • 日誌構成
  • hosted service 的啟動
  • 停止通知
  • IHostApplicationLifetime 做整體停止

都進同一個框架。

也就是說「這個應用程式怎麼啟動、怎麼停止」的入口容易集中到一處。 常駐系應用程式中這之後會有效。

3.3. 容易把 graceful shutdown 放入設計

常駐處理,停止比開始難。 真的。開始 3 行,結束有泥土味。

例如終止時:

  • 想取消進行中的 I/O
  • 想不要開始下個週期
  • 想決定佇列殘件流到哪
  • 想關閉 socket 或 COM 物件
  • 想等待日誌 flush 或狀態儲存

把這些放在 FormClosing 會與畫面方便混在一起變痛苦。

Host / BackgroundServiceCancellationTokenStopAsync, 「停止用的路徑」從一開始就有。

當然不是魔法。 崩潰或 kill 時 StopAsync 可能不會被呼叫。 但有「正常終止時以這個路線停止」的設計,就會安靜許多。

3.4. DI / 日誌 / 設定從一開始就齊備

Generic Host 的好處不只 BackgroundService

  • Host.CreateApplicationBuilder 備好 DI / 構成 / 日誌的基礎
  • 容易直接用 appsettings.json 或環境變數
  • ILogger<T> 在 UI 與 worker 都能以同樣流派使用
  • 必要時可用 IOptions<T> 系統整合設定

特別在 Windows 工具案件中, 「最初小所以用 static 粗略持有設定或 logger,之後很痛苦」 的情況相當多。

這裡從一開始就放在 host, 應用程式稍微長胖時的喘不過氣會減少。

4. 適合的情況

Generic Host / BackgroundService 特別容易有效的例如以下。

  • 托盤常駐應用程式 有定期同步、監視、通知、重連
  • 裝置 / 攝影機 / socket 連線應用程式 有連線維持、監視、重試、狀態取得
  • 檔案連動工具 有監視、匯入佇列、有順序的處理
  • 預防公司內部工具肥大化 最初小但設定・日誌・外部 I/O 可能會增
  • 終止品質重要的應用程式 不想在關閉時留下半途的狀態

反之以下情況不一定要立刻放入 host。

  • 單次啟動只處理一次結束的小工具
  • 幾乎沒背景處理、只靠 UI 事件完結的畫面
  • 相依或設定幾乎不增的真正小型公司內部輔助工具

也就是說,Host 不是「必需」。 但 常駐處理看到 2 個以上時,可以很積極地檢討。 之後收拾 Task.Run 殖民地會更便宜。

5. 最小構成例(WPF 例)

以 WPF 啟動 host、每 5 秒讀取外部狀態的 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);
    }
}

這形式重點有 3 個。

  1. 在 UI 顯示前啟動 host
  2. 終止時明確 await StopAsync
  3. 把 DI / hosted service / shutdown timeout 集中到入口

OnExit 是 async 因 UI 框架的方便要稍微費神,但清楚寫「終止時停止 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
  • 例外記錄
  • 需要 scoped 相依時每次切 scope

做成這個形式, 「現在這個常駐處理在哪開始、在哪停止、在哪看失敗」 會變很好讀。

5.3. 狀態共享不要直接接 UI

從 worker 直接碰 UI 物件,結果會在那邊再次發生 UI 執行緒問題。

所以先:

  • worker 更新 狀態儲存或訊息層
  • UI 在 自己的 context 讀取 / 反映該狀態

這樣分離比較安全。

例如 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");
}

需要即時通知 UI 的話用 Dispatcher / BeginInvoke / 事件 / messenger 等。 但 那個責任在 UI 邊界持有 比較不會混。

6. StartAsync / ExecuteAsync / StopAsync 的分法

這 3 個混在一起,讀者的腦袋馬上混濁。 先以下分法相當穩定。

6.1. StartAsync

StartAsync 是放 參與啟動的短處理 的地方。

適合的:

  • 啟動日誌
  • 輕量訂閱開始
  • 立刻結束的初始狀態準備
  • base.StartAsync 前後最小的整序

不適合的:

  • 花幾十秒的 warm-up
  • 無限迴圈
  • 排很多重 I/O 的本體處理

StartAsync 做重會讓整個應用程式的啟動看起來遲鈍。 這裡想成「寫開始訊號」的地方事故少。

6.2. ExecuteAsync

ExecuteAsync服務壽命的本體

適合的:

  • 輪詢
  • 監視迴圈
  • 重連迴圈
  • Channel<T> 的消費者
  • 週期處理
  • 「活到停止」的處理全般

要點有 3 個。

  1. CancellationToken 從頭到尾傳下去
  2. 不要讓例外沉默殺整個迴圈
  3. 不要臨時增加重試或 backoff 過多

BackgroundService 方便,但放著會成為「什麼都吞的巨大迴圈」。 實處理切到別服務,ExecuteAsync 本身靠向 壽命管理和協作 比較好讀。

6.3. StopAsync

StopAsync正常終止時整理 的地方。

適合的:

  • 停止日誌
  • 計時器 / 訂閱 / 監視的解除
  • 明確要 close / flush 的資源整理
  • 透過 base.StopAsync 的終止等待

但別太指望 StopAsync

  • 行程掉了
  • 強制終止
  • 被 OS kill

這類終止可能不通過。

所以,

  • 永續化盡量在平常時小量完成
  • 不要做「只在終止時才一致」的設計
  • cleanup 做成冪等

這一帶重要。 只靠終止時救世界通常會混濁。

6.4. .NET 10 以後的注意

2025 年以後變更點,.NET 10 中 BackgroundService.ExecuteAsync 的全部都會作為背景任務執行。

之前,第一個 await 前的同步部分會阻塞其他服務啟動,是稍微難懂的行為。 此變更讓「ExecuteAsync 最初幾行讓啟動變重」的事故減少。

但設計上仍

  • 參與啟動的短處理 → StartAsync
  • 長時間跑的本體 → ExecuteAsync

分開比較好讀。

想更嚴格控制啟動時機的話,IHostedLifecycleService 也進入視野。 這一帶是常駐應用程式長大後樸素有效的論點。

7. 常見的反面模式

7.1. 在 Window_Loaded / Form_Shown 開始無限迴圈

最初輕鬆。 但停止職責與例外職責緊貼 UI 側。

「畫面關閉就停」 「最小化到 tray 時不停」 「設定變更時重啟」 這類條件增加後馬上痛苦。

7.2. 把 Task.Run 拋著不管

Task.Run 本身不壞。 壞的是 壽命和例外沒主人

特別用 Task.Run(async () => { while (...) { ... } }) 啟動常駐處理時,

  • 何時結束
  • 誰等待
  • 例外怎麼看
  • 終止時等到哪

都會曖昧。

這個放到 BackgroundService 上就會相當容易整理。

7.3. 從 BackgroundService 直接碰 UI

這是地雷。 UI 執行緒問題和 lifetime 問題一起混在。

worker 不直接弄 UI,用

  • 狀態
  • 事件
  • 訊息
  • queue

其中之一置邊界比較安全。

7.4. 只把重要儲存處理放到 StopAsync

StopAsync 可幫正常終止,但不是最後審判。

只終止時儲存、 只終止時 flush、 只終止時一致性才對,

這種設計會因崩潰崩壞。

7.5. 用了 host 卻用 Environment.Exit 粗略結束

這也常見。

「麻煩就結束它吧」 呼 Environment.Exit, 會自己切掉 host 持有的 graceful shutdown 路徑。

想因致命錯誤整體終止, 先用 IHostApplicationLifetime.StopApplication(), 通過 停止的正規路線 比較自然。

8. 審查時的檢核表

審查用 Generic Host / BackgroundService 的 desktop app 時,依序看以下比較好懂。

  • 該處理是 掛在應用程式壽命上的處理 還是只是 UI 事件處理
  • 啟動職責是否適當分到 StartAsync / ExecuteAsync / StopAsync
  • StartAsync 是否變太重
  • ExecuteAsync 是否把 CancellationToken 傳到最後
  • 是否從 hosted service 直接握 scoped 相依
  • worker 是否直接碰 UI 物件
  • 例外是否被沉默壓住
  • 重試迴圈是否變無限高頻
  • 終止時的等待時間有沒有上限
  • 是否混入以 Environment.Exit 或行程 kill 為前提的終止

用這檢核表看, 「總之放了 Host」 和 「能以設計整理壽命」 的差異會相當明顯。

9. 大致的區分使用

想做的事 先選的
對齊整個應用程式的 DI / 日誌 / 設定 Host.CreateApplicationBuilder
讓常駐迴圈動作 BackgroundService
一定間隔轉 PeriodicTimer + BackgroundService
讓有順序的後處理流動 Channel<T> + BackgroundService
使用 scoped service IServiceScopeFactory.CreateScope()
通知正常終止給全體 IHostApplicationLifetime.StopApplication()
UI 更新 UI 側用 Dispatcher / Invoke
一次性畫面操作 一般 async 方法
啟動時的嚴格生命週期控制 檢討 IHostedLifecycleService

10. 總結

把 Generic Host / BackgroundService 帶進桌面應用程式的理由, 不是「想用 Web 風的寫法」。

真正有效的是下列 3 個。

  1. 能把啟動與停止的職責集中到一處
  2. 能把長期生存處理的壽命當作設計持有
  3. 能把 graceful shutdown 從入口就處理,不是事後補

Windows 工具或常駐系應用程式,最初雖小, 但監視、同步、重連、佇列、日誌、設定會一點點增加。 那時用 UI 程式碼附帶運營,之後會靜靜地變痛苦。

反之,

  • UI 是 UI
  • 常駐處理是 hosted service
  • 實處理是 DI 服務
  • 終止用 StopAsyncCancellationToken

這樣分光這樣就整齊很多。

沒有華麗感。 但這類樸素設計在實務上確實有效。 能減少 「關閉時偶爾會怪怪的」 「不知道在哪停止」 這類討厭的黏稠感。

在 Windows 工具或常駐系應用程式中,若在 BackgroundService 化、啟動 / 停止設計、監視迴圈、COM / socket / 檔案監視的壽命整理、終止時缺陷的辨別上卡住,歡迎從設計審查或方針整理諮詢。

11. 參考資料

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽