在桌面應用程式使用 .NET Generic Host 與 BackgroundService 的理由

· 更新日期: · · C#, .NET, Generic Host, BackgroundService, WPF, WinForms, Windows 開發, 設計

更新紀錄(2 筆,最後更新 2026年09月04日)

本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。

已將繁體中文版改寫為日文原文的完整翻譯。先前的繁體中文版只譯出日文原文的一部分,遺漏了章節、表格、Mermaid 圖、圖說與 FAQ。本次依日文原文將這些內容全部補回,並新增本文的知識地圖章節。技術主張與日文版一致。 查看更新前的版本 (DOI: 10.5281/zenodo.22279357)
補上了日文原文中已有的諮詢引導(consultation_services)。內文沒有改動。 查看更新前的版本 (DOI: 10.5281/zenodo.21616264)
初次發布
引用本文(DOI: 10.5281/zenodo.21616263)

本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。

Go Komura(2026)。〈在桌面應用程式使用 .NET Generic Host 與 BackgroundService 的理由〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616263 https://comcomponent.com/zh-TW/blog/2026/03/12/002-generic-host-backgroundservice-desktop-app/

DOI(最新版本)
10.5281/zenodo.21616263
DOI(此版本)
10.5281/zenodo.22297095

把 Windows 工具或常駐型應用程式養大一點,UI 外側的處理就會慢慢變多。 定期輪詢、檔案監控、重新連線、佇列處理、啟動時初始化、終止時 flush。 一開始靠 Form_Load、OnStartup 或 Task.Run 還能撐過去,但就這樣長大下去,誰負責開始、誰負責停止、誰負責看例外,全都會變得模糊。

比起 async / await 本身的寫法,這種場合更該先決定處理的生命週期由誰持有。 這時派得上用場的,就是 .NET 的 Generic Host 與 BackgroundService。

UI 執行緒這一側的 async / await,和 以一頁整理 WPF / WinForms 的 async 與 UI 執行緒 以及 C# async/await 實務判斷表 - Task.Run 與 ConfigureAwait 是相連的話題。 本文只聚焦在更外側的「整個應用程式的啟動與停止」該怎麼梳理。

實務上最容易慢慢亂掉的,大致是這幾處。

  • 表單或 ViewModel 的各個角落都長出 Task.Run
  • 常駐迴圈的停止條件用 bool 旗標四處散落
  • 終止時還有處理在跑,偶爾關不乾淨
  • 日誌 / 設定 / DI 的入口依技術各自分開
  • 想用 Environment.Exit 收尾,結果 finally 被跳過

本文以 .NET 6 以後的 WPF / WinForms / 常駐型 Windows 應用程式為前提, 梳理 Generic Host 與 BackgroundService 為什麼不起眼卻很有用、 帶進來到什麼程度才划算、 在哪裡草率處理會在之後反過來咬你一口。

本文預設的讀者,重點不在於是否知道 BackgroundService,而是還在猶豫常駐處理該放哪裡、生命週期該由誰持有的階段。第一次看到這些名詞的人也讀得下去,因為下一章會先把用語固定下來;已經在用的人,從 2.2 的判斷表和第 6 章的劃分方式開始讀也接得上。

逐漸亂掉的樣子到處長出的Task.Run、用bool旗標散落的停止條件、終止時關不乾淨的處理、依技術各自分開的入口,這些亂法最後都歸結到同一點:誰負責開始、誰負責停止、誰負責看例外並不明確的圖。散落的Task.Run生命週期的持有者不明確bool旗標的停止條件關不乾淨的終止處理依技術各自分開的入口先決定生命週期由誰持有

圖 1: 亂法各式各樣,但根源都是沒有決定「處理的生命週期由誰持有」。

另外,本文出現的程式碼已經整理成可以建置、執行的完整範例(函式庫、從啟動走到 graceful shutdown 的主控台示範、單元測試),公開在 GitHub 上。

generic-host-backgroundservice-desktop-app - komurasoft-blog-samples (GitHub)

先把用語對齊

這類話題如果詞彙的意思一直含糊,讀起來會突然變吃力。 所以先把本文使用的說法大致固定下來。

  • 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 的縮寫,是不把相依物件的組裝寫死在呼叫端,而是透過容器接收的做法。
    • 在本文,理解成「不要到處 new 出 logger、設定或 reader,而是在入口統一組態」的程度就夠了。

