※この記事はC++20を相談しながら調べる会 #3の成果として書かれました。
C++20より、一部のvolatile
の用法が非推奨化されます。提案文書は「Deprecating volatile」という壮大なタイトルなのでvolatileそのものが無くなるのかと思ってしまいますがそうではありません。
この提案文書をもとに何が何故無くなるのかを調べてみます・・・
- そもそもvolatileってなんだろう・・・
- volatileの正しい用法
- この提案(P1152R4)の目的
- C++20から非推奨となるコア言語のvolatile
- C++20より非推奨となる標準ライブラリ内のvolatile
- 検討されていた他の候補
- volatile_load<T> / volatile_store<T>
- 不適切と思われるvolatileの用例
- 参考文献
- 謝辞
そもそもvolatileってなんだろう・・・
長くなったので別記事に分離しました。以下でお読みください。
C++におけるvolatile
の意味とは
volatile
指定されたメモリ領域はプログラム外部で利用されうるという事をコンパイラに通知- 特に、そのような領域は外部から書き換えられうる
- そして、実装は必ずしもそれを検知・観測できない
volatile
領域への1回のアクセスは正確に1回だけ行われる必要がある- 0回にも2回以上にもなってはならない
そして、volatile
の効果は
- シングルスレッド実行上において
volatile
オブジェクトへのアクセス(読み/書き)の順序を保証しvolatile
オブジェクトへのアクセスに関してはコンパイラの最適化対象外となる
となります。
そして、マルチスレッドにおいての同期用に使用すると未定義動作の世界に突入します。マルチスレッド時のための仕組みにはなっていません。
volatileの正しい用法
- 共有メモリ(not スレッド間)
- シグナルハンドラ
- シグナルハンドラ内で行えることは多くなく、共有メモリにシグナルが発生したことを書きこみ速やかに処理を終えることくらい
- そのような共有メモリに
volatile static
変数を利用できる
setjmp
/longjmp
setjmp
の2回の呼び出しの間で(つまり、longjmp
によって戻ってきた後でも)、変数が変更されないようにするのに使用する
- プログラム外部で変更されうるメモリ領域
- 無限ループにおいて副作用を示す
- 無限ループが削除されてしまわないように
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, c
がvolatile
である場合に限ってこの様な代入演算子の使用は非推奨となります。
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
関連は次の項で説明します。
tuple
とvariant
はその実装がどうなっているのか規定されておらず、その実装にどのようにvolatile
アクセスするのかが不明瞭です。
また、標準ライブラリのその他のクラステンプレートは特にvolatile
修飾を意識して書かれておらず、一貫していません。
従って、std::atomic
関連とnumeric_limits
を除くこれらのクラステンプレートのvolatile
に対する部分特殊化は非推奨とされます。
std::atomic
のvolatile
メンバ関数
volatile
オブジェクトの操作はアトミックではなく、その順序も変更される可能性がります(非volatile
オブジェクトとの間の相対順序やCPUのアウトオブオーダー実行時)。しかし、そのようなアクセスは確実に実行され、volatile
な領域の各バイトに対して正確に1度だけアクセスし、コンパイラによる最適化の対象とはなりません。
std::atomic
オブジェクトへの操作は分割されることは無く、完全なメモリモデルを持ち、それらは最適化の対象となります。
volatile std::atomic
はこれらを合わせた性質を持つことが期待されますが、現在の実装はそうなってはいません。
提案文書によれば、ロックフリーではないアトミック操作(の実装)はvolatile
オブジェクトに対して行われたときに、その原子性が失われることがある(アクセス順序や回数の変更ができないことから?)。とされています。
さらに、複合代入のような「読み出し - 更新 - 書き込み」操作の実装は特に指定されておらず、実装としては、再試行ループ、ロック命令、トランザクショナルメモリ、メモリコントローラの操作等の実装方法がありますが、volatile
領域に正確に1度だけアクセスするという事を達成できるのはメモリコントローラの操作という実装だけです。
volatile
の効果を適切に再現するためにはこうした実装を指定する必要がありますが、この様なハードウェアでの実装を指定することはC++標準の範囲から逸脱しています。
このように、現状のvolatile
とstd::atomic
の同時利用は必ずしも両方の特性を合わせたものにはなっておらず、場合によってはどちらかの性質が失われていることがあります。
この様な理由から、std::atomic
の全てのメンバ関数のvolatile
修飾版及び、フリー関数のstd::atomic_init
のvolatile
オーバーロードが、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つのアプローチを提案していました。
- 全てのメンバ関数が
volatile
修飾相当である、又は全くそうではない、という事を規定する - 例えば
struct volatile
のように、volatile
で使用される集成体の宣言を追加する - 集成体では
volatile
を禁止する(PODクラスとフリー関数を使用するようにする)
いずれの場合でも、メンバ変数のvolatile
修飾を禁止しません。
提案文書の著者は3番目の方法を押している雰囲気でした。
他にも共用体やビットフィールドの扱いなど考慮すべきところはあったようですが、結局これはC++20では見送られました。
std::atomic
のvolatile
メンバ関数の意味合いについて検討する必要があったためのようです。ただ、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ではない
参考文献
- P1152R0 : Deprecating volatile
- P1152R1 : Deprecating volatile
- P1152R2 : Deprecating volatile
- P1152R4 : Deprecating volatile
- P1831R0 : Deprecating volatile: library
- P1831R1 : Deprecating volatile: library
- P1382R0 : volatile_load
and volatile_store - Time of check to time of use - Wikipedia
- setjmp - cppreference.com
- Memory corruption due to word sharing. - Linus Torvalds. GCC mailing list
- C++0xのメモリバリアをより深く解説してみる - yamasaのネタ帳
- C++20 を相談しながら調べる会 #3 共有ドキュメント
謝辞
この記事の7割は以下の方々によるご指摘によって成り立っています。