コンテンツにスキップ

最終更新:2026-10-12

C++ シングルトンパターン

0.はじめに

面接でデザインパターンの話題を逃れられる人はいないでしょう。本節では面接で最もよく聞かれるデザインパターン(シングルトンパターン)を、分類、スレッド安全性、C++11標準を前提としない観点とC++11標準に基づく観点の両面から解説し、スレッドセーフなシングルトンパターンの解決策にはどのようなものがあるかを説明します。この記事をじっくり読めば、今後の面接で心配はいらなくなります。

1.よく知られたシングルトン

一般の書籍や皆さんが比較的よく知っているシングルトンパターンは次のようなものです:

class singleton {
private:
    singleton() {}
    static singleton *p;
public:
    static singleton *instance();
};

singleton *singleton::p = nullptr;

singleton* singleton::instance() {
    if (p == nullptr)
        p = new singleton();
    return p;
}

これは非常にシンプルな実装です。コンストラクタをprivateまたはprotectとして宣言して外部からのインスタンス化を防ぎ、内部に静的なクラスポインタで唯一のインスタンスを保持し、インスタンスの実装はpublicメソッドによって行われ、このメソッドはこのクラスの唯一のインスタンスを返します。

もちろんこのコードはシングルスレッドでのみ適切であり、マルチスレッドでは安全ではありません。二つのスレッドが同時に初めてinstanceメソッドを呼び出し、同時にpがnullptrであると検出した場合、二つのスレッドは同時に一つのインスタンスを構築してpに代入し、これはシングルトンの原則に違反します。

2.レイジーとイーガー

シングルトンには二つの実装方法があります:

  • レイジー(遅延初期化)
  • クラスのインスタンスが初めて使われるときに初めてインスタンス化を行います。上記がレイジー実装です。
  • イーガー(即時初期化)
  • シングルトンクラスを定義した時点でインスタンス化を行います。

ここでもイーガーの実装を示します:

class singleton {
private:
    singleton() {}
    static singleton *p;
public:
    static singleton *instance();
};

singleton *singleton::p = new singleton();
singleton* singleton::instance() {
    return p;
}

もちろんこれはスレッドセーフです。私たちが通常スレッドセーフでないと述べているのはレイジーモードのことで、以下ではレイジーモードのスレッドセーフなコードへの最適化を説明します。

3.マルチスレッドでのロック

C++でのロックには、RAIIを原理とするクラスがあり、unlockを手動で管理する必要がありません。それがlock_guardです。ここではこれを使ってロックをかけます。

class singleton {
private:
    singleton() {}
    static singleton *p;
    static mutex lock_;
public:
    static singleton *instance();
};

singleton *singleton::p = nullptr;

singleton* singleton::instance() {
    lock_guard<mutex> guard(lock_);
    if (p == nullptr)
        p = new singleton();
    return p;
}

この書き方では、上記のように二つのスレッドがともにp=nullptrの中まで実行される状況は発生しません。スレッドAがp = new Singleton()を実行しているときに、スレッドBがinstance()を呼び出した場合、必ずロックをかけた箇所でブロックされ、スレッドAの実行終了後にこのロックが解放されるのを待ちます。したがってスレッドセーフです。

しかしこの書き方は性能が非常に低くなります。なぜならinstance()が呼び出されるたびにロックをかけ、解放するからです。このステップが必要なのは最初のnew Singleton()のときだけで、pが作成された後は、いくつのスレッドが同時にアクセスしても、if (p == nullptr)で判定すれば十分です(読み取り操作のみなので、ロックは不要です)。スレッドセーフの問題はなく、ロックをかけると逆に性能問題が生じます。

そこからDCLが導かれます。

4.ダブルチェックロックパターン

上の書き方はどんな場合でもロックをかけてから解放しますが、読み取り操作にはスレッド安全性の問題はないため、ロックが必要なのは最初のインスタンス作成時だけで、それ以降は不要です。まずDCLPの実装を見てみましょう:

singleton* singleton::instance() {
    if(p == nullptr) {  // 第一次检查
        Lock lock;
        if(p == nullptr){ // 第二次检查
            p = new singleton;
        }
    }
    return p;
}

上記に基づいて、ダブルチェックロック+自動回収を書くことができます

class singleton {
private:
    singleton() {}

    static singleton *p;
    static mutex lock_;
public:
    static singleton *instance();

    // 实现一个内嵌垃圾回收类
    class CGarbo
    {
    public:
        ~CGarbo()
        {
            if(singleton::p)
                delete singleton::p;
        }
    };
    static CGarbo Garbo; // 定义一个静态成员变量,程序结束时,系统会自动调用它的析构函数从而释放单例对象
};

singleton *singleton::p = nullptr;
singleton::CGarbo Garbo;
std::mutex singleton::lock_;

singleton* singleton::instance() {
    if (p == nullptr) {
        lock_guard<mutex> guard(lock_);
        if (p == nullptr)
            p = new singleton();
    }
    return p;
}