這段話不只是「介紹 BackgroundService 這個方便的類別」, 把它讀成 把整個應用程式的啟動與停止集中到 host,並把常駐處理的 lifetime 當成設計來持有 這樣的主題,會比較容易跟上。

本文使用的詞彙之間的關係Generic Host是啟動、相依性、設定、日誌與停止的基礎,build出來的實體是IHost,掛在它的生命週期上的是Hosted Service,而BackgroundService是它好寫的實作輔助,呈現這種關係的圖。Generic Host〔基礎〕IHost〔build出來的實體〕Hosted ServiceBackgroundService長時間執行的本體寫在ExecuteAsynclifetime = 開始職責與停止職責

圖 2: 用語的層次。Hosted Service 掛在 host 的生命週期上,BackgroundService 是它的實作輔助。

圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 17 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle

目錄

  1. 先講結論(一句話)
  2. 先用一頁整理
    • 2.1. 全貌
    • 2.2. 放置位置的判斷表
  3. 為什麼在桌面應用程式特別有用
    • 3.1. 容易劃分 UI 與常駐處理的職責
    • 3.2. 啟動、停止、例外的入口可以收在一處
    • 3.3. 容易把 graceful shutdown 放進設計裡
    • 3.4. DI / 日誌 / 設定一開始就齊備
  4. 合適的情境
  5. 最小組成範例(WPF 範例)
  6. StartAsync / ExecuteAsync / StopAsync 的劃分方式
    • 6.1. StartAsync
    • 6.2. ExecuteAsync
    • 6.3. StopAsync
    • 6.4. .NET 10 以後的注意事項
  7. 常見的反模式
  8. 審查時的檢查清單
  9. 大致的取捨
  10. 總結
  11. 參考資料

1. 先講結論(一句話)

  • Generic Host 在桌面應用程式裡,作為啟動與 lifetime 管理的基礎也相當有力。
  • BackgroundService 是把「長期存活的處理」從 fire-and-forget 的 Task.Run 移到受管理的生命週期上的容器。
  • 實務上最有用的一點,是能把開始職責 / 停止職責 / 例外監控 / 日誌 / DI / 設定收攏到同一處的設計裡。
  • StartAsync 寫短,長時間執行的本體放 ExecuteAsync,終止時的善後放 StopAsync,讀起來會清楚非常多。
  • 常駐應用程式、系統匣應用程式、設備監控、定期同步、有順序的後處理、重新連線迴圈特別合用。
  • 反過來說,連只有按下按鈕才跑一次的處理都做成 BackgroundService,就顯得有點誇張。
  • StopAsync 很方便,但它不是處理程序當掉或強制終止時的保險。不要把善後過度集中在那裡,這點也很重要。

說到底,Generic Host 與 BackgroundService 在桌面應用程式裡管用, 與其說是「因為有背景處理」, 不如說是「因為想把那些背景處理的生命週期,當成設計來持有,而不是順手掛在 UI 上」。

會收攏到同一處設計的東西開始職責、停止職責、例外監控、日誌與DI與設定這些容易散落的要素能收攏到同一處的設計裡,這是實務上最有用的一點的圖。開始職責收攏到同一處的設計停止職責例外監控日誌、DI、設定放到受管理的生命週期上

圖 3: BackgroundService 的價值,在於容易散落的職責集中到同一處的設計裡。

2. 先用一頁整理

2.1. 全貌

先看這張圖,話會快很多。

桌面應用程式啟動(WPF / WinForms)Build Host / StartAsync準備 DI / Logging / ConfigurationHostedService.StartAsyncBackgroundService.ExecuteAsyncPeriodicTimer / Queue / 重新連線 / 監控迴圈顯示 MainWindow / MainForm狀態更新 / 日誌 / 外部I/OUI 只在必要處使用 Dispatcher / Invoke使用者結束 / Fatal error / StopApplicationIHost.StopAsyncCancellationToken 通知HostedService.StopAsync連線 close / flush / graceful shutdown

圖 4: 從 host 啟動、UI 顯示、常駐迴圈、停止通知,一直到 graceful shutdown 的全貌。

