C++0xのメモリバリアをより深く解説してみる

もはや誰得レベルになりつつありますが、今回もメモリバリアについての話です。以前の話の続きになるので、まだの方は初回前回のエントリを先にどうぞ。
さて、まず最初に、C++0xのatomicクラスを使った「正しく同期化されているコード」の例を挙げてみます。

struct Hoge {
   int  foo;
};

// 初期値
std::atomic p(nullptr);

// Thread 1
Hoge* r1 = new Hoge();
r1->foo = 42;
p.store(r1, std::memory_order_release);

// Thread 2
Hoge* r2;
do {
  r2 = p.load(std::memory_order_acquire);
} while (r2 == nullptr);
std::cout << r2->foo;   // 42が出力されることが保証される

Hogeインスタンスのメンバ変数 foo への読み書きに注目すると、アトミック変数 p を介して以下のような順序付けが成立しています。releaseやacquireメモリバリアの意味については初回の記事を参照してください。

r1->foo = 42
  ↓  releaseメモリバリアによる順序付け
p.store(r1, std::memory_order_release)
  ↓
r2 = p.load(std::memory_order_acquire)
  ↓  acquireメモリバリアによる順序付け
r2->foo

このような順序付けが成立している状態を「メンバ変数 foo へのアクセスが正しく同期化されている」と呼び、その結果、 r2->foo の値が42になることが保証されるのです。
では、次のコードはどうでしょうか?

// 初期値
std::atomic p(nullptr);

// Thread 1
Hoge* r1 = new Hoge();
r1->foo = 42;
p.store(r1, std::memory_order_release);

// Thread 2
Hoge* r2;
do {
  r2 = p.load(std::memory_order_relaxed);
} while (r2 == nullptr);
std::cout << r2->foo;   // Data race?

最初のコードとの違いは、Thread 2 側のメモリバリア指定が memory_order_relaxed (メモリバリア無し) に変わっている点です。つまり、先ほどの図で示した「acquireメモリバリアによる順序付け」が無くなっています。その結果、このコードはメンバ変数 foo への読み書きが正しく順序付けられていない、すなわち「正しく同期化されていない」不正なコードということになります。
でも、本当にこのコードは正しく動作しないのでしょうか? Thread 2において、式 r2->foo を評価するには、当然ながら r2 の値がわかっている必要があります。よって、明示的にacquireバリアを指定しなくても、 r2->foo の実行は p.load(...) より前にリオーダーされることはないと考えてよさそうです。従って、「r2->foo の結果は必ず 42 になる」と言い切ってもよさそうな気がします。
しかし、残念ながら一部のプロセッサではそうならないことが知られています(参考文献)。メモリバリアはキャッシュコヒーレンシの動作にも影響を与えているので、メモリバリアが無いとキャッシュメモリ内の古い値を読み込んでしまう可能性があるのです。そのため、acquireメモリバリアを省略した2つめのコードは、やはり正しく同期化されておらず不正だということになります。

続きを読む