DCLPの鍵は、instanceに対する大半の呼び出しではpが非空であると分かるため、初期化を試みることすらしない点にあります。そのため、DCLPはロックの取得を試みる前にpが空かどうかを検査します。検査が成功した場合(つまりpがまだ初期化されていない場合)にのみロックを取得し、その後、pがまだ空であるかを再度検査します(だからダブルチェックロックと名付けられています)。二度目の検査は必要です。ちょうど見たように、別のスレッドが偶然にも最初の検査の後、ロックの取得に成功する前にpを初期化する可能性があるからです。

上記のコードは非常に美しく見えますが、かなりの時間が経ってから、ようやくこの欠陥が発見されました。原因は**メモリ読み書きのアウトオブオーダー実行(コンパイラの問題)**です。

再びpを初期化するこの行を考えてみましょう:

p = new singleton;

この文は三つのことを引き起こします:

  1. singletonオブジェクトを格納できるメモリを割り当てる;
  2. 割り当てられたメモリの中にsingletonオブジェクトを構築する;
  3. pにこの割り当てられたメモリを指させる。

この三つのステップは順番に実行されると思われがちですが、実際に確実なのはステップ1が最初に実行されることだけで、ステップ2、3の順番は保証されません。問題はここにあります。

  • スレッドAがinstanceを呼び出し、最初のpの検査を実行し、ロックを取得し、1,3の順で実行して、その後中断されたとします。このときpは非空ですが、pが指すメモリにはまだSingletonオブジェクトが構築されていません。
  • スレッドBがinstanceを呼び出し、pが非空と判定し、それをinstanceの呼び出し元に返します。呼び出し元はポインタを参照解除してsingletonを取得しますが、なんと、まだ構築されていないオブジェクトです。ここでbugが発生します。

DCLPが正しく機能するのは、ステップ一と二がステップ三の前に実行される場合のみですが、CやC++でこの制約を表現する方法はありません。これはDCLPの心臓に突き刺さった短剣のようなものです。私たちは相対的な命令順序に制約を定義する必要がありますが、私たちの言語はこの制約を表現する方法を与えてくれないのです。

5.memory barrier命令

DCLPの問題はC++11で解決されました。

新しいC++11は新しいメモリモデルを規定しており、上述の三つのステップの実行中にスレッドの切り替えが発生しないことを保証します。この初期化プロセスは「原子的」な操作とみなせ、DCLは再び正しく使えるようになりました。ただし、C++11ではより簡潔なマルチスレッドsingletonの書き方があるので、それは後で紹介します。

C++11より前の解決方法はbarrier命令です。正しく実行させるには、ステップ2、3の間にmemory barrierを一つ挟む必要があります。CPUに、1、2、3のステップの順序で実行することを強制します。

第一の実装:

operator new+placement newに基づき、1,2,3の実行順序に従ってコードを書きます。

// method 1 operator new + placement new
singleton *instance() {
    if (p == nullptr) {
        lock_guard<mutex> guard(lock_);
        if (p == nullptr) {
            singleton *tmp = static_cast<singleton *>(operator new(sizeof(singleton)));
            new(tmp)singleton();
            p = tmp;
        }
    }
    return p;
}

第二の実装:

ASMアセンブリ命令mfenceを直接埋め込むものです。unixのbarrierマクロもこの命令を通じて実装されています。

#define barrier() __asm__ volatile ("lwsync")
singleton *singleton::instance() {
    if (p == nullptr) {
        lock_guard<mutex> guard(lock_);
        barrier();
        if (p == nullptr) {
            p = new singleton();
        }
    }
    return p;
}

通常、CPUが提供する一つの命令を呼び出します。この命令の役割は、この命令より前の命令をこの命令より後に交換することをCPUに禁止させることで、この命令は通常barrierと呼ばれます。上のコードの**asm**はこれが一条のアセンブリ命令であることを示し、volatileは任意です。これを使うと、コンパイラに対してこのアセンブリ命令の最適化を許可しないと宣言することになります。lwsyncはPOWERPCが提供するbarrier命令です。

6.静的ローカル変数

Scott Meyerは『Effective C++』の中で簡潔なsingletonの書き方を提案しました

singleton &singleton::instance() {
    static singleton p;
    return p;
}
  • シングルスレッドでは、正しい。
  • C++11以降のバージョン(C++14など)のマルチスレッドでは、正しい。
  • C++11より前のマルチスレッドでは、正しいとは限らない。

理由は、C++11より前の標準ではlocal static変数のメモリモデルが規定されていなかったことにあります。そこで、これはスレッドセーフではなくなっていました。しかしC++11ではスレッドセーフです。新しいC++標準は、あるスレッドがある変数を初期化しているとき、他のスレッドはその初期化が完了してからでなければアクセスできないと規定しているからです。