為了圖無法顯示的環境,同樣的流程也用文字列一次。

  1. 應用程式啟動(WPF 是 App.OnStartup,WinForms 是 Main)
  2. 用 Host.CreateApplicationBuilder 註冊服務後 Build
  3. 用 IHost.StartAsync 啟動 host(DI / 日誌 / 組態在這裡定案)
  4. 呼叫已註冊的 HostedService.StartAsync
  5. BackgroundService.ExecuteAsync 開始跑(監控迴圈、PeriodicTimer、佇列處理等本體)
  6. 顯示 UI(MainWindow / MainForm)。worker 更新狀態儲存區與日誌,UI 在自己的執行內容中讀取它們
  7. 使用者的結束操作或致命錯誤會呼叫 IHostApplicationLifetime.StopApplication,進入 IHost.StopAsync
  8. 停止會以 CancellationToken(stoppingToken)的形式通知,ExecuteAsync 的迴圈跳出
  9. 在 HostedService.StopAsync 關閉連線、flush 日誌之後結束

UI 應用程式常見的情況,是職責一點一點散落到 Program.cs / App.xaml.cs / Form_Load / Closing / Task.Run / Timer / static singleton 之中。

導入 Host 之後,大致可以變成下面的分工。

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

光是能這樣劃分,審查的難易度就差很多。

導入host之後的三種分工UI負責畫面、輸入與顯示,HostedService與BackgroundService負責常駐處理、監控、佇列處理與定期處理,DI服務負責業務邏輯、外部連線、設定與日誌,可以這樣分工的圖。桌面應用程式UI: 畫面、輸入、顯示HostedService: 常駐、監控DI服務: 業務邏輯佇列處理與定期處理也在這裡

圖 5: 從職責散落的形態,收攏成 UI、常駐處理、實際處理的三種分工。

2.2. 放置位置的判斷表

想做的事 放置位置的第一選擇 理由
啟動後立刻做的輕量初始化 StartAsync 作為參與啟動的短處理,意義明確
長期存活的監控 / 輪詢 / 重新連線 ExecuteAsync 容易跟著服務的生命週期一起執行
終止時的停止通知 / flush / close StopAsync 搭配 CancellationToken 容易寫出 graceful shutdown
相依性的組態、設定、日誌 Host.CreateApplicationBuilder 入口能收攏到一處
畫面更新 UI 這一側 不從 worker 直接碰 UI,出事的機會比較少
每次按下按鈕的單次處理 一般的 async 方法 多半不必做成 HostedService
有順序的背景後處理 Channel<T> + BackgroundService 比 fire-and-forget 更容易管理生命週期與上限

導入 Host 的價值,比起讓某件事「可以非同步」, 更在於該放在哪裡的判斷會變得明確。

3. 為什麼在桌面應用程式特別有用

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

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

例如:

  • 每 10 秒一次的狀態同步
  • 與設備或伺服器的重新連線
  • 檔案監控與匯入
  • 堆在佇列裡的後處理
  • 日誌轉送與指標傳送
  • 啟動時的快取 warm-up

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

如果把這一塊放到表單或視窗的程式碼後置檔裡, 關閉畫面時要停止的責任、 攔下例外的責任、 決定重試與 backoff 的責任, 就會開始跟 UI 的狀況混在一起。

用了 BackgroundService, 「這段處理會在應用程式運作期間一直存活」 這個宣告就會直接體現在程式碼的形狀上。 這一點不起眼卻很有力。

常駐處理放在哪裡的對比把常駐處理放在程式碼後置檔裡,停止責任、例外責任與重試的判斷會跟UI的狀況混在一起,但放到BackgroundService上,掛在應用程式生命週期上的宣告會體現在程式碼的形狀上的圖。程式碼後置檔BackgroundService常駐處理要放在哪裡停止、例外、重試與UI混在一起一直存活的宣告體現在形狀上

圖 6: 同樣的處理,放置的位置不同,職責混雜的程度就完全不同。

3.2. 啟動、停止、例外的入口可以收在一處

不使用 Host 的 desktop app,只要分別擺出 ServiceCollection、ConfigurationBuilder、LoggerFactory,也能做到類似的事。

