Last Updated 2011/09/21
並列コンピューティング、並列プログラミング、並列処理など、聞くだけで心地よいコンピューターテクノロジが .NET Framework 4.0 の登場とともに身近になりました。このページでは並列コンピューティングとは何かを中心として紹介し、かつてのマルチスレッドプログラミングとは一味違う世界をのぞくことにします。
なお、サンプルコードは UI アプリケーションを対象として作成しました。並列処理に関係する .NET Framework SDK や WEB サイトなどの記事はほとんどがコンソールアプリケーションを対象としていますが、私自身がコンソールアプリケーションを好まないということと、コンソールアプリケーションと UI アプリケーションとでは並列処理であるがゆえに扱い方が異なるからです。
最近のパソコンの CPU は複数のコアを持つものが普通になってきました。単独の CPU の演算速度を大きくすることが困難になってきたので、コアを複数化することで高速化しようということのようです。したがって、コア数はこれからも増えるものと考えます。
並列コンピューティング "Parallel Computing" とは、複数のプロセッサ(CPU)、あるいは複数のコアを持つプロセッサを持つシステム(パソコン)において、複数の処理を同時実行するコンピューター処理と言っていいと思います。並列プログラミングも並列処理もほぼ同義と考えていいでしょう。
シングルコアによる従来型のマルチスレッド処理は同時実行しているわけではありません。同時実行しているようにみせかけているだけです。
Windows が動作するマルチプロセッサのパソコンがあるのかどうかは知りませんが、マルチコアの CPU を持つものはかなり普及しています。並列処理の効果をテストするためには少なくともマルチコアの CPU を持つパソコンでなければなりません。そこで、まず、今お使いのパソコンのプロセッサ数(コア数)を調べましょう。これは次のコードで取得することができます。
int count = System.Environment.ProcessorCount;
ちなみに、私のパソコンの CPU は Intel Core i5 760 2.80 GHz で、上記のプロパティの戻り値は 4 です。
さて、何はともあれ、並列処理すると本当に速くなるのかを調べてみましょう。
private void button1_Click(object sender, RoutedEventArgs e) { var stopWatch = Stopwatch.StartNew(); Method1(); Method2(); stopWatch.Stop(); Debug.WriteLine(String.Format("シリアル処理の場合:{0} ミリ秒", stopWatch.ElapsedMilliseconds)); stopWatch.Restart(); Parallel.Invoke(Method1, Method2); stopWatch.Stop(); Debug.WriteLine(String.Format("並行処理の場合:{0} ミリ秒", stopWatch.ElapsedMilliseconds)); } private void Method1() { int count = 10000; string s = ""; Debug.WriteLine("Method1 開始"); for (int i = 0; i < count; i++) { s += i.ToString(); } Debug.WriteLine("Method1 終了"); } private void Method2() { int count = 10000; string s = ""; Debug.WriteLine("Method2 開始"); for (int i = 0; i < count; i++) { s += i.ToString(); } Debug.WriteLine("Method2 終了"); }
実行結果:
Method1 開始 Method1 終了 Method2 開始 Method2 終了 シリアル処理の場合:535 ミリ秒 Method2 開始 Method1 開始 Method2 終了 Method1 終了 並行処理の場合:194 ミリ秒
535 / 194 = 2.76 で、約 3 倍の速度になりましたので、速くなったことは間違いありません。
さて、ここで面白い実験をします。以下は、数字を順番に出力させるものです。
private void button1_Click(object sender, RoutedEventArgs e) { Debug.Write("シリアル処理:"); for (int i = 0; i < 10; ++i) { Debug.Write(String.Format("{0} ", i)); } Debug.Write("\r\n並列処理 :"); Parallel.For(0, 10, (n) => Debug.Write(String.Format("{0} ", n))); }
実行結果:
シリアル処理:0 1 2 3 4 5 6 7 8 9 並列処理 :0 1 2 3 4 5 8 9 6 7
実行結果を見ると、シリアル処理の場合、数字は順番に並んでいますが、並列処理の場合は一部の順番が不同になっています。この実行結果はテストした結果のままで編集していません。つまり、並列処理は順番を維持するかどうかは保証されていません。この状況をもう少し詳しく見てみましょう。次のコードは並列処理時のスレッド ID の状況を調べたものです。
private void button1_Click(object sender, RoutedEventArgs e) { int N = 10; Parallel.For(0, N, Method); } private void Method(int n) { int id = Thread.CurrentThread.ManagedThreadId; Debug.WriteLine(String.Format("ThreadId = {0}, n = {1}", id, n)); }
実行結果:
ThreadId = 10, n = 0 ThreadId = 10, n = 1 ThreadId = 10, n = 2 ThreadId = 10, n = 3 ThreadId = 11, n = 4 ThreadId = 11, n = 6 ThreadId = 10, n = 5 ThreadId = 12, n = 8 ThreadId = 11, n = 7 ThreadId = 10, n = 9
ThreadId を見てのとおり、Parallel.For メソッドからデリゲートに n が飛んできますが、デリゲート内の処理は異なるスレッドを使っていることが分かります。内部的にどうなっているのかまでは分かりませんが、4 つあるコアのうちのいくつかを使い分けたのかもしれません。
並列コンピューティングとは何かに対する答えはここまで見てきた点から明らかです。複数のコアに処理を分散することでいいと思います。ただし、何らかの処理は処理の結果があってこそ意味がありますから処理を正しく制御すること、あるいは処理中に不都合が発生したときの対処法を含めて並列コンピューティングと呼ぶと考えていいと思います。
ところで、並列コンピューティングとマルチスレッドプログラミングとの違いは何でしょうか。複数のコアを使う処理の場合もそれぞれのコアでスレッドが形成されますからマスチスレッドであることにかわりありません。したがって、並列コンピューティングも広い意味のマルチスレッドプログラミングと言っていいでしょう。しかし、せまい意味では明らかに違います。マルチスレッドプロミングという言葉を使う場合はどちらをさすかを明らかにしておかなければなりません。
私は並列処理の使い方と効果を確かめるるためにいろいろなコードを書きましたが、シーケンシャル処理のほうが高速なケースが意外と多いことに気付きました。.NET Framework 4.0 SDK の中に「.NET Framework の並列プログラミング」-「タスク並列ライブラリ」-「データとタスクの並列化における注意点」という項目があります。これを見て私なりの注釈を付けてみようと思います。
先に説明したとおりです。グラフィックスを扱うアプリケーションを並列化しようとしたことがあります。扱うファイル数が多くなると並列処理のほうが高速ですが、ファイル数が少ないときはシーケンシャル処理のほうが速くなるという結果になりました。しかも、並列処理では処理すべきファイルの順番が不同になりますからそれを整列する処理も必要になり、かなり面倒です。
実務的なアプリケーションを作成するときは、並列処理という言葉に魅了されることなく、シーケンシャル処理のコードを書いたあと、並列化することをすすめます。
「共有メモリの位置」というずいぶん遠まわしな表現ですが、「クラスのメンバ変数」と考えていいと思います。複数のスレッドからメンバ変数にアクセスするときはロックしてスレッド間で同期させれば可能ですが、そのための手続きのためにパフォーマンスの低下は免れません。したがって、並列処理ではメンバ変数に直接アクセスすることをできるだけ避けるべきです。
ForEach<TSource> メソッド
private void buttonl_Click(object sender, RoutedEventArgs e) { int[] nums = Enumerable.Range(0, 1000000).ToArray(); long total = 0; Parallel.For<long>(0, nums.Length, () => 0, // スレッドローカル変数 subtotal を 0 で初期化 (n, loopState, subtotal) => // 配列内の各要素を加算し、スレッドローカル変数に代入する { subtotal += nums[n]; return subtotal; }, (x) => Interlocked.Add(ref total, x) // ローカル変数に対してスレッドスーフに加算する ); textBox.Text = String.Format("total: {0}", total); }
ForEach<TSource, TLocal> メソッド
private void buttonl_Click(object sender, RoutedEventArgs e) { int[] nums = Enumerable.Range(0, 1000000).ToArray(); long total = 0; Parallel.ForEach<int, long>(nums, () => 0, // スレッドローカル変数 subtotal を 0 で初期化 (j, loop, subtotal) => // 配列内の各要素を加算し、スレッドローカル変数に代入する { subtotal += nums[j]; return subtotal; }, (x) => { Debug.WriteLine(String.Format("x: {0}", x)); Interlocked.Add(ref total, x); }); textBox.Text = String.Format("total: {0}", total); }
スレッド数をパソコンの CPU のコア数より多く作ることはできますが、キューに並んで順番を待つだけになります。つまり、コア数より多くのスレッドを要求しても処理の高速化にはつながりません。
.NET Framework 4.0 が提供する機能を使うと処理の並列化は容易ですが、むやみに並列化することを避けるべきです。並列化が過剰になるケースとしては並列ループを入れ子にして二重のループを構成する場合です。以下のコードの button1 は二重ループで、button2 は内側のループをシーケンシャルにしています。実行結果は button2 のほうが2 倍の速度になりました。
private void buttonl_Click(object sender, RoutedEventArgs e) { var stopWatch = Stopwatch.StartNew(); Parallel.For(0, 10000, i => { Parallel.For(0, 5000, j => Method(i, j)); }); stopWatch.Stop(); textBox.Text = String.Format("{0}", stopWatch.ElapsedMilliseconds); } private void button2_Click(object sender, RoutedEventArgs e) { var stopWatch = Stopwatch.StartNew(); Parallel.For(0, 10000, i => { for (int j = 0; j < 5000; ++j) { Method(i, j); } }); stopWatch.Stop(); textBox.Text = String.Format("{0}", stopWatch.ElapsedMilliseconds); } private void Method(int n1, int n2) { double d; d = Math.Sqrt(n1 * n2); }
並列ループを二重にしても効果があるケースを以下にリストアップしておきます。
.NET Framework の機能の中で、スレッドセーフなメソッドはそう多くはありません。.NET Framework 4.0 より以前の機能は基本的にスレッドセーフではありません。 この項に対する .NET Framework 4.0 SDK の解説では以下のコード例をあげていますが、ファイルの入出力は何かとトラブルが多いですから並列化は避けるほうが安全です。特に、ハードディスクは同時に複数箇所への書き込みをサポートしませんから高速化を目的とした並列化に意味はありません。
FileStream fs = File.OpenWrite(....); int[] nums = .... Parallel.ForEach(nums, Sub(n) fs.Write(n))
static なメソッドのほとんどはスレッドセーフなので、複数のスレッドから同時に呼び出すことは可能です。しかし、むやみに呼び出すとパフォーマンスが低下する可能性があります。
ずいぶんザックリした表現ですが、現在どのスレッド上にいるかを意識しろということのようです。UI アプリケーションにおいて UI コントロールにアクセスするにはコントロールを作成したスレッド上からでしかできません。これについては、「並列処理における UI コントロールの操作」を参照してください。
まず、この項は、Parallel.Invoke から同じデリゲートを呼び出す場合に関する注意です。同一デリゲート内で別のタスクを待機するのはどう考えてもダメでしょう。デッドロックになる可能性がありますから。
この項の真意は、「常に並列実行されるとは限らない」ということよりも並列処理では処理する順番を予想するなということではないでしょうか。つまり、この順番になるはずだと決め付けてコードを書くと、その予想がはずれたときにデッドロックが発生する可能性があるということです。
UI コントロールは UI スレッドからでしかアクセスできないとすでに説明しました。UI スレッドはユーザーとの応答のために存在するものなので、UI スレッド上で並列処理をするのは避けるべきです。「並列処理における UI コントロールの操作」を参照してください。
以下は、.NET Framework SDK のこの項に対するコード例として示されたものに手を入れたものです。WPF アプリケーションとして作成しました。
// デッドロックを発生する例 private void button1_Click(object sender, RoutedEventArgs e) { int N = 10; Parallel.For(0, N, i => { button1.Dispatcher.Invoke((Action)delegate { DisplayProgress(i); }); }); } // 正常に動作する例 private void button2_Click(object sender, RoutedEventArgs e) { int N = 10; Task.Factory.StartNew(() => Parallel.For(0, N, i => { // Windows フォームアプリケーションの場合は Dispatcher は不要 button1.Dispatcher.Invoke((Action)delegate { DisplayProgress(i); }); })); } private void DisplayProgress(int n) { textBox.Text += String.Format("{0} ", n); }
−以上−