C# async Task の return と break の違い|await Task.CompletedTask は必要?

C# の async Task メソッド内で foreach を回している最中に、条件を満たしたら処理を打ち切りたい——そんなときに迷うのが break と return の使い分けです。本記事では Task.CompletedTask の位置づけも含め、意図どおりに終了させる判断基準と実装例をまとめます。

目次

結論:やりたいことが「メソッド終了」か「ループ終了」かで決まる

最初に結論を整理します。async Task であっても、break と return の役割は同期メソッドと同じです。「どこまで処理を終わらせたいか」で選びます。

やりたいこと使うキーワード何が終わる?ありがちな使いどころ
条件を満たしたら、以降の処理を一切せずに呼び出し元へ戻りたいreturn;メソッド全体「ここで終わり」が確定している(エラー、検出、終了条件など)
ループは止めたいが、ループ後の後処理(ログ、集計、クリーンアップなど)は続けたいbreak;現在のループループ結果を使って後続処理を行う、最後に必ず後処理をしたい
「非同期メソッドだから何か await しないといけない気がする」ので、とりあえず待ちたいawait Task.CompletedTask;ほぼ何も変わらないこの用途では不要。設計意図がある場合を除き避ける

つまり、質問の「ある条件を満たしたら戻りたい」がメソッド自体を終えたい意味なら return; が正解です。ループだけ抜けたいなら break; を選びます。

前提:質問のコードで何が起きているか

質問のイメージは次のような形です。

public async Task MyMethod()
{
    string[] lists = GetCustomerLists();

    foreach (string lst in lists)
    {
        // いろいろ処理
        // ある条件を満たしたら「戻りたい」
        ????
    }
}

ここで迷いやすいポイントは、「async Task って Task を返すんだから、return; だけでいいの?」「Task.CompletedTask を返したり await したりする必要があるの?」という不安です。結論から言うと、この場面では return; と break; の選択だけで足りることがほとんどです。

なぜ async Task でも return; で終われるのか

async メソッドは、コンパイラが裏側で「状態(ステート)を持つ仕組み」に変換し、呼び出し元に Task(完了を表す入れ物)を返します。重要なのは次の2点です。

  • return; は「完了」を意味する:async Task(戻り値なし)の場合、return; でメソッドを抜けると、コンパイラが完了済みの Task を返したのと同等に扱ってくれます。
  • 例外は Task に乗る:async メソッド内で例外が投げられると、呼び出し側から見た Task は「失敗(Faulted)」になります。await している側では、その例外が再スローされます。

そのため、「非同期だから Task を明示的に返さないといけない」という発想はここでは不要です。return; と書けば、コンパイラが「この Task は完了した」として扱ってくれます。

もう少し実務に近い言い方をすると、async Task は最後まで実行し切るか、途中で return; するか、例外で落ちるかのどれかで Task の状態が決まります。これが理解できると、Task.CompletedTask をわざわざ書かなくてよい理由も自然に腑に落ちます。

メソッド自体を途中で終了したいなら return;

「条件を満たしたら処理を打ち切り、MyMethod 自体を終わらせたい」なら return; を使います。ループの途中であっても、メソッドの外に一気に戻れます。

public async Task MyMethod()
{
    string[] lists = GetCustomerLists();

    foreach (string lst in lists)
    {
        // 条件を満たしたらメソッドを終了
        if (ShouldStop(lst))
        {
            return; // MyMethod 全体がここで終了(完了済み Task を返すのと同等)
        }

        await DoSomethingAsync(lst);
    }

    // ここには到達しない
}

ポイントは、return; が「非同期らしくない」わけではない、ということです。await が存在するかどうかとは関係なく、return; は「これ以上続けない」という明確な意思表示になります。

return; しても finally や using は動く

「途中で return したら後始末が飛ぶのでは?」と心配する方もいますが、C# は return / break / 例外などでスコープを抜けるとき、finally ブロックや using による破棄処理を実行します。async でも基本は同じです。

public async Task MyMethod()
{
    using var scope = new SomeScope(); // IDisposable の例

    foreach (var lst in GetCustomerLists())
    {
        if (ShouldStop(lst))
        {
            return; // scope.Dispose() は呼ばれる
        }

        await DoSomethingAsync(lst);
    }
}