只是那種形態大多會一點一點散開。

  • DI 在 Program.cs
  • 設定用自訂的 static
  • 日誌用另一個 factory
  • 終止處理在 ApplicationExit
  • 常駐處理用 Task.Run

這種狀態一開始也會動。 但幾個月後回頭看,誰持有應用程式的生命週期就變得難以看清。

用了 Generic Host,

  • 服務註冊
  • 組態讀取
  • 日誌組態
  • hosted service 的啟動
  • 停止通知
  • 透過 IHostApplicationLifetime 停止整體

都會進入同一個框架。

也就是說,「這個應用程式怎麼啟動、怎麼停止」的入口,容易收攏到一處。 在常駐型應用程式上,這一點之後才會顯現。

把散開的入口集中到host從DI、設定、日誌、終止處理與常駐處理各自散開的形態,變成服務註冊、組態讀取、日誌組態、hosted service的啟動、停止通知與整體停止都進入同一個框架的形態的圖。入口依技術散開誰持有生命週期變得不明集中到Generic Host啟動與停止的入口收在一處註冊、組態、日誌、停止通知

圖 7: 各自擺出來也會動,但是否進入同一個框架,會在幾個月後顯現差別。

3.3. 容易把 graceful shutdown 放進設計裡

常駐處理是開始容易、停止困難。 開始三行就寫得完,終止要考慮的事卻一口氣變多。

例如終止時:

  • 想取消進行中的 I/O
  • 想讓下一個週期不要開始
  • 想決定佇列的剩餘項目要處理到哪裡
  • 想關閉通訊端或 COM 物件
  • 想等待日誌 flush 與狀態儲存

把這一帶塞進 FormClosing,就會跟畫面的狀況混在一起而變得難受。

用 Host / BackgroundService 的話,因為有 CancellationToken 與 StopAsync, 「用來停止的路徑」一開始就存在。

當然這不是魔法。 當掉或被 kill 時,StopAsync 也可能不會被呼叫。 即使如此,光是有「正常終止時走這條路線停止」的設計,情況就會安靜很多。

用來停止的路徑終止時需要取消進行中的I/O、停止下一個週期、決定佇列剩餘項目的處理方式、等待close與flush,而CancellationToken與StopAsync這條停止的路徑一開始就存在的圖。停止的信號CancellationToken通知不開始下一個週期取消進行中的I/O用StopAsync做close與flush當掉或kill時不會經過

圖 8: 常駐處理正因為停止比較難,一開始就有停止的路徑才顯得有用。

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

Generic Host 的好處不只有 BackgroundService。

  • 用 Host.CreateApplicationBuilder 就備齊 DI / 組態 / 日誌的基礎
  • appsettings.json 與環境變數容易直接使用
  • UI 與 worker 都能用同一套流派使用 ILogger<T>
  • 需要的話可以用 IOptions<T> 系列統一管理設定

特別是 Windows 工具的案子, 「一開始很小,所以隨手用 static 持有的設定或 logger,之後變得很難收拾」 這種情況相當常見。

一開始就把這裡放到 host 上, 應用程式稍微變胖時比較不會喘不過氣。

4. 合適的情境

Generic Host / BackgroundService 特別容易發揮作用的,是這些情境。

  • 系統匣常駐應用程式 有定期同步、監控、通知、重新連線
  • 設備 / 相機 / 通訊端連線應用程式 有連線維持、監控、重試、狀態取得
  • 檔案介接工具 有監控、匯入佇列、有順序的處理
  • 預防內部工具肥大化 一開始很小,但設定、日誌、外部 I/O 看起來會增加
  • 重視終止品質的應用程式 關閉時不想留下處理到一半的狀態

反過來,也有不必一開始就導入 host 的情境。

  • 啟動一次、處理一次就結束的小工具
  • 幾乎沒有背景處理,只靠 UI 事件就能完成的畫面
  • 相依性與設定幾乎不會增加、真的很小的內部輔助工具

Host 不是「必要」。 不過,只要看到 2 個以上的常駐處理,就可以相當積極地考慮導入。 這比之後再去收拾散落的 Task.Run 便宜得多。

