はじめに
今日はコードを書いててふと思ったことから、知識を深めていきたいなと思います!
C# で複数件の非同期処理を書くとき、こんな感じで foreach を回すことありますよね。
var updatedStaffs = new List<Staff>();
foreach (var staff in staffs)
{
var updated = await _staffRepository.UpdateAsync(staff);
// UpdateAsyncの内部でSaveChangesAsyncまで行う想定
updatedStaffs.Add(updated);
}
foreach で回してそのリストを取得したいパターンです。
ただこれ、LINQ に慣れてくると少し冗長なんじゃない?と思ったりするわけです。
だって、非同期処理回してリストを取得したいわけでしょ?
じゃぁ、Select 使えば、もっと短く書けそうなんじゃないか。。。
// ※後述しますが、UpdateAsync が同じ DbContext を共有している場合、
// このコードでは例外が発生する可能性があります。
// DbContext はスレッドセーフではなく、複数の処理から同時に操作できないためです。
var updatedStaffs = await Task.WhenAll(
staffs.Select(staff => _staffRepository.UpdateAsync(staff))
);
こちらの方が、
- ループを明示的に書かなくていい
- 結果の配列も一発で取れる
- コードが短く見える
…最高じゃないか。
んー、、、でもこれってやってること同じなのかな…????
何がどう違うんだっけ。。。
というのが今回のテーマです。
それぞれの処理の仕方
foreach の場合
まずは foreach ってなんだったっけ?を整理。
foreach で await するコードは、基本的に1件ずつ順番に処理します。わかりやすい。
foreach (var staff in staffs)
{
await _staffRepository.UpdateAsync(staff);
}
イメージとしてはこんな感じ。
staff1: [更新------------完了]
staff2: [更新------------完了]
staff3: [更新------------完了]
1件目の処理が終わったら2件目、2件目が終わったら3件目……という順番です。
Task.WhenAll & Select の場合
一方で、Task.WhenAll と Select。
調べてみたところによると、これらを組み合わせると、
「全件分の処理をまとめて開始し、すべてが終わるまで待つ」
ということになるらしい。
await Task.WhenAll(
staffs.Select(staff => _staffRepository.UpdateAsync(staff))
);
イメージとしてはこんな感じ。
staff1: [更新------------完了]
staff2: [更新--------完了]
staff3: [更新----------------完了]
つまり、staffs が100件あれば、1件目の完了を待たずに、2件目、3件目……と短時間に全件分の UpdateAsync が呼び出されます。
その結果、複数の非同期処理が同時進行する形になります。
foreach を「短く書いた」だけではなく、「逐次的に待つ処理から、複数の非同期処理を並行して進める処理に変えた」というところが大きく違う点です。
ここで注意したいのが、「並列処理」と「並行処理」の違いです。
Task.WhenAll を使えば、必ず複数のCPUで処理されるというわけではありません。
今回のようなデータベース更新やHTTP通信などでは、1件目の応答を待っている間に2件目、3件目の処理も進める、という「並行処理」になります。
ただこれ、
「全件の処理を待つって言ったって……Select を1回書いているだけだから、Task も1つしか作られないんじゃないの?」
と思ってしまうけども、、、
このように書き直すと少しわかりやすくなるかもしれない。
var tasks = staffs
.Select(staff => _staffRepository.UpdateAsync(staff))
.ToArray(); // Task<Staff>[]
var updatedStaffs = await Task.WhenAll(tasks);
ToArray() によって、各 staff の UpdateAsync が呼び出され、全件分の Task<Staff> が配列として作られます。
この tasks は1つの Task ではなく、Task の集合体なわけです。
つまり、こんな感じで書いているのと同じイメージですね。
var tasks = new[]
{
_staffRepository.UpdateAsync(staff1),
_staffRepository.UpdateAsync(staff2),
_staffRepository.UpdateAsync(staff3),
};
var updatedStaffs = await Task.WhenAll(tasks);
それぞれの UpdateAsync は、前の処理の完了を待たずに次々と呼び出されます。
Task.WhenAll は処理を開始するものではなく、作成されたすべての Task が完了するまで待つものです。
それぞれのデメリット
見た感じ、並行実行する方が早くてよさそうに見えますね。
ただし、それぞれにデメリットがあります。
foreach の場合
処理時間が長い
待ち時間を有効活用できないので、大量件数では全体の処理時間が伸びやすいです。
1件目の処理が終わるまで2件目を開始しないので、DBやHTTP通信などの待ち時間がそのまま積み重なっていきます。
途中で1件失敗すると、以降の処理が実行されない
1件ずつ進めていくので、途中で例外が発生すると、そこで処理が止まります。
全項目を処理したい場合には向かないかも。
※もちろん、例外をその場で try-catch して次へ進むようにすれば続行できます。
staff1: 成功
staff2: 成功
staff3: 失敗
staff4: 未実行
staff5: 未実行
結果を集めるコードがやや冗長
これは今回の初めに書いた通り、リストを事前に用意するのが冗長に見えるということですね。
ただ、これは実行上のデメリットではなく、記述上のデメリット。
Task.WhenAll & Select の場合
DBや外部APIに負荷が集中しやすい
全件分の処理をまとめて開始するため、件数が多いと大量のリクエストが短時間に発生して負荷がかかります。
メモリ使用量なども増える可能性があります。
共有リソースを同時に利用できない場合がある
同じ DbContext など、複数の処理から同時に使用できないオブジェクトを共有していると、エラーになってしまう可能性があります。
途中で処理を止めにくい
複数の非同期処理を並行して進めるため、
「1件失敗したら後続を実行しない」
という制御には向きません。
実行順序を制御しにくい
各処理の完了順序は保証されないため、順番に意味がある処理には向きません。
なお、「完了順序が保証されない」のであって、Task.WhenAll が返す結果配列まで完了順に並び替わるわけではありません。
結果配列は、渡した Task の順番に対応します。
例外処理が複雑になりやすい
Task.WhenAll は、どれか1件が失敗した瞬間に処理を終了するわけではありません。
すでに開始されたほかの Task もそのまま処理を続け、すべてが完了したあとで Task.WhenAll 全体が失敗として扱われます。
staff1: 成功
staff2: 失敗
staff3: 処理継続 → 成功
staff4: 処理継続 → 失敗
staff5: 処理継続 → 成功
全件完了後、await Task.WhenAll(...) で例外
なので、どの要素が成功して、どの要素が失敗したのかを個別に判定したい場合は、元データと Task の対応を保持するなど、追加の実装が必要になります。
なるほど。
ここまでくるとわかりますが、さっきの Task.WhenAll でデータを UpdateAsync していたコードは、場合によっては少し危険です。
たとえば、それぞれの UpdateAsync が同じ DbContext を共有している場合です。
EF Core の DbContext はスレッドセーフではないため、同じインスタンスに対して複数の非同期操作を並行して実行すると、例外が発生する可能性があります。
ただし、DB更新だから必ず Task.WhenAll が使えないというわけではありません。
処理ごとに別の DbContext を使う設計であれば並行実行できる場合もありますが、その場合も接続数やDBへの負荷には注意が必要です。
まとめ
何となく短く書けそう、ということで Task.WhenAll という書き方がよさそうに思ってしまいますが、
しっかり何をやっているのか理解すれば、
短いからいい、並行実行する方が早そうだからいい、という単純なことではない
ということがわかりますね。
「foreach は冗長」ではなく「逐次処理をすることを明示している」
foreach は、たしかに Select より長く見えました。
var updatedStaffs = new List<Staff>();
foreach (var staff in staffs)
{
var updated = await _staffRepository.UpdateAsync(staff);
updatedStaffs.Add(updated);
}
しかし、このコードは単に冗長なのではなく、次の意図を示しているのです。
- この更新処理は1件ずつ実行する
- 更新が終わったものから結果に追加する
- 途中で例外が起きたら、その場で止める
- 必要ならログや条件分岐を挟める
つまり、foreach は「古い書き方」ではなく、同時実行数を1に制御する書き方なんですね。
Task.WhenAll + Select は「前の処理の完了を待たずに、全件分の処理を開始してよい」を明示している
逆に、Task.WhenAll + Select が常に悪いという話でもありません。
件数が少ない場合や、全件分の処理を並行して進めても問題ない場合は、便利に使える書き方だということです。
問題になりやすいのは、次のようなケースです。
- 件数が多い
- 同じ
DbContextなど、同時利用できないリソースを共有している - DBや外部APIが大量の同時アクセスを想定していない
- 外部APIの同時接続数やレートに制限がある
- Azure Blob などへのアクセスを大量に同時実行する
- 同時実行数を制御していない
- 失敗した要素を個別に追跡したい
- 1件失敗した時点で後続の処理を止めたい
これらを考えずに書いてしまうと、
- 接続数が急増し、DBや外部APIに負荷が集中する
- 外部サービスの制限を超えると、429や503などが返る可能性がある
- タイムアウトする
- 一部失敗時の扱いが複雑になる
といった問題につながる可能性があります。
これらの違いや特性を理解したうえで使うのがよさそうです。
ちなみに、
「foreach で1件ずつ実行する」
か、
「Task.WhenAll で全件を一気に実行する」
か、という二択ではありません。
たとえば、
同時に実行する処理を最大5件までに制限する
といった中間の方法も考えられます。
1件ずつ実行するか、全件を並行実行するか、同時実行数を制限するか。
処理の件数や性質に合わせて、最適な実行方法を選べるようになっていきたいですね。