C# の async/await とは、時間のかかる処理(通信・ファイル・DB など)の完了を、スレッドを止めずに待つための書き方です。 メソッドに async を付け、戻り値を Task か Task<T> にして、待ちたい場所に await を書きます。待っている間、スレッドはほかの仕事に回せるので、画面は固まらず、サーバーは少ないスレッドで多くのリクエストをさばけます。

この記事では、すべてのコードを .NET 8 で実際に動かし、出力をそのまま載せています。 スレッド ID を出力して「await の前後で何が起きるか」を目で確かめ、Task.WhenAll の所要時間を計り、.Result / .Wait() で起きるデッドロックも自作の SynchronizationContext で再現しました。コードはそのまま Program.cs に貼って動かせます。

async/awaitの早見表

やりたいこと 書き方 ポイント
非同期メソッドを作る async Task FooAsync() / async Task<int> FooAsync() 戻り値なしは Task、ありは Task<T>。名前は Async で終える
結果を待つ int n = await FooAsync(); スレッドを止めずに待つ。例外は元の型のまま届く
複数を同時に進めて全部待つ await Task.WhenAll(t1, t2, t3) 所要時間は「いちばん長い1つ」ぶん
最初に終わった1つを受け取る await Task.WhenAny(t1, t2) 戻り値は「終わった Task」。もう一度 await して結果を得る
例外を捕まえる try { await FooAsync(); } catch (...) { } .Wait() / .Result だと AggregateException に包まれる
キャンセルできるようにする 引数に CancellationToken ct を受け取り、下位の API に渡す 止まると OperationCanceledException が届く
期限を付ける new CancellationTokenSource(TimeSpan.FromSeconds(1)) / task.WaitAsync(期限) 後者は TimeoutException
CPU を使う重い計算を逃がす await Task.Run(() => 計算()) I/O 待ちには不要
ライブラリでコンテキストに戻らない await FooAsync().ConfigureAwait(false) .Result で待たれてもデッドロックしにくくなる
同期的に待つ(避けたい書き方) .Result / .Wait() / .GetAwaiter().GetResult() UI など単一スレッドのコンテキストではデッドロックの原因になる
イベントハンドラー async void Button_Click(...) async void はここだけ。例外は呼び出し元で捕まえられない
await の付け忘れに気付く コンパイラの警告 CS4014 待たずに進めたいときは _ = FooAsync(); と意図を書く

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「C# async/awaitの使い方|Taskとデッドロック回避をコードで解説」

この記事のコードを動かす準備

検証環境は .NET SDK 8.0.131(ランタイム 8.0.31)、C# 12、Linux、CPU 2 コアです。 次のコマンドでコンソールアプリを作り、Program.cs の中身を各セクションのコードに置き換えて dotnet run してください。

dotnet new console -o AsyncDemo
cd AsyncDemo
# Program.cs を書き換えてから
dotnet run

コードは「トップレベルステートメント」(Main メソッドを書かずに、ファイルの先頭から処理を書ける形式)で書いています。この形式ではファイルの中でそのまま await が使えます。using System; や using System.Threading.Tasks; は、テンプレートの ImplicitUsings が有効なので省略できます。スレッド ID や所要時間は、実行するたびに少し変わります。

async/awaitの基本をスレッドIDで確かめる

await は「ここで止まる」のではなく、「続きを予約して、いったん呼び出し元に戻る」動きをします。 各行でスレッド ID を出力して確かめます。

// Program.cs(.NET 8 / C# 12)
static int Tid() => Environment.CurrentManagedThreadId;

Console.WriteLine($"[1] Main 開始           thread={Tid()}");
Task<int> task = GetLengthAsync("hello");          // ここでは待たない
Console.WriteLine($"[3] 呼び出し元に戻った  thread={Tid()} IsCompleted={task.IsCompleted}");
int length = await task;                            // ここで結果を待つ
Console.WriteLine($"[5] 結果 = {length}            thread={Tid()}");

