2020/03/21

Double-checked locking は使わないようにしましょう

Double-checked locking (DCL) とは?

Double-checked locking とは、マルチスレッドプログラムにおいて、複数スレッドから参照される変数について、初めて参照されたときに一度だけ初期化を行いたい場合に、同期化のコストを最小化するために考えられた idiom です。
変数への代入が行われるのは初期化時だけなので、同期化が必要なのはそのときだけです。したがって、初期化が終わっているときは同期化をしないようにすることで、同期化のコストを回避しようという発想です。

DCL はイマドキのコンピュータでは使えません

しかし、イマドキのコンピュータでは DCL が期待通りに機能しません。理由は、アウト・オブ・オーダー実行という CPU レベルでの最適化によるものです。アウト・オブ・オーダー実行についてはこちらの記事で詳しく説明していますので、ご覧ください。
DCL においては、インスタンスの初期化処理と変数への代入処理のリオーダーが問題となります。

Java によるサンプルコード

シングルトンのホルダー変数の初期化に DCL を使用する例を見てみましょう。

まず、DCL を使用しない場合、次のようになります。
public final class NotDCLSingleton {
  private static NotDCLSingleton instance;
  private NotDCLSingleton() {
  }
  public static synchronized NotDCLSingleton getInstance() {
    if (instance == null) {
      instance = new NotDCLSingleton();
    }
    return instance;
  }
}

getInstance() を synchronized メソッドにしており、呼び出しが常に同期化されているため、パフォーマンスが悪くなります。

そこで DCL を使用したとすると、こうなります。
public final class DCLSingleton {
  private static DCLSingleton instance;

  private DCLSingleton() {
  }

  public static DCLSingleton getInstance() {
    if (instance == null) {
      synchronized (DCLSingleton.class) {
        if (instance == null) {
          instance = new DCLSingleton();
        }
      }
    }
    return instance;
  }
}

getInstance() の呼び出しは同期化されておらず、DCL により初期化時のみ同期化しています。パフォーマンスの問題はなくなりました。

では、何が問題か?

前述の通り、アウト・オブ・オーダー実行が行われるため、DCL は使ってはいけません。
上記のサンプルで言うと、11行目の new DCLSingleton() によるインスタンスの初期化処理と instance への参照の代入処理がリオーダーされる可能性があります。リオーダーされた場合、あるスレッドで初期化が完了していないインスタンスを instance に割り当て、その後でインスタンスの初期化をしている最中に、他のスレッドが instance を参照して初期化の完了していないインスタンスを取得してしまうことが起こり得ます。

どうやって避けるの?

これを避けるためには、instance への代入にあたってメモリバリアをかければ良いのですが (例えば Java, C# であれば instance に volatile 修飾子を付ける)、instance 変数へのアクセスの都度ある種の同期がかかることになるため、同期化のコストを最小化するという意味が薄れてしまいます。
Java では、Initialization-on-demand holder idiom という方法があります。別の記事で紹介したいと思います。

なお、FindBugs/SpotBugs や Checkstyle には、DCL を使用している箇所を検出する機能が用意されていますので、不安な方は適用してみるといいかもしれません。