上述で使われているメモリ順序:

  • memory_order_relaxed:緩和されたメモリ順序で、原子オブジェクトに対する操作が原子的であることのみを保証する
  • memory_order_acquire:獲得操作。ある原子オブジェクトを読み取るとき、現在のスレッドの後続のいかなる読み書き操作も、この操作の前へ並べ替えることは許されず、また、他のスレッドが同じ原子オブジェクトに対して解放する前のすべてのメモリ書き込みが、現在のスレッドから見える
  • memory_order_release:解放操作。ある原子オブジェクトに書き込むとき、現在のスレッドの先行するいかなる読み書き操作も、この操作の後へ並べ替えることは許されず、また、現在のスレッドのすべてのメモリ書き込みが、同じ原子オブジェクトに対して獲得を行う他のスレッドから見える

7.Atomic

C++11より前のバージョンでは、ロックによってスレッドセーフなSingletonを実現する以外に、各コンパイラに内蔵のatomic operationを使って実現することもできます。

javaとc#は、アウトオブオーダーの問題を発見した後、volatileというキーワードを追加しました。p変数を宣言するときにvolatile修飾を付けると、コンパイラはそれを見て、この箇所では並べ替え(reorder)ができない(必ず先にメモリを割り当て、次にコンストラクタを実行し、すべて完了してから代入する)ことを知ります。

一方、c++標準はずっと修正されなかったので、VC++は2005バージョンでこのキーワードを追加しました。しかしこれはクロスプラットフォームではありません(マイクロソフトのプラットフォームのみサポート)。

そしてc++ 11バージョンに至り、これらの欠陥を根本から除去するため、マルチスレッドに適したメモリモデルが導入されました。ついに、クロスプラットフォームな実装を助けてくれる仕組みが手に入ったのです。

mutex singleton::lock_;
atomic<singleton *> singleton::p;

/*
* std::atomic_thread_fence(std::memory_order_acquire); 
* std::atomic_thread_fence(std::memory_order_release);
* 这两句话可以保证他们之间的语句不会发生乱序执行。
*/
singleton *singleton::instance() {
    singleton *tmp = p.load(memory_order_relaxed);
    atomic_thread_fence(memory_order_acquire);
    if (tmp == nullptr) {
        lock_guard<mutex> guard(lock_);
        tmp = p.load(memory_order_relaxed);
        if (tmp == nullptr) {
            tmp = new singleton();
            atomic_thread_fence(memory_order_release);
            p.store(tmp, memory_order_relaxed);
        }
    }
    return p;
}

注目に値するのは、上のコードが獲得と解放という二つの比較的重要な用語を使っていることです:

  • 獲得はメモリに対する読み取り操作で、現在のスレッドの後続のいかなる読み書き操作も、この操作の前へ並べ替えることは許されません。
  • 解放はメモリに対する書き込み操作で、現在のスレッドの先行するいかなる読み書き操作も、この操作の後へ並べ替えることは許されません。

acquire と release は通常ペアで現れ、その目的は、同じ原子オブジェクトに対する release が acquire より前に発生する場合、release の前に発生したメモリ変更が、acquire の後のメモリ読み取りによってすべて見えることを保証することです。

8.pthread_once

unixプラットフォームであれば、atomic operationを使う以外に、C++11を使わない状況でpthread_onceを通じてSingletonを実現することもできます。

プロトタイプは次のとおりです:

int pthread_once(pthread_once_t once_control, void (init_routine) (void));

実装:

class singleton {
private:
    singleton(); //私有构造函数,不允许使用者自己生成对象
    singleton(const singleton &other);

    //要写成静态方法的原因:类成员函数隐含传递this指针(第一个参数)
    static void init() {
        p = new singleton();
    }

    static pthread_once_t ponce_;
    static singleton *p; //静态成员变量 
public:
    singleton *instance() {
        // init函数只会执行一次
        pthread_once(&ponce_, &singleton::init);
        return p;
    }
};

9.まとめ

本記事ではいくつかのシングルトンパターンを説明し、スレッドセーフなシングルトンパターン、およびC++11を使わないで実現するいくつかのスレッドセーフなシングルトンパターン(memory barrier、静的ローカル変数、pthread_once方式、C++11のatomic実装など)を説明しました。

最後に注目してほしいのは、上述のシングルトンクラスのデストラクタについては、ダブルチェックロックパターン+自動回収の実装を参照してください。クラスの中に静的ローカル変数を一つ宣言する必要があります。静的ローカル変数はグローバル変数とみなすことができ、プログラム終了時に、その静的ローカル変数のデストラクタが自動的に呼び出されます。クラスの中でネストされたクラスを宣言・定義する理由、そしてシングルトンのデストラクタを直接書かない理由はここにあります。

10.参考資料

https://thorns.cn/2019/03/09/DCLP.html

https://www.cnblogs.com/liyuan989/p/4264889.html

https://segmentfault.com/a/1190000015950693

コメント