ただし、IAsyncDisposable を非同期に破棄したい場合は await using を使います(C# 8 以降)。

public async Task MyMethod()
{
    await using var scope = new AsyncScope(); // IAsyncDisposable の例

    foreach (var lst in GetCustomerLists())
    {
        if (ShouldStop(lst))
        {
            return; // scope.DisposeAsync() が await される
        }

        await DoSomethingAsync(lst);
    }
}

「途中終了したいけど、必ず後処理をしたい」場合は return を避けるのではなく、try/finally(または using / await using)で後処理を保証するのが筋のよい設計です。

return; を使うと読みやすいケース

実務では、次のような場合に早期 return が特に効きます。

  • 前提条件が満たされない(入力不正、設定不足、対象が存在しない)
  • 致命的な状態を検出した(DB 接続不可、認証失敗など)
  • 「見つけたら終わり」(検索・探索系)
  • 「成功したら終わり」(リトライや複数候補の試行)

こうした場面では、無理にフラグ変数でループを続けるより、return でスパッと終わらせる方がコードが短く、意図もはっきりします。

ループだけ抜けて後続処理を続けたいなら break;

「ループは打ち切りたいが、このメソッドとしてはまだやることがある」なら break; が適切です。break はあくまで foreach(または for / while)から抜けるだけで、メソッドの実行は続きます。

public async Task MyMethod()
{
    string[] lists = GetCustomerLists();

    foreach (string lst in lists)
    {
        if (ShouldStop(lst))
        {
            break; // ループだけ抜ける
        }

        await DoSomethingAsync(lst);
    }

    // ここには到達する
    await AfterLoopAsync();
}

break; を選ぶべき典型パターン

  • ループの後に必ず実行したい後処理がある(ログ、通知、集計、リソース解放など)
  • 「途中までの結果」を使って後で判断したい(例:処理件数が一定なら別の処理へ)
  • ループ終了理由によって分岐したい(停止理由を変数に持っておく)

特に最後の「終了理由で分岐したい」は、break と相性が良いです。単に break するだけでなく、「なぜ抜けたのか」を残すと保守性が上がります。

public async Task MyMethod()
{
    bool stoppedByCondition = false;

    foreach (var lst in GetCustomerLists())
    {
        if (ShouldStop(lst))
        {
            stoppedByCondition = true;
            break;
        }

        await DoSomethingAsync(lst);
    }

    if (stoppedByCondition)
    {
        await NotifyStoppedAsync();
    }
    else
    {
        await NotifyCompletedAsync();
    }
}

「メソッドから戻りたい」という意図で break を使ってしまうと、ループ後の処理が走ってしまい、想定外の副作用につながります。ループ後に何があるかを見て、本当に break でいいのかを必ず確認してください。

await Task.CompletedTask; はこの用途では不要

結論として、質問の「条件を満たしたら処理を打ち切りたい」という用途で await Task.CompletedTask; を使う必要はありません。理由はシンプルで、Task.CompletedTask は「すでに完了している Task」だからです。

  • await は「未完了なら待つ」ための構文
  • 完了済みの Task を await しても、基本的に待ちが発生しない(その場で終わる)
  • つまり await Task.CompletedTask; は、多くのケースで意味のない 1 行になりやすい

「非同期メソッドなのだから、最後に await Task.CompletedTask; を書いておけば安心」という発想は、可読性を下げるだけになりがちです。読み手は「ここで何か待つ理由があるのかな?」と誤解しますし、レビュー時に説明が必要になります。

例外:Task.CompletedTask が登場する「正しい」場面

Task.CompletedTask 自体が悪いわけではありません。次のように、「async ではないが Task を返したい」ときに便利です。

// await が一切ないなら、async を付けない選択肢がある
public Task MyMethod()
{
    // 同期処理だけで完了する
    DoSyncWork();
    return Task.CompletedTask;
}

async Task にしてしまうと、コンパイラは状態マシン生成などのオーバーヘッドを持ちます。実際には非同期処理がないのに async を付けると、次のような警告(代表例:CS1998)が出ることがあります。

  • 「この async メソッドには await がなく、同期的に実行されます」

この場合は、async を外して Task.CompletedTask を返す方が、意図が明確で効率的です。「とりあえず await を書いて警告を消す」ために await Task.CompletedTask を置くのは避けた方が無難です。

「意図的に一度だけ非同期にしたい」なら Task.Yield() を検討する

もし「必ず一度は非同期にして、呼び出し元に制御を返したい」という明確な意図があるなら、Task.CompletedTask ではなく Task.Yield()(または状況に応じた待機)を検討します。

public async Task MyMethod()
{
    // 意図的に一度だけスケジューラに制御を返す
    await Task.Yield();

    // ここから先は後続処理
    await DoSomethingAsync();
}

ただし Task.Yield() は UI アプリや一部の特殊な状況で意味が出るもので、乱用すると逆にパフォーマンスや追跡性が悪化します。「何となく非同期っぽくしたい」という理由では使わない方が安全です。

Task<T> を返す場合:return 値; で早期終了する

戻り値がある非同期メソッド(Task<T>)の場合、return 値; で T を返します。ここでも、値を包んで Task<T> にするのはコンパイラの仕事です。

public async Task<int> CountAsync()
{
    int count = 0;

    foreach (var lst in GetCustomerLists())
    {
        if (ShouldStop(lst))
        {
            return count; // Task<int> の T にあたる int を返す
        }

        count += await CountOneAsync(lst);
    }

    return count;
}

Task<T> では、return;(式なし)は書けません。必ず T の値を返す必要があります。逆に Task(戻り値なし)では、return; だけでよい、という違いがポイントです。

「途中で止めたい」を設計で扱う:実務向けの考え方

ループの途中終了は、単に break / return を選ぶだけでなく、「なぜ止めたいのか」「誰が止めるのか」を整理すると設計が安定します。ここではよくある要件別にパターンを紹介します。

内部条件で止める:return と break を素直に使う

停止条件がメソッド内部で完結しているなら、シンプルに書くのが最善です。

  • 後続処理が不要なら return;
  • 後続処理が必要なら break;(またはフラグを立ててから break)

このパターンで重要なのは、「停止後に何をしたいのか」を最初に決めることです。途中で仕様が変わりやすい場合は、後処理を finally に寄せると修正が楽になります。

外部要因で止める:CancellationToken を使う

「ユーザーがキャンセルした」「サーバー側がタイムアウトさせたい」「上位処理から止めたい」といった外部要因で止める場合は、CancellationToken を受け取る設計が定石です。単に return で抜けるよりも、呼び出し側と合意された形で終了できます。

public async Task MyMethodAsync(CancellationToken ct)
{
    foreach (var lst in GetCustomerLists())
    {
        ct.ThrowIfCancellationRequested(); // キャンセル要求があれば例外で終了

        if (ShouldStop(lst))
        {
            return; // 内部条件による終了
        }

        await DoSomethingAsync(lst, ct);
    }
}

キャンセルを「例外(OperationCanceledException)」として扱うのが一般的なのは、呼び出し側で await したときに「キャンセルだった」と判定しやすいからです。もちろん、アプリの方針によっては return で静かに終える設計もありますが、その場合でも「キャンセルで終わったのか、正常終了なのか」が分かるように戻り値(結果型)を設計すると混乱が減ります。

タイムアウトで止める:CancelAfter を組み合わせる

処理全体にタイムアウトを設けたい場合は、CancellationTokenSource の CancelAfter を使うと実装が素直になります。

public async Task MyMethodAsync(CancellationToken ct)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
    cts.CancelAfter(TimeSpan.FromSeconds(10));

    foreach (var lst in GetCustomerLists())
    {
        cts.Token.ThrowIfCancellationRequested();
        await DoSomethingAsync(lst, cts.Token);
    }
}

