17年プログラマをやっていて、いまさら i++
がスレッドセーフじゃないことを知った 1 ことを反省しての記事です。
ざっくりいうと
- C#のインクリメント演算子はスレッドセーフではありません。
- スレッドセーフを求める場合は、
System.Threading.Interlocked.Increment
を利用しましょう。 -
System.Threading.Interlocked.Increment
がスレッドセーフたる理由を調べてみた。
本文
検証してみる
以下のMSTestを実行してみたところ、テストはエラーになりました。
[TestMethod]
public void TestIncrementersThreadSafe()
{
var tasks = new List<Task>();
int counter = 0;
for (int i = 0; i < 250; i++)
{
tasks.Add(Task.Run(() => counter++));
}
Task.WaitAll(tasks.ToArray());
Assert.AreEqual(250, counter);
}
結果
Assert.AreEqual に失敗しました。<250> が必要ですが、<247> が指定されました。
次のように修正したところ、テストは成功しました。
[TestMethod]
public void TestIncrementersThreadSafe()
{
var tasks = new List<Task>();
int counter = 0;
for (int i = 0; i < 250; i++)
{
tasks.Add(Task.Run(() => Interlocked.Increment(ref counter)));
}
Task.WaitAll(tasks.ToArray());
Assert.AreEqual(250, counter);
}
どういう仕組みなの?
この時点で、なにかしらLockを使ってるんだろうなぁと考えたのですが、どのポインタに対してLockを取っているのかが気になりました。それを知っておかないと、安全には使えませんよね。
というわけで、ソースを覗いてみました。
Microsoft Reference Source - Interlocked.cs
public static class Interlocked
{
//...(中略)...
[ResourceExposure(ResourceScope.None)]
[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
public static int Increment(ref int location)
{
return Add(ref location, 1);
}
//...(中略)...
[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
public static int Add(ref int location1, int value)
{
return ExchangeAdd(ref location1, value) + value;
}
//...(中略)...
[ResourceExposure(ResourceScope.None)]
[MethodImplAttribute(MethodImplOptions.InternalCall)]
[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
internal static extern int ExchangeAdd(ref int location1, int value);
ソースを見ても、Lockをかけているような実装は見当たりません。
どうやらカギは、外部メソッド ExchangeAdd
にあるようです。
でも、うーん、それ以上のヒントは見当たりません。
.NETのドキュメントを読んでも、次のように書かれているだけ。
Microsoft Docs - Interlocked Class
The Add method atomically adds an integer value to an integer variable and returns the new value of the variable.
↓Google翻訳
Addメソッドは、整数値を整数変数にアトミックに追加し、変数の新しい値を返します。
「アトミックに」できている理由を知りたいんだよこっちは。。。
ヒントはstackoverflowで見つけた
こんなやり取りを見つけました。
How does Interlocked work and why is it faster than lock? [duplicate]
↓
Reed Copseyさんの回答によると:
Interlocked has support at the CPU level, which can do the atomic operation directly.
↓Google翻訳 注:カッコ内は私の意訳
InterlockedはCPUレベルでサポートされており、(これを利用することで)アトミック操作を直接実行できます。
CPUレベルで!?
続けて、こうおっしゃっています。
For example, Interlocked.Increment is effectively an XADD
↓Google翻訳
たとえば、Interlocked.Incrementは事実上XADDです
なるほど、XADDね!(・∀・)
いや、なんですか、XADDて。。。(;´・ω・)
XADDを調べた
探すのが難しかったのですが、私の結論は以下の記事で行きつきました。
atomic fetch-and-add vs compare-and-swap - An Oracle blog about Transactional locks - Dave's Blog
↓
The x86 architecture exposes LOCK:XADD which I'll use in the discussion below.
↓Google翻訳
x86アーキテクチャは、以下の説明で使用するLOCK:XADDを公開します。
そしてWikipediaの以下のあたりの記述。
Fetch-and-add - wikipedia.org
つまり、x86アーキテクチャは、加算をアトミックに(つまり一連の操作として)行うことをサポートしていると。
なるほど、Interlocked.Increment
はこれを利用しているから、加算している間に他スレッドの割り込みを受けることがなく、スレッドセーフになっていると。納得!
性能ってどうなの?
まとめてくださっている記事がありました。
マルチスレッドで高速なC#を書くためのロック戦略 - @tadokoro
スレッドセーフの中では、Interlocked.Increment
が最速ですね。さすがCPUレベル。
最後に
私の中のエンジニアが「それを知っておかないと、安全には使えませんよね。」とか言い出したために、思ってたよりもめんどくさ大変な調査になりましたが、いろいろ有意義な情報にたどり着くことが出来ました。
但し、CPUレベル
な部分について殆ど知識がない中、公開されている情報や回答をつなぎ合わせて推論し出した結論ですので、理解が間違っている部分があるかもしれません。その場合はご教授いただけると有り難いです。
ところで x64アーキテクチャ
の場合はどうなの?
うん、力尽きた。( ;∀;)
どなたかご教授いただけましたら、ありがたいです。。。(情報のソースもあると嬉しいです)
-
今までそんな実装の需要がなかったんだもの。。。 ↩