static async Task<int> GetLengthAsync(string text)
{
    Console.WriteLine($"[2] await の前          thread={Tid()}");
    await Task.Delay(500);                          // 0.5秒かかる I/O の代わり
    Console.WriteLine($"[4] await の後          thread={Tid()}");
    return text.Length;
}

実行結果(.NET 8):

[1] Main 開始           thread=1
[2] await の前          thread=1
[3] 呼び出し元に戻った  thread=1 IsCompleted=False
[4] await の後          thread=5
[5] 結果 = 5            thread=5

番号の順に読むと、流れがわかります。

番号 起きていること
[1]→[2] GetLengthAsync を呼ぶと、最初の await までは呼び出し元と同じスレッド(1)で普通に進む
[3] await Task.Delay(500) に着くと、メソッドは未完了の Task<int> を返して呼び出し元に戻る。IsCompleted=False がその証拠
[4] 0.5 秒後、続き(await の後ろ)が実行される。コンソールアプリではスレッドプールの別スレッド(5)が担当した
[5] 呼び出し元の await task の続きも、同じくスレッドプール上で進む

押さえておきたい点は3つです。

  • async を付けただけでは別スレッドにならない。 最初の await までは同期的に進みます。
  • await の後ろがどのスレッドで動くかは、環境で決まる。 コンソールアプリや ASP.NET Core ではスレッドプールのどれか、WinForms / WPF のような UI アプリでは元の UI スレッドに戻ります(後述の SynchronizationContext の働き)。
  • Task は「いつか終わる処理」を表す引換券。 await するとその結果(Task<int> なら int)を受け取れます。

戻り値の型はどれを選ぶ?Task・ValueTask・async void

迷ったら Task / Task<T> を選びます。 ほかの型は、理由があるときに使います。

戻り値の型 使う場面 await できる 例外の届き方 注意点
Task 結果を返さない非同期メソッド できる await した側に届く 基本の選択肢
Task<T> 結果を返す非同期メソッド できる await した側に届く 基本の選択肢
ValueTask / ValueTask<T> 多くの呼び出しが await なしで終わる(キャッシュ命中など)、かつ呼ばれる回数が多い できる await した側に届く 1回だけ await する。変数に取って2回 await したり、.Result を完了前に読んだりしない
async void イベントハンドラー(Button_Click など) できない 呼び出し元では捕まえられない それ以外では async Task にする
IAsyncEnumerable<T> 要素を少しずつ非同期に返す(await foreach で受ける) await foreach 列挙中に届く この記事では扱いません(未実行)

ValueTask<T> が向く形を1つ動かしてみます。キャッシュに値があれば await せずにすぐ返すメソッドです。

using System.Diagnostics;

var cache = new Dictionary<int, string>();

// キャッシュにあれば await せずにすぐ返す → ValueTask<T> が向く形
async ValueTask<string> GetMemberNameAsync(int id)
{
    if (cache.TryGetValue(id, out string? name)) return name;   // 同期的に完了
    await Task.Delay(200);                                      // DB 問い合わせの代わり
    return cache[id] = $"会員{id}";
}

var sw = Stopwatch.StartNew();
string first = await GetMemberNameAsync(1);
Console.WriteLine($"1回目: {first} {sw.ElapsedMilliseconds} ms");

sw.Restart();
ValueTask<string> second = GetMemberNameAsync(1);
Console.WriteLine($"2回目: IsCompletedSuccessfully={second.IsCompletedSuccessfully}");
Console.WriteLine($"2回目: {await second} {sw.ElapsedMilliseconds} ms");
1回目: 会員1 205 ms
2回目: IsCompletedSuccessfully=True
2回目: 会員1 0 ms

2回目は IsCompletedSuccessfully=True、つまり呼んだ時点で完了済みです。Task<T> はクラスなので完了済みでもオブジェクトを1つ作りますが、ValueTask<T> は構造体なので、こうした場面でメモリの確保を減らせます。一方で使い方の制約が増えるため、公開 API やふだんのコードでは Task<T> で十分なことがほとんどです。

複数の処理を並行に進める:Task.WhenAllとTask.WhenAny

