【C#】async Main完全ガイド|Top-level statements・IHost・Ctrl+C・Graceful Shutdown・終了コードまで

【C#】async Main の使い方|非同期エントリポイント C#

C# 7.1 で async Main が使えるようになり、Task.Wait()GetAwaiter().GetResult() でデッドロックに陥る古いパターンは過去のものになりました。さらに C# 9 のトップレベルステートメント(Top-level statements)Main メソッド自体を書かずに非同期処理を記述できるようになり、現代的な Console / Worker アプリのエントリポイントは大きく様変わりしました。

本記事では4種類の Main シグネチャ・Top-level statements の内部仕組み・Host.CreateApplicationBuilder による DI 統合・Ctrl+C の優雅なキャンセル処理・IHostApplicationLifetime による Graceful Shutdown・AppDomain.UnhandledException での例外集約・終了コードの慣習まで体系的に解説します。

スポンサーリンク

Main メソッドの4つのシグネチャ

シグネチャ 用途 導入バージョン
static void Main() 従来形式。戻り値不要 C# 1.0
static int Main() 終了コードを返す C# 1.0
static async Task Main() 非同期処理 C# 7.1
static async Task<int> Main() 非同期処理 + 終了コード C# 7.1
各シグネチャの使用例
// ① 同期 void: 最もシンプル
class Program1
{
    static void Main(string[] args)
    {
        Console.WriteLine($"args: {string.Join(",", args)}");
    }
}

// ② 同期 int: 終了コードを返す(CI / シェルスクリプトで有用)
class Program2
{
    static int Main(string[] args)
    {
        if (args.Length == 0)
        {
            Console.Error.WriteLine("引数が必要です");
            return 1;           // 異常終了
        }
        Process(args);
        return 0;                // 正常終了
    }
}

// ③ 非同期 Task: async/await を直接使える
class Program3
{
    static async Task Main(string[] args)
    {
        using var http = new HttpClient();
        string data = await http.GetStringAsync("https://api.example.com");
        Console.WriteLine(data);
    }
}

// ④ 非同期 Task<int>: 非同期 + 終了コード
class Program4
{
    static async Task<int> Main(string[] args)
    {
        try
        {
            await ExecuteAsync(args);
            return 0;
        }
        catch (Exception ex)
        {
            Console.Error.WriteLine(ex.Message);
            return 1;
        }
    }
}

// NG: async void は不可(Main では許されていない)
// static async void Main() { ... }  // コンパイルエラー CS5001

