並列プログラミングの落とし穴①データ競合
Visual Studio 2010 および .NET Framework 4 の並列ライブラリーや同時実行ランタイム・スレッドプールによって、スケーラブルで自動的に負荷分散可能な並列プログラミングが可能になりました。
しかし、並列プログラミングには固有の落とし穴があります。代表的な落とし穴を数回にわたって紹介していきます。
まずは「データ競合」です。これは同時実行する複数のタスクが同じリソース(変数やオブジェクト)を共有するとき発生する代表的な落とし穴の一つです。
次のカウンター プログラムを見てください。Visual Studio 2010 でコンソールアプリケーションのプロジェクトを作成すれば、実際に実行できます(名前空間 System.Threding.Tasks と System.Diagnostics を追加)。
class ParallelCounter
{
internal static int s_curr = 0;
internal static int GetNext()
{
return s_curr++;
}
static void Main(string[] args)
{
int count_a = 0;
int count_b = 1;
Task A = Task.Factory.StartNew(() =>
{
while (true)
{
count_a = GetNext();
}
});
Task B = Task.Factory.StartNew(() =>
{
while (true)
{
count_b = GetNext();
Debug.Assert(count_a != count_b, “同じデータ!”);
}
});
Task.WaitAll(A, B);
}
}
2つのタスクがGetNextメソッドを使って共有変数 s_curr をインクリメントし続けます。しかしGetNextメソッドに複数のタスクが同時にアクセスしても、s_curr 自身は一つなので、それぞれのタスクでの戻り値が同じになることはないように思われるかもしれません。しかし、実行するとアサートが発生します。
その理由は、s_curr++ が次の3つの手順にコンパイルされるため、2つのタスクが同じ値を読み取って同じ値に書き換えてしまうことがあるためです。
- 変数 s_curr の現在の値をレジスターに読み込む
- そのレジスターをインクリメントする
- そのレジスターの値で変数 s_curr を書き換える
複数の手順に分解できない命令をアトミックと呼びますが、s_curr++はアトミックではないのです。
このプログラムは単純なので、以下のようにGetNextメソッドでロックを使えばこの問題は解決できます(名前空間 System.Threading を追加)。このロックによって、インクリメント中は他のタスクからアクセスできなくなります。
internal static int GetNext()
{
return Interlocked.Increment(ref s_curr);
}
しかしロックの使用は、次のような副作用があるのであまり推奨されません。
- ロックの開始・終了を必ずペアにしなくてはならず、バグの原因になりやすい
- デッドロックが発生し、ソフトウェアが停止してしまうかもしれない
- スタベーションが発生し、必要なリソースに永遠にアクセスできないタスクが発生するかもしれない
- 長期にわたってロックすると、並列化による性能向上を阻害するかもしれない