📘

[C#]asyncを利用してCPUバウンドな処理を呼び出すと、スレッドは分散しない

2023/12/23に公開

前置き

この記事は VR法人HIKKY Advent Calendar 2023 の16日目に投稿予定だった記事です。

私の部署では主にC#を利用しています。その中で並列処理をよく書くのですが、数コアのCPU利用率は100%になるが他のコアは全く使えない事がありました。
上手く行かなかったことを理解も兼ねて書き残します。

問題

このようなコードを実行していました。別の処理部からキューに詰んだデータを取り出し、取り出したデータを処理するコードです。

while(await _channelReader.WaitToReadAsync())
{
  _channelReader.TryRead(out var item)
  _ = HeavyTaskAsync(item)
}

HeavyTaskAsyncはCPUバウンドかつ高頻度で呼び出される処理です。
HeavyTaskAsyncをawaitで待たずに実行しているので、全てのスレッドが詰まるまで並列実行できると予想しました。
結果は一部のCPUコアのみ使用率が高くなり、全体のCPU使用率は低い状態でした。

解決策

HeavyTaskAsyncをTask.Runから呼び出すとCPU全体を利用して実行されます。
Task.RunからFuncなどを実行すると別のスレッドも含めたThreadPoolから処理が割り当てられます。

while(await _channelReader.WaitToReadAsync())
{
  _channelReader.TryRead(out var item)
  _ = Task.Run(HeavyTaskAsync(item))
}

原因

Task.Runを使えば解決はします。しかし、根本の原因はasyncメソッドの挙動や用途を理解していないことでした。
async/awaitは、UIスレッドなどのメインスレッド上でIOバウンドな処理を呼び出すときに、メインスレッド上で簡単に非同期処理を取り扱う手段です。CPUに負担のかかる処理を分散するための構文ではないです。

asyncメソッドを呼び出しても、asyncメソッド内でawaitが呼び出されるまで同期的に実行されます。I/O待ちなどのために、メソッド内でawaitに到達すると、その間はメソッドの呼び出し元に制御を戻します。
別スレッドで実行されるわけでは無いので、同一スレッドのリソースが実行上限になります。

あとがき

この記事を書くために曖昧だった所を整理しながら書いたのですが、Task, Threading, TaskSchedulerなど全体の理解が出来ていないと感じる部分が多いと感じています。C#難しい

asyncメソッドを呼び出しても、asyncメソッド内でawaitが呼び出されるまで同期的に実行されます。I/O待ちなどのために、メソッド内でawaitに到達すると、その間はメソッドの呼び出し元に制御を戻します。

書いた後に、この辺りのasyncの挙動など触れている記事を見つけました、興味があれば読んでみてください。
https://aerie.hatenablog.jp/entry/2015/08/29/022932
https://aerie.hatenablog.jp/entry/2015/09/17/110425

土曜日の投稿なので実質計画通り(違う)
次はもう少し早めに書きます・・・

Discussion