是否導入host的判斷基準常駐處理看到2個以上就積極考慮導入,若是啟動一次就結束的小工具或只靠UI事件就能完成的畫面則不必一開始就導入,說明這個判斷基準的圖。是否是否看到2個以上的常駐處理積極考慮導入host不必一開始就導入比之後收拾Task.Run便宜

圖 9: host 不是必要的,但在常駐處理開始變多時導入,會比事後收拾便宜。

5. 最小組成範例(WPF 範例)

作為範例,寫一個在 WPF 啟動 host、每 5 秒讀取一次外部狀態的 BackgroundService 最小組成。 WinForms 也一樣,只是入口換成 Main / ApplicationContext,想法幾乎相同。

因為程式碼會分成 3 份出現,先把檔案組成列出來。

檔案 內容 刊載處
App.xaml.cs host 的建立、DI 註冊、StartAsync / StopAsync、MainWindow 的顯示 5.1
DevicePollingBackgroundService.cs 每 5 秒讀取一次狀態的常駐迴圈 5.2
StatusStore.cs worker 與 UI 共用的狀態。DeviceStatus 記錄也放在這裡 5.3
IDeviceStatusReader.cs / DeviceStatusReader.cs 實際從外部讀取狀態的處理 本文省略。GitHub 的範例裡有實作
MainWindow.xaml / MainWindow.xaml.cs 畫面。讀取 StatusStore 後顯示 本文省略。就是 WPF 一般的畫面程式碼

GitHub 的範例把這個組成做成可以在主控台執行的形式,除了 BackgroundService 與 StatusStore 的實作之外,還放了從啟動走到 graceful shutdown 的示範與單元測試。

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

ShutdownTimeout 是 IHost.StopAsync 等待終止處理的預設上限。預設值依版本而異,.NET 6 是 5 秒,.NET 7 以後是 30 秒。這裡寫 15 秒,是為了配合最慢的終止處理,自己決定上限。大致的參考值是「進行中的 I/O 逾時時間 + close / flush 所需的時間」再加上一點餘裕。太短會在 flush 途中被打斷,太長則看起來像「關不掉的應用程式」,所以不要用預設值就這樣帶過,先決定一次可以少出事。

ShutdownTimeout的決定方式等待終止處理的上限不要用預設值帶過,要配合最慢的終止處理自己決定。太短會在flush途中被打斷,太長則看起來像關不掉的應用程式的圖。掌握最慢的終止處理I/O逾時時間再加上flush的時間自己決定上限並設定太短: flush會被打斷太長: 看起來像關不掉的應用程式

圖 10: ShutdownTimeout 不要用預設值帶過,要從最慢的終止處理反推決定。

把 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

寫成這種形態, 「這個常駐處理現在從哪裡開始、在哪裡停止、在哪裡能看見失敗」 就會變得相當好讀。

受管理的while迴圈的形態用PeriodicTimer等待週期,scoped相依性每次切出scope取得後讀取狀態並更新儲存區,例外留在日誌後繼續迴圈,stoppingToken取消時跳出迴圈的圖。失敗stoppingTokenPeriodicTimer的週期等待切出scope取得相依性讀取狀態並更新儲存區例外留在日誌後繼續跳出迴圈結束

圖 11: ExecuteAsync 是「受管理的 while 迴圈」。週期、停止、例外、scope 的處理都在同一處讀得到。

5.3. 狀態共用不要直接接到 UI

從 worker 直接碰 UI 物件,結果就是在那裡再度引發 UI 執行緒問題。

所以,首先:

  • worker 更新狀態儲存區或訊息傳遞層
  • UI 在自己的執行內容中讀取該狀態並套用

這樣的分離比較安全。

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 邊界持有,比較不會混在一起。

狀態共用不直接接到UI的分離worker不直接碰UI物件,而是更新狀態儲存區或訊息傳遞層,UI在自己的執行內容中讀取該狀態並套用,立即通知的責任由UI邊界持有的圖。worker〔常駐迴圈〕更新狀態儲存區UI在自己的執行內容中讀取立即通知由UI邊界負責

圖 12: 在 worker 與 UI 之間放一層薄的共用層,可以防止 UI 執行緒問題再度發生。

6. StartAsync / ExecuteAsync / StopAsync 的劃分方式

