C# による並列プログラミング

HPCインテル® VTune™ プロファイラー

この記事は、インテル® デベロッパー・ゾーンに掲載されている「Parallel Programming with C#」(https://software.intel.com/en-us/articles/parallel-programming-with-c) の日本語参考訳です。


マルチコア・プロセッサーが登場してからすでに何年も経ち、今日ではほとんどのデバイスにマルチコア・プロセッサーが搭載されています。しかし、多くの開発者は未だにシングルスレッドのプログラムを開発しています。つまり、マルチコア・プロセッサーによってもたらされる処理能力を活用していないのです。これは、多くのタスクがあり、それらを実行できる多くの人々がいるにもかかわらず、複数の人にタスクを任せる方法が分からないため、すべてのタスクを 1 人に任せているのと同じです。非常に非効率的と言えます。ユーザーは追加性能のために投資しているにもかかわらず、ソフトウェアがその利用を妨げているのです。

経験豊富な C# 開発者にとって、マルチスレッド処理は目新しいものではありませんが、プロセッサーの性能を最大限に利用するプログラムを開発することは簡単なことではありません。この記事は、C# における並列プログラミングの進化と C# バージョン 5.0 で追加された新しいメソッドの使用法について説明します。

並列プログラミングとは?

並列プログラミングについて述べる前に、並列プログラミングと関連が深い「同期実行モード」と「非同期実行モード」という 2 つの概念について説明します。これらのモードは、アプリのパフォーマンスを向上する上で重要です。同期実行では、図 1 に示すように、プログラムはすべてのタスクをシーケンス順に実行します。つまり、1 つのタスクの実行を開始し、それが完了したら次のタスクを開始します。


図 1. 同期実行

非同期実行では、プログラムはタスクをシーケンス順に実行しません。図 2 に示すように、複数のタスクを開始し、その完了を待機します。


図 2. 非同期実行

非同期実行のほうが同期実行よりも合計時間が短いのであれば、なぜ同期実行を使用するのでしょうか? 図 1 に示すように、すべてのタスクがシーケンス順に実行されるため、プログラムにとって処理しやすいからです。多くの開発者が長年この方法を利用してきました。非同期実行では、次のようなプログラミングの課題があります。

  • タスクを同期する必要があります。例えば、図 2 で 3 つのタスクが完了してから新しいタスクを実行する必要がある場合、 新しいタスクを実行する前に、すべてのタスクの完了を待機するメカニズムを作成しなければなりません。
  • 並行性 (コンカレンシー) の問題に対応する必要があります。あるタスクによって書き込まれ、別のタスクによって読み取られるリストなどの共有リソースがある場合、それを既知の状態にしておかなければなりません。
  • プログラムロジックの大幅な変更が必要です。論理シーケンスは意味がありません。タスクがいつ完了するか、どのタスクが最初に完了するかを制御することはできません。

一方、同期プログラミングにもいくつかの問題があります。

  • 同期の完了により多くの時間がかかります。
  • ユーザー・インターフェイス (UI) スレッドが停止する可能性があります。通常、この問題が発生するプログラムには UI スレッドが 1 つしかなく、ブロッキング操作にそれを使用すると、プログラムが応答しなくなります (ウィンドウタイトルに “応答なし” と表示されます)。これは、良いユーザー・エクスペリエンスとは言えないでしょう。
  • 新しいプロセッサーのマルチコア・アーキテクチャーを利用しません。1 コアのプロセッサーでも、64 コアのプロセッサーでも、同じ速さ (遅さ) で実行されます。

非同期プログラミングは、これらの問題を解決します。UI スレッドはバックグラウンド・タスクとして実行できるためハングアップすることがなくなり、マシン上のすべてのコアを利用できるためマシンのリソースが活用されます。皆さんは、簡単なプログラミングとリソースの活用のどちらを選びますか? 幸いにも、どちらかを選択する必要はありません。非同期実行のプログラミングの課題を最小限に抑えるため、Microsoft* からいくつかの方法が提供されています。

Microsoft* .NET の非同期プログラミング・モデル

Microsoft* .NET において非同期プログラミングは目新しいものではありません。「非同期プログラミング・モデル (APM)」は、2001 年の初期リリース (バージョン 1.0) から提供されている .NET の最も古いモデルであり、これまで進化をかさねてプログラマーがより使いやすいものになっています。しかし、実装方法が複雑なため、.NET 2.0 で新しいモデル「イベントベースの非同期パターン (EAP)」が追加されました。ここでは、これらのモデルについて取り上げませんが、興味がある場合は「参考資料」のリンクを参照してください。EAP により色々なことが簡単になりましたが、まだ十分ではありませんでした。そこで、.NET 4.0 で新しいモデル「並列ライブラリー (TPL)」が追加されました。

