yohhoyの日記

技術的メモをしていきたい日記

const, mutableキーワードとスレッド安全性

C++11標準ライブラリにおいて const, mutable キーワードが持つセマンティクスと、自作クラスのスレッド安全性に関するイディオムについてメモ。本記事の内容はC++ and Beyond 2012でのHerb Sutter氏プレゼン "You Don't Know const and mutable" に基づく。

要約:

  • C++98/03:constメンバ関数はオブジェクトに対して「logical const(論理的const)」な操作であることを意味する。constメンバ関数ではmutableメンバ変数の変更操作を含む可能性があるが*1、クラス外部から観測する限りはconst(不変操作)のように振る舞う。
  • C++11以降:C++03以前のセマンティクスに加えて、「同一オブジェクトのconstメンバ関数を複数スレッドから同時に呼び出してもよい」というスレッド安全性を表明する。標準ライブラリが提供するクラス(テンプレート)では、C++標準規格により前述のスレッド安全性が保証されている。(→id:yohhoy:20120513
  • ユーザ定義クラスを設計・実装する場合にも、C++11標準ライブラリと同じスレッド安全性レベルを保証すべきである*2。この保証レベルを実現するために、クラス内部実装では「mutable修飾されたmutexメンバ変数」を利用した同期処理が必要になる。→「M&Mルール*3

C++11ではconstキーワードとmutableキーワードに、それぞれ新しいセマンティクス「スレッド安全(thread safe)」が追加される。(下表はスライドp6, p10より抜粋要約)

キーワード C++98 C++11
const logically const thread safe
(bitwise const or internally synchronized)
mutable not observably non-const thread safe
(bitwise const or internally synchronized)

ここでの「スレッド安全」とは、C++11標準ライブラリが基本的に保障するスレッド安全性を意味する。すなわち、あるオブジェクトにおいて “constメンバ関数の呼び出し=読込操作(read)”、“非constメンバ関数の呼び出し=変更操作(modify)” として下記が成り立つ。これはintなどのプリミティブ型オブジェクトに対するデータ競合(data race)を避ける規則と等しい。

  • 異なるスレッド上における同一オブジェクトに対する操作が、全てreadアクセスであれば呼び出し側における同期処理(排他制御)は不要
  • 少なくとも1つが変更操作である場合は、操作が同時発生しないよう呼び出し側の責任で排他制御を行わなければならない

クラス利用者に対して前述のスレッド安全性を提供するために、そのクラス内部実装では次のルールを守ること。

  • constメンバ関数を「bitwise const」として実装する。つまりconstメンバ関数内ではメンバ変数のread操作しか行わない。
  • またはconstメンバ関数を「logical const」とする場合、データ競合が生じないよう下記方針で実装する。
    • mutableなメンバ変数に対する変更操作を、mutableなmutexオブジェクトを用いて排他制御する。
    • atomic変数*4などの基本的なスレッド安全性よりも強い保証を提供するクラスを用い、それらをmutableなメンバ変数として利用する。

具体例

下記コードは、多角形(Polygon)を表すクラスとキャッシュ付き面積計算の例。このPolygonクラスは、C++11標準ライブラリと同等の基本的なスレッド安全性を提供する。

  • Polygon::get_areaはconstメンバ関数となっているため、呼び出し側は複数スレッドから同時に同メンバ関数を呼び出すことができる(とC++11 constセマンティクスにより解釈する)。get_areaメンバ関数の内部実装では、mutableなstd::mutexオブジェクトを利用した排他制御を行っている(C++11 constセマンティクスを守るためにこう実装する義務がある)。
  • Polygon::countもconstメンバ関数であるが、内部実装ではreadアクセス(std::vector<double>::size=constメンバ関数の呼び出し)しか行わないため排他制御の必要がない。
  • 一方Polygon::appendは非constメンバ関数のため、呼び出し側はこのメンバ関数を他メンバ関数と同時に呼び出す事はない(とC++11 constセマンティクスにより制限される)。このため、cached_area_メンバ変数に対して排他制御なしに変更操作を行っている(これが安全であるとC++ constセマンティクスにより保証される)。
#include <vector>
#include <mutex>

class Polygon {
  std::vector<double> pts_;
  mutable std::mutex mtx_;
  mutable double cached_area_;
public:
  Polygon() : cache_area_(0.0) {}

  // 頂点追加(非constメンバ関数)
  void append(double x, double y)
  {
    pts_.append(x);  pts_.append(y);
    cached_area_ = 0.0;  // 計算済みキャッシュをクリア
  }

  // 多角形の面積を取得(constメンバ関数)
  double get_area() const
  {
    std::lock_guard<std::mutex> lk(mtx_);
    if (cached_area_ == 0.0) {
      // 面積未計算ならば計算結果をキャッシュ
      cached_area_ = calc_area();
    }
    return cached_area_;  // 計算済みキャッシュ値を返す
  }

  // 頂点数を返す(constメンバ関数)
  std::size_t count() const
  {
    return (pts_.size() / 2);
  }
private:
  double calc_area() const
  { return /* pts_から複雑な面積計算... */; }
};

関連URL

*1:constメンバ関数は「bitwise const」とは限らない。mutableメンバ変数に対する変更、つまりビット単位という物理的な観点では変更操作が行おこなわれる可能性がある。論理的constについては プログラミング言語DのFAQ の説明が詳しい(注:D言語のconstセマンティクスはC++言語とは異なる)。

*2:独自のスレッド安全性レベルを採用し、ドキュメンテーションを行うことも自由ではある。ただし、クラスライブラリ利用者からみると、C++標準ライブラリのスレッド安全性レベルよりも保証が弱いクラスライブラリは使いづらく、誤った利用によるデータ競合(data race)を引き起こすリスクが高くなる。

*3:http://herbsutter.com/2013/05/24/gotw-6a-const-correctness-part-1-3/

*4:C++11標準ライブラリが提供する std::atomic 型では、atomic変数に対するいかなる操作もデータ競合を生じない。