「途中で止めたい」が性能やUXの要件から来ている場合、break / return だけでなく、キャンセルやタイムアウトを先に設計として用意しておくと、将来的に仕様が増えても対応しやすくなります。

並列化している場合:return しても裏で Task が走り続けることがある

質問のコードは await DoSomethingAsync(lst) のように逐次待機している想定なので、return すれば「それ以降は何も実行されない」状態になります。ただし実務では、次のような非 await の Task(いわゆる fire-and-forget)を作っていると、return しても処理が裏で継続することがあります。

public async Task MyMethod()
{
    foreach (var lst in GetCustomerLists())
    {
        // await しない Task を開始(注意)
        _ = DoSomethingAsync(lst);

        if (ShouldStop(lst))
        {
            return; // ここで戻っても、開始した Task は裏で動き続ける
        }
    }
}

この形は、例外が握りつぶされたり(未観測例外)、リソース競合が起きたり、アプリ終了時に中途半端な状態になったりと、トラブルの温床になります。並列に走らせたいなら、次のようにTask を収集して最後に待つか、キャンセルを設計に組み込みます。

public async Task MyMethodAsync(CancellationToken ct)
{
    var tasks = new List<Task>();

    foreach (var lst in GetCustomerLists())
    {
        ct.ThrowIfCancellationRequested();

        tasks.Add(DoSomethingAsync(lst, ct));

        if (ShouldStop(lst))
        {
            break; // ここでは break して、最後にまとめて待つ設計もあり
        }
    }

    await Task.WhenAll(tasks);
}