Top-level statements(C# 9+)

Program.cs に直接処理を書く
// C# 9 以降: Program.cs の冒頭にコードを書くだけで実行される
// クラス・Main メソッド・namespace が不要

// Program.cs
using System;

Console.WriteLine("Hello, World!");
await Task.Delay(1000);
Console.WriteLine("1秒経過");

// args はそのまま args 変数として使える
Console.WriteLine($"引数: {args.Length} 個");

// 終了コードも return で返せる
if (args.Length == 0)
    return 1;

return 0;

// コンパイラが裏で生成するコード(概念)
// internal class Program
// {
//     private static async Task<int> Main(string[] args)
//     {
//         Console.WriteLine("Hello, World!");
//         await Task.Delay(1000);
//         // ...
//         return 0;
//     }
// }

// 制約:
// - トップレベルステートメントは1プロジェクトに1ファイルまで
// - using や namespace はステートメントより先に書く
// - メソッド / クラス定義はステートメントより後に書く
トップレベルは小さなツール・サンプルで威力
短いユーティリティ、プロトタイプ、教育用サンプルではボイラープレートが消えて本質に集中できるのが大きな利点です。業務の大規模コンソールアプリでも「起動エントリだけを簡潔に書き、ロジックは別クラスに分離」するスタイルが一般的になりました。.NET 6 以降の新規テンプレートはすべてトップレベルがデフォルトです。

async Main の内部実装

コンパイラが裏で生成する同期ラッパー
// ソース: async Main
static async Task<int> Main(string[] args)
{
    await DoWorkAsync();
    return 0;
}

// コンパイラが生成する実体(概念)
// - CLR のエントリポイントは常に同期メソッド
// - async メソッドは .GetAwaiter().GetResult() で同期的に待つラッパーが挿入される
private static int $Main(string[] args)
{
    return Main(args).GetAwaiter().GetResult();
}

// 重要:
// - SynchronizationContext は Main では null(既定)
// - そのため .GetAwaiter().GetResult() でもデッドロックしない
// - ASP.NET Core の旧 Classic / UI スレッドで問題になる
//   「sync-over-async デッドロック」は Main では発生しない
Main では .Result / .Wait() でもデッドロックしない(特殊ケース)
一般論として .Result / .Wait() は UI スレッドや旧 ASP.NET でデッドロックしますが、Main メソッドには SynchronizationContext が存在しないためこの問題は起きません。それでも async Main を使う方がコードが明瞭で例外スタックトレースも綺麗になるため、C# 7.1+ では一貫して async Main を使ってください。

終了コードの慣習

終了コード 意味 慣習
0 正常終了 常に 0
1 汎用エラー 特定できない失敗
2 引数・使い方エラー POSIX 的に “misuse of shell builtins”
64〜78 sysexits.h 系(UNIX) 64=EX_USAGE, 69=EX_UNAVAILABLE 等
130 Ctrl+C で中止 SIGINT(128 + 2)
255 以下 任意定義 アプリ固有のエラーコード
終了コードの実践
static async Task<int> Main(string[] args)
{
    try
    {
        using var cts = new CancellationTokenSource();
        Console.CancelKeyPress += (_, e) =>
        {
            e.Cancel = true;   // デフォルトのプロセス即終了を防ぐ
            cts.Cancel();
        };

        await RunAsync(args, cts.Token);
        return 0;
    }
    catch (OperationCanceledException)
    {
        Console.Error.WriteLine("中止されました");
        return 130;   // SIGINT 慣習
    }
    catch (ArgumentException ex)
    {
        Console.Error.WriteLine($"引数エラー: {ex.Message}");
        return 2;
    }
    catch (Exception ex)
    {
        Console.Error.WriteLine($"エラー: {ex.Message}");
        return 1;
    }
}

// Environment.ExitCode でも終了コードは設定可能
Environment.ExitCode = 42;
// → main が return する値、または明示的な Environment.Exit() で採用される

// Environment.Exit(42) は「即時プロセス終了」なので finally / Dispose が走らない
// → 通常は return で終了コードを返す方が安全

Ctrl+C 対応 — Graceful Cancellation

Console.CancelKeyPress で優雅に中止
static async Task<int> Main(string[] args)
{
    using var cts = new CancellationTokenSource();

    // Ctrl+C(SIGINT)を捕まえてトークンをキャンセル
    Console.CancelKeyPress += (sender, e) =>
    {
        Console.WriteLine("\nCtrl+C を検知 — 停止処理を開始");
        e.Cancel = true;           // ★ 即プロセス終了を防ぐ
        cts.Cancel();              // キャンセル伝搬
    };

    try
    {
        await ProcessAsync(cts.Token);
        return 0;
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("正常に中止されました");
        return 130;
    }
}

// アプリ側は渡された CancellationToken を全ての非同期呼び出しに伝搬させる
static async Task ProcessAsync(CancellationToken ct)
{
    for (int i = 0; i < 100; i++)
    {
        ct.ThrowIfCancellationRequested();
        await Task.Delay(100, ct);
        Console.WriteLine($"処理 {i}");
    }
}
SIGTERM(Docker / K8s)への対応
// Linux / Docker / Kubernetes では SIGTERM で停止要求が来る
// .NET 6+ の IHostApplicationLifetime が自動で SIGTERM を捕捉する

// 手動でハンドルする場合(.NET 6+)
AppDomain.CurrentDomain.ProcessExit += (sender, e) =>
{
    Console.WriteLine("ProcessExit を検知 — クリーンアップ");
    // 注: ProcessExit ハンドラの実行時間は約 2〜3 秒に制限される
    // 長時間の後処理は IHostApplicationLifetime.ApplicationStopping で行う
};

// PosixSignalRegistration(.NET 7+)でより細かく制御できる
using var sigTerm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx =>
{
    Console.WriteLine("SIGTERM を受信");
    ctx.Cancel = true;  // デフォルトの終了動作をキャンセル
    // 自前で終了処理を開始
});