這 3 個一旦混在一起,讀的人腦袋馬上就濁掉。 先照下面的方式劃分,會相當穩定。

6.1. StartAsync

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

適合的:

  • 啟動日誌
  • 輕量的訂閱開始
  • 馬上就結束的初始狀態準備
  • 在 base.StartAsync 前後做最小限度的排序

不適合的:

  • 要花好幾十秒的 warm-up
  • 無限迴圈
  • 排一堆重 I/O 的本體處理

把 StartAsync 寫重,整個應用程式的啟動看起來都會遲鈍。 把這裡當成寫「開始的信號」的地方,比較不會出事。

分辨什麼該放進StartAsync啟動日誌或輕量訂閱開始這種參與啟動的短處理適合放進StartAsync,但放進長時間warm-up、無限迴圈或重I/O,整個應用程式的啟動看起來就會遲鈍的圖。是否是否為參與啟動的短處理放進StartAsync移到ExecuteAsync等本體側太重會讓應用程式的啟動變鈍

圖 13: StartAsync 是寫「開始的信號」的地方,不是放重處理的地方。

6.2. ExecuteAsync

ExecuteAsync 是服務生命週期的本體。

適合的:

  • 輪詢
  • 監控迴圈
  • 重新連線迴圈
  • 讀取 Channel<T> 的消費端
  • 週期處理
  • 「活到停止為止」的處理全般

這裡的訣竅有 3 個。

  1. 把 CancellationToken 從頭傳到尾
  2. 不要讓整個迴圈因為例外而無聲地死掉
  3. 不要臨時起意地一直增加重試與 backoff

BackgroundService 很方便,但放著不管也會變成「什麼都吸進來的巨大迴圈」。 把實際處理切出到別的服務,讓 ExecuteAsync 本身偏向生命週期管理與編排,會比較好讀。

讓ExecuteAsync保持在本體角色的訣竅把CancellationToken從頭傳到尾、不要讓迴圈因例外而無聲地死掉、不要臨時起意增加重試與backoff這3個訣竅,並把實際處理切出到別的服務的圖。ExecuteAsync把token傳到最後不要無聲地死掉不要增加太多重試偏向生命週期管理

圖 14: 不讓 ExecuteAsync 變成巨大迴圈,把它保持為生命週期管理場所的 3 個訣竅。

6.3. StopAsync

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

適合的:

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

不過,不要對 StopAsync 期待過頭也很重要。

  • 處理程序當掉
  • 被強制終止
  • 被 OS kill

這類終止方式,本來就可能不會經過。

所以,

  • 永續化盡量在平常時就小量完成
  • 不要做出只有終止時才能取得一致的設計
  • cleanup 做成 idempotent

這幾點很重要。 想只靠終止時拯救世界,多半會變濁。

可以對StopAsync期待的範圍正常終止時可以用StopAsync整理,但處理程序當掉、強制終止或被OS kill時可能不會經過,所以永續化要在平常時小量完成,cleanup要做成idempotent的圖。正常終止當掉或kill是哪一種終止可以用StopAsync整理StopAsync可能不會經過靠平常時的永續化來準備cleanup做成idempotent

圖 15: StopAsync 是正常終止的幫手,不是異常終止的保險。

6.4. .NET 10 以後的注意事項

.NET 10(2025 年 11 月發行)的重大變更中,BackgroundService.ExecuteAsync 的行為改成整體都以背景工作執行。

以前有個不太好懂的行為:第一個 await 之前的同步部分,會在啟動時阻塞其他服務的開始。 有了這項變更,「ExecuteAsync 的前幾行讓啟動變重」這類事故比較不容易發生。 反過來說,如果目標是 .NET 9 以前,那還是尚未改變的那一側行為。請先查看自己的專案屬於哪一邊。

不過即使如此,設計上還是

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

這樣劃分比較好讀。

如果想更嚴格地控制啟動時機,可以把 IHostedLifecycleService 也納入視野。 這一帶是常駐應用程式變胖之後才會顯現的、不起眼的論點。

