在 ASP.NET Core 網站執行定時排程
18 |
為網站加入定期排程算很常見的設計,可用來處理過期 Cache 清除、資料定期刷新,系統狀態監控及自動問題修復。過去在 ASP.NET 時代,我會用一種笨但有效的方法 - 跑 Windows 排程每隔幾分鐘呼叫特定網頁執行任務,遇到較複雜需管理介面甚至要能重試的需求,也曾用過 Hangfire。來到 ASP.NET Core 時代,ASP.NET Core 本身內建背景排程機制,面對需要持續定期執行的作業,現在又多了輕巧高整合性的新選擇。
這篇文章將以範例展示如何在現有 ASP.NET Core 實現一個簡單定期排程 - 每隔五秒記錄當下網站應用程式記憶體用量。
首先,我們要寫一個類別實作 IHostedService 介面,其中要包含兩個方法,StartAsync(CancellationToken) 及 StopAsync(CancellationToken)。
StartAsync() 通常是在 Startup.Configure() 及 IApplicationLifetime.ApplicationStarted 事件之前被呼叫,以便在其中設定定時器、啟動執行緒好運行背景作業。常見寫法是在 Startup.ConfigureServices() 中 services.AddHostedService<T>(),這裡借用 ASP.NET Core 新增修改刪除(CRUD)介面傻瓜範例的程式修改:
public void ConfigureServices(IServiceCollection services)
{
//註冊 DB Context,指定使用 Sqlite 資料庫
services.AddDbContextPool<JournalDbContext>(options =>
{
//TODO: 實際應用時,連線字串不該寫死在程式碼裡,應應移入設定檔並加密儲存
options.UseSqlite(Configuration.GetConnectionString("SQLite"));
});
services.AddRazorPages();
//加入背景服務
services.AddHostedService<MemoryUsageMonitor>();
}
若要在 Startup.Configure() 後執行,則可加在 Program.cs:
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
})
.ConfigureServices(services =>
{
//加入背景服務
services.AddHostedService<MemoryUsageMonitor>();
});
StopAsync() 則在網站停機過程觸發,通常用於執行 Disposable / finalizer 等 Unmanaged 資源的回收。當然,如果是意外關機或程式崩潰就不會被呼叫,故需考量此類意外狀況的善後(如果有需要的話)。StopAsync() 會接受一個 Cancellation Token 參數,預設有 5 秒的 Timeout,意味著 StopAsync() 的收拾動作必須在五秒內完成,超過時限 IsCancellationRequested 會變成 true,雖然 ASP.NET Core 仍會繼續等事情做完,但會被判定關機程序異常。
以下是 MemoryUsageMonitor.cs 範例:
using Microsoft.Extensions.Hosting;
using NLog;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace CRUDExample.Models
{
public class MemoryUsageMonitor : IHostedService, IDisposable
{
static Timer _timer;
ILogger _logger = NLog.LogManager.GetCurrentClassLogger();
public MemoryUsageMonitor()
{
}
public Task StartAsync(CancellationToken cancellationToken)
{
_timer = new Timer(DoWork, null,
TimeSpan.Zero,
TimeSpan.FromSeconds(10));
return Task.CompletedTask;
}
private int execCount = 0;
public void DoWork(object state)
{
//利用 Interlocked 計數防止重複執行
Interlocked.Increment(ref execCount);
if (execCount == 1)
{
try
{
_logger.Info(
$"Memory Usage = {Process.GetCurrentProcess().WorkingSet64}"
);
}
catch (Exception ex)
{
_logger.Error("發生錯誤 = " + ex);
}
}
Interlocked.Decrement(ref execCount);
}
public Task StopAsync(CancellationToken cancellationToken)
{
//調整Timer為永不觸發,停用定期排程
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
public void Dispose()
{
_timer?.Dispose();
}
}
}
補充一點,Timer 的原則是時間到就觸發,故要考慮前一次呼叫 DoWork 還沒跑完,下個週期又將開始的狀況,若要做到前次還沒跑完先不執行,需自行實作防止重複執行機制,我在範例是用支援多執行緒的 Interlocked.Increment() 及 Decrement() 增刪計數器,確保同時間只有單一作業會執行。
實測成功:
最後我還想驗證一事,前面有提到 StopAsync() 會收到 CancellationToken,應在五秒內完成工作,否則將視為關機異常。我故意將 StopAsync() 改成乾等 10 秒,看看會發生什麼事:
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.Debug("Start StopAsync...");
//調整Timer為永不觸發,停用定期排程
_timer?.Change(Timeout.Infinite, 0);
//故意等10秒才結束
var waitTime = DateTime.Now.AddSeconds(10);
while (DateTime.Now.CompareTo(waitTime) < 0)
{
Thread.Sleep(1000);
_logger.Debug(cancellationToken.IsCancellationRequested);
}
return Task.CompletedTask;
}
在 Debug Console 按下 Ctrl-C 停止網站,由 Log 可觀察到 cancellationToken.IsCancellationRequested 確實在五秒後變成 true,但 10 秒迴圈仍會跑完:
但因超過時限,會得到 OperationCanceledException。若將 waitTime 改為 2 秒,錯誤即告消失,得證:
參考文件:Background tasks with hosted services in ASP.NET Core
Example of using IHostedService to run timer task in background of ASP.NET Core.
Comments
# by rico
如果是單純的timer工作,我會更建議使用BackgroundService實作而不是使用IHostedService,因為BackgroundService使用上不但比較直覺,而且CancellationToken是更加真實執行事件取消,反之IHostedService的是StartAsync事件的取消,BackgroundService對於一些要保證執行完成才能結束的工作能更好控制。 不過更推quartz就是了。 以上是小弟拙見
# by Jeffrey
to rico,感謝分享經驗。
# by Milkker
黑大想請問一下,在 MemoryUsageMonitor.StopAsync 停止 Timer 的作法是將週期設為無限大,而不是使用 Dispose 方法的原因。
# by Jeffrey
to Mikker,設為無限大保留一絲再次啟用的可能,Dispose() 的話要再用就得建一顆新的,依本案例寫法,計時器無法重啟再利用,用 Dispose() 亦可。
# by Milkker
恩恩,了解了 感謝分享><
# by ccko
請問AddHostedService 之後要如何remove and stop background service呢? 我想要再繼承的startup class 做上面的處理, 謝謝
# by Eric
請問使用之後 Pod會一直Restart是為什麼呢?
# by Jeffrey
to Eric, 試著加 try catch 寫 Log 追看看,看能不能蒐集到更多線索。
# by Ryan
請問在Docker中,定時任務會失效嗎? 我用這篇的作法上傳到centos主機後,發現完全不會去執行 但在自己本機上是沒有問題的
# by Jeffrey
to Ryan, 會是排程作業本身在Docker無法執行嗎?若先換成簡單動作(例如定期在List<string>加入字串)會成功嗎?
# by Ryan
to Jeffrey, 原本作業內容是更新資料表某欄位,我有試過單純往Directory.GetCurrentDirectory()目錄下 txt檔 寫字串進去,但也沒有看到內容增加
# by Ryan
to Jeffrey, 已找到問題 單純是定時任務的內容有部分有寫錯
# by Ken
黑大 想請問一下 execCount 是不是應該設為 private static int execCount = 0; 啊?
# by Jeffrey
to Ken, MemoryUsageMontior 透過 AddHostedService<T>() 建立形同 Singleton (參考: https://stackoverflow.com/a/51481139/288936 ) 只會有一個 Instance,static 主要用於多 Instance 共用情境,我認為在本案例用不到。
# by Hans
黑大您好, 我照著你的程式碼做,只有把間隔改成10分鐘,但程式只會執行三次,看log也沒有錯誤,我需要做什麼修正呢?
# by Hans
黑大您好, 剛才發現Log中有這句Microsoft.Hosting.Lifetime|Application is shutting down...
# by Jeffrey
to Hans, 在 IIS 執行嗎?會是閒置逾時 (Idle Time-out) 造成?https://learn.microsoft.com/zh-tw/iis/configuration/system.applicationhost/applicationpools/add/processmodel#how-to-configure-the-idle-timeout-action
# by Hans
謝謝黑大, 我去看IIS的設定,的確是Terminate,只是我應該把它改成Suspend,或是把逾時(分)這設定從20改成不閒置?