IHost による現代的なエントリポイント

Host.CreateApplicationBuilder で DI + 設定 + ログを統合
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

// 最小構成の Console アプリ(.NET 7+)
// .NET 6 以前では Host.CreateDefaultBuilder(args).Build() を使う
var builder = Host.CreateApplicationBuilder(args);

// 設定(appsettings.json が自動読み込み)
builder.Services.Configure<AppOptions>(
    builder.Configuration.GetSection("App"));

// サービス登録
builder.Services.AddHttpClient();
builder.Services.AddTransient<OrderService>();

using var host = builder.Build();

// DI から取り出して実行
var svc = host.Services.GetRequiredService<OrderService>();
await svc.RunAsync(host.Services.GetRequiredService<IHostApplicationLifetime>().ApplicationStopping);

// IHostApplicationLifetime を経由すると:
// - Ctrl+C → ApplicationStopping がキャンセル
// - SIGTERM → 同じく
// - アプリの Dispose タイミングが統一される

// OrderService 側
public class OrderService(HttpClient http, ILogger<OrderService> logger, IOptions<AppOptions> opt)
{
    public async Task RunAsync(CancellationToken ct)
    {
        logger.LogInformation("処理開始: {BaseUrl}", opt.Value.BaseUrl);
        string body = await http.GetStringAsync(opt.Value.BaseUrl, ct);
        logger.LogInformation("取得完了: {Length} 文字", body.Length);
    }
}
BackgroundService で常駐型アプリ
// 定期実行・常駐処理は BackgroundService を使うのが定石
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<PollingWorker>();

using var host = builder.Build();
await host.RunAsync();   // Ctrl+C / SIGTERM で終了

public class PollingWorker(ILogger<PollingWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            logger.LogInformation("ポーリング実行");
            await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
        }
    }
}
IHost ベースの起動が 2026 年時点の標準
単発コマンド・定期実行・常駐ワーカー・ CLI ツールのいずれもHost.CreateApplicationBuilder を起点に書くのが現代的です。DI・構成ファイル・ログ・キャンセル・Graceful Shutdown がすべて統合済みで、ASP.NET Core と同じパターンをコンソールアプリでも使えるのが最大の利点です。新規作成時は dotnet new workerdotnet new console でテンプレートを確認してください。

ルートレベルの例外処理

Main の try-catch と AppDomain ハンドラー
static async Task<int> Main(string[] args)
{
    // ① 観測されなかった Task の例外
    TaskScheduler.UnobservedTaskException += (sender, e) =>
    {
        Console.Error.WriteLine($"UnobservedTask: {e.Exception}");
        e.SetObserved();   // プロセスを落とさない
    };

    // ② どこでもキャッチされなかった例外の最後の砦
    AppDomain.CurrentDomain.UnhandledException += (sender, e) =>
    {
        // このハンドラの後は必ずプロセス終了する(.NET 4+ 既定)
        var ex = e.ExceptionObject as Exception;
        Console.Error.WriteLine($"Unhandled: {ex}");
    };

    try
    {
        await RunAsync(args);
        return 0;
    }
    catch (OperationCanceledException)
    {
        return 130;   // Ctrl+C
    }
    catch (Exception ex)
    {
        Console.Error.WriteLine($"Fatal: {ex}");
        return 1;
    }
}

// Serilog 等のログ基盤を使う場合は
// catch で logger.LogCritical → Log.CloseAndFlush を必ず呼ぶ
Main の例外は「必ず」ログ + 終了コードで扱う
async Main で例外が await を超えて伝播すると、CLR が AggregateException にラップしてコンソールに出力し、終了コード 1 で終わります。これは「クラッシュダンプが残らず、デバッグしにくい」原因になるため、必ず最外殻で try-catch してログ + 意味のある終了コードを返してください。Serilog 使用時は Log.CloseAndFlush() を finally で呼ばないとログが欠落します。

引数処理と環境変数

