[C++]Deprecating volatile を見つめて

※この記事はC++20を相談しながら調べる会 #3の成果として書かれました。

C++20より、一部のvolatileの用法が非推奨化されます。提案文書は「Deprecating volatile」という壮大なタイトルなのでvolatileそのものが無くなるのかと思ってしまいますがそうではありません。
この提案文書をもとに何が何故無くなるのかを調べてみます・・・

そもそもvolatileってなんだろう・・・

長くなったので別記事に分離しました。以下でお読みください。

onihusube.hatenablog.com

C++におけるvolatileの意味とは

  • volatile指定されたメモリ領域はプログラム外部で利用されうるという事をコンパイラに通知
    • 特に、そのような領域は外部から書き換えられうる
  • そして、実装は必ずしもそれを検知・観測できない
  • volatile領域への1回のアクセスは正確に1回だけ行われる必要がある
    • 0回にも2回以上にもなってはならない

そして、volatileの効果は

  • シングルスレッド実行上において
  • volatileオブジェクトへのアクセス(読み/書き)の順序を保証し
  • volatileオブジェクトへのアクセスに関してはコンパイラの最適化対象外となる

となります。

そして、マルチスレッドにおいての同期用に使用すると未定義動作の世界に突入します。マルチスレッド時のための仕組みにはなっていません。

volatileの正しい用法

  • 共有メモリ(not スレッド間)
    • 共有相手はGPU等の外部デバイスや別プロセス、OSカーネルなど
    • 特に、time-of-check time-of-use (ToCToU)を回避する正しい方法の一つ
  • シグナルハンドラ
    • シグナルハンドラ内で行えることは多くなく、共有メモリにシグナルが発生したことを書きこみ速やかに処理を終えることくらい
    • そのような共有メモリにvolatile static変数を利用できる
  • setjmp/longjmp
    • setjmpの2回の呼び出しの間で(つまり、longjmpによって戻ってきた後でも)、変数が変更されないようにするのに使用する
  • プログラム外部で変更されうるメモリ領域
    • メモリマップドI/Oにおける外部I/Oデバイスレジスタなど
    • コンパイラはこのようなメモリ領域がプログラム内でしか使われていない事を仮定できない
  • 無限ループにおいて副作用を示す
    • 無限ループが削除されてしまわないようにvolatileオブジェクトを使用する
    • これはstd::atomicや標準ライブラリのI/O操作によっても代替可能
  • volatileの効果を得るためのポインタキャスト
    • volatileなのはデータではなくコードである、という哲学の下で一般に利用されている
    • volatileへのキャストが有効なのはポインタ間のみであり、オブジェクト間で非volatileからvolatile付の型にキャストしてもvolatileの効果は得られない
  • Control dependenciesを保護する
    • コンパイラの最適化によってこのような依存関係が削除されないようにする
  • memory_order_consumeのいくつかの実装の保護
    • コンパイラの最適化によってデータ依存の順序が崩されないようにする?

この様な正しい用途の中には、volatileの代替となる方法がインラインアセンブリを使う(コンパイラへの指示 or 機械語命令の直接出力)しか無いものがあります。
そのような方法には移植性がありません・・・

なおここには含まれていない正しい用例があるかもしれませんが、そこにマルチスレッド間共有メモリが入ることは決してありません。

この提案(P1152R4)の目的

この提案の目的は、C++標準内で間違っているvolatileの利用を正すことにあります。
volatileが意味がないから非推奨とか、マルチスレッド利用について混乱の下だから非推奨とか言う事ではありません。

そのため、volatileの正しい用法のための文言には一切手を付けていません。たとえそこにいくつかの問題がある事が分かっていても、この提案ではその修正さえもしていません。

また、そのような間違ったvolatileの用法はC++20に対してはとりあえず非推奨としていますが、将来的にそれらを削除することを目指しています。

C++20から非推奨となるコア言語のvolatile

