在桌面應用程式使用 .NET Generic Host 與 BackgroundService 的理由
· 更新日期: · Go Komura · C#, .NET, Generic Host, BackgroundService, WPF, WinForms, Windows 開發, 設計
更新紀錄(2 筆,最後更新 2026年09月04日)
本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。
引用本文(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 章的劃分方式開始讀也接得上。
flowchart TB
accTitle: 逐漸亂掉的樣子
accDescr: 到處長出的Task.Run、用bool旗標散落的停止條件、終止時關不乾淨的處理、依技術各自分開的入口,這些亂法最後都歸結到同一點:誰負責開始、誰負責停止、誰負責看例外並不明確的圖。
s1["散落的Task.Run"] --> core["生命週期的持有者不明確"]
s2["bool旗標的停止條件"] --> core
s3["關不乾淨的終止處理"] --> core
s4["依技術各自分開的入口"] --> core
core --> fix["先決定生命週期由誰持有"]
圖 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來寫。
BackgroundServiceIHostedService好寫的實作輔助。- 長時間執行的本體可以寫在
ExecuteAsync裡,監控迴圈與定期處理比較好整理。
- lifetime
- 本文用它表示「這段處理何時開始、何時結束、由誰負責停止」。
- 與其說是單純的存活時間,不如說是包含開始職責與停止職責的生命週期管理。
- graceful shutdown
- 不是強制終止,而是先發出停止的信號,盡量把進行中的處理整理好之後再結束。
- 例如「不再開始下一個週期」「決定佇列要處理到哪裡」「等待 close 與 flush」都屬於這裡。
- DI
- Dependency Injection 的縮寫,是不把相依物件的組裝寫死在呼叫端,而是透過容器接收的做法。
- 在本文,理解成「不要到處 new 出 logger、設定或 reader,而是在入口統一組態」的程度就夠了。
這段話不只是「介紹 BackgroundService 這個方便的類別」,
把它讀成
把整個應用程式的啟動與停止集中到 host,並把常駐處理的 lifetime 當成設計來持有
這樣的主題,會比較容易跟上。
flowchart TB
accTitle: 本文使用的詞彙之間的關係
accDescr: Generic Host是啟動、相依性、設定、日誌與停止的基礎,build出來的實體是IHost,掛在它的生命週期上的是Hosted Service,而BackgroundService是它好寫的實作輔助,呈現這種關係的圖。
gh["Generic Host〔基礎〕"] --> ihost["IHost〔build出來的實體〕"]
ihost --> hs["Hosted Service"]
hs --> bs["BackgroundService"]
bs --> exec["長時間執行的本體寫在ExecuteAsync"]
hs -.-> lt["lifetime = 開始職責與停止職責"]
圖 2: 用語的層次。Hosted Service 掛在 host 的生命週期上,BackgroundService 是它的實作輔助。
圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 17 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle
目錄
- 先講結論(一句話)
- 先用一頁整理
- 2.1. 全貌
- 2.2. 放置位置的判斷表
- 為什麼在桌面應用程式特別有用
- 3.1. 容易劃分 UI 與常駐處理的職責
- 3.2. 啟動、停止、例外的入口可以收在一處
- 3.3. 容易把 graceful shutdown 放進設計裡
- 3.4. DI / 日誌 / 設定一開始就齊備
- 合適的情境
- 最小組成範例(WPF 範例)
StartAsync/ExecuteAsync/StopAsync的劃分方式- 6.1.
StartAsync - 6.2.
ExecuteAsync - 6.3.
StopAsync - 6.4. .NET 10 以後的注意事項
- 6.1.
- 常見的反模式
- 審查時的檢查清單
- 大致的取捨
- 總結
- 參考資料
1. 先講結論(一句話)
- Generic Host 在桌面應用程式裡,作為啟動與 lifetime 管理的基礎也相當有力。
BackgroundService是把「長期存活的處理」從 fire-and-forget 的Task.Run移到受管理的生命週期上的容器。- 實務上最有用的一點,是能把開始職責 / 停止職責 / 例外監控 / 日誌 / DI / 設定收攏到同一處的設計裡。
StartAsync寫短,長時間執行的本體放ExecuteAsync,終止時的善後放StopAsync,讀起來會清楚非常多。- 常駐應用程式、系統匣應用程式、設備監控、定期同步、有順序的後處理、重新連線迴圈特別合用。
- 反過來說,連只有按下按鈕才跑一次的處理都做成
BackgroundService,就顯得有點誇張。 StopAsync很方便,但它不是處理程序當掉或強制終止時的保險。不要把善後過度集中在那裡,這點也很重要。
說到底,Generic Host 與 BackgroundService 在桌面應用程式裡管用,
與其說是「因為有背景處理」,
不如說是「因為想把那些背景處理的生命週期,當成設計來持有,而不是順手掛在 UI 上」。
flowchart TB
accTitle: 會收攏到同一處設計的東西
accDescr: 開始職責、停止職責、例外監控、日誌與DI與設定這些容易散落的要素能收攏到同一處的設計裡,這是實務上最有用的一點的圖。
a["開始職責"] --> one["收攏到同一處的設計"]
b["停止職責"] --> one
c["例外監控"] --> one
d["日誌、DI、設定"] --> one
one --> win["放到受管理的生命週期上"]
圖 3: BackgroundService 的價值,在於容易散落的職責集中到同一處的設計裡。
2. 先用一頁整理
2.1. 全貌
先看這張圖,話會快很多。
flowchart LR
A["桌面應用程式啟動<br/>(WPF / WinForms)"] --> B["Build Host / 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["連線 close / flush / graceful shutdown"]
圖 4: 從 host 啟動、UI 顯示、常駐迴圈、停止通知,一直到 graceful shutdown 的全貌。
為了圖無法顯示的環境,同樣的流程也用文字列一次。
- 應用程式啟動(WPF 是
App.OnStartup,WinForms 是Main) - 用
Host.CreateApplicationBuilder註冊服務後Build - 用
IHost.StartAsync啟動 host(DI / 日誌 / 組態在這裡定案) - 呼叫已註冊的
HostedService.StartAsync BackgroundService.ExecuteAsync開始跑(監控迴圈、PeriodicTimer、佇列處理等本體)- 顯示 UI(
MainWindow/MainForm)。worker 更新狀態儲存區與日誌,UI 在自己的執行內容中讀取它們 - 使用者的結束操作或致命錯誤會呼叫
IHostApplicationLifetime.StopApplication,進入IHost.StopAsync - 停止會以
CancellationToken(stoppingToken)的形式通知,ExecuteAsync的迴圈跳出 - 在
HostedService.StopAsync關閉連線、flush 日誌之後結束
UI 應用程式常見的情況,是職責一點一點散落到 Program.cs / App.xaml.cs / Form_Load / Closing / Task.Run / Timer / static singleton 之中。
導入 Host 之後,大致可以變成下面的分工。
- UI: 畫面、輸入、顯示
- HostedService / BackgroundService: 常駐處理、監控、佇列處理、定期處理
- DI 服務: 實際的業務邏輯、外部連線、設定、日誌
光是能這樣劃分,審查的難易度就差很多。
flowchart TB
accTitle: 導入host之後的三種分工
accDescr: UI負責畫面、輸入與顯示,HostedService與BackgroundService負責常駐處理、監控、佇列處理與定期處理,DI服務負責業務邏輯、外部連線、設定與日誌,可以這樣分工的圖。
app["桌面應用程式"] --> ui["UI: 畫面、輸入、顯示"]
app --> hs["HostedService: 常駐、監控"]
app --> di["DI服務: 業務邏輯"]
hs -.-> note["佇列處理與定期處理也在這裡"]
圖 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,
「這段處理會在應用程式運作期間一直存活」
這個宣告就會直接體現在程式碼的形狀上。
這一點不起眼卻很有力。
flowchart TB
accTitle: 常駐處理放在哪裡的對比
accDescr: 把常駐處理放在程式碼後置檔裡,停止責任、例外責任與重試的判斷會跟UI的狀況混在一起,但放到BackgroundService上,掛在應用程式生命週期上的宣告會體現在程式碼的形狀上的圖。
q{"常駐處理要放在哪裡"}
q -->|"程式碼後置檔"| mix["停止、例外、重試與UI混在一起"]
q -->|"BackgroundService"| decl["一直存活的宣告體現在形狀上"]
圖 6: 同樣的處理,放置的位置不同,職責混雜的程度就完全不同。
3.2. 啟動、停止、例外的入口可以收在一處
不使用 Host 的 desktop app,只要分別擺出 ServiceCollection、ConfigurationBuilder、LoggerFactory,也能做到類似的事。
只是那種形態大多會一點一點散開。
- DI 在
Program.cs - 設定用自訂的 static
- 日誌用另一個 factory
- 終止處理在
ApplicationExit - 常駐處理用
Task.Run
這種狀態一開始也會動。 但幾個月後回頭看,誰持有應用程式的生命週期就變得難以看清。
用了 Generic Host,
- 服務註冊
- 組態讀取
- 日誌組態
- hosted service 的啟動
- 停止通知
- 透過
IHostApplicationLifetime停止整體
都會進入同一個框架。
也就是說,「這個應用程式怎麼啟動、怎麼停止」的入口,容易收攏到一處。 在常駐型應用程式上,這一點之後才會顯現。
flowchart TB
accTitle: 把散開的入口集中到host
accDescr: 從DI、設定、日誌、終止處理與常駐處理各自散開的形態,變成服務註冊、組態讀取、日誌組態、hosted service的啟動、停止通知與整體停止都進入同一個框架的形態的圖。
before["入口依技術散開"] --> pain["誰持有生命週期變得不明"]
host["集中到Generic Host"] --> one["啟動與停止的入口收在一處"]
one -.-> items["註冊、組態、日誌、停止通知"]
圖 7: 各自擺出來也會動,但是否進入同一個框架,會在幾個月後顯現差別。
3.3. 容易把 graceful shutdown 放進設計裡
常駐處理是開始容易、停止困難。 開始三行就寫得完,終止要考慮的事卻一口氣變多。
例如終止時:
- 想取消進行中的 I/O
- 想讓下一個週期不要開始
- 想決定佇列的剩餘項目要處理到哪裡
- 想關閉通訊端或 COM 物件
- 想等待日誌 flush 與狀態儲存
把這一帶塞進 FormClosing,就會跟畫面的狀況混在一起而變得難受。
用 Host / BackgroundService 的話,因為有 CancellationToken 與 StopAsync,
「用來停止的路徑」一開始就存在。
當然這不是魔法。
當掉或被 kill 時,StopAsync 也可能不會被呼叫。
即使如此,光是有「正常終止時走這條路線停止」的設計,情況就會安靜很多。
flowchart TB
accTitle: 用來停止的路徑
accDescr: 終止時需要取消進行中的I/O、停止下一個週期、決定佇列剩餘項目的處理方式、等待close與flush,而CancellationToken與StopAsync這條停止的路徑一開始就存在的圖。
stopreq["停止的信號"] --> token["CancellationToken通知"]
token --> loop["不開始下一個週期"]
token --> io["取消進行中的I/O"]
stopreq --> sa["用StopAsync做close與flush"]
sa -.-> limit["當掉或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 便宜得多。
flowchart TB
accTitle: 是否導入host的判斷基準
accDescr: 常駐處理看到2個以上就積極考慮導入,若是啟動一次就結束的小工具或只靠UI事件就能完成的畫面則不必一開始就導入,說明這個判斷基準的圖。
q{"是否看到2個以上的常駐處理"}
q -->|"是"| yes["積極考慮導入host"]
q -->|"否"| no["不必一開始就導入"]
yes -.-> why["比之後收拾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 個。
- 在顯示 UI 之前先啟動 host
- 終止時明確地 await
StopAsync - 在入口統一處理 DI / hosted service / shutdown timeout
ShutdownTimeout 是 IHost.StopAsync 等待終止處理的預設上限。預設值依版本而異,.NET 6 是 5 秒,.NET 7 以後是 30 秒。這裡寫 15 秒,是為了配合最慢的終止處理,自己決定上限。大致的參考值是「進行中的 I/O 逾時時間 + close / flush 所需的時間」再加上一點餘裕。太短會在 flush 途中被打斷,太長則看起來像「關不掉的應用程式」,所以不要用預設值就這樣帶過,先決定一次可以少出事。
flowchart TB
accTitle: ShutdownTimeout的決定方式
accDescr: 等待終止處理的上限不要用預設值帶過,要配合最慢的終止處理自己決定。太短會在flush途中被打斷,太長則看起來像關不掉的應用程式的圖。
base["掌握最慢的終止處理"] --> calc["I/O逾時時間再加上flush的時間"]
calc --> setv["自己決定上限並設定"]
setv -.-> short["太短: flush會被打斷"]
setv -.-> longw["太長: 看起來像關不掉的應用程式"]
圖 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
寫成這種形態, 「這個常駐處理現在從哪裡開始、在哪裡停止、在哪裡能看見失敗」 就會變得相當好讀。
flowchart TB
accTitle: 受管理的while迴圈的形態
accDescr: 用PeriodicTimer等待週期,scoped相依性每次切出scope取得後讀取狀態並更新儲存區,例外留在日誌後繼續迴圈,stoppingToken取消時跳出迴圈的圖。
tick["PeriodicTimer的週期等待"] --> scope["切出scope取得相依性"]
scope --> read["讀取狀態並更新儲存區"]
read --> tick
read -.->|"失敗"| logx["例外留在日誌後繼續"]
tick -.->|"stoppingToken"| exitx["跳出迴圈結束"]
圖 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 邊界持有,比較不會混在一起。
flowchart TB
accTitle: 狀態共用不直接接到UI的分離
accDescr: worker不直接碰UI物件,而是更新狀態儲存區或訊息傳遞層,UI在自己的執行內容中讀取該狀態並套用,立即通知的責任由UI邊界持有的圖。
worker["worker〔常駐迴圈〕"] --> store["更新狀態儲存區"]
ui["UI"] --> readq["在自己的執行內容中讀取"]
store --> readq
readq -.-> notify["立即通知由UI邊界負責"]
圖 12: 在 worker 與 UI 之間放一層薄的共用層,可以防止 UI 執行緒問題再度發生。
6. StartAsync / ExecuteAsync / StopAsync 的劃分方式
這 3 個一旦混在一起,讀的人腦袋馬上就濁掉。 先照下面的方式劃分,會相當穩定。
6.1. StartAsync
StartAsync 是放參與啟動的短處理的地方。
適合的:
- 啟動日誌
- 輕量的訂閱開始
- 馬上就結束的初始狀態準備
- 在
base.StartAsync前後做最小限度的排序
不適合的:
- 要花好幾十秒的 warm-up
- 無限迴圈
- 排一堆重 I/O 的本體處理
把 StartAsync 寫重,整個應用程式的啟動看起來都會遲鈍。
把這裡當成寫「開始的信號」的地方,比較不會出事。
flowchart TB
accTitle: 分辨什麼該放進StartAsync
accDescr: 啟動日誌或輕量訂閱開始這種參與啟動的短處理適合放進StartAsync,但放進長時間warm-up、無限迴圈或重I/O,整個應用程式的啟動看起來就會遲鈍的圖。
q{"是否為參與啟動的短處理"}
q -->|"是"| ok["放進StartAsync"]
q -->|"否"| ng["移到ExecuteAsync等本體側"]
ng -.-> why["太重會讓應用程式的啟動變鈍"]
圖 13: StartAsync 是寫「開始的信號」的地方,不是放重處理的地方。
6.2. ExecuteAsync
ExecuteAsync 是服務生命週期的本體。
適合的:
- 輪詢
- 監控迴圈
- 重新連線迴圈
- 讀取
Channel<T>的消費端 - 週期處理
- 「活到停止為止」的處理全般
這裡的訣竅有 3 個。
- 把
CancellationToken從頭傳到尾 - 不要讓整個迴圈因為例外而無聲地死掉
- 不要臨時起意地一直增加重試與 backoff
BackgroundService 很方便,但放著不管也會變成「什麼都吸進來的巨大迴圈」。
把實際處理切出到別的服務,讓 ExecuteAsync 本身偏向生命週期管理與編排,會比較好讀。
flowchart TB
accTitle: 讓ExecuteAsync保持在本體角色的訣竅
accDescr: 把CancellationToken從頭傳到尾、不要讓迴圈因例外而無聲地死掉、不要臨時起意增加重試與backoff這3個訣竅,並把實際處理切出到別的服務的圖。
exec["ExecuteAsync"] --> c1["把token傳到最後"]
exec --> c2["不要無聲地死掉"]
exec --> c3["不要增加太多重試"]
exec -.-> role["偏向生命週期管理"]
圖 14: 不讓 ExecuteAsync 變成巨大迴圈,把它保持為生命週期管理場所的 3 個訣竅。
6.3. StopAsync
StopAsync 是做正常終止時的整理的地方。
適合的:
- 停止日誌
- 解除計時器 / 訂閱 / 監控
- 想明確 close / flush 的資源整理
- 透過
base.StopAsync等待結束
不過,不要對 StopAsync 期待過頭也很重要。
- 處理程序當掉
- 被強制終止
- 被 OS kill
這類終止方式,本來就可能不會經過。
所以,
- 永續化盡量在平常時就小量完成
- 不要做出只有終止時才能取得一致的設計
- cleanup 做成 idempotent
這幾點很重要。 想只靠終止時拯救世界,多半會變濁。
flowchart TB
accTitle: 可以對StopAsync期待的範圍
accDescr: 正常終止時可以用StopAsync整理,但處理程序當掉、強制終止或被OS kill時可能不會經過,所以永續化要在平常時小量完成,cleanup要做成idempotent的圖。
endkind{"是哪一種終止"}
endkind -->|"正常終止"| sa["可以用StopAsync整理"]
endkind -->|"當掉或kill"| skip["StopAsync可能不會經過"]
skip --> ready["靠平常時的永續化來準備"]
ready -.-> idem["cleanup做成idempotent"]
圖 15: StopAsync 是正常終止的幫手,不是異常終止的保險。
6.4. .NET 10 以後的注意事項
.NET 10(2025 年 11 月發行)的重大變更中,BackgroundService.ExecuteAsync 的行為改成整體都以背景工作執行。
以前有個不太好懂的行為:第一個 await 之前的同步部分,會在啟動時阻塞其他服務的開始。
有了這項變更,「ExecuteAsync 的前幾行讓啟動變重」這類事故比較不容易發生。
反過來說,如果目標是 .NET 9 以前,那還是尚未改變的那一側行為。請先查看自己的專案屬於哪一邊。
不過即使如此,設計上還是
- 參與啟動的短處理 →
StartAsync - 長時間執行的本體 →
ExecuteAsync
這樣劃分比較好讀。
如果想更嚴格地控制啟動時機,可以把 IHostedLifecycleService 也納入視野。
這一帶是常駐應用程式變胖之後才會顯現的、不起眼的論點。
flowchart TB
accTitle: ExecuteAsync因版本而異的行為差異
accDescr: .NET 9以前,第一個await之前的同步部分可能阻塞其他服務的開始,.NET 10以後則是ExecuteAsync整體以背景工作執行,兩者都建議把啟動的短處理分到StartAsync的圖。
v{"目標的.NET是?"}
v -->|".NET 9以前"| oldb["await前的同步部分可能塞住啟動"]
v -->|".NET 10以後"| newb["整體以背景執行"]
oldb --> split["啟動的短處理移到StartAsync"]
newb --> split
圖 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(),
走用來停止的正規路線比較直接了當。
flowchart TB
accTitle: 終止整體的兩條路線
accDescr: 用Environment.Exit砍掉會自己切掉host持有的graceful shutdown路徑,所以想因致命錯誤終止整體時,要用IHostApplicationLifetime.StopApplication走正規路線的圖。
fatal["想因致命錯誤終止"] --> q{"要用哪一種砍掉"}
q -->|"Environment.Exit"| cut["切掉graceful shutdown的路徑"]
q -->|"StopApplication"| route["用來停止的正規路線"]
route --> clean["一路走到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 點。
- 啟動與停止的職責可以收攏到一處
- 長期存活的處理,其生命週期可以當成設計來持有
- graceful shutdown 不是事後補,而是從入口就開始處理
Windows 工具與常駐型應用程式,一開始就算很小, 監控、同步、重新連線、佇列、日誌、設定也會一點一點增加。 那時如果順手掛在 UI 程式碼上維運,之後會安靜地變得難受。
反過來,
- UI 就是 UI
- 常駐處理交給 hosted service
- 實際處理交給 DI 服務
- 終止交給
StopAsync與CancellationToken
光是這樣劃分,就會整齊很多。
flowchart TB
accTitle: 總結的分工
accDescr: UI就是UI、常駐處理交給hosted service、實際處理交給DI服務、終止交給StopAsync與CancellationToken,光是這樣分工就會整齊很多的圖。
all["整個應用程式"] --> u["UI就是UI"]
all --> h["常駐處理交給hosted service"]
all --> d["實際處理交給DI服務"]
all --> s["終止交給StopAsync與token"]
圖 18: 這種分工並不華麗,但要減少「關閉時偶爾會怪怪的」就靠這番整理。
沒有華麗之處。 不過這種不起眼的設計,在實務上確實很有用。 它能減少 「關閉時偶爾會怪怪的」 「不知道是在哪裡停止的」 這類討厭的黏膩感。
如果在 Windows 工具或常駐型應用程式上,卡在 BackgroundService 化、啟動 / 停止設計、監控迴圈、COM / 通訊端 / 檔案監控的生命週期整理、終止時缺陷的原因釐清等地方,歡迎從設計審查或方針梳理開始諮詢。
11. 參考資料
- 本文的完整範例程式碼(函式庫、示範、單元測試) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/generic-host-backgroundservice-desktop-app
- 相關文章: C# async/await 實務判斷表 - Task.Run 與 ConfigureAwait
- 相關文章: 以一頁整理 WPF / WinForms 的 async 與 UI 執行緒
- .NET 中的 Generic Host
- 在 ASP.NET Core 中使用 Hosted Service 的背景工作
- BackgroundService 類別
- 重大變更: BackgroundService 會把整個 ExecuteAsync 當成工作執行
- HostOptions.ShutdownTimeout 屬性
- Logging in C# - .NET
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
WinForms / WPF 應用程式的 CI/CD 實務 ── 用 GitHub Actions 把從建置到簽章・發布全部自動化
本文整理用 GitHub Actions 為 WinForms / WPF 應用程式建置 CI/CD 的實務指南,內容涵蓋在 windows-latest 上進行建置+測試的最小 YAML、以標籤驅動的版本編號、透過 signtool 整合簽章,以及依 MSI/MSIX/C...
Windows 應用程式的工作列通知區常駐與 Toast 通知 —— NotifyIcon 的陷阱與 AppNotification 的選型
本文整理將業務用 Windows 應用程式常駐於工作列通知區,並透過 Toast 通知告知使用者的實作要點。內容涵蓋 NotifyIcon 的正確用法與「關閉後仍留在通知區」的設計、檔案總管重新啟動時的重新註冊、三種 Toast API(Windows App SDK Ap...
委外・委託開發 Windows 應用程式前該整理的事項
在委外・委託開發 Windows 應用程式之前,整理既有軟體改版、設備整合、COM/ActiveX、發布與更新、維護等應留意的重點。
Windows 列印驅動程式停止提供 ── 業務應用程式的報表與標籤列印如何因應
Microsoft 正分階段推動 v3/v4 列印驅動程式的停止提供,從 2026 年 7 月起會優先選用 IPP 類別驅動程式。本文整理 Windows protected print mode 之下會消失哪些東西,並以判斷表梳理業務應用程式的報表、標籤列印中相依處的盤點...
WinForms/WPF 應用程式的多語言化 ── resx、附屬組件與文化特性切換的實務
本文從實務角度整理 Windows 桌面應用程式的多語言化,包括 CurrentCulture 與 CurrentUICulture 的差異、resx 與附屬組件(Satellite Assembly)所構成的資源機制、WinForms 的 Localizable 屬性、W...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
Generic Host & 應用程式架構
整理 Generic Host、BackgroundService、DI、組態、日誌以及應用程式生命週期設計的主題頁面。
UI 執行緒 & 計時器
整理 WPF / WinForms UI 執行緒、非同步流程、Dispatcher 使用、計時器判斷的主題頁面。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
這個主題很接近桌面應用程式開發本身,涵蓋背景處理、定期處理、重新連線一直到終止處理。
技術諮詢 & 設計審查
如果想先重新檢視 UI 與常駐處理的職責劃分,或 graceful shutdown 的設計,可以用技術諮詢與設計審查的形式來梳理。
常見問題
整理諮詢這個主題時常見的問題。
- 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 來設計,同時另外準備一套即使異常終止也不會壞掉的前提。