「途中で止めたい」の真意が「並列処理を止めたい」なのか「単にループをやめたい」なのかで、最適解は変わります。前者なら CancellationToken とタスク管理が重要です。

break / return / continue / throw の使い分け早見表

途中終了の文脈では、continue や throw も含めて整理しておくと迷いが減ります。

キーワード効果向いている用途注意点
continue;現在のループの残りをスキップして次の反復へ「この要素は処理対象外」など、スキップしたい意図が曖昧だと読み手が迷う。条件は分かりやすく書く
break;現在のループを終了して次の文へ「これ以上ループする意味がない」メソッドは終わらない。後続処理が走ることを忘れない
return;メソッドを終了して呼び出し元へ「ここで処理を終える」後続処理が必要なら break + 後処理、または try/finally を検討
throw;例外で終了(Task は Faulted になる)異常終了を明確に伝えたい例外設計(例外型・メッセージ・ログ)が重要。キャンセルは別扱い

よくある勘違い・落とし穴と対処法

「break でも return でも動くように見える」ことがあるのが、このテーマのややこしい点です。ありがちな落とし穴を先に知っておくと、バグの芽を潰せます。

症状原因対処
「条件を満たしたら止めたつもり」なのに、ループ後の処理が走ってしまうbreak を使っていてメソッドが終了していないメソッドを終えたいなら return;。後処理が必要なら構造を見直す
await Task.CompletedTask; を書いたが、動作が変わらない完了済み Task を待っているだけで実質 no-op目的が「終了」なら return / break。目的が「一度非同期」なら Task.Yield 等を検討
非同期処理を止めたつもりが、裏で走り続ける開始した Task を await していない(fire-and-forget)Task を収集して WhenAll で待つ、またはキャンセル可能に設計する
例外がどこで起きたか分かりにくいループ内で例外を握りつぶしている / ログがない例外は基本的に上に伝える。握るなら意味のあるログとリカバリ方針を用意
メソッドが async なのに await がなく警告が出る非同期処理が実際には存在しないasync を外して Task.CompletedTask を返す。将来のために残すなら理由をコメントで明確に

実装をスッキリさせる小技:処理を分割して「戻りたい場所」を明確にする

ループの途中で戻る条件が複雑になると、break と return の混在で読みづらくなることがあります。そんなときは、処理をメソッドに分割して「どこまでがループの責務か」をはっきりさせると見通しが良くなります。

public async Task MyMethodAsync()
{
    var result = await ProcessListsAsync();

    // ここから先は「ループとは別の責務」に分離
    await AfterProcessAsync(result);
}

private async Task<ProcessResult> ProcessListsAsync()
{
    foreach (var lst in GetCustomerLists())
    {
        if (ShouldStop(lst))
        {
            return ProcessResult.Stopped;
        }

        await DoSomethingAsync(lst);
    }

    return ProcessResult.Completed;
}

こうしておくと、途中終了は ProcessListsAsync の中で完結し、呼び出し側は結果に応じた後処理を書くだけになります。規模が大きくなるほど、こうした分割は効果が出ます。

まとめ:async Task の途中終了は「同期と同じ感覚」でOK

  • メソッド自体を途中で終えたいなら return;(async Task でも明示的に Task を返す必要はない)
  • ループだけ抜けて後続処理を続けたいなら break;(ループ後の処理が走ることを前提に設計する)
  • await Task.CompletedTask; はこの目的では不要(意味のある待機ではない)
  • 「外部から止めたい」「並列処理も止めたい」なら CancellationToken とタスク管理を合わせて考える

async は「await できる」仕組みを提供するだけで、break と return の基本ルールを変えるものではありません。意図(どこまで止めるか)を先に決め、コード構造でそれをはっきり表現するのが、最短でバグを減らすコツです。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次