為網站加入定期排程算很常見的設計,可用來處理過期 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改成不閒置?

Post a comment