タスク並列ライブラリー (TPL)

TPL はそれ以前のモデルと比べると大きく進化しています。並列処理が単純化され、システムリソースをより効率良く利用することができます。プログラムで並列処理が必要な場合は、TPL を利用すると良いでしょう。

比較のため、2 ~ 10,000,000 の範囲の素数を計算する同期プログラムを作成します。このプログラムは、見つかった素数の数と処理にかかった時間を出力します。

GitHub – サンプル同期プログラム

これは、素数を見つけるのに最適なアルゴリズムではありませんが、アプローチによる違いを確認するのに役立ちます。私のマシン (インテル® Core™ i7 プロセッサー 3.4GHz 搭載) では、このプログラムの実行時間は 3 秒でした。インテル® VTune™ Amplifier XE を使用してプログラムを解析します。これは有償プログラムですが、30 日間の無料評価版があります (「参考資料」を参照)。

プログラムの同期バージョンで Basic Hotspots 解析を実行したところ、図 3 のような結果が得られました。


図 3. 素数プログラムの同期バージョンをインテル® VTune™ Amplifier XE で解析した結果

結果から、プログラムの実行時間は 3.369 秒で、そのほとんどは IsPrimeNumber (3.127 秒) で費やされており、1 つの CPU のみ使用されていることが分かります。つまり、このプログラムはリソースを効率良く使用していません。

TPL は、非同期処理を表現するタスクの概念をもたらし、 暗黙的または明示的にタスクを生成できるようになります。暗黙的にタスクを生成するには、Parallel クラス (For、ForEach、Invoke メソッドを含むスタティック・クラス) を使用します。For および ForEach メソッドはループを並列に実行でき、Invoke メソッドは複数のアクションを並列にキューに追加できます。

このクラスを使用することで、サンプルプログラムの同期バージョンを並列バージョンへ簡単に変換できます。

GitHub – サンプル並列プログラム

処理を10 個のパートに分割し、各パートの実行には Parallel.For を使用します。そして、処理の最後に、各リストで見つかった素数の数を合計し、表示します。このコードは、同期バージョンと似ています。図 4 は、インテル® VTune™ Amplifier XE でこのコードを解析した結果です。


図 4. 素数プログラムの並列バージョンをインテル® VTune™ Amplifier XE で解析した結果

プログラムの実行時間は 1 秒になり、マシン上の 8 つのプロセッサーがすべて使用されています。つまり、リソースの効率的な使用と簡単な実装の両方を達成できたことになります。

Task クラスでタスクを明示的に生成して使用することもできます。新しい Task を作成して、Start メソッドで開始します。あるいは、より合理的な Task.Run メソッドでタスクを作成したり、Task.Factory.StartNew メソッドをでタスクを作成し開始することもできます。例えば、Task クラスを使用して次のような並列プログラムを作成できます。

GitHub – サンプル Task プログラム

Task.WaitAll は、すべてのタスクの完了を待機してから実行を継続します。インテル® VTune™ Amplifier XE でこのプログラムを解析すると、並列バージョンに似た結果になります。

Parallel LINQ

Parallel LINQ (PLINQ) は、LINQ クエリー言語の並列実装です。PLINQ では、AsParallel 拡張メソッドを使って LINQ クエリーを並列バージョンに変換できます。例えば、同期バージョンを少し変更するだけでパフォーマンスが大幅に向上します。

GitHub – サンプル PLINQ プログラム

Enumerable.Range に AsParallel を追加することで、クエリーのシーケンシャル・バージョンを並列バージョンに変更できます。インテル® VTune™ Amplifier XE でこの並列バージョンを解析すると、大幅なパフォーマンスの向上を確認できます (図 5)。


図 5. PLINQ バージョンをインテル® VTune™ Amplifier XE で解析した結果

簡単な変更により、プログラムの実行時間が 1 秒に短縮され、8 つのプロセッサーがすべて使用されるようになります。ただし、AsParallel を追加する位置が処理の並列性に影響します。例えば、次のように変更してみます。

return Enumerable.Range(minimum, count).Where(IsPrimeNumber).AsParallel().ToList();