互いに関係のない待ち時間は、先に全部スタートさせてから Task.WhenAll でまとめて待つと、合計時間が短くなります。 1000ms・800ms・600ms かかる3つの処理で比べます。

using System.Diagnostics;

static async Task<string> FetchAsync(string name, int ms)
{
    await Task.Delay(ms);                 // 通信や DB 問い合わせの代わり
    return $"{name}({ms}ms)";
}

var sw = Stopwatch.StartNew();

// (1) 1つずつ await:前の処理が終わってから次を始める
string a = await FetchAsync("A", 1000);
string b = await FetchAsync("B", 800);
string c = await FetchAsync("C", 600);
Console.WriteLine($"順番に await : {a}, {b}, {c} => {sw.ElapsedMilliseconds} ms");

// (2) 先に3つとも開始し、Task.WhenAll でまとめて待つ
sw.Restart();
Task<string> ta = FetchAsync("A", 1000);
Task<string> tb = FetchAsync("B", 800);
Task<string> tc = FetchAsync("C", 600);
string[] results = await Task.WhenAll(ta, tb, tc);   // 結果は渡した順に並ぶ
Console.WriteLine($"Task.WhenAll : {string.Join(", ", results)} => {sw.ElapsedMilliseconds} ms");

// (3) Task.WhenAny:最初に終わった1つを受け取る
sw.Restart();
Task<string> first = await Task.WhenAny(
    FetchAsync("A", 1000), FetchAsync("B", 800), FetchAsync("C", 600));
Console.WriteLine($"Task.WhenAny : {await first} => {sw.ElapsedMilliseconds} ms");
順番に await : A(1000ms), B(800ms), C(600ms) => 2404 ms
Task.WhenAll : A(1000ms), B(800ms), C(600ms) => 998 ms
Task.WhenAny : C(600ms) => 600 ms
書き方 所要時間(実測) 理由
1つずつ await 2404 ms 1000 + 800 + 600 の合計になる
Task.WhenAll 998 ms 3つが同時に進み、いちばん長い 1000ms で全部そろう
Task.WhenAny 600 ms 最初に終わった C の時点で戻る

ポイントは「await を書いた場所で待つ」ことです。FetchAsync("A", 1000) を呼んだ時点で処理は始まっているので、Task を変数に受けておき、あとでまとめて await すれば並行になります。

  • Task.WhenAll の戻り値の配列は、終わった順ではなく渡した順に並びます(上の出力でも A, B, C の順)。
  • Task.WhenAny が返すのは「終わった Task そのもの」です。結果を取り出すには await first ともう一度待ちます。残りの Task は止まらず動き続けるので、不要なら後述の CancellationToken で止めます。
  • 同時に進める数が多すぎると相手のサーバーや DB に負荷が集中します。件数が多いときは SemaphoreSlim や Parallel.ForEachAsync で同時実行数を絞ります(この記事では未実行)。

例外処理:awaitと.Wait()で届く例外の型が変わる

await なら元の例外がそのまま、.Wait() や .Result なら AggregateException に包まれて届きます。 同じメソッドを3通りの待ち方で確かめます。

static async Task FailAsync(string message)
{
    await Task.Delay(100);
    throw new InvalidOperationException(message);
}

// (1) await:元の例外がそのまま届く
try { await FailAsync("在庫が足りません"); }
catch (Exception ex) { Console.WriteLine($"await    : {ex.GetType().Name}: {ex.Message}"); }

// (2) .Wait():AggregateException に包まれて届く
try { FailAsync("在庫が足りません").Wait(); }
catch (Exception ex)
{
    Console.WriteLine($".Wait()  : {ex.GetType().Name}: {ex.Message}");
    Console.WriteLine($"   Inner : {ex.InnerException!.GetType().Name}: {ex.InnerException.Message}");
}

