前陣子提到在 IIS 網站資料夾放置 app_offline.htm 會中止 AppPool,解除對 ASP.NET Core DLL 檔案的鎖定以便換版更新。放 app_offline.htm 檔案跟在 Kestrel 主控台視窗按下 Ctrl-C 的效果一樣,ASP.NET Core 接收到停機訊號後會顯示 Application is shutting down...,停止執行中作業,最後終止程序:

這裡有個有趣問題,「執行中作業」包含還沒跑完的 HTTP Request 嗎?例如:還在跑的 SQL 查詢、執行一半的批次作業、產生中的報表... ASP.NET Core 不可能等它們做完,處理的原則又是什麼?

要解惑,動手做實驗是最簡單又準確的方法。

首先,我用 dotnet new mvc 新建一個 MVC 專案,在 Program.cs 加上 ApplicationStopping、ApplicationStopped 事件偵錯 Log:

//...略...
app.Lifetime.ApplicationStopping.Register(() => {
    app.Logger.LogInformation($"{DateTime.Now:mm:ss.fff} ApplicationStopping");
});
app.Lifetime.ApplicationStopped.Register(()=> {
    app.Logger.LogInformation($"{DateTime.Now:mm:ss.fff} ApplicationStopped");
});

app.Run();

如此,每次按下 Ctrl-C 時可由 Log 得知 ASP.NET Core 花了多久完成停機:(實測網站閒置時,從 Stopping 到 Stopped 不到 0.1 秒)

接著,我設計一個等待 30 秒才回傳結果的 MVC Action,測試 ASP.NET Core 是否會等它跑完才關機?

    public async Task<IActionResult> Slow() {
        var st = DateTime.Now;
        _logger.LogInformation($"Slow Request Begin {st:mm:ss.fff}");
        await Task.Delay(30_000);
        _logger.LogInformation($"Slow Request End {DateTime.Now:mm:ss.fff}");
        return Content($"Done {st:mm:ss} - {DateTime.Now:mm:ss}");
    }

實測若 Slow Action 在執行中,ASP.NET Core 雖不會等足 30 秒,但會延遲 6 秒才停止:

為什麼是 6 秒?因為 HostOptions.ShutdownTimeout.NET 6 的預設值是 5 秒(註:這個預設值在不同版本可能不同)。為了驗證,我在 Program.cs 加入 builder.Services.Configure<HostOptions>(options => options.ShutdownTimeout = TimeSpan.FromSeconds(10));,重新測試可觀察到停機延遲變成 11 秒,由此得證。

如果某些作業被強迫中止的善後很麻煩,有沒有可能要求 ASP.NET Core 等它做完呢?坦白說,這不是良好的設計,停機程序包含無法預期的長延遲,易造成系統維運上的困擾。真的非做不可,可從 ApplicationStopping 事件下手。ASP.NET Core 關機時會等待 ApplicationStopping 註冊的事件完成才結束程序。,我隨便用靜態屬性胡亂拼湊出了一個版本,在 HomeController 加一個 ConcurrentDictionary<int, Task> PendingTasks 監控執行中的 Task :(聲明:程式僅為示範用途,不考慮設計原則與可靠性,不建議用於正式環境)

    public static ConcurrentDictionary<int, Task> PendingTasks = new  ConcurrentDictionary<int, Task>();

    public async Task<IActionResult> Slow()
    {
        var st = DateTime.Now;
        _logger.LogInformation($"Slow Request Begin {st:mm:ss.fff}");
        var pendingTask = Task.Delay(30_000);
        try {
            PendingTasks.TryAdd(pendingTask.GetHashCode(), pendingTask);
            await pendingTask;
            _logger.LogInformation($"Slow Request End {DateTime.Now:mm:ss.fff}");
        return Content($"Done {st:mm:ss} - {DateTime.Now:mm:ss}");
        }
        finally {
            PendingTasks.TryRemove(pendingTask.GetHashCode(), out var _);
        }
    }

Program 則在 ApplicationStopping 加入 Task.WaitAll() 等待 PendingTasks 的執行中作業全部完成,:

app.Lifetime.ApplicationStopping.Register(() => {
    app.Logger.LogInformation($"{DateTime.Now:mm:ss.fff} ApplicationStopping");
    app.Logger.LogInformation($"Pending Tasks Count = {HomeController.PendingTasks.Count()}");
    Task.WaitAll(HomeController.PendingTasks.Values.ToArray());
});

如下圖所示,Request 28 秒啟動[1],預定 58 秒結束。31 秒[2]按下 Ctrl-C,硬是等到 58 秒 Request 執行完[3]才完成停機[4],代表 ApplicationStopping 的等待有發生作用。

最後,反向思考,叫 ASP.NET Core 等待 Request 結束不妥,那麼由 Request 偵測網站停機訊號,儘快取消或放棄進行中作業,或許是更好的選擇。

要這麼做需在 HomeController 建構式取得 IHostApplicationLifetime,許多非同步方法都支援 CancellationToken 參數允許半途中斷,Task.Delay 也不例外,呼叫 Task.Delay 時傳入 appLifeTime.ApplicationStopping,當 ASP.NET Core 要停機時,CancellationToken 將會拋出錯誤中斷 Task.Delay():

    public HomeController(ILogger<HomeController> logger, IHostApplicationLifetime appLifeTime)
    {
        _logger = logger;
        this.appLifeTime = appLifeTime;
    }

    public async Task<IActionResult> Slow()
    {
        var st = DateTime.Now;
        _logger.LogInformation($"Slow Request Begin {st:mm:ss.fff}");
        var pendingTask = Task.Delay(30_000, appLifeTime.ApplicationStopping);
        await pendingTask;
        _logger.LogInformation($"Slow Request End {DateTime.Now:mm:ss.fff}");
        return Content($"Done {st:mm:ss} - {DateTime.Now:mm:ss}");
    }

加入後,當按下 Ctrl-C,執行中的 Request 立即因 TaskCanceledException 結束,而 ASP.NET Core 毫無延遲地完成停機:

如果想把 TaskCanceledException 視為預期內例外,我們可以用 try catch 捕捉它,回傳較友善的訊息:

    public async Task<IActionResult> Slow()
    {
        var st = DateTime.Now;
        _logger.LogInformation($"Slow Request Begin {st:mm:ss.fff}");
        try
        {
            var pendingTask = Task.Delay(30_000, appLifeTime.ApplicationStopping);
            await pendingTask;
            _logger.LogInformation($"Slow Request End {DateTime.Now:mm:ss.fff}");
            return Content($"Done {st:mm:ss} - {DateTime.Now:mm:ss}");
        }
        catch (TaskCanceledException)
        {
            return Content("ApplicationShutDown");
        }
    }

演練完畢。

【延伸閱讀】

Some experiments about how request terminated while ASP.NET Core shutting down.


Comments

Be the first to post a comment

Post a comment