この場合、処理に最も時間のかかる IsPrimeNumber メソッドが並列に実行されないため、パフォーマンスは向上しません。

非同期プログラミング

C# バージョン 5 で async と await の 2 つのキーワードが追加されました。一見たいしたことがないように見えますが、これは大きな改良点です。この 2 つのキーワードは、C# の非同期処理の中心と言えます。並列処理を実装する場合、実行シーケンスを大幅に変更しなければならないことがあります。非同期処理は、そのような煩わしさをなくします。

async キーワードを使用すると、同期コードと同様の方法でコードを記述でき、 複雑な処理はコンパイラーに任せて、ロジックの記述に集中することができます。

async メソッドを記述する際は、次のガイドラインに従います。

  • メソッドのシグネチャーに async キーワードを含める必要があります。
  • メソッド名の最後は Async にすべきです (強制ではありませんが、一般的な慣例です)。
  • メソッドは、Task、Task、または void をリターンします。

このメソッドを使用する場合は、await メソッドで結果を待機します。これらのガイドラインに従った場合、コンパイラーは待機可能なメソッドを見つけるとそれを実行し、その他のタスクの実行を継続します。メソッドが完了すると、実行はその呼び出し元にリターンします。async を使用して素数を計算するプログラムは次のようになります。

GitHub – サンプル async プログラム

このプログラムでは、新しいメソッド ProcessPrimesAsync を作成しています。メソッドで await を使用する場合、async として宣言する必要がありますが、Main は async として宣言することができません。そのため、この新しいメソッドを作成しています。このメソッドは void をリターンします。Main で await キーワードを指定しないでこのメソッドを実行すると、Main はメソッドを開始しますが、その完了を待機しません。そのため、Console.ReadLine を追加しています (そうでないと、実行前にプログラムが終了します)。プログラムの残りの部分は、同期バージョンに似ています。

さらに、このプログラムでは、primes 変数が Task> ではなく List です。これは、Task で async メソッドを呼び出さないようにするためです。await キーワードを指定すると、コンパイラーはメソッドを呼び出し、メソッドが完了するまでリソースを解放し、メソッドがリターンしたら結果を Task から通常の値に変換します。メソッドをリターンする際は、Task ではなく、同期メソッドと同様に通常の値をリターンすべきです。

このプログラムは、タスクが 1 つしかないため、実行しても同期バージョンよりも速くなりません。より高速にするには、複数のタスクを作成し、同期する必要があります。次のように変更できます。

GitHub – サンプル並列 async プログラム

新しいメソッドにより、10 個のタスクが作成されますが、プログラムはそれらを待機しません。そのため、すべてのタスクの完了を待機する次の行を追加しています。

var results = await Task.WhenAll(primes);

結果変数は、List 型の配列なので変換処理は不要です。インテル® VTune™ Amplifier XE でこのバージョンを解析したところ、すべてのタスクが並列に実行されました (図 6)。


図 6. 素数プログラムの並列 async バージョンをインテル® VTune™ Amplifier XE で解析した結果

async と await キーワードによって、C# の非同期処理に新たな可能性が追加されました。この記事では、その一部を紹介しました。この変更は、タスクの取り消し、例外処理、タスク管理など、さまざまなことに影響します。

まとめ

C# で並列実行プログラムを作成する方法は多数あります。マルチコア・プロセッサーの普及により、システムリソースを活用せず、ユーザーに不要な遅延を強いるシングルスレッド・プログラムを作成することに対し、言い訳はできなくなりました。

C# 言語に async キーワードが追加されたことで、コードをシーケンシャル順に記述し、システムリソースを効率良く利用できるようになりました。並行性 (コンカレンシー)、タスク同期、取り消しなど、まだいくつかの課題がありますが、優れた並列プログラムを作成するために必要なことと比べれば、これらは些細なことです。ここで紹介した手法を適用し、並列プログラムを作成してみてください。きっと、システムリソースを効率良く利用し、ユーザーに高速なアプリケーションを提供できるでしょう。

参考資料

著者紹介

Bruno Sonnino
ブラジル在住の Microsoft Most Valuable Professional (MVP) です。開発者、コンサルタント、そして著者でもあります。これまでに、Delphi 関連の書籍 5 冊 (ブラジル Pearson Education 刊、ポルトガル語) に加え、ブラジルとアメリカの雑誌/Web サイトで多数の記事を執筆しています。

コンパイラーの最適化に関する詳細は、最適化に関する注意事項を参照してください。

タイトルとURLをコピーしました