// (3) Task.WhenAll:await で届くのは1件。全件は Task.Exception から取り出す
Task all = Task.WhenAll(FailAsync("1件目のエラー"), FailAsync("2件目のエラー"), Task.Delay(50));
try { await all; }
catch (Exception ex)
{
    Console.WriteLine($"WhenAll  : {ex.GetType().Name}: {ex.Message}");
    Console.WriteLine($"   全件数: {all.Exception!.InnerExceptions.Count}");
    foreach (Exception inner in all.Exception.InnerExceptions)
        Console.WriteLine($"   - {inner.Message}");
}
await    : InvalidOperationException: 在庫が足りません
.Wait()  : AggregateException: One or more errors occurred. (在庫が足りません)
   Inner : InvalidOperationException: 在庫が足りません
WhenAll  : InvalidOperationException: 2件目のエラー
   全件数: 2
   - 2件目のエラー
   - 1件目のエラー
待ち方 catch に届く型 元の例外の取り出し方
await task InvalidOperationException(元の型) そのまま catch (InvalidOperationException) で受けられる
task.Wait() / task.Result AggregateException ex.InnerException / ex.InnerExceptions
await Task.WhenAll(...) 例外のうちの1件(元の型) 全件は whenAllTask.Exception.InnerExceptions

.Wait() の例外メッセージは One or more errors occurred. (在庫が足りません) となり、catch (InvalidOperationException) と書いていても型が合わないので素通りします。await に書き換えると、同期コードと同じ感覚で try/catch が書けます。

Task.WhenAll で複数が同時に例外を出した場合、await で届くのは1件だけです。上の実行では「2件目のエラー」が届き、all.Exception.InnerExceptions には2件とも入っていました。全件をログに残したいときは、WhenAll の戻り値の Task を変数に取っておき、Exception プロパティを見ます。

async voidは例外を呼び出し元で捕まえられない

async void のメソッドは Task を返さないので、呼び出し側は完了も例外も受け取れません。 try/catch で囲んでも捕まえられないことを再現します。

try
{
    SendMailAsync();                       // async void なので await できない
    Console.WriteLine("try を抜けました(catch には入っていません)");
}
catch (Exception ex)
{
    Console.WriteLine($"catch できました: {ex.Message}");
}

await Task.Delay(1000);
Console.WriteLine("この行には到達しません");

static async void SendMailAsync()
{
    await Task.Delay(100);
    throw new InvalidOperationException("送信に使う設定が見つかりません");
}

実行結果(終了コード 134。スタックトレースのパスは短く書き換えています):

try を抜けました(catch には入っていません)
Unhandled exception. System.InvalidOperationException: 送信に使う設定が見つかりません
   at Program.<<Main>$>g__SendMailAsync|0_0() in /work/demo/Program.cs:line 17
   at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__128_1(Object state)
   at System.Threading.ThreadPoolWorkQueue.Dispatch()
   at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()

try ブロックは例外が起きる前に抜けてしまい、0.1 秒後に発生した例外は捕まえる相手がいないため、プロセス全体が Unhandled exception で終了しました。最後の Console.WriteLine には到達していません。

直し方は、戻り値を void から Task に変えて await することです。

try
{
    await SendMailAsync();                 // Task を返すので await できる
    Console.WriteLine("送信しました");
}
catch (Exception ex)
{
    Console.WriteLine($"catch できました: {ex.Message}");
}

await Task.Delay(1000);
Console.WriteLine("プログラムは最後まで進みました");

static async Task SendMailAsync()          // void → Task に変えただけ
{
    await Task.Delay(100);
    throw new InvalidOperationException("送信に使う設定が見つかりません");
}
catch できました: 送信に使う設定が見つかりません
プログラムは最後まで進みました
場面 書き方
通常のメソッド async Task / async Task<T> にして、呼び出し側で await
イベントハンドラー(シグネチャが void と決まっている) async void を使い、メソッドの中全体を try/catch で囲む
ラムダ式を Action 型の引数に渡す async () => ... が async void になる。Func<Task> を受け取る API を選ぶ

.Resultや.Wait()でデッドロックする仕組みと再現

デッドロックは「単一スレッドの SynchronizationContext がある環境で、非同期メソッドを .Result / .Wait() で同期的に待つ」と起きます。 SynchronizationContext は、await の続きを「どこで実行するか」を決める仕組みです。WinForms や WPF では「UI スレッドに戻す」役目を持っています。

