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+)
// 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 では発生しない
一般論として
.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
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}");
}
}
// 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 による現代的なエントリポイント
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 を使うのが定石
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);
}
}
}
単発コマンド・定期実行・常駐ワーカー・ CLI ツールのいずれも
Host.CreateApplicationBuilder を起点に書くのが現代的です。DI・構成ファイル・ログ・キャンセル・Graceful Shutdown がすべて統合済みで、ASP.NET Core と同じパターンをコンソールアプリでも使えるのが最大の利点です。新規作成時は dotnet new worker や dotnet new console でテンプレートを確認してください。ルートレベルの例外処理
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 を必ず呼ぶ
async Main で例外が await を超えて伝播すると、CLR が AggregateException にラップしてコンソールに出力し、終了コード 1 で終わります。これは「クラッシュダンプが残らず、デバッグしにくい」原因になるため、必ず最外殻で try-catch してログ + 意味のある終了コードを返してください。Serilog 使用時は Log.CloseAndFlush() を finally で呼ばないとログが欠落します。引数処理と環境変数
// ① 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 (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 を自然に伝搬できる
よくある落とし穴
// 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 のエントリポイントとして成立しない
// 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());
}
// 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)
よくある質問
async Main を使ってください。旧パターン(.Wait())は Main では技術的に動作しますが、スタックトレースに AggregateException が混入して読みにくくなり、例外処理も面倒です。async Main は try-catch で自然に例外を捕まえ、CancellationToken も自然に伝搬できます。既存コードを見かけたら積極的に移行してください。Host.CreateApplicationBuilder の利用が推奨です。appsettings.json の自動読み込み・ILogger<T> の DI・Graceful Shutdown・Ctrl+C 対応が全部ただで付いてくるため、自前で実装する手間に比べれば圧倒的に楽です。50行を超えるコンソールアプリなら IHost ベースを検討してください。Console.CancelKeyPress のハンドラ内で e.Cancel = true; を設定していると、デフォルトの即時終了動作がキャンセルされ、CancellationToken を伝搬して各タスクが自前で終了するまで待ちます。処理が長い場合は「1回目の Ctrl+C で Graceful Shutdown 開始、2回目で強制終了」というパターンも実装できます。IHost ベースだと同じ動作が自動実装されるため便利です。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 は finally で CloseAndFlushAsync() |
| async void Main | 不可。必ず Task / Task<int> |
関連する非同期機能は以下を参照してください。async/await 完全ガイドでステートマシン・ConfigureAwait・ValueTask、CancellationToken 完全ガイドでキャンセル伝搬の実装パターン、依存性注入(DI)完全ガイドで IHost との統合、appsettings.json 完全ガイドで IHost の構成管理、ログ出力完全ガイドで Serilog の2段階初期化を解説しています。

