TL;DR
- C#はParallelで簡単に並列処理を実装できる
- マルチスレッド処理なのでConcurrentやlockを適切に使用しないと事故る
はじめに
C#ではParallelで簡単に並列処理を実装できてパフォーマンスの改善が望めます。
しかしなまじマルチスレッド処理なので、しっかり理解していないと事故ります。
問題のあるコード
名前を10個並べたListから、indexをkeyとした連想配列を作るコードです。
ぱっと見、ループ部分を並列で実行しているだけで問題ないように見えます。
public void TestParallelDictionary()
{
var names = new List<string>()
{
"John Doe",
"Jane Smith",
"Alice Johnson",
"Bob Brown",
"Charlie Davis",
"Dana White",
"Eve Black",
"Frank Green",
"Grace Miller",
"Henry Wilson"
};
var dic = new Dictionary<long, string>();
Parallel.ForEach(names, (name, state, index) =>
{
dic.Add(index, name);
});
_helper.WriteLine(JsonSerialize(dic));
}
{
"0": null,
"0": null,
"0": "John Doe",
"8": "Grace Miller",
"5": "Dana White",
"1": "Jane Smith",
"9": "Henry Wilson",
"3": "Bob Brown",
"2": "Alice Johnson",
"6": "Eve Black"
}
例外にならずnullが入ってきました。
また実行回数を増やすとたまに例外を吐きます。
One or more errors occurred. (An item with the same key has already been added. Key: 0)
Dictionaryはスレッドセーフではない
Dictionary含め、普段常用するような配列はスレッドセーフではありません。
ですので配列の同じ要素を使おうとして整合性を保てなくなりnullが入っていると思われます。
Listを使用しても同様にスレッドセーフではないのでnullや例外が発生します。
[Fact]
public void TestParallelList()
{
var names = new List<string>()
{
"John Doe",
"Jane Smith",
"Alice Johnson",
"Bob Brown",
"Charlie Davis",
"Dana White",
"Eve Black",
"Frank Green",
"Grace Miller",
"Henry Wilson"
};
var list = new List<string>();
// このくらい試行回数を増やさないと発生しない
for (int i = 0; i < 1000; i++)
{
Parallel.ForEach(names, (name, state, index) =>
{
list.Add(name);
});
}
foreach (var s in list)
{
Assert.NotNull(s);
}
}
結果
Assert.NotNull() Failure: Value is null
解決策① Concurrentな配列を使用する
C#ではスレッドセーフな配列が用意されています。
ConcurrentDictionaryはスレッドセーフなので、配列の安全性が確保されます。
public void TestParallelConcurrentDictionary()
{
var names = new List<string>()
{
"John Doe",
"Jane Smith",
"Alice Johnson",
"Bob Brown",
"Charlie Davis",
"Dana White",
"Eve Black",
"Frank Green",
"Grace Miller",
"Henry Wilson"
};
var dic = new ConcurrentDictionary<long, string>();
Parallel.ForEach(names, (name, state, index) =>
{
dic.TryAdd(index, name);
});
_helper.WriteLine(JsonSerialize(dic));
}
当たり前ですが基本的にConcurrentじゃないクラスを使用したほうが速いので、なんでもかんでもこれで実装するのはやめましょう。
キーまたは値の読み取りのみを行う場合、ディクショナリがスレッドによって変更されないのであれば同期は不要なため、Dictionary の方が高速です。
解決策② lockをかけてしまう
基本的にConcurrentで大丈夫だと思いますし簡単ですが、並列処理内でごちゃごちゃやりたいときは対象のオブジェクトをlockしてしまうのも手かと思います。
public void TestParallelLockDictionary()
{
var names = new List<string>()
{
"John Doe",
"Jane Smith",
"Alice Johnson",
"Bob Brown",
"Charlie Davis",
"Dana White",
"Eve Black",
"Frank Green",
"Grace Miller",
"Henry Wilson"
};
var dic = new Dictionary<long, string>();
Parallel.ForEach(names, (name, state, index) =>
{
lock (dic)
{
dic.Add(index, name);
}
});
_helper.WriteLine(JsonSerialize(dic));
}
まとめ
Parallelは簡単にパフォーマンスを改善できる手段ですが、並列処理を理解していないと気づきづらい事故が発生します。
適切に使用していきましょう。