ここから、この提案によって非推奨とされるvolatileの用法を見て行きましょう。その際重要な事は、volatileメモリへのアクセスは読み込みと書き込み、およびその順序に意味があるという事です。

複合代入演算子、インクリメント演算子

復号代入演算子とは+= -= *= /=のような演算子の事です。

インクリメント演算子++ --)と合わせて、これらの演算子は「読み出し - 更新 - 書き込み」という3つの操作を1文で行います。
すなわち、復号代入演算子の左辺オペランドvolatileだった場合に、そのメモリ領域には少なくとも2回のアクセスが発生します。

volatile int a = 0;
int b = 10;

a += b;
//これは以下と等価
//int tmp = a; 
//a = tmp + b;

++a;
//int tmp = a;
//a = tmp + 1;

a--;
//int tmp = a;
//a = tmp - 1;

実際にはこの様な展開はアセンブラコードとしてのものであり、tmpレジスタ上のどこかです。

ですが、復号代入演算子及びインクリメント演算子はこの場合のaに一回しかアクセス(最後の書き込みのみ)しかしないと思われがちです。また、このような一連の操作がアトミックに行われるとも勘違いされがちです。
std::atomicでさえも、このような操作には「読み出し - 更新 - 書き込み」という3つの操作が必要です。

volatileに対するこのような複合操作は明示的に「読み出し - 更新 - 書き込み」を分けて書くか、volatileなatomic操作を利用すべきです。

従って、volatileオブジェクトに対するこれらの演算子はバグの元であり、その使用は適切ではないため、非推奨とされます。
ただし、非推奨となるのは算術型・ポインタ型に対する組み込みの演算子のみです。

連鎖した代入演算子

類似の問題としてa = b = cのように連なった代入演算子の用法があります。

volatile int a, b, c;

a = b = c = 10;
//これは
//c = 10;
//b = 10;
//a = 10;
//それとも
//c = 10;
//b = c;
//a = b;
//もしくは
//c = 10;
//b = c;
//a = c;
//どうなの!?

実際は2番目の形になる様子ですが、この場合のb cにどのような順番で何回の読み書きが発生するのかが不明瞭です。
そのため、この場合のb, cvolatileである場合に限ってこの様な代入演算子の使用は非推奨となります。

volatile int a, b, c;

a = b = c = 10; //ng

int e;

a = e = 10;     //ok

a = e = c = 10; //ng

c = 10;
a = c;          //ok

a = e = c;      //ok

代入演算子が2つ以上連なる場合に、両端にある変数を除いてvolatile修飾された変数が現れてはいけません。ただし、これは非クラス型の場合のみ非推奨です。

関数引数のvolatile、戻り値型のvolatile

関数の引数がポインタや参照ではないのにvolatile修飾されている場合、const修飾でも同様ですが関数の内部では明確な意味を持ちます。

しかし、呼び出し側から見ると引数型のトップレベルのCV修飾の有無は無視され、呼び出し規約もC++コード上では無視されるため、その意味は非volatile引数をとる関数と全く同様になります。 また、わずかとはいえ関数実装詳細が漏洩してしまいます。

//以下関数は全て同じ関数と見なされ、オーバーロード出来ない
void f(volatile int n);
void __fastcall f(const int n);
void __stdcall f(int n);

volatile int g(volatile int n);

int n = 10;
int r = g(n);  //非volatileな変数をコピーして渡し、volatileな戻り値を非volatileな変数にコピーして受ける

仮に引数をvolatileとして扱いたい場合、非volatile引数をvolatileなローカル変数にコピーする方が良いでしょう。処理系によっては、この場合のコピーは省略されます。

同様に、ポインタや参照でないvolatileな戻り値型には意味がありません。GCCやclangでは効果が無いとして警告を出します。

これらの事はvolatileの正しい効果を考えると自明です。この様に、引数及び戻り値型に対するvolatile修飾は無意味であるので非推奨とされます。
これはトップレベルのvolatile修飾がある場合のみ非推奨とされます。従って、volatile修飾されたポインタや参照型の引数・戻り値は以前使用可能です。