コンソールアプリには SynchronizationContext がないため、そのままでは再現できません。そこで、続きを1本のスレッドで順番に実行する小さな SynchronizationContext を自作して、UI スレッドの代わりにしました。永久に止まると観察できないので、.Wait() には3秒の期限を付けています。

using System.Collections.Concurrent;

static int Tid() => Environment.CurrentManagedThreadId;

Console.WriteLine($"コンソールアプリの SynchronizationContext = {(SynchronizationContext.Current is null ? "null" : "あり")}");

Console.WriteLine("--- (A) .Wait() でブロックする ---");
SingleThreadContext.Run(() =>
{
    Console.WriteLine($"[UI役] 開始                  thread={Tid()}");
    Task<string> task = LoadAsync(continueOnCapturedContext: true);
    // 本来の .Wait() / .Result は永久に待つ。ここでは観察のため3秒で打ち切る
    bool completed = task.Wait(TimeSpan.FromSeconds(3));
    Console.WriteLine($"[UI役] 3秒待った結果 completed={completed} status={task.Status}");
    return Task.CompletedTask;
});

Console.WriteLine("--- (B) ConfigureAwait(false) を付ける ---");
SingleThreadContext.Run(() =>
{
    Console.WriteLine($"[UI役] 開始                  thread={Tid()}");
    Task<string> task = LoadAsync(continueOnCapturedContext: false);
    bool completed = task.Wait(TimeSpan.FromSeconds(3));
    Console.WriteLine($"[UI役] 3秒待った結果 completed={completed} status={task.Status}");
    return Task.CompletedTask;
});

Console.WriteLine("--- (C) 呼び出し元も await にする ---");
SingleThreadContext.Run(async () =>
{
    Console.WriteLine($"[UI役] 開始                  thread={Tid()}");
    string result = await LoadAsync(continueOnCapturedContext: true);
    Console.WriteLine($"[UI役] await の結果 = {result}   thread={Tid()}");
});

static async Task<string> LoadAsync(bool continueOnCapturedContext)
{
    Console.WriteLine($"  LoadAsync: await の前      thread={Tid()}");
    await Task.Delay(300).ConfigureAwait(continueOnCapturedContext);
    Console.WriteLine($"  LoadAsync: await の後      thread={Tid()}");
    return "done";
}

// UI スレッドの代わり:続きの処理(Post)を1本のスレッドで順番に実行する
sealed class SingleThreadContext : SynchronizationContext
{
    private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new();

    public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state));

    public static void Run(Func<Task> func)
    {
        var context = new SingleThreadContext();
        var thread = new Thread(() =>
        {
            SetSynchronizationContext(context);
            Task task = func();
            task.ContinueWith(_ => context._queue.CompleteAdding(), TaskScheduler.Default);
            foreach (var (callback, state) in context._queue.GetConsumingEnumerable())
                callback(state);                    // メッセージループ
        });
        thread.Start();
        thread.Join();
    }
}
コンソールアプリの SynchronizationContext = null
--- (A) .Wait() でブロックする ---
[UI役] 開始                  thread=4
  LoadAsync: await の前      thread=4
[UI役] 3秒待った結果 completed=False status=WaitingForActivation
  LoadAsync: await の後      thread=4
--- (B) ConfigureAwait(false) を付ける ---
[UI役] 開始                  thread=9
  LoadAsync: await の前      thread=9
  LoadAsync: await の後      thread=8
[UI役] 3秒待った結果 completed=True status=RanToCompletion
--- (C) 呼び出し元も await にする ---
[UI役] 開始                  thread=10
  LoadAsync: await の前      thread=10
  LoadAsync: await の後      thread=10
[UI役] await の結果 = done   thread=10

(A) で起きていること

sequenceDiagram
    participant UI as UI役スレッド(1本だけ)
    participant L as LoadAsync
    UI->>L: LoadAsync() を呼ぶ
    L-->>UI: await Task.Delay で未完了の Task を返す
    UI->>UI: task.Wait() でブロック(スレッドがふさがる)
    Note over L: 0.3秒後、続きを「UI役スレッドで実行して」と Post
    L--xUI: UI役スレッドは Wait 中で受け取れない
    Note over UI,L: Wait は Task の完了待ち、Task は UI役スレッドの空き待ち

出力の completed=False status=WaitingForActivation が、3秒待っても終わらなかったことを示しています。0.3 秒で終わるはずの処理です。そして .Wait() が期限切れでスレッドを手放した直後に、LoadAsync: await の後 thread=4 が出ています。続きの処理は「UI 役スレッドが空くのをずっと待っていた」ことがわかります。期限のない .Wait() や .Result なら、ここで永久に止まります。

(B) と (C):2つの直し方

直し方 何が変わるか 実行結果
(B) 呼ばれる側で ConfigureAwait(false) 続きを元のコンテキストに戻さず、スレッドプール(thread=8)で実行する。UI 役スレッドがふさがっていても完了できる completed=True status=RanToCompletion
(C) 呼び出し元も await にする UI 役スレッドをブロックしないので、続きを受け取れる。await の後も同じスレッド(thread=10)に戻る await の結果 = done

基本の方針は (C)、つまり「呼び出し元まで全部 async/await でつなぐ」ことです。 そのうえで、UI に触らないライブラリのコードには ConfigureAwait(false) を付けておくと、誰かが .Result で待ったときにも止まりにくくなります。

環境 SynchronizationContext .Result / .Wait() でのデッドロック
コンソールアプリ なし(上の出力で null を確認) この形では起きない
WinForms / WPF などの UI アプリ あり(UI スレッド1本) 起きる(今回の自作コンテキストと同じ形)
ASP.NET Core なし(一般に知られた仕様。今回は未実行) この形では起きないが、スレッドを1本ふさぐので await が望ましい

どうしても同期メソッドの中から呼ぶ必要があるときは、.GetAwaiter().GetResult() を使うと例外が AggregateException に包まれずに届きます。ただしスレッドをブロックする点は .Result と同じなので、デッドロックの条件も同じです。

キャンセルとタイムアウト:CancellationToken

長い処理には CancellationToken を引数で受け取り、下位の非同期 API にそのまま渡します。 止める側は CancellationTokenSource を持ちます。

static async Task ImportAsync(CancellationToken ct)
{
    for (int i = 1; ; i++)
    {
        ct.ThrowIfCancellationRequested();   // 自前のループでは区切りごとに確認
        await Task.Delay(300, ct);           // 非同期 API にはトークンを渡す
        Console.WriteLine($"  {i} 件目を取り込みました");
    }
}

// (1) 1秒たったら自動でキャンセルする CancellationTokenSource
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)))
{
    try { await ImportAsync(cts.Token); }
    catch (OperationCanceledException ex)
    {
        Console.WriteLine($"キャンセル: {ex.GetType().Name}: {ex.Message}");
    }
}

// (2) トークンを受け取らない処理に、待つ側だけ期限を付ける(.NET 6 以降)
static async Task<string> SlowAsync()
{
    await Task.Delay(3000);
    return "完了";
}
try
{
    string result = await SlowAsync().WaitAsync(TimeSpan.FromMilliseconds(500));
    Console.WriteLine(result);
}
catch (TimeoutException ex)
{
    Console.WriteLine($"タイムアウト: {ex.GetType().Name}: {ex.Message}");
}

// (3) 手動キャンセル:Cancel() を呼んだ時点で止まる
using (var cts = new CancellationTokenSource())
{
    Task work = ImportAsync(cts.Token);
    await Task.Delay(700);
    cts.Cancel();
    try { await work; }
    catch (OperationCanceledException) { Console.WriteLine($"手動キャンセル: status={work.Status}"); }
}
  1 件目を取り込みました
  2 件目を取り込みました
  3 件目を取り込みました
キャンセル: TaskCanceledException: A task was canceled.
タイムアウト: TimeoutException: The operation has timed out.
  1 件目を取り込みました
  2 件目を取り込みました
手動キャンセル: status=Canceled
やりたいこと 書き方 届く例外
一定時間で自動キャンセル new CancellationTokenSource(TimeSpan.FromSeconds(1)) または cts.CancelAfter(...) OperationCanceledException(今回は派生型の TaskCanceledException)
手動でキャンセル cts.Cancel() 同上。Task の Status は Canceled
自前のループで確認 ct.ThrowIfCancellationRequested() OperationCanceledException
トークンを受け取らない処理に期限を付ける task.WaitAsync(TimeSpan)(.NET 6 以降) TimeoutException
  • catch は基底の OperationCanceledException で受けると、TaskCanceledException もまとめて扱えます。
  • WaitAsync は「待つのをやめる」だけで、元の処理は裏で動き続けます。 処理そのものを止めたいときは、トークンを渡せる形にします。
  • CancellationTokenSource は IDisposable なので、using で後始末します。

Task.Runの使いどころ:CPUを使う計算だけに使う

Task.Run は「CPU を使い続ける計算」をスレッドプールに任せるためのものです。 通信やファイルのような I/O 待ちは、await するだけでスレッドを使わずに待てるので、Task.Run で包む必要はありません。

using System.Diagnostics;

static int Tid() => Environment.CurrentManagedThreadId;

// CPU を使い続ける計算(await できる I/O を含まない)
static long Sum()
{
    long total = 0;
    for (int i = 0; i < 300_000_000; i++) total += i % 7;
    return total;
}

Console.WriteLine($"呼び出し元              thread={Tid()}");
var sw = Stopwatch.StartNew();
long result = await Task.Run(() =>
{
    Console.WriteLine($"  Sum を実行中          thread={Tid()}");
    return Sum();
});
Console.WriteLine($"結果 = {result}  {sw.ElapsedMilliseconds} ms");

// I/O 待ち(Task.Delay)には Task.Run は不要:スレッドを使わずに待てる
sw.Restart();
await Task.WhenAll(Enumerable.Range(0, 1000).Select(_ => Task.Delay(1000)));
Console.WriteLine($"1秒待つ処理を 1000 個同時に: {sw.ElapsedMilliseconds} ms / スレッドプールのスレッド数 = {ThreadPool.ThreadCount}");
呼び出し元              thread=1
  Sum を実行中          thread=4
結果 = 899999997  564 ms
1秒待つ処理を 1000 個同時に: 1004 ms / スレッドプールのスレッド数 = 2

前半は、計算が呼び出し元(thread=1)とは別のスレッド(thread=4)で動いています。UI アプリなら、この間も画面は操作を受け付けられます。後半は、1秒待つ処理を 1000 個同時に待っても約1秒、スレッドプールのスレッド数は 2 のままでした。I/O 待ちがスレッドを占有しないことが数字で確かめられます。

処理の種類 例 書き方
I/O 待ち HTTP 通信、DB 問い合わせ、ファイル読み書き await client.GetStringAsync(url) のように非同期 API を直接 await
CPU を使う計算(UI アプリ) 画像の変換、大きな集計 await Task.Run(() => 計算()) で UI スレッドから逃がす
CPU を使う計算(サーバー側) 同上 リクエスト処理はもともとスレッドプール上なので、Task.Run で包んでも並べ替えになるだけ。そのまま実行する
同期 API しかない I/O 非同期版のないライブラリ UI を固めないための手段として Task.Run で包む。非同期版が用意されたら置き換える

awaitの付け忘れに気付く:警告CS4014

await を付け忘れると、処理の完了を待たずに次の行へ進みます。 コンパイラは警告 CS4014 で教えてくれます。

static async Task SaveLogAsync()
{
    await Task.Delay(300);
    Console.WriteLine("ログを保存しました");
}

SaveLogAsync();                 // await を付け忘れた行(警告 CS4014)
Console.WriteLine("Main 終了");
await Task.Delay(0);

dotnet build の出力:

Program.cs(7,1): warning CS4014: Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.

dotnet run の出力:

Main 終了

「ログを保存しました」が出ていません。SaveLogAsync() の完了を待たずに Main が終わり、プロセスが終了したためです。例外が起きても誰にも届きません。

直し方 書き方 使う場面
待つ await SaveLogAsync(); ほとんどの場合はこれ
あとでまとめて待つ Task t = SaveLogAsync(); ... await t; 並行に進めたいとき
待たないと明示する _ = SaveLogAsync(); 本当に結果が不要なとき。メソッドの中で例外を try/catch しておく

CS4014 が出るのは、呼び出し元が async メソッドで、呼んだメソッドが Task を返すときです。async でないメソッドから呼んだ場合や async void を呼んだ場合は警告が出ないので、戻り値の Task を捨てていないか目で確認します。

つまずきやすい点のチェックリスト

症状 原因 直し方
画面が固まる/処理が永久に終わらない UI スレッドで .Result / .Wait() 呼び出し元まで await でつなぐ。ライブラリ側は ConfigureAwait(false)
catch (InvalidOperationException) に入らない .Wait() で AggregateException に包まれている await に変える
例外でアプリごと終了する async void の中で例外 async Task にして await。イベントハンドラーは中で try/catch
処理の途中でプログラムが終わる await の付け忘れ(CS4014) await を付ける
並行にしたつもりが速くならない 1つずつ await している 先に全部呼んで Task.WhenAll
キャンセルしても止まらない トークンを下位の API に渡していない Task.Delay(ms, ct) のように引数で渡す
async を付けたら警告 CS1998 が出る メソッドの中に await が1つもない await する処理がなければ async を外し、Task.FromResult(値) や Task.CompletedTask を返す(CS1998 は今回未実行)

よくある質問

Q. async を付けると別スレッドで動きますか? A. いいえ。async は「このメソッドの中で await を使う」という印です。最初の await までは呼び出し元のスレッドで進み、本記事の実行結果でも [1][2][3] は同じ thread=1 でした。別スレッドで計算させたいときは Task.Run を使います。

Q. .Result と .Wait() と GetAwaiter().GetResult() の違いは何ですか? A. どれも完了までスレッドをブロックします。違いは例外の届き方で、.Result / .Wait() は AggregateException に包み、GetAwaiter().GetResult() は元の例外をそのまま投げます。単一スレッドの SynchronizationContext がある環境では、3つともデッドロックの条件は同じです。

Q. ConfigureAwait(false) はすべての await に付けるべきですか? A. UI に依存しないライブラリのコードでは付けておくと安心です。アプリ側のコードで、await の後に画面の部品を触る場合は付けません(UI スレッドに戻る必要があるため)。コンソールアプリのように SynchronizationContext がない環境では、付けても付けなくても動きは変わりません。

Q. Main メソッドで await は使えますか? A. 使えます。C# 7.1 以降は static async Task Main() と書け、トップレベルステートメントではそのまま await を書けます。コンソールアプリで .Result を使って待つ必要はありません。

まとめ

  • await は「続きを予約して呼び出し元に戻る」動き。 最初の await までは同じスレッドで進み、続きはコンソールではスレッドプール上で動く
  • 戻り値は Task / Task<T> が基本。 ValueTask は同期完了が多い場面、async void はイベントハンドラーだけ
  • Task.WhenAll で並行に待つと、3つの処理が 2404 ms → 998 ms になった
  • await なら元の例外、.Wait() / .Result なら AggregateException が届く
  • .Result / .Wait() は単一スレッドのコンテキストでデッドロックする。 呼び出し元まで await でつなぎ、ライブラリには ConfigureAwait(false)
  • キャンセルは CancellationToken を下位の API まで渡す。 期限は CancellationTokenSource(TimeSpan) や WaitAsync
  • Task.Run は CPU を使う計算に。 await の付け忘れは警告 CS4014 で気付ける

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「C# async/awaitの使い方|Taskとデッドロック回避をコードで解説」

株式会社RJCでは、C# の async/await や非同期処理の考え方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?