args / Environment / System.CommandLine
// ① args パラメーター(最もシンプル)
// 実行: dotnet run -- --verbose 10
static async Task Main(string[] args)
{
    bool verbose = args.Contains("--verbose");
    int count = int.Parse(args.SkipWhile(a => a != "--count").Skip(1).FirstOrDefault() ?? "0");
}

// トップレベルステートメントでも args はそのまま使える
// Console.WriteLine($"args: {string.Join(",", args)}");

// ② Environment.GetCommandLineArgs() — 第1要素は実行ファイルのパス
string[] all = Environment.GetCommandLineArgs();
// all[0] = "MyApp.exe"(またはフルパス)
// all[1..] = 実際の引数

// ③ 環境変数
string? apiKey = Environment.GetEnvironmentVariable("API_KEY");
string workDir = Environment.GetEnvironmentVariable("WORK_DIR") ?? ".";

// ④ System.CommandLine(.NET 8+ 公式ライブラリ)— 本格的な CLI
// dotnet add package System.CommandLine --prerelease
var rootCommand = new RootCommand("サンプルアプリ");
var nameOption = new Option<string>("--name") { IsRequired = true };
rootCommand.AddOption(nameOption);
rootCommand.SetHandler(async (name) =>
{
    Console.WriteLine($"Hello, {name}");
    await Task.Delay(100);
}, nameOption);
return await rootCommand.InvokeAsync(args);

旧パターンからの移行

Before / After の移行例
// Before (C# 7.0 以前): sync Main から Wait / GetAwaiter
class OldProgram
{
    static void Main(string[] args)
    {
        DoWorkAsync().GetAwaiter().GetResult();
        // または .Wait()
    }

    static async Task DoWorkAsync()
    {
        await Task.Delay(1000);
    }
}

// After (C# 7.1+): async Main
class NewProgram
{
    static async Task Main(string[] args)
    {
        await DoWorkAsync();
    }

    static async Task DoWorkAsync()
    {
        await Task.Delay(1000);
    }
}

// After (C# 9+): トップレベルステートメント
// Program.cs
await DoWorkAsync();

static async Task DoWorkAsync()
{
    await Task.Delay(1000);
}

// After (.NET 6+): IHost + DI(本格的なアプリ)
using var host = Host.CreateApplicationBuilder(args).Build();
var svc = host.Services.GetRequiredService<IMyService>();
await svc.ExecuteAsync();

// 移行のメリット:
// - スタックトレースが綺麗(GetAwaiter().GetResult() のノイズがない)
// - 例外処理が自然
// - CancellationToken を自然に伝搬できる

よくある落とし穴

落とし穴① — async void Main は不可
// NG: コンパイルエラー
// static async void Main(string[] args) { ... }
//   → CS5001: Program does not contain a static Main method

// OK: async Task Main を使う
static async Task Main(string[] args) { ... }

// 理由: void async では呼び出し側が完了を待てないため、
//   CLR のエントリポイントとして成立しない
落とし穴② — Main の直後にプログラムが終わる
// NG: fire-and-forget タスクを放置すると Main が先に終わって処理が完了しない
static async Task Main(string[] args)
{
    _ = LongRunningAsync();    // 結果を待たない
    // ここで Main が return → LongRunningAsync の続きは実行されない可能性
}

// OK: 必ず await する
static async Task Main(string[] args)
{
    await LongRunningAsync();
}

// 複数タスクを並行に実行する場合は Task.WhenAll
static async Task Main(string[] args)
{
    await Task.WhenAll(Task1Async(), Task2Async(), Task3Async());
}
落とし穴③ — IDisposable / IAsyncDisposable の解放忘れ
// NG: HttpClient が Dispose されずにプロセスが終わる
static async Task Main(string[] args)
{
    var http = new HttpClient();
    await http.GetStringAsync("https://api.example.com");
    // Main が終わって http が GC されるが、接続プールが残ることがある
}

// OK: using で明示的に解放
static async Task Main(string[] args)
{
    using var http = new HttpClient();
    await http.GetStringAsync("https://api.example.com");
}   // ← ここで Dispose

