【ITニュース解説】🚀 Parallel.ForEachAsync vs Task.Run in C#: A Beginner’s Guide
2025年09月15日に「Dev.to」が公開したITニュース「🚀 Parallel.ForEachAsync vs Task.Run in C#: A Beginner’s Guide」について初心者にもわかりやすく解説しています。
ITニュース概要
C#のTask.RunはCPU負荷の高い単一処理を別スレッドで実行し、アプリのフリーズを防ぐ。Parallel.ForEachAsyncはAPI呼び出しなどI/O負荷の高い複数の処理を並列実行する。用途を誤ると効率が悪化するため、CPU処理にはTask.Run、I/O処理にはParallel.ForEachAsyncを正しく使い分けよう。
ITニュース解説
システムエンジニアを目指す上で、複数の処理を同時に進める「並列処理」の概念は非常に重要だ。C#で並列処理を実装する際、よく出会う二つの方法にTask.RunとParallel.ForEachAsyncがある。これらは一見すると同じ「複数の処理を同時に実行する機能」のように見えるかもしれないが、実はそれぞれ異なる目的と得意分野を持っている。この違いを理解することは、効率的で応答性の高いアプリケーションを開発するために不可欠だ。
まず、それぞれの基本的な役割を理解する必要がある。
Task.Runは、「この一つの重い処理を、アプリケーションの動作を妨げないように裏側のスレッドで実行してほしい」という指示を出すものだと考えると分かりやすい。これは主に「CPU負荷の高い処理(CPUバウンドな処理)」に適している。例えば、大量のデータを使って複雑な計算を実行する場合、大きな画像をリサイズして画質を調整する場合、あるいはファイルを暗号化するといった処理は、CPUの計算能力を長時間使い続ける。もしこれらの処理をアプリケーションのメインスレッド(ユーザーインターフェースの更新やウェブサーバーのリクエスト応答などを行う主要な流れ)で直接実行すると、その間アプリケーションは固まってしまい、ユーザーは操作ができなくなる。Task.Runを使うことで、このような重い処理を別のスレッドに任せ、メインスレッドは引き続き応答性を保つことができる。
一方、Parallel.ForEachAsyncは、「たくさんの処理対象のリストがあり、それらを並行して処理したいが、システムに過度な負担をかけないようにうまく制御してほしい」という要求に応えるものだ。これは主に「I/O負荷の高い処理(I/Oバウンドな処理)」、つまり、CPUの計算能力よりも、データの読み書きや通信の待ち時間が支配的になる処理に適している。例えば、複数のウェブサイトから情報を同時に取得する(API呼び出し)、多数のファイルをインターネットからダウンロードする、あるいはデータベースに同時にたくさんのクエリを発行するといったケースがこれに該当する。Parallel.ForEachAsyncの大きな特徴は、並行して実行する処理の数をMaxDegreeOfParallelismという設定で制限できる点だ。これにより、サーバーに一斉にリクエストを送りつけて負荷をかけすぎたり、自分のコンピューターのリソースを使い果たしたりすることを防ぎながら、効率的に並列処理を進めることができる。また、処理の途中で中止したい場合に使うCancellationTokenにも標準で対応している。
ここで少し歴史を振り返ってみよう。かつて、.NET Framework 4でParallel.ForEachという機能が導入された。これは主にCPUバウンドなインメモリ処理、つまりコンピューターのメモリ上で配列を処理したり、数値を計算したりするような作業のために設計されていた。当時は、Parallel.ForEachの中でウェブサービス呼び出しのようなI/O処理を行うことは推奨されていなかった。なぜなら、I/O処理はデータが届くまでの「待ち時間」が長く、その間スレッドは何もせずにブロックされてしまう。このような状態のスレッドが増えると、「スレッドプール枯渇」という問題が発生し、アプリケーション全体のパフォーマンスが著しく低下したり、応答しなくなったりする可能性があったからだ。このため、「Parallel.ForEachはインメモリのCPU処理にのみ使うべき」というアドバイスが一般的だった。
しかし、時代は進み、.NET 6でParallel.ForEachAsyncが導入されたことで、状況は大きく変わった。この新しい機能は、非同期処理を扱うasync/awaitの仕組みと連携し、I/Oバウンドなタスク(HTTPリクエスト、データベースクエリ、ファイルI/Oなど)を安全かつ効率的に並列実行できるように設計された。これにより、I/O処理で発生する長い待ち時間の間にスレッドがブロックされることなく、他の処理に利用できるようになるため、スレッドを無駄にすることがなくなった。また、前述のMaxDegreeOfParallelismが組み込まれたことで、開発者が自分で複雑なスロットリング(処理の制限)の仕組みを実装する必要がなくなったのも大きなメリットだ。したがって、もし「Parallelはインメモリデータにしか使えない」という古い情報に触れたことがあるなら、それは同期版のParallel.ForEachの話であり、.NET 6以降ではParallel.ForEachAsyncが非同期I/O処理を並列で扱う推奨される方法であることを理解してほしい。
具体的な例を見てみよう。
もし複数のウェブページから情報を同時に取得したい(I/Oバウンドな処理)場合、Parallel.ForEachAsyncが最適だ。例えば、3つの異なるURLからウェブページの内容を同時にダウンロードするようなケースでは、Parallel.ForEachAsyncのMaxDegreeOfParallelismを3に設定することで、同時に最大3つのリクエストが実行される。各リクエストは非同期で動作し、データが届くまでの待ち時間中に他のリクエストを処理したり、メインスレッドが他の作業を行ったりできるため、スレッドが無駄にならない。また、途中で処理を中止したくなった場合もCancellationTokenで簡単にキャンセルできる。
一方で、もし巨大な画像を処理する(CPUバウンドな処理)といった単一の重い作業がある場合、Task.Runを使うのが適切だ。例えば、入力された画像を加工するHeavyImageProcessingという関数が非常に多くのCPU時間を使うとする。この関数をTask.Runで呼び出すことで、この重い処理はバックグラウンドスレッドで実行され、ウェブサーバーが他のリクエストに応答するのを妨げたり、ユーザーインターフェースが固まったりするのを防ぐことができる。
ここで、初心者が陥りやすい間違いも理解しておこう。
一つは、非同期I/O処理をTask.Runでラップしてしまうケースだ。例えば、HttpClient.GetStringAsync(url)のような非同期のウェブリクエストをawait Task.Run(() => httpClient.GetStringAsync(url))のようにしてしまうと、これは間違いだ。GetStringAsync自体が非同期処理であり、I/O待ちの間はスレッドをブロックしないように設計されている。それをTask.Runで別のスレッドに送っても、そのスレッドはI/O待ちで結局何もせず待機することになり、単にスレッドを一つ無駄に消費するだけになってしまう。
もう一つは、Parallel.ForEachAsyncをCPU負荷の高いループに使うことだ。例えば、ファイルのリストに対して、各ファイルをCPU負荷の高いProcessFile関数で処理するようなケースでParallel.ForEachAsyncを使うと、これも非効率だ。Parallel.ForEachAsyncはI/O待ちの間にスレッドを解放する仕組みだが、CPU処理では常にスレッドを占有するため、結局スレッドを使い果たしてしまう。このようなCPU負荷の高い並列ループには、Task.Runを使うか、あるいはParallel.ForのようなCPUバウンドな並列処理に適したAPIを使うべきである。
これらの区別をシンプルにまとめるなら、以下のようになる。
多くの非同期I/Oタスク(API呼び出し、データベースクエリ、ファイルのダウンロードなど)を並行して実行したい場合は、Parallel.ForEachAsyncを選ぶ。
単一のCPU負荷の高いジョブ(複雑な計算、画像処理など)をメインスレッドから切り離して実行したい場合は、Task.Runを選ぶ。
どちらの機能もCancellationTokenに対応しているため、必要に応じて処理を中断できるという共通点も覚えておくと良い。
つまり、ネットワーク越しにデータを待つような処理にはParallel.ForEachAsyncを使い、CPUを集中的に使うような処理にはTask.Runを使うと覚えると良い。これらを適切に使い分けることで、プログラムはより高速に、そしてより応答性高く動作するようになるだろう。まずは、自分の書いているコードの中でループ処理や時間がかかる処理を見つけ、それがI/Oバウンドなのか、それともCPUバウンドなのかを考えてみることが、これらのツールをマスターする第一歩となる。