void f1(volatile int);  //ng
void f2(volatile int*); //ok
void f3(int volatile*); //ok、f2と同じ引数型
void f4(int* volatile); //ng
void f5(volatile int&); //ok
void f6(int volatile&); //ok、f5と同じ引数型

volatile int  g1(); //ng
volatile int* g2(); //ok
int volatile* g3(); //ok、g2と同じ戻り値型
int* volatile g4(); //ng
volatile int& g5(); //ok
int volatile& g6(); //ok、g5と同じ戻り値型

また、この提案では、const修飾も同様であるとしてまとめて非推奨とする提案を行っていましたが、それは承認されなかったようです。

構造化束縛宣言のvolatile

構造化束縛宣言にもCV修飾を指定できますが、実際の所そのCV修飾は構造化束縛宣言に指定した変数名に直接作用しているわけではありません。
構造化束縛宣言の右辺にある式の暗黙の結果オブジェクトに対してCV修飾がなされます。

構造化束縛宣言の動作の詳細については以下をお読みください。 onihusube.hatenablog.com

その結果オブジェクトがstd::tupleの場合、std::getを用いて要素の参照が行われるため、そこでエラーになります(std::get()volatile std::tuple<...>を受け取るオーバーロードを持たないため)。これはstd::pairでも同様です。
ただ、配列や構造体の場合は意図通りになります。

auto f() -> std::tuple<int, int, double>;

volatile auto [a, b, c] = f();   //コンパイルエラー!!
//ここでは以下の様な事が行われている
//volatile auto tmp = f();
//std::tuple_element_t<0, decltype(tmp)>& a = std::get<0>(tmp);

int array[3]{};

volatile auto [a, b, c] = array; //ok
//ここでは以下の様な事が行われている
//volatile int tmp[] = {array[0], array[1], array[2]};
//volatile int a = tmp[0];

static_assert(std::is_volatile_v<decltype(a)>); //ok

この様な非一貫的な挙動及び、前項の関数の戻り値型のvolatileと同様の無意味さがあることから、構造化束縛宣言に対するvolatile修飾は非推奨とされます。
もし構造化束縛にvolatile修飾したい場合は、分解元の型の要素・メンバに対してvolatile修飾しておくべきであり、volatileの用途としてはおそらくそれが適切でしょう(構造化束縛の名前自体は変数名ではないので・・・)。

auto f() -> std::tuple<int, int, double>;

volatile auto [a, b, c] = f();  //ng

auto g() -> std::tuple<volatile int,volatile int,volatile double>;
auto [a, b, c] = g();  //ok

static_assert(std::is_volatile_v<decltype(a)>); //ok
static_assert(std::is_volatile_v<decltype(b)>); //ok
static_assert(std::is_volatile_v<decltype(c)>); //ok

C++20より非推奨となる標準ライブラリ内のvolatile

当初の提案は標準ライブラリ内にあるvolatileに関する所も非推奨とする提案を含んでいましたが、のちにそれは別の提案(P1831R1 Deprecating volatile: library)として分離され、その後同様にC++20に採択されました。

標準ライブラリ内テンプレートのvolatileオーバーロード

標準ライブラリ内で提供されているクラステンプレートにはvolatile用にオーバーロード(部分特殊化)が明示的に提供されているものがあります。

  • numeric_limits
  • tuple_size
  • tuple_element
  • variant_size
  • variant_alternative
  • std::atomic関連

このうちnumeric_limitsは有用性があるという事でそのままとのことです。また、std::atomic関連は次の項で説明します。

tuplevariantはその実装がどうなっているのか規定されておらず、その実装にどのようにvolatileアクセスするのかが不明瞭です。
また、標準ライブラリのその他のクラステンプレートは特にvolatile修飾を意識して書かれておらず、一貫していません。

