[.NET 6] 如何優雅的關閉 .NET Core Console 應用程式

當有一個應用程序被用戶 ( SIGINT /Ctrl+C) 或 Docker ( SIGTERM / docker stop ) 停止時,它需要優雅地關閉一個長時間運行的工作;換句煥說,當應用程式收到關閉訊號的時候,要把工作做完,應用程式才可以關閉。微軟的 Microsoft.Extensions.Hosting 可以幫我們接收/處理關閉訊號,我們只需要告訴它要怎麼做就可以了,我在實作的過程當中,碰到了一些問題,以下是我的心得

 

開發環境

  • .NET 6
  • Rider 2021.3-EAP9-213.5744.160)

接收 SIGINT / SIGTERM 訊號

新增一個 .NET 6 Console 應用程式,並加入以下套件

dotnet add package Microsoft.Extensions.Hosting --version 6.0.0

在 .NET Core 的應用程式我知道有以下的方法

  1. Console.CancelKeyPress
  2. AssemblyLoadContext.Default.Unloading
  3. AppDomain.CurrentDomain.ProcessExit
using Lab.GracefulShutdown.Net6;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Runtime.Loader;

var tcs = new TaskCompletionSource();
var sigintReceived = false;

Console.WriteLine("等待以下訊號 SIGINT/SIGTERM");

Console.CancelKeyPress += (sender, e) =>
{
    e.Cancel = true;
    Console.WriteLine("已接收 SIGINT (Ctrl+C)");
    tcs.SetResult();
    sigintReceived = true;
};

AssemblyLoadContext.Default.Unloading += ctx =>
{
    if (!sigintReceived)
    {
        Console.WriteLine("已接收 SIGTERM");
        tcs.SetResult();
    }
    else
    {
        Console.WriteLine("@AssemblyLoadContext.Default.Unloading,已處理 SIGINT,忽略 SIGTERM");
    }
};

AppDomain.CurrentDomain.ProcessExit += (sender, e) =>
{
    if (!sigintReceived)
    {
        Console.WriteLine("已接收 SIGTERM");
        tcs.SetResult();
    }
    else
    {
        Console.WriteLine("@AppDomain.CurrentDomain.ProcessExit,已處理 SIGINT,忽略 SIGTERM");
    }
};

await Host.CreateDefaultBuilder(args)
          .ConfigureServices((hostContext, services) =>
          {
              // services.AddHostedService<GracefulShutdownService_Fail>();
              services.AddHostedService<GracefulShutdownService>();
          })
          .RunConsoleAsync();
Console.WriteLine("下次再來唷~");

 

Graceful Shutdown

@ Program.cs

註冊 HostedService

為了解釋,我使用兩個 ServiceHost,一個是有問題的,一個是正常工作的

await Host.CreateDefaultBuilder(args)
          .ConfigureServices((hostContext, services) =>
          {
              // services.AddHostedService<GracefulShutdownService_Fail>();
              services.AddHostedService<GracefulShutdownService>();
          })
          .RunConsoleAsync();

 

GracefulShutdownService_Fail

  1. StartAsync 調用 ExecuteAsync,ExecuteAsync 是一個無窮迴圈,我用它來模擬一個服務不斷的運行
  2. StopAsync 等待關閉指令
using Microsoft.Extensions.Hosting;

namespace Lab.GracefulShutdown.Net6;

internal class GracefulShutdownService_Fail : IHostedService
{
    private readonly IHostApplicationLifetime _appLifetime;
    private bool _stop;

    public GracefulShutdownService_Fail(IHostApplicationLifetime appLifetime)
    {
        this._appLifetime = appLifetime;
    }

    public async Task StartAsync(CancellationToken cancel)
    {
        Console.WriteLine($"{DateTime.Now} 服務啟動中...");
        await this.ExecuteAsync(cancel);
    }

    public Task StopAsync(CancellationToken cancel)
    {
        this._stop = true;
        Console.WriteLine("服務關閉");
        return Task.CompletedTask;
    }

    private async Task ExecuteAsync(CancellationToken cancel)
    {
        Console.WriteLine($"{DateTime.Now} 服務已啟動!");

        while (!this._stop)
        {
            Console.WriteLine($"{DateTime.Now} 服務運行中...");
            await Task.Delay(TimeSpan.FromSeconds(1), cancel);
        }

        Console.WriteLine($"{DateTime.Now} 服務已完美的停止(Graceful Shutdown)");
    }
}

 

當我按下 F5 運行的發現不大對,沒有看到 started,按下 Ctrl+C 的時候則是噴出了例外

 

死在這一行,關閉事件的動作跟我預期的不太一樣,它並沒有等我把工作做完就送出取消了

 

雖然我們可以攔截關閉訊號,但這樣處理起來肯定複雜許多,於是,我再搜尋相關的資料,有了下列的解法

 

GracefulShutdownService

這次改成 

  1. StartAsync(),用 Task.Run 調用 ExecuteAsync
    this._backgroundTask = Task.Run(async () => { await this.ExecuteAsync(cancel); }, cancel);
  2. StopAsync(),等待 _backgroundTask 完成
using Microsoft.Extensions.Hosting;

namespace Lab.GracefulShutdown.Net6;

internal class GracefulShutdownService : IHostedService
{
    private readonly IHostApplicationLifetime _appLifetime;
    private Task _backgroundTask;
    private bool _stop;

    public GracefulShutdownService(IHostApplicationLifetime appLifetime)
    {
        this._appLifetime = appLifetime;
    }

    public Task StartAsync(CancellationToken cancel)
    {
        Console.WriteLine($"{DateTime.Now} 服務啟動中...");

        this._backgroundTask = Task.Run(async () => { await this.ExecuteAsync(cancel); }, cancel);

        return Task.CompletedTask;
    }

    public async Task StopAsync(CancellationToken cancel)
    {
        Console.WriteLine($"{DateTime.Now} 服務停止中...");

        this._stop = true;
        await this._backgroundTask;

        Console.WriteLine($"{DateTime.Now} 服務已停止");
    }

    private async Task ExecuteAsync(CancellationToken cancel)
    {
        Console.WriteLine($"{DateTime.Now} 服務已啟動!");

        while (!this._stop)
        {
            Console.WriteLine($"{DateTime.Now} 服務運行中...");
            await Task.Delay(TimeSpan.FromSeconds(1), cancel);
        }

        Console.WriteLine($"{DateTime.Now} 服務已完美的停止(Graceful Shutdown)");
    }
}

 

很好,這次事件順序跟我想的就一樣了

Shutdown Timeout

在 .NET 5 的時候會有 Shutdown Timeout 的問題

Extending the shutdown timeout setting to ensure graceful IHostedService shutdown (andrewlock.net)

我在 .NET 6 模擬不出這個問題,翻了一下代碼,已經沒有 token.ThrowIfCancellationRequested() 這一行
https://github.com/dotnet/runtime/blob/e9036b04357e8454439a0e6cf22186a0cb19e616/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs#L111

 

參考資料

當 .NET Core 執行在 Linux 或 Docker 容器中如何優雅的結束 | The Will Will Web (miniasp.com)

Graceful Shutdown C# Apps. I recently had a C# application that… | by Rainer Stropek | Medium

範例位置

sample.dotblog/Graceful Shutdown/Lab.GracefulShutdown at master · yaochangyu/sample.dotblog (github.com)

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo