1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【C#】async/awaitでハマりやすい落とし穴とその対処法【初心者向け】

1
Posted at

【C#】async/awaitでハマりやすい落とし穴とその対処法【初心者向け】


はじめに

C#の非同期プログラミングは、async/await構文の登場によって格段に書きやすくなりました。しかし、実際に業務や個人開発で使い始めると、「なぜか思った通りに動かない」「画面が固まる」「例外が消える」「キャンセルが効かない」など、初心者がつまずきやすい落とし穴が数多く存在します。

本記事では、C#の非同期処理でよくあるミスやバグの再現例とその解説、正しい書き方、実務で役立つベストプラクティスを、初心者にも分かりやすく丁寧にまとめます。Qiita読者が「ストックしたくなる」ような、実務で本当に役立つ視点やTipsも盛り込みました。


非同期処理の基礎とasync/awaitの役割

非同期処理とは?

非同期処理とは、「時間のかかる作業を待っている間も、プログラム全体を止めずに動かし続ける」仕組みです。例えば、ファイルの読み込みやWeb APIの呼び出しなど、外部とのやりとりが発生する処理は、同期的に書くとアプリ全体が固まってしまいます。非同期処理を使うことで、UIの応答性を維持しつつ、効率的にリソースを活用できます。

async/awaitの基本

  • asyncキーワード:メソッドが非同期であることを示します。
  • awaitキーワード:非同期処理の完了を待ちますが、その間スレッドをブロックしません。
public async Task<string> DownloadDataAsync(string url)
{
    using (var client = new HttpClient())
    {
        return await client.GetStringAsync(url);
    }
}

このように、awaitを使うことで、**「待つけれど、他の処理も進められる」**という柔軟なプログラムが書けます。


よくあるミス・バグとその再現例

1. awaitの付け忘れ

再現コード

public async Task ProcessAsync()
{
    Task task = DoSomethingAsync();
    // await task; // ← これを忘れる
    Console.WriteLine("処理が完了しました");
}

private async Task DoSomethingAsync()
{
    await Task.Delay(1000);
    Console.WriteLine("内部処理完了");
}

解説

awaitを付け忘れると、DoSomethingAsync()完了を待たずに次の行が実行されます。結果として、「処理が完了しました」が先に表示され、内部処理が後から実行されるなど、意図しない順序になります。

正しい書き方

public async Task ProcessAsync()
{
    await DoSomethingAsync();
    Console.WriteLine("処理が完了しました");
}

ポイント

  • awaitを忘れると「fire-and-forget」になり、例外も捕捉できません。
  • 必ずawaitで待つ、またはTaskを返して呼び出し元でawaitするようにしましょう。

2. .Result / .Wait()によるデッドロック

再現コード

public async Task<string> GetDataAsync()
{
    await Task.Delay(1000);
    return "データ";
}

public void Button_Click(object sender, EventArgs e)
{
    // 非同期メソッドを同期的に待つ
    string result = GetDataAsync().Result; // または .Wait()
    MessageBox.Show(result);
}

解説

UIスレッド(WPF/WinFormsなど)で.Result.Wait()を使うと、デッドロックが発生しやすくなります。

  • UIスレッドがResultでブロック
  • await完了後、UIスレッドで再開しようとする
  • しかしUIスレッドはブロック中
  • お互い待ち合ってフリーズ

正しい書き方

private async void Button_Click(object sender, EventArgs e)
{
    string result = await GetDataAsync();
    MessageBox.Show(result);
}

ポイント

  • 「asyncは感染する」:一箇所を非同期にすると、呼び出し元もasyncにする必要がある
  • .Result.Wait()原則使わない。どうしても必要な場合はTask.Runで切り離す(ただし例外処理に注意)。

3. async voidの危険性と正しい使い分け

再現コード

public async void DoWork()
{
    await Task.Delay(1000);
    throw new Exception("エラー発生");
}

解説

  • async voidメソッドは完了を待てないため、呼び出し元が「いつ終わったか」「例外が発生したか」を知ることができません。
  • 例外が発生しても呼び出し元でキャッチできず、アプリが突然終了することもあります。

正しい使い方

  • イベントハンドラのみで使うprivate async void Button_Click(...)など)
  • それ以外は**必ずasync Taskまたはasync Task<T>**を使う
// イベントハンドラでの正しい例
private async void Button_Click(object sender, EventArgs e)
{
    try
    {
        await DoWorkAsync();
    }
    catch (Exception ex)
    {
        MessageBox.Show(ex.Message);
    }
}

// 通常の非同期処理
public async Task DoWorkAsync()
{
    await Task.Delay(1000);
    // ...
}

ポイント

  • async voidは**「配達中の荷物を追跡番号なしで送る」**ようなもの。完了も例外も追えません。
  • イベントハンドラ以外では絶対に使わない

4. ConfigureAwait(false)の誤用

再現コード

public async Task<string> GetDataAsync()
{
    var data = await FetchDataAsync().ConfigureAwait(false);
    // UIスレッドでUI更新しようとすると例外
    label.Text = data; // ← ここで例外
    return data;
}

解説

  • ConfigureAwait(false)は**「await後に元のスレッド(UIスレッドなど)に戻らない」**ことを意味します。
  • ライブラリやバックグラウンド処理では有効ですが、UI更新が必要な箇所では使うと例外になります。

正しい使い方

  • ライブラリやUIに依存しないコードでのみ使う
  • UIアプリやASP.NET Coreでは、基本的にConfigureAwait(false)は不要(ASP.NET Coreは同期コンテキストがないため)
// ライブラリ内での正しい例
public async Task<string> GetDataAsync()
{
    var data = await FetchDataAsync().ConfigureAwait(false);
    return data;
}

ポイント

  • ConfigureAwait(false)は**「デッドロック対策」ではなく、**「コンテキストスイッチの削減によるパフォーマンス向上」**が目的
  • UIスレッドでUI更新が必要な場合は使わない。

5. Task.Runの誤用

再現コード

public async Task<string> LoadTextAsync(string path)
{
    // ❌ I/O処理をTask.Runで包むのは無意味
    return await Task.Run(() => File.ReadAllTextAsync(path));
}

解説

  • I/Oバウンド(DB・HTTP・ファイルなど)の処理は、既に非同期APIが用意されているため、Task.Runで包む必要はありません。
  • Task.RunCPUバウンド処理(重い計算など)をUIスレッドから切り離すときに使います。

正しい使い方

// I/Oバウンドはそのままawait
public async Task<string> LoadTextAsync(string path)
{
    return await File.ReadAllTextAsync(path);
}

// CPUバウンド処理を切り離す例
public Task<byte[]> HashManyTimesAsync(byte[] data, int repeat, CancellationToken token)
{
    return Task.Run(() =>
    {
        token.ThrowIfCancellationRequested();
        using var sha256 = System.Security.Cryptography.SHA256.Create();
        byte[] current = data;
        for (int i = 0; i < repeat; i++)
        {
            token.ThrowIfCancellationRequested();
            current = sha256.ComputeHash(current);
        }
        return current;
    }, token);
}

ポイント

  • I/Oバウンドはawait、CPUバウンドはTask.Run
  • ASP.NET CoreではTask.Runはほぼ不要(リクエスト自体がスレッドプールで処理されるため)。

6. 非同期メソッドの戻り値の扱い(Task/Task/ValueTask/void)

再現コード

public async void DoWorkAsync() { ... } // ❌
public async Task DoWorkAsync() { ... } // ◎
public async Task<int> GetValueAsync() { ... } // ◎
public ValueTask<int> GetCachedValueAsync() { ... } // △

解説

  • 通常はTaskまたはTask<T>を使う。これにより、呼び出し元でawaitでき、例外も捕捉できます。
  • ValueTask<T>高頻度で同期的に完了する処理(キャッシュヒットなど)でのみ使う。誤用するとパフォーマンスが悪化したり、バグの原因になります。
  • async voidはイベントハンドラ専用。

使い分け表

戻り値型 使用場面 メリット デメリット
Task 値を返さない非同期処理 一般的で理解しやすい メモリ割り当てが必要
Task 値を返す非同期処理 型安全性が高い メモリ割り当てが必要
ValueTask キャッシュヒット率が高い処理 メモリ割り当ての削減 再利用や誤用に注意
void イベントハンドラのみ 必要最小限 例外が捕捉できない

ポイント

  • 基本はTask/Task。ValueTaskは限定的に使う
  • ValueTask<T>Task.WhenAllなどのタスクコンビネーターと相性が悪い(AsTask()で変換が必要)。

7. CancellationTokenの正しい扱いとキャンセル伝播

再現コード

public async Task DoWorkAsync()
{
    await Task.Delay(10000); // キャンセルできない
}

解説

  • 非同期メソッドは必ずCancellationTokenを引数に取るようにしましょう。
  • await対象がCancellationTokenを受け取れる場合は必ず渡す。
  • 受け取れない場合はtoken.ThrowIfCancellationRequested()を適宜呼び出す。

正しい書き方

public async Task DoWorkAsync(CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    await Task.Delay(10000, token);
}

キャンセルの伝播例

public async Task ParentAsync(CancellationToken token)
{
    await ChildAsync(token);
}

public async Task ChildAsync(CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    // ...
}

ポイント

  • キャンセルは例外(OperationCanceledException)で伝える
  • キャンセル時はcatchで握りつぶさず、必ず再throwまたは上流に伝播させる。

8. 並列実行の正しいパターン:Task.WhenAll/WhenAnyと並列制御

再現コード(逐次実行)

public async Task<List<Result>> ProcessItemsBadlyAsync(IEnumerable<Item> items)
{
    var results = new List<Result>();
    foreach (var item in items)
    {
        var result = await ProcessItemAsync(item); // 直列実行
        results.Add(result);
    }
    return results;
}

改善例(並列実行)

public async Task<List<Result>> ProcessItemsProperlyAsync(IEnumerable<Item> items)
{
    var tasks = items.Select(item => ProcessItemAsync(item));
    var results = await Task.WhenAll(tasks);
    return results.ToList();
}

並列数制限

public async Task ProcessWithLimitAsync(IEnumerable<Item> items, int maxDegreeOfParallelism)
{
    using var semaphore = new SemaphoreSlim(maxDegreeOfParallelism);
    var tasks = items.Select(async item =>
    {
        await semaphore.WaitAsync();
        try
        {
            await ProcessItemAsync(item);
        }
        finally
        {
            semaphore.Release();
        }
    });
    await Task.WhenAll(tasks);
}

ポイント

  • 依存関係のない複数処理はTask.WhenAllで並列実行
  • 並列数を制限したい場合はSemaphoreSlimParallel.ForEachAsyncを活用。

9. fire-and-forgetの管理方法と安全な実装

再現コード(危険なfire-and-forget)

public void FireAndForget()
{
    Task.Run(async () =>
    {
        await DoSomethingAsync();
        // 例外が消える、終了タイミングが不明
    });
}

解説

  • fire-and-forgetは例外や終了タイミングを見失いやすいため、安易に使うとバグの温床になります。
  • 本当に呼び出し元と寿命を切り離す必要がある場合は、ChannelやHostedServiceなど管理された場所で実行するのが安全です。

安全な実装例(ASP.NET CoreのHostedService)

public class BackgroundJobQueue : BackgroundService
{
    private readonly Channel<Func<Task>> _queue = Channel.CreateUnbounded<Func<Task>>();

    public ValueTask EnqueueAsync(Func<Task> work, CancellationToken token)
        => _queue.Writer.WriteAsync(work, token);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var work in _queue.Reader.ReadAllAsync(stoppingToken))
        {
            try
            {
                await work();
            }
            catch (Exception ex)
            {
                // ログ出力など
            }
        }
    }
}

ポイント

  • fire-and-forgetは管理された場所で実行し、例外や終了を追跡できるようにする
  • ASP.NET CoreではIHostedServiceBackgroundServiceを活用。

10. 例外処理とログ:非同期内の例外伝播と捕捉方法

再現コード(例外の握りつぶし)

public async Task ProcessDataBadlyAsync()
{
    try
    {
        await RiskyOperationAsync();
    }
    catch (Exception)
    {
        // 何もしない - 危険!
    }
}

改善例

public async Task ProcessDataProperlyAsync()
{
    try
    {
        await RiskyOperationAsync();
    }
    catch (SpecificException ex)
    {
        await HandleSpecificErrorAsync(ex);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Unexpected error occurred");
        throw;
    }
}

非同期メソッドの例外伝播

  • async Task/async Task<T>メソッドで発生した例外は、await時に呼び出し元へ伝播します。
  • async voidの場合は呼び出し元でキャッチできず、アプリがクラッシュすることも。

ポイント

  • catchで例外を握りつぶさない。ログ出力や再throwで痕跡を残す
  • 非同期メソッドはTask/Taskを返し、awaitで例外を捕捉する。

11. I/OバウンドとCPUバウンドの見分け方と対処法

I/Oバウンド

  • DB・HTTP・ファイルなど、外部の完了待ちが中心の処理
  • async APIをそのままawaitするのが基本

CPUバウンド

  • 圧縮・画像処理・重い計算など、CPU計算が中心の処理
  • Task.Runでスレッドプールに逃がす(UIスレッドをフリーズさせない)

使い分け表

種類 対処法
I/Oバウンド DB・HTTP・ファイル async APIをawait
CPUバウンド 画像処理・集計 Task.Runで切り離す

ポイント

  • I/Oバウンドはawait、CPUバウンドはTask.Run
  • I/OバウンドをTask.Runで包むのは無意味。

12. LINQでタスクを扱うときの落とし穴

再現コード(逐次実行)

var results = new List<Result>();
foreach (var item in items)
{
    var result = await ProcessItemAsync(item); // 直列実行
    results.Add(result);
}

改善例(並列実行)

var tasks = items.Select(item => ProcessItemAsync(item));
var results = await Task.WhenAll(tasks);

解説

  • LINQでタスクを生成しただけでは実行されませんToArray()ToList()で確定し、Task.WhenAllでまとめてawaitすることで並列実行が可能です。

ポイント

  • LINQでタスクを生成したら、ToArray/ToListで確定し、Task.WhenAllでawait

13. awaitをまたぐ排他制御とSemaphoreSlimの使い方

再現コード(lockの誤用)

private readonly object _lockObj = new object();

public async Task FooAsync()
{
    lock (_lockObj)
    {
        await Task.Delay(1000); // ❌ lock内でawaitはNG
    }
}

改善例(SemaphoreSlimの利用)

private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);

public async Task FooAsync()
{
    await _semaphore.WaitAsync();
    try
    {
        await Task.Delay(1000);
    }
    finally
    {
        _semaphore.Release();
    }
}

解説

  • lockは同期的な排他制御であり、awaitと組み合わせるとデッドロックや予期せぬ動作の原因になります。
  • 非同期処理の排他制御にはSemaphoreSlimを使うのが定石です。

ポイント

  • 非同期の排他制御はSemaphoreSlimを使う
  • WaitAsync/Releaseで排他制御、try/finallyで必ず解放。

14. IAsyncEnumerableとawait foreachの使い方と注意点

サンプルコード

public async IAsyncEnumerable<string> ReadLinesAsync(string path)
{
    using var reader = new StreamReader(path);
    string? line;
    while ((line = await reader.ReadLineAsync()) != null)
    {
        yield return line;
    }
}

// 呼び出し側
await foreach (var line in ReadLinesAsync("file.txt"))
{
    Console.WriteLine(line);
}

解説

  • IAsyncEnumerable<T>非同期ストリームを表現します。
  • await foreachで逐次的にデータを処理でき、大量データのメモリ効率が向上します。
  • ただし、複数回の列挙や並列処理には注意が必要です。

ポイント

  • 大量データやストリーミング処理にIAsyncEnumerable/await foreachを活用
  • 列挙のたびに新しいストリームを開く設計にする。

15. 非同期での破棄:IAsyncDisposableとawait using

サンプルコード

public class ProperResourceManager : IAsyncDisposable
{
    private readonly HttpClient _client = new();
    private bool _disposed;

    public async ValueTask DisposeAsync()
    {
        if (_disposed) return;
        _disposed = true;
        _client.Dispose();
        await CleanupAsync();
        GC.SuppressFinalize(this);
    }

    private async Task CleanupAsync()
    {
        await Task.CompletedTask;
    }
}

// 利用側
await using var manager = new ProperResourceManager();

解説

  • 非同期でリソースを破棄する場合はIAsyncDisposable/await usingを使う
  • ファイルやネットワークリソースなど、非同期での解放が必要な場合に有効

ポイント

  • 非同期破棄はIAsyncDisposable/await usingで実装
  • DisposeAsyncで非同期リソース解放。

16. ValueTaskの利点と落とし穴

解説

  • ValueTask<T>同期的に完了するケースが多い処理でメモリ割り当てを削減できます。
  • ただし、構造体であるためコピーコストが増え、状態機械のオーバーヘッドも増加します。
  • Task.WhenAllなどのタスクコンビネーターと組み合わせると、結局Taskに変換されてしまい、メリットが失われます。

使い分け表

手法 メリット デメリット 推奨度
Task 汎用性・デバッグ容易 常にヒープ割り当て 高(既定)
ValueTask 同期完了時のアロケーション削減 コピーコスト・タスクコンビネーター非対応 低(限定的)

ポイント

  • ValueTaskは「本当に必要な場合のみ」使う
  • プロファイリングでボトルネックを確認してから導入。

17. UIアプリ(WPF/WinForms)での非同期:SynchronizationContextとUI再開

解説

  • awaitは既定でSynchronizationContext(UIスレッド)に復帰します。
  • UI更新が必要な場合はそのままawait、バックグラウンド処理ではConfigureAwait(false)でコンテキストスイッチを減らす。
  • UIスレッドで.Result.Wait()を使うとデッドロックの原因になるので注意。

ポイント

  • UIアプリではawaitでUIスレッドに戻ることを前提に設計
  • バックグラウンド処理はConfigureAwait(false)で効率化。

18. ASP.NET Coreでの非同期ベストプラクティス

解説

  • ASP.NET Coreは既にスレッドプールでリクエストを処理しているため、Task.Runはほぼ不要。
  • I/Oバウンド処理は必ずasync/awaitで非同期化し、スレッドをブロックしない設計にする。
  • キャンセルはHttpContext.RequestAbortedを伝播させる。
  • fire-and-forgetはHostedServiceやChannelで管理する。

ポイント

  • Controllerからデータアクセス層まで全面非同期化
  • .Result/.Wait()/GetAwaiter().GetResult()は使わない
  • キャンセル・タイムアウト・ストリーミングを徹底

19. 非同期コードの単体テストとテストパターン(xUnitなど)

再現コード(悪い例)

[TestMethod]
public void IncorrectlyPassingTest()
{
    SystemUnderTest.SimpleAsync(); // awaitしない
}

改善例

[TestMethod]
public async Task CorrectlyFailingTest()
{
    await SystemUnderTest.FailAsync();
}

解説

  • テストメソッドもasync Taskで書き、awaitで非同期メソッドを待つのが正しい
  • async voidのテストはサポートが不安定で、例外が捕捉できないため避ける

xUnitの例

public class MyTests
{
    [Fact]
    public async Task MyAsyncTest()
    {
        var result = await MyAsyncMethod();
        Assert.Equal(expected, result);
    }
}

ポイント

  • 非同期テストはasync Taskでawaitする
  • 例外テストはAssert.ThrowsAsyncなどを使う。

20. デバッグと診断:Visual Studio/ログで非同期バグを見つける方法

解説

  • Visual Studioの**並列スタックウィンドウ(タスクビュー)**を活用すると、非同期呼び出し履歴やブロックされたタスクを可視化できる
  • 同期オーバー非同期パターン(.Result/.Wait())によるスレッドプール枯渇も検出しやすい
  • ログ出力は例外発生時のスタックトレースや関連データも記録する

ポイント

  • デバッグ時はタスクビューやスレッドビューを併用
  • ログにはエラー内容・スタックトレース・関連データを残す。

21. 実務でのチェックリストとコードレビュー項目

チェックリスト例

  • Controller/Minimal APIからデータアクセス層まで全て非同期APIで貫通している
  • .Result/.Wait()/GetAwaiter().GetResult()が存在しない
  • 外部HTTP/DB/ファイルのキャンセルがHttpContext.RequestAbortedに繋がっている
  • 大きな応答はストリーミング、巨大リストの一括シリアライズを避ける
  • fire-and-forgetはHostedServiceやChannelで管理されている
  • 例外処理・ログ出力が適切に実装されている
  • 非同期テストはasync Taskでawaitしている

ポイント

  • コードレビュー時は非同期処理の一貫性・例外処理・キャンセル伝播・リソース解放などを重点的に確認
  • チェックリストを活用して抜け漏れを防ぐ

コピペで使える再現コード集・テンプレート

非同期メソッドの基本形

public async Task ProcessDataAsync(Data data)
{
    await Task.Delay(100); // 例示用の遅延
    await ProcessInternalAsync(data);
}

値を返す非同期メソッド

public async Task<Result> GetResultAsync()
{
    var data = await FetchDataAsync();
    return new Result { Data = data };
}

イベントハンドラでのasync void

private async void Button_Click(object sender, EventArgs e)
{
    try
    {
        await ProcessDataAsync(new Data());
    }
    catch (Exception ex)
    {
        MessageBox.Show(ex.Message);
    }
}

並列実行(Task.WhenAll)

public async Task ProcessMultipleAsync(IEnumerable<Item> items)
{
    var tasks = items.Select(item => ProcessItemAsync(item));
    var results = await Task.WhenAll(tasks);
}

排他制御(SemaphoreSlim)

private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);

public async Task FooAsync()
{
    await _semaphore.WaitAsync();
    try
    {
        // 排他的な処理
    }
    finally
    {
        _semaphore.Release();
    }
}

非同期破棄(IAsyncDisposable/await using)

public class ResourceManager : IAsyncDisposable
{
    public async ValueTask DisposeAsync()
    {
        // 非同期リソース解放
        await Task.CompletedTask;
    }
}

// 利用側
await using var manager = new ResourceManager();

まとめ:覚えておきたい3つの原則

  1. async all the way down
    .Result.Wait()は使わず、呼び出し階層を上まですべて非同期にする

  2. I/OバウンドとCPUバウンドを区別する
    I/Oはawait、CPU処理を切り離したいときだけTask.Run

  3. ConfigureAwait(false)の目的を正しく理解する
    デッドロック対策ではなく、ライブラリコードでのコンテキストスイッチ削減が目的


おわりに

C#の非同期プログラミングは、正しく使えばアプリの応答性やスケーラビリティを大きく向上させます。しかし、落とし穴も多く、**「なぜ動かないのか」「なぜ固まるのか」**に悩むことも少なくありません。

本記事が、Qiita読者のみなさんの「非同期処理の壁」を乗り越える一助となれば幸いです。ストックやいいね、コメントでのフィードバックも大歓迎です!

今後も、実務で役立つC#や.NETのTips、非同期プログラミングの応用例、パフォーマンスチューニング、テストやデバッグのノウハウなどを発信していきます。Qiitaのフォローもぜひお願いします!


あなたの「非同期処理の悩み」や「こんなケースで困った」など、コメントで教えてください。次回以降の記事で取り上げるかもしれません!

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?