ExecuteAsync因版本而異的行為差異.NET 9以前,第一個await之前的同步部分可能阻塞其他服務的開始,.NET 10以後則是ExecuteAsync整體以背景工作執行,兩者都建議把啟動的短處理分到StartAsync的圖。.NET 9以前.NET 10以後目標的.NET是?await前的同步部分可能塞住啟動整體以背景執行啟動的短處理移到StartAsync

圖 16: 即使版本讓行為改變,把 StartAsync 與 ExecuteAsync 分開的設計並不會變。

7. 常見的反模式

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

一開始很輕鬆。 但停止職責與例外職責會牢牢黏在 UI 這一側。

「畫面關閉就停止」 「最小化 to tray 時不停止」 「設定變更時重新啟動」 這類條件一旦開始增加,馬上就會很難受。

7.2. 用 fire-and-forget 的方式丟出 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(), 走用來停止的正規路線比較直接了當。

終止整體的兩條路線用Environment.Exit砍掉會自己切掉host持有的graceful shutdown路徑,所以想因致命錯誤終止整體時,要用IHostApplicationLifetime.StopApplication走正規路線的圖。Environment.ExitStopApplication想因致命錯誤終止要用哪一種砍掉切掉graceful shutdown的路徑用來停止的正規路線一路走到StopAsync才結束

圖 17: 明明用了 host 卻用 Environment.Exit 砍掉,等於自己切掉自己準備的停止路徑。

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 服務
  • 終止交給 StopAsync 與 CancellationToken

光是這樣劃分,就會整齊很多。

總結的分工UI就是UI、常駐處理交給hosted service、實際處理交給DI服務、終止交給StopAsync與CancellationToken,光是這樣分工就會整齊很多的圖。整個應用程式UI就是UI常駐處理交給hosted service實際處理交給DI服務終止交給StopAsync與token

圖 18: 這種分工並不華麗,但要減少「關閉時偶爾會怪怪的」就靠這番整理。

沒有華麗之處。 不過這種不起眼的設計,在實務上確實很有用。 它能減少 「關閉時偶爾會怪怪的」 「不知道是在哪裡停止的」 這類討厭的黏膩感。

如果在 Windows 工具或常駐型應用程式上,卡在 BackgroundService 化、啟動 / 停止設計、監控迴圈、COM / 通訊端 / 檔案監控的生命週期整理、終止時缺陷的原因釐清等地方,歡迎從設計審查或方針梳理開始諮詢。

11. 參考資料

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

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

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

常見問題

整理諮詢這個主題時常見的問題。

Generic Host 在桌面應用程式也能用嗎?
能用。Generic Host 不是 ASP.NET Core 專屬的機制,在主控台、worker、WPF / WinForms 的桌面應用程式中,都能當成統一照料啟動、相依性、設定、日誌與停止的基礎。特別是常駐應用程式、系統匣應用程式、設備監控、定期同步、有順序的後處理與重新連線迴圈,都相當合用。
BackgroundService 是為了什麼而使用的?
它是把「長期存活的處理」從 fire-and-forget 的 Task.Run 移到受管理的生命週期上的容器。它是 IHostedService 好寫的實作輔助,可以把監控迴圈或定期處理的本體寫在 ExecuteAsync 裡。「這段處理會在應用程式運作期間一直存活」這個宣告會直接體現在程式碼的形狀上,開始職責、停止職責、例外監控、日誌、DI 與設定也能收攏到同一處的設計裡。
StartAsync / ExecuteAsync / StopAsync 該怎麼劃分?
把參與啟動的短初始化放在 StartAsync,長時間執行的本體放在 ExecuteAsync,終止時的停止通知與 flush、close 放在 StopAsync,讀起來會清楚很多。另一方面,只有按下按鈕時才跑一次的處理用一般的 async 方法就好,什麼都做成 BackgroundService 反而顯得誇張。
終止處理可以全部集中到 StopAsync 嗎?
不行。StopAsync 很方便,但它不是處理程序當掉或強制終止時的保險,所以不要把善後過度集中在上面。graceful shutdown(取消進行中的 I/O、停止下一個週期、決定佇列剩餘項目的處理方針、關閉連線、flush 日誌)要用 CancellationToken 與 StopAsync 來設計,同時另外準備一套即使異常終止也不會壞掉的前提。

作者檔案

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

Go Komura

小村軟體有限公司 代表

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

回到部落格一覽