読者です 読者をやめる 読者になる 読者になる

C++0x時代の Double-Checked Locking

今回は "Double-Checked Locking" (以下DCL)というマルチスレッドプログラム向けのイディオムを例にして、C++0xの(低レイヤ向け)マルチスレッド機能の利用方法を紹介してみます。
DCLとは、「ロック→条件判定」というロジックを「条件判定→ロック→(再度)条件判定」と書き換えるイディオムで、主に遅延初期化などの処理においてロックのオーバーヘッドを減らすために用いられます。DCLはシンプルかつ効果の高いイディオムだったので、一時期もてはやされました。ところが、DCLはコンパイラやCPUによるリオーダーの影響により正しく動作しない場合があることがわかったため(参考1参考2)、今ではアンチパターンと呼ばれることすらある始末です。
しかし、DCLの問題点は、メモリモデルに関する知識があまり知られていなかったことと、プログラミング言語の仕様でメモリモデルが正しく定義されていなかったことにあり、DCLというイディオムそのものは正当なものです。そして、atomic変数とメモリバリアがきちんと定義されている C++0x では、DCLを正しく実装することが可能になったのです。
ではさっそく、「正しい」DCLの実装例を挙げてみましょう。

std::atomic p(nullptr);
std::mutex m;

Hoge& getInstance() {
  Hoge* tmp = p;
  if (tmp == nullptr) {
    std::lock_guard lk(m);
    tmp = p;
    if (tmp == nullptr) {
      tmp = new Hoge();
      p = tmp;
    }
  }
  return *tmp;
}

std::mutex や std::lock_guard は C++0x で追加されたロックを行うためのAPIです。上のコードだと「"std::lock_guard lk(m);" の行で排他ロックを行い、変数lkのスコープの終わり(つまりreturn文の前の行)でアンロックする」という意味になります*1。DCLの特徴である「条件判定→ロック→(再度)条件判定」という実装になっていることがわかるでしょうか。
このDCL実装の肝は、変数 p がatomic型として宣言されている点です。 C++0x のatomic型は、これまで繰り返し説明してきたように、操作がアトミック(不可分)に行われるだけでなく、メモリアクセスの順序付けを保証する効果(メモリバリア)も持っています。変数 p への読み書きの際にメモリバリア効果が発揮されるため、最初に挙げた「DCLの問題点」を解消できているのです。

release/acquireメモリバリアによるDCL

ちなみに、こちらの記事で説明したように、atomic型でのデフォルトのメモリバリアは memory_order_seq_cst という「最も強力だが遅い」ものになっています。しかし、DCLを実装するにはもっと弱いメモリバリア指定でも十分です。明示的に release や acquire のメモリバリア種別を指定することで、先のDCL実装を「より高速に」動作するように書きかえてみると以下のようになります。

std::atomic p(nullptr);
std::mutex m;

Hoge& getInstance() {
  Hoge* tmp = p.load(std::memory_order_acquire);
  if (tmp == nullptr) {
    std::lock_guard lk(m);
    tmp = p.load(std::memory_order_relaxed);
    if (tmp == nullptr) {
      tmp = new Hoge();
      p.store(tmp, std::memory_order_release);
    }
  }
  return *tmp;
}

太字の部分が、明示的に指定したメモリバリア種別になります。最後の p.store(tmp, std::memory_order_release) と最初の p.load(std::memory_order_acquire) が対になることで、newされたHogeインスタンスへのアクセスが「正しく同期化」されるのです。
一方、真ん中のloadには「メモリバリアなし」を意味する memory_order_relaxed が指定されています。その理由は、std::mutex による排他制御が暗黙的なメモリバリアとして働いているために*2、このload自体にはメモリバリアが不要となるからです。

Data-Dependency OrderingによるDCL

さらに、前回説明したData-Dependency Orderingを利用して、もっと高速なDCLを実装することも可能です。

std::atomic p(nullptr);
std::mutex m;

[[carries_dependency]] Hoge& getInstance() {
  Hoge* tmp = p.load(std::memory_order_consume);
  if (tmp == nullptr) {
    std::lock_guard lk(m);
    tmp = p.load(std::memory_order_relaxed);
    if (tmp == nullptr) {
      tmp = new Hoge();
      p.store(tmp, std::memory_order_release);
    }
  }
  return *tmp;
}

[[carries_dependency]] というヘンテコな句が増えていますが、これについてはこちらの文書を参照してください。

std::call_onceによる遅延初期化

いろいろDCLの実装方法について述べてきたのですが、単に「最初の一回だけ処理を行いたい」ということであれば、C++0xには std::call_once という関数が用意されています。利用例は以下のようになります。

Hoge* p = nullptr;
std::once_flag flag;

void init() {
  p = new Hoge();
}

Hoge& getInstance() {
  std::call_once(flag, init);
  return *p;
}

この std::call_once は、最初に呼び出されたときのみ init() を実行し、2回目以降は何も行いません。また、複数のスレッドが同時に std::call_once を呼び出した場合でも、必ず1つのスレッドのみが init() を実行し、他のスレッドは init() の実行が完了するまで待機させられます。
std::call_once は、DCLをわざわざコーディングしなくて済むためのユーティリティ関数と考えてよいでしょう。実際、std::call_once と同等のものは std::atomic と std::mutex を用いたDCLによって実装することが可能です。

ブロックスコープのstatic変数による遅延初期化

とまあ、いろいろ例を挙げてきたのですが、実はもっと単純な記述でインスタンスの遅延初期化を実現することが可能です。それは以下のコードです。

Hoge& getInstance() {
  static Hoge hoge;
  return hoge;
}

このようにブロックスコープでstatic変数を宣言すると、最初の getInstance() の呼び出しのときにHogeインスタンスが初期化されます。そしてC++0xでは、マルチスレッド環境においてもこの仕組みが正しく動作することが保証されます。つまり、最初に getInstance() を呼び出したスレッドだけがHogeのコンストラクタを呼び出し、他のスレッドはコンストラクタの実行が完了するまで待機させられるのです。*3
ということで、複雑なDCLのコードをわざわざ書かなくても、C++0xでは遅延初期化のコードをスマートに記述することが可能です。つまり、「C++0x時代では(遅延初期化のための) Double-Checked Locking は必要ない」というのが、この記事の結論なのです。:-)

*1:これらのAPIについてはこちらの記事が参考になります。

*2:こちらの記事を参照。

*3:さらに、このstatic変数を用いたバージョンでは、プログラム終了時にちゃんとデストラクタが呼びだされる点が他の例よりも優れています。