// IAsyncDisposable なら await using
static async Task Main(string[] args)
{
    await using var dbContext = new AppDbContext();
    await dbContext.SaveChangesAsync();
}
落とし穴④ — トップレベルで関数定義のスコープ
// Program.cs(トップレベル)
await DoAsync();

// ローカル関数はトップレベルの下に書く
static async Task DoAsync()
{
    await Task.Delay(100);
}

// クラス定義もトップレベルの下に書く(C# 10+)
public class MyHelper
{
    public static void Log() => Console.WriteLine("log");
}

MyHelper.Log();   // ステートメント内から使える(宣言が後でも OK)

よくある質問

Qasync Main と Wait()/GetAwaiter().GetResult() はどちらを使うべき?
AC# 7.1 以降では常に async Main を使ってください。旧パターン(.Wait())は Main では技術的に動作しますが、スタックトレースに AggregateException が混入して読みにくくなり、例外処理も面倒です。async Maintry-catch で自然に例外を捕まえ、CancellationToken も自然に伝搬できます。既存コードを見かけたら積極的に移行してください。
Qトップレベルステートメントと従来の Main、どちらが良い?
A新規プロジェクトならトップレベル既存コードベースは無理に変更しないのが基本です。トップレベルはボイラープレートが減って起動コードが読みやすくなりますが、チームで「Main を明示的に書く」スタイルが定着しているなら従来形式のままでも問題ありません。.NET 6+ のテンプレートはトップレベルがデフォルトで、ほとんどの新規コードはこの形式で始まります。
Qコンソールアプリで DI を使うのはオーバーエンジニアリング?
A短いスクリプトならオーバースペックですが、ログ・設定・DB アクセス・HTTP 呼び出しを行う実用アプリでは Host.CreateApplicationBuilder の利用が推奨です。appsettings.json の自動読み込み・ILogger<T> の DI・Graceful Shutdown・Ctrl+C 対応が全部ただで付いてくるため、自前で実装する手間に比べれば圧倒的に楽です。50行を超えるコンソールアプリなら IHost ベースを検討してください。
QCtrl+C を押してもすぐにアプリが落ちません。なぜ?
AConsole.CancelKeyPress のハンドラ内で e.Cancel = true; を設定していると、デフォルトの即時終了動作がキャンセルされ、CancellationToken を伝搬して各タスクが自前で終了するまで待ちます。処理が長い場合は「1回目の Ctrl+C で Graceful Shutdown 開始、2回目で強制終了」というパターンも実装できます。IHost ベースだと同じ動作が自動実装されるため便利です。
QSerilog のログをすべて書き出してから終了したいです
AMain の finally ブロックで await Log.CloseAndFlushAsync();(または Log.CloseAndFlush())を呼んでください。非同期 Sink(File・Seq・Elasticsearch)はバッファリングしているため、明示的に flush しないと最後の数件のログが失われます。IHost を使う場合は using var host = ...; の Dispose で自動的に flush されます。

まとめ

場面 推奨
C# 7.1+ の非同期エントリ static async Task Main or Task<int>
新規プロジェクト C# 9+ のトップレベルステートメント
DI + 設定 + ログが必要 Host.CreateApplicationBuilder(args)
常駐・定期実行 BackgroundService + AddHostedService
Ctrl+C 対応 Console.CancelKeyPress + CancellationToken
SIGTERM(Docker/K8s) IHostApplicationLifetime.ApplicationStopping
終了コード 0=正常・1=エラー・130=中止・2=引数誤り
CLI 引数 単純なら args、複雑なら System.CommandLine
ルート例外処理 try-catch + TaskScheduler.UnobservedTaskException
リソース解放 using / await using で Dispose 保証
ログの flush Serilog は finallyCloseAndFlushAsync()
async void Main 不可。必ず Task / Task<int>

関連する非同期機能は以下を参照してください。async/await 完全ガイドでステートマシン・ConfigureAwait・ValueTask、CancellationToken 完全ガイドでキャンセル伝搬の実装パターン、依存性注入(DI)完全ガイドで IHost との統合、appsettings.json 完全ガイドで IHost の構成管理、ログ出力完全ガイドで Serilog の2段階初期化を解説しています。