従って、std::atomic関連とnumeric_limitsを除くこれらのクラステンプレートのvolatileに対する部分特殊化は非推奨とされます。

std::atomicvolatileメンバ関数

volatileオブジェクトの操作はアトミックではなく、その順序も変更される可能性がります(非volatileオブジェクトとの間の相対順序やCPUのアウトオブオーダー実行時)。しかし、そのようなアクセスは確実に実行され、volatileな領域の各バイトに対して正確に1度だけアクセスし、コンパイラによる最適化の対象とはなりません。

std::atomicオブジェクトへの操作は分割されることは無く、完全なメモリモデルを持ち、それらは最適化の対象となります。

volatile std::atomicはこれらを合わせた性質を持つことが期待されますが、現在の実装はそうなってはいません。

提案文書によれば、ロックフリーではないアトミック操作(の実装)はvolatileオブジェクトに対して行われたときに、その原子性が失われることがある(アクセス順序や回数の変更ができないことから?)。とされています。

さらに、複合代入のような「読み出し - 更新 - 書き込み」操作の実装は特に指定されておらず、実装としては、再試行ループ、ロック命令、トランザクショナルメモリ、メモリコントローラの操作等の実装方法がありますが、volatile領域に正確に1度だけアクセスするという事を達成できるのはメモリコントローラの操作という実装だけです。
volatileの効果を適切に再現するためにはこうした実装を指定する必要がありますが、この様なハードウェアでの実装を指定することはC++標準の範囲から逸脱しています。

このように、現状のvolatilestd::atomicの同時利用は必ずしも両方の特性を合わせたものにはなっておらず、場合によってはどちらかの性質が失われていることがあります。

この様な理由から、std::atomicの全てのメンバ関数volatile修飾版及び、フリー関数のstd::atomic_initvolatileオーバーロードが、std::atomic<T>::is_always_lock_free == falseとなる特殊化に対してのみ非推奨とされます。

検討されていた他の候補

メンバ関数のvolatile修飾

volatile修飾メンバ関数は特殊な場合を除いて通常使用されません。これはSTLのクラスがconstメンバ関数を用意していてもvolatileメンバ関数を用意していない事からも伺うことができます。
それに対応しようとすると、あるメンバ関数を定義するのにその記述量が倍になってしまう(CV無し+C有+V有+CV有)割にその恩恵が不明瞭で使用頻度も低い為だと思われます。

クラスはconstでもそうでなくても利用でき、そのconst修飾の有無でメンバ関数の挙動を変えることができます。この有用性は誰もが認めることだと思われます。しかしvolatileはどうでしょうか?

クラスがvolatile修飾されているとき、そのオブジェクトは外部から変更されうる領域に置かれていることになります。しかし果たして、そのようなクラスがvolatileとしてもそうでない場合も使えるように設計される必要は本当にあるのでしょうか?そしてその場合、volatile修飾されたメンバ関数とそうでない関数の違いとは一体何でしょうか・・・・

また、あるオブジェクトに対するconst修飾の効果が表れるのは、そのオブジェクトの最派生コンストラクタの呼び出しが完了したときであり、コンストラクタ内ではそのメンバに対するアクセスでもconst性はありません(これはデストラクタも同様)。
これはvolatileでも同様ですが、volatileの場合はその領域は外部から変更されうる場所であり、その領域へのアクセス順序や回数には意味があるはずです。その時、volatile性なしでオブジェクトの構築・破棄を行う事は適切ではありません。

このように、volatile修飾メンバ関数は実質的に無意味であるため、非推奨とする事が提案されていました。

ただし、上記の事を踏まえても、集成体(aggregate)はvolatileで適切に使用されている可能性があります。その場合volatile修飾メンバ関数が非推奨となると、そのような集成体の全てのメンバを再帰的にvolatile修飾して回ることになってしまいます。
そのため、この文書では3つのアプローチを提案していました。

  1. 全てのメンバ関数volatile修飾相当である、又は全くそうではない、という事を規定する
  2. 例えばstruct volatileのように、volatileで使用される集成体の宣言を追加する
  3. 集成体ではvolatileを禁止する(PODクラスとフリー関数を使用するようにする)

いずれの場合でも、メンバ変数のvolatile修飾を禁止しません。
提案文書の著者は3番目の方法を押している雰囲気でした。

他にも共用体やビットフィールドの扱いなど考慮すべきところはあったようですが、結局これはC++20では見送られました。
std::atomicvolatileメンバ関数の意味合いについて検討する必要があったためのようです。ただ、volatileメンバ関数の非推奨については合意を得られていたようなので将来的には非推奨とされる可能性があります。

volatile_load<T> / volatile_store<T>

正確に言えばこの提案には含まれていませんでしたが、この提案から派生した提案として、volatileな値の読み書きを行う特別な関数の提案が出されています。

namespace std {
  template<typename T>
  constexpr T volatile_load(const T* p);

  template<typename T>
  constexpr void volatile_store(T* p, T v);
}

このstd::volatile_load<T>std::volatile_store<T>は引数として渡されたメモリ領域から値を読み込み、また書き込みます。
そこでは引数がvolatileかどうかに関わらず、volatileセマンティクスが適用されます。すなわち、1回のアクセスは正確に1回だけpの指すメモリ領域へアクセスし、この関数は最適化対象外となりその順序の入れ替えも行われません。
また、単なるvolatileオブジェクトへのアクセス以上の保証を与えているようです(可能な限りアクセスを分割しない等)。

これは、volatileなのはデータ(変数)ではなくコードであるという考え方を体現したものといえ、Linuxカーネルで使用されるREAD_ONCE()/WRITE_ONCE()マクロや、D言語peek()/poke()、Rustのstd::ptr::read_volatile()/std::ptr::write_volatile()と同様のアプローチです。

ここまで見てきた(そしてこの後紹介する)ような、volatile修飾の間違った用例を見ると、このアプローチの方が正しいのでは?という気もしてきます・・・

合意は取れているようなので将来的にC++に入ることは間違い無いと思われますが、C++20に入るかは不透明です。

不適切と思われるvolatileの用例

最後に、提案文書にある不適切なvolatileの使用例をコピペ紹介しておきます。上記の事の理解の一助になるかと思われます。

struct foo {
  int a : 4;
  int b : 2;
};
volatile foo f;

//どんな命令が生成されるでしょう?また、領域に何回アクセスするでしょう??
f.a = 3;
struct foo {
  volatile int a : 4;
  int b : 2;
};
foo f;

f.b = 1; // aの領域へアクセスする?
union foo {
  char c;
  int i;
};
volatile foo f;

//これはsizeof(int) [byte]の領域へアクセスする?それとも、sizeof(char) [byte]だけ??
f.c = 42;
volatile int i;

//それぞれ何回領域アクセスが発生するでしょう?
i += 42;
++i;
volatile int i, j, k;

//iへの代入時にjの値を再読み込みしますか?
i = j = k;
struct big { int arr[32]; };
volatile _Atomic struct big ba;
struct big b2;

//この操作はatomicに行われる?
ba = b2;
int what(volatile std::atomic<int> *atom) {
    int expected = 42;

    //ここでは*atomの領域に何回のアクセスが起こるでしょう?
    atom->compare_exchange_strong(expected, 0xdead);
    
    return expected;
}
void what_does_the_caller_care(volatile int);
volatile int nonsense(void);
struct retme { int i, j; };
volatile struct retme silly(void);
struct device {
  unsigned reg;
  device() : reg(0xc0ffee) {}
  ~device() { reg = 0xdeadbeef; }
};
volatile device dev; //初期化(コンストラクタ内)、破棄(デストラクタ内)はともにvolatileではない

参考文献

謝辞

この記事の7割は以下の方々によるご指摘によって成り立っています。

この記事のMarkdownソース