相信大家都知道如何在 .NET 中執行后臺(定時)任務。首先我們會選擇實現 IHostedService 接口或者繼承BackgroundService 來實現后臺任務。然后注冊到容器內,然后注冊到容器內,之后這些后臺任務 service 就會自動被 觸發(trigger)。本文不是初級的入門教程,而是試圖告訴讀者一些容易被忽略的細節。
IHostedService
IHostedService 是一個.NET Core 的接口,用于實現后臺服務。通過實現這個接口,你可以在應用程序運行期間在后臺執行任務,例如定時任務、監聽事件、處理隊列等。IHostedService 提供了 StartAsync() 和 StopAsync() 方法,分別用于啟動和停止后臺服務,并且框架會根據應用程序的生命周期自動調用這兩個方法。
以下是這個接口的源碼:
其中 StartAsync
方法由 IApplicationLifetime.ApplicationStarted
事件觸發
其中 StopAsync
方法由 IApplicationLifetime.ApplicationStopped
事件觸發
public interface IHostedService
{
Task StartAsync(CancellationToken cancellationToken);
Task StopAsync(CancellationToken cancellationToken);
}
通常我們的后臺任務會被框在一個while循環里,定時去執行某些邏輯。以下是我們模擬的一段演示代碼。StartAsync 方法被 call 的時候就會執行這個 while。代碼很簡單,不過多解釋。
public class HostServiceTest_A : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
Console.WriteLine("HostServiceTest_A starting.");
while (!cancellationToken.IsCancellationRequested)
{
Console.WriteLine("HostServiceTest_A is doing work.");
await Task.Delay(3000, cancellationToken);
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
把這個服務注冊到容器內。
builder.Services.AddHostedService<HostServiceTest_A>();
下面讓我們啟動一下程序試試。可以看到程序可以啟動,這個 while 循環也是一直在工作。咋看好像沒啥問題,但是仔細看看的話好像缺了點什么。
問題
對了,我們這個 ASP.NET Core 程序啟動日志沒有了。也就是整個程序的啟動過程被 block 住了。原因在于 HostedService 是順序的,一旦某個 HostedService 的 StartAsync 方法沒有盡快 return 的話,后面所有的任務全部不能執行了。比如你注冊了多個 HostedService,第一個使用了這種錯誤的方法來執行任務,后面的 HostedService 全部都沒有機會被執行。
HostServiceTest_A starting.
HostServiceTest_A is doing work.
HostServiceTest_A is doing work.
HostServiceTest_A is doing work.
HostServiceTest_A is doing work.
···
下面讓我們改進一下,使用 Task.Run 來讓這個任務變成異步,并且不去 await 這個 task。
public class HostServiceTest_A : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken)
{
Console.WriteLine("HostServiceTest_A starting.");
Task.Run(async () => {
while (!cancellationToken.IsCancellationRequested)
{
Console.WriteLine("HostServiceTest_A is doing work.");
await Task.Delay(3000, cancellationToken);
}
});
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
再次執行一下程序,可以看到 HostedService 跟 ASP.NET Core 主程序都可以正確執行了。
HostServiceTest_A starting.
HostServiceTest_A is doing work.
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5221
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: D:\workspace\BackgroundServiceDemo\BackgroundServiceDemo
HostServiceTest_A is doing work.
改進
我們的后臺任務通常是一個長期任務,這種情況下更加推薦 LongRunning Task 來 handle 這種任務。至于為什么可以參考以下文檔:
https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskcreationoptions?view=net-9.0
Task.Factory.StartNew(async () => {
while (!cancellationToken.IsCancellationRequested)
{
Console.WriteLine("HostServiceTest_A is doing work.");
await Task.Delay(3000, cancellationToken);
}
}, TaskCreationOptions.LongRunning);
return Task.CompletedTask;
退出
以上我們都在說如何啟動后臺任務,還沒討論如何取消這個后臺任務。參入的那個 cancellationToken 在 Application 被 stop 的時候并不會主動 cancel。所以我們需要在 StopAsync 方法觸發的時候手動來 Cancel 這個 token。
public class HostServiceTest_A : IHostedService
{
private CancellationTokenSource _cancellationTokenSource;
public Task StartAsync(CancellationToken cancellationToken)
{
Console.WriteLine("HostServiceTest_A starting.");
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Task.Factory.StartNew(async () => {
while (!_cancellationTokenSource.Token.IsCancellationRequested)
{
Console.WriteLine("HostServiceTest_A is doing work.");
await Task.Delay(1000, cancellationToken);
}
Console.WriteLine("HostServiceTest_A task done.");
}, TaskCreationOptions.LongRunning);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
if (!cancellationToken.IsCancellationRequested)
{
_cancellationTokenSource.Cancel();
}
Console.WriteLine("HostServiceTest_A stop.");
return Task.CompletedTask;
}
}
讓我們運行一下,然后按下 Ctrl + C 來主動退出程序,可以看到我們的 while 被安全退出了。
HostServiceTest_A starting.
HostServiceTest_A is doing work.
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5221
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: D:\workspace\BackgroundServiceDemo\BackgroundServiceDemo
HostServiceTest_A is doing work.
HostServiceTest_A is doing work.
HostServiceTest_A is doing work.
info: Microsoft.Hosting.Lifetime[0]
Application is shutting down...
HostServiceTest_A stop.
HostServiceTest_A task done.
BackgroundService
除了,HostedService
,微軟還給我們提供了 BackgroundService
這個類。一看這個類名就知道他能干嘛。其實也未必想的這么簡單。BackgroundService 實際上是 IHostedService 的一個實現類。它的核心是將后臺任務邏輯放在 ExecuteAsync
這個抽象方法中。下面我們通過一個具體案例來分析。。
public class BackgroundServiceTest_A : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
Console.WriteLine("ExecuteAsyncA is running.");
await Task.Delay(3000);
}
}
}
運行這個代碼,可以看到 BackgroundService 正常啟動了,而且也沒 block 住 ASP.NET Core 的程序。看是一切完美。
ExecuteAsyncA is running.
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http:
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: D:\workspace\BackgroundServiceDemo\BackgroundServiceDemo
ExecuteAsyncA is running.
ExecuteAsyncA is running.
ExecuteAsyncA is running.
ExecuteAsyncA is running.
ExecuteAsyncA is running.
問題
以上代碼真的沒有問題嗎?其實不盡然。讓我們上點強度。如果我們在循環中加一個耗時很長的步驟。事實上這個很常見。比如以下代碼:
public class BackgroundServiceTest_A : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
Console.WriteLine("ExecuteAsyncA is running.");
LongTermTask();
await Task.Delay(3000);
}
}
private void LongTermTask()
{
Console.WriteLine("LongTermTaskA is doing work.");
Thread.Sleep(30000);
}
}
再次運行以下,我們可以發現 ASP.NET Core 的主程序起不來了,被 block 住了。只有等第一個循環周期過后,主程序才能啟動起來。
ExecuteAsyncA is running.
LongTermTaskA is doing work.
那么問題到底出在哪?讓我們看看 BackgroundService
的源碼。
public virtual Task StartAsync(CancellationToken cancellationToken)
{
_stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_executeTask = ExecuteAsync(_stoppingCts.Token);
if (_executeTask.IsCompleted)
{
return _executeTask;
}
return Task.CompletedTask;
}
可以看到 StartAsync
方法會調用 ExecuteAsync
,但是它沒有 await 這個方法,也就是說 StartAsync
內部實現是個同步方法。也就是說 ExecuteAsync
方法跟 StartAsync
會在同一個線程上被執行(在遇到第一個 await 之前)。如果你注冊了多個 BackgroundService
并且他們一次 loop 都非常耗時,那么這個程序啟動將會非常耗時。其實微軟已經在文檔上提醒大家了:
Avoid performing long, blocking initialization work in ExecuteAsync.
改進
那么改進方法,同樣使用 Task.Factory.StartNew 來構造一個 LongRunning 的 task 就可以解決。
public class BackgroundServiceTest_A : BackgroundService
{
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
return Task.Factory.StartNew(async () =>
{
while (!stoppingToken.IsCancellationRequested)
{
Console.WriteLine("HostServiceTest_A is doing work.");
LongTermTask();
await Task.Delay(1000, stoppingToken);
}
Console.WriteLine("HostServiceTest_A task done.");
}, TaskCreationOptions.LongRunning);
}
private void LongTermTask()
{
Console.WriteLine("LongTermTaskA is doing work.");
Thread.Sleep(30000);
}
}
運行一下,完美啟動后臺任務跟主程序。
HostServiceTest_A is doing work.
LongTermTaskA is doing work.
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5221
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: D:\workspace\BackgroundServiceDemo\BackgroundServiceDemo
繼續改進
如果要繼續吹毛求疵的話,我們還可以改進一下。從 .NET6 開始 PeriodicTimer
被加入進來。它是一個 timer,可以替換一部分 Task.Delay
活。使用 PeriodicTimer
話相對于 Task.Delay
來說可以讓 loop 的間隔更加精準的被控制。
詳見這里 https://learn.microsoft.com/en-us/dotnet/api/system.threading.periodictimer.waitfornexttickasync?view=net-9.0
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
return Task.Factory.StartNew(async () =>
{
var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
Console.WriteLine("HostServiceTest_A is doing work.");
LongTermTask();
}
Console.WriteLine("HostServiceTest_A task done.");
}, TaskCreationOptions.LongRunning);
}
總結
通過以上的演示,我們可以感受到,實現一個后臺任務還是有非常多的點需要被注意的。特別是不要在 StartAsync 或者 ExcuteAsync
方法內執行耗時的同步方法。如果有耗時任務請包裹在新的 Task 內執行。我們要保證這兩個方法輕量化能夠被快速的執行完畢,這樣的話不會影響應用程序的啟動。
轉自https://www.cnblogs.com/kklldog/p/19020718