C#における変数のアトミック性

マルチスレッドにはいろいろな恐怖が潜んでいます。

スポンサーリンク

実験

次のコードを見ていただきたい。

long n = 10000000L;
long countA = 0;
long countB = 0;
long countOther = 0;
 
long d = 5;
long dA = 5;
long dB = 50000000000L;
 
//dに5を代入し続けるスレッド
Task.Run(() =>
{
    while(true) d = dA;
});
//dに500億を代入し続けるスレッド
Task.Run(() =>
{
    while(true) d = dB;
});
 
for (long i = 0; i < n; i++)
{
    //dの値を検査
    long x = d;
    if (x == dA) countA++;
    else if (x == dB) countB++;
    else countOther++;
}
 
Console.WriteLine($"異常値が{countOther}回");

スレッドを2つ立て、1つはlong型変数dに5を代入し続けてブン回すスレッド、もう1つは同じくdに500億を代入し続けてブン回すスレッド。

2つのスレッドは並行に走るので、あるタイミングでのdの値は5か500億のいずれかであると期待されます。それを1000万回検査して、5ならcountAを増やし、500億ならcountBを増やします。念のため、5でも500億でもない場合にcountOtherを増やすようにしておきます。

5でも500億でもない場合は異常値だということで、その回数countOtherを出力してみると、

異常値の回数

え、うそ…。約0.4%の確率で異常値になってる…。

どんな異常値になってるか出力してみると、

異常値

な、なにコレぇぇぇ!

long型は64ビット

long型は64ビットなので、メモリ上には以下のように値が配置されています(リトルエンディアン)。

メモリ配置

32ビットの動作環境の場合、long型変数dに500億を代入するときは32ビットずつ2ステップに分けて代入が行われます。

longの代入

CPUの動作の最小動作単位では64ビットの値の移動ができないため、32ビットずつ2ステップに分かれてしまいます。CPUの動作の最小動作単位というのは最も原子的(Atomicity:アトミック性)なので不可分ですが、アトミック性の保証できない2ステップのCPUの実行は、その間に別のスレッドの実行が割り込んでしまう可能性があります。

その結果、値5の下位32ビットと、値500億の上位32ビットが混在して変数dに入り込んでしまう可能性があります。

値が混ざる

こうして別々の値の32ビットずつの断片が混在してしまった結果が、さきほどの47244640261という異常な値です。逆の混ざり方をすると、2755359744になります。

この現象は、64ビットの動作環境では起こりません。OSが64ビットの場合は、long型変数の64ビットを一気に代入するようCPUに指示するからです。

つまり、OSのビット数を超えるサイズの変数を代入するときに、アトミック性が崩れる危険性があるということになります。

サイズの大きい他の型はどうか

longの他にサイズが大きい型として、double型(64ビット)、decimal型(128ビット)があります。それぞれアトミック性が保証されるかどうかは以下の通りになります。

  32ビットOS 64ビットOS
long(64ビット) 崩れる 保証
double(64ビット) 保証 保証
decimal(128ビット) 崩れる 崩れる

OSのビット数を超えたサイズの型はアトミック性が崩れるのですが、面白いのは32ビットOSにおけるdouble型です。

double型は64ビットなので32ビットOSではアトミック性が崩れるような気がするのですが、なぜかアトミック性が保証されています。

回避方法

これを回避するには、lockを使います。

object objLock = new object();
//中略
//dに5を代入し続けるスレッド
Task.Run(() =>
{
    while(true) 
        lock(objLock) d = dA;
});
//dに500億を代入し続けるスレッド
Task.Run(() =>
{
    while(true) 
        lock(objLock) d = dB;
});
 
for (long i = 0; i < n; i++)
{
    //dの値を検査
    lock(objLock) long x = d;
    if (x == dA) countA++;
    else if (x == dB) countB++;
    else countOther++;
}

スレッドの排他処理をするときによく使うlockですが、単なる変数の代入に使うには少し冗長なところもあります。これを簡単にするために、System.Threading.Interlockedを使う方法もあります。

//dにdAを代入
System.Threading.Interlocked.Exchange(ref d, dA);
//dの値を読み出す
x = System.Threading.Interlocked.Read(ref d);

ただしこのInterlockedはdecimal型には使えません。decimal型でスレッドセーフに値の代入や読み出しを行う場合は、素直にlockを使うか、object型に一旦変換してから(いわゆるboxing)Interlockedを使います。

マルチスレッドは怖い

マルチスレッド特有のこのような動作は再現性が乏しく、何百万回に1回の確率でしか起こらないようなこともあります。しかしその何百万回かに1回の「たった1回」が、システムに重大な障害を引き起こす可能性もあります。

マルチスレッドを安易に扱う事が無いよう、十分に事前に勉強しておきましょう。

その他のアトミック性

アトミック性という言葉は大体このような1つの変数に対する挙動を表わすときに使う言葉ですが、もう少し広い意味でアトミック性を考えてみると、他にも似たようなケースがあることに気付きます。

if (DateTime.Now.Hour == 23 && DateTime.Now.Minute == 59 && DateTime.Now.Second == 59)
{
  //当日の最終処理を行う
    LastAction(DateTime.Now.Date);
}

23時59分59秒になったらその日の最終処理を行うというコードです。しかしこのコードには危険が潜んでいます。

例えば2020年1月4日の23時59分59秒になったとき、LastActionメソッドに「2020年1月4日」という情報を渡して処理を行いたいわけですが、ifの中のDateTime.Nowを検査している時点で23時59分59秒99999999くらいだったらどうでしょう。

LastActionの引数で再びDateTime.Nowを確認したときにはもう、2020年1月5日0時0分0秒になっているかもしれません。極めて低い確率ですが、絶対にそのようなことが起こらないとも限りません。

なので、日付や時刻を扱うときには、

DateTime dtNow = DateTime.Now;
if (dtNow.Hour == 23 && dtNow.Minute == 59 && dtNow.Second == 59)
{
  //当日の最終処理を行う
    LastAction(dtNow.Date);
}

必ずこのように絶対に変化することのない変数に一旦格納しましょう。これもある種のアトミック性の保証と言えると思います。

取り扱い注意

これらのケースはとにかく、発生確率が何百万分の1とか何億分の1とかの極めて低いものです。それはつまり再現性が困難ということであり、バグの修正が困難だということです。

そんな恐怖の事態にならないように、何億分の1であろうと発生しうる可能性のあるバグの芽は、早めに摘んでおきましょう。

スポンサーリンク