syghの新フラグメント置き場

プログラミングTipsやコード断片の保管場所です。お絵描きもときどき載せます。

C++のmutableの使い道

(これは2010-10-20に書いた故OCNブログの記事を加筆修正したものです)

C++mutableconstメソッド(constメンバー関数)内でも変更可能なフィールドを定義する場合などに使います。mutableはヘタに使うと混乱を招くだけの余計な機能なんですが、以下のような感じでスレッドセーフなクラスを実現する際に使うことができます。

template<class T, class Container = std::queue<T> >
class CThreadSafeQueue {
private:
    mutable CCriticalSection m_cs; // MFC の同期オブジェクト。
    Container m_queue;
public:
    void Push(const T& data) {
        CSingleLock lock(&m_cs, TRUE);
        m_queue.push(data);
    }
    bool Pop() {
        CSingleLock lock(&m_cs, TRUE);
        if (m_queue.empty()) {
            return false;
        }
        else {
            m_queue.pop();
            return true;
        }
    }
    bool GetFront(T& info) const {
        CSingleLock lock(&m_cs, TRUE);
        if (m_queue.empty()) {
            return false;
        }
        else {
            info = m_queue.front();
            return true;
        }
    }
    bool GetBack(T& info) const {
        CSingleLock lock(&m_cs, TRUE);
        if (m_queue.empty()) {
            return false;
        }
        else {
            info = m_queue.back();
            return true;
        }
    }
    size_t GetSize() const {
        CSingleLock lock(&m_cs, TRUE);
        return m_queue.size();
    }
};

CCriticalSectionはboost::mutexやstd::mutexに相当します。
CSingleLockはboost::lock_guardやstd::lock_guardに相当します。

ここで重要なのはGetFront(), GetBack(), GetSize()のconst修飾子です。const修飾子の付いたメソッド内では、フィールド(メンバー変数)に対する変更を行なうことができなくなり、これによって「constメソッドであればオブジェクトの内部状態に変更を加えることがない」というコンパイラ保証を付加することができるため、より安全かつ可読性の高いコードを書くことが可能となるのですが、スレッドセーフなクラスを作るときはメソッド呼び出しをスレッドセーフにする*1ための同期オブジェクト(ロックオブジェクト)を非constで扱う必要があり、衝突することになります。これを回避するために、クラスオブジェクトの本質的なコンテキストとは関係ない同期オブジェクトのメンバーはmutableにしておきます。

なお、通常size_t自体の読み出しと書き込みは分割されることがないため、GetSize()は別にロックしなくてもよさそうに見えるかもしれませんが、規格上ロックは必須です。ロックなしだと「他のスレッドでの非const操作中に呼び出してもよい」という保証はsize_tやstd::queueの内部実装に依存する羽目になります。単純なsize_tの読み出しだけであれば通例32bit版でも64bit版でも1命令で実行できる操作(≒アトミック操作)となるため、一見するとロックなしでもよさそうですが、size_tが32bit環境において32bit幅である保証や、64bit環境において64bit幅である保証はどこにもありません*2。また、内部的に保持・読み書きしているデータが構造体型だったりすると話が変わってきます。書き換え途中の半端な状態を観測してしまう可能性があるからです。std::queue::size()の計算量は定数時間とは限らず、リストの要素をたどって数え上げる実装になっているかもしれません。要素を追加/削除している途中の状態を無理やり他のスレッドから読み取ってしまうと、ダングリングポインタ経由で不正なメモリを参照してしまうこともありえます。このようにコンテナの内部実装に依存するようなコードを書くべきではありません。仮にstd::queue::size()がメンバー変数にキャッシュされた値を読み取って返す実装になっていたとしても、そもそも複数のスレッドからの読み書きアクセスがアトミックでもなく排他制御されてもいない場合、データ競合(data race)となり、未定義動作を引き起こします。未定義動作ということはつまり、規格上C++コンパイラはどのようなコードを出力してもよくなるので、最適化の結果まったく意図しない動作をするコードが出力される可能性もあります*3

ちなみに、C++11のconstメソッドは、constメソッドのみを呼び出す場合に関してはスレッドセーフ性も表明することになります(排他制御を行なうか、ロックフリーであるかは問わない)。記憶にとどめておく必要があるでしょう。

*1:共有変数に複数のスレッドから読み書きアクセスされる可能性のあるコード(少なくとも一方が書き込みアクセスであるコード)は、明示的に排他制御しておかないとデータ競合の未定義動作を引き起こします。

*2:さらに言うと、32bit/64bitアーキテクチャにおいて32bit/64bitの値が1命令で読み書きできるかどうかは、そのデータのアライメントにも依存します。例えば強制的にパディングを無くしてパッキングされた構造体メンバーなど、4バイト/8バイトのアライメント境界をまたいで配置されているようなデータの場合、1命令で読み書きすることはできません。

*3:コンパイラ最適化による命令の入替(リオーダー)だけに限らず、アウトオブオーダー実行のようにCPUによって命令の入替が実施されることもあります。