プログラミング言語C++のvolatile変数がスレッド間の同期機構として機能するか否かという論点について、有りそうな質問とその答えについての簡易メモ(→id:yohhoy:20121016)の続き。(自身の思考整理用)
ある変数がスレッド間の同期機構として機能するためには、下記3つの性質が保証されることが要求される。volatile変数ではこれらのいずれも保証されないため、同期機構としては利用できない。
- 原子性(atomicity)
- 可視性(visibility)
- 順序性(ordering)
原子性の保証
前回記事では、原子性(atomicity)の保証についてのみ説明している。
volatile変数とマルチスレッドとの関係についての押し問答(前編)
- volatile変数ではダメだという反例を挙げよ
- volatile変数への読み書きは不可分(atomic)操作であるとは保証されない。仮にレジスタサイズ2byte長のマシンを想定した場合、同環境では4byteサイズのvolatile変数アクセスはおそらく2回の2byteメモリアクセスに分割される。このとき異なるスレッドからvolatile変数へ読み/書きを同時に行うと、4byte領域のうち半分だけ更新された中間の値を読み込む可能性がある。つまり、プログラムの動作上は “どこにも存在しない値” を観測してしまうリスクがある。
- atomic変数ならば問題ないのか
- atomic変数に対する操作は、字義通り不可分な操作であるとC++11言語仕様によって保証される。前出の想定であれば、4byteサイズのatomic変数に対する読み/書きは不可分操作として実行され、他スレッドからは中間状態を観測しないと保証される。
可視性の保証
可視性(visibility)の保証とは、「あるスレッドから変数に書き込んだ値が、いつかは必ず別スレッドから観測できるようになる」ことを意味する*1。前回記事で掲げた下記コードにおいて「制御側スレッドが変数runningに書き込んだ値0を、処理スレッドのwhile条件式評価にて読み込めるか?」という論点になる。可視性が保証されない場合、処理スレッドでは変数runningの初期値1を観測し続けるため、明らかにスレッド間の同期機構として機能しなくなる。
volatile int running = 1; // 処理スレッド void another_thread() { while (running) { // ??? //... } } // 制御側スレッド void main_thread() { //... running = 0; // ??? }
- volatile変数と可視性
- C++言語仕様が定める範囲のvolatile変数ではこのような可視性が保証されないため、スレッド間の同期機構として期待通り動作する保証がない。
- それは直観/経験則に反する
- volatile変数がI/Oマップドメモリである場合や単なるメモリ位置を指す場合のいずれにおいても、プロセッサAが変数(外部リソースやメモリ)へ値を書き込んだ後にプロセッサBによる同一リソースからの読み込みで、書き込んだ値を読み取れないという処理系の存在は考えにくい。しかし、前回記事の繰り返しとなるが、volatileとマルチスレッドにまつわる未定義動作(undefined behavior)は「全ての処理系において意図通り動作する保証がなされない」ことを意味する。
- 可視でない具体例は?
- volatile変数にマップされた外部リソースとプロセッサユニットとの間にキャッシュ機構が媒介するとき、(ハードウェアシステムが原始的な場合は)複数キャッシュ間でのコヒーレンス(coherence)が自動的に取られないケースが考えられる。このようなシステムで可視性を保証するには、volatile変数アクセスに加えて明示的なキャッシュ同期処理(おそらくasm文や組込関数等を要求)が別途必要になる。
- atomic変数と可視性
- 「あるatomic変数に対して最後に書き込まれた値は、いつかは必ず全てのスレッドから可視となる」とC++11言語仕様によって保証される*2。これはハードウェアシステムがどのようなキャッシュ機構を有していても、処理系(コンパイラ/OS/ハードウェア等)の責任でatomic変数に対しては異なるスレッド間での可視性を保証することを意味する。*3
順序性の保証
注意:簡単のため、atomic変数への逐次一貫性(sequential consistency)アクセスのみを対象とする。これはC++11 atomicアクセス操作における既定動作である。
順序性(ordering)の保証とは、「あるスレッド上で行われた複数変数に対する書き込み結果が、別スレッドから観測したときも同一順序で観測できる」ことを意味する。下記コードではスレッド間の同期機構として変数flag
を利用し、かつ “data
の値を更新→flag
の値を更新→flag
更新結果を観測→data
更新結果を観測” という実行順を期待している。volatile変数flag
を用いたこのようなプログラムでは順序性が保証されず、その結果として未定義動作を引き起こす。
int data = 0; volatile int flag = 0; // 生産側スレッド void producer_thread() { data = 42; flag = 1; // ??? } // 消費側スレッド void consumer_thread() { while ( !flag ) {} assert(data == 42); // ??? }
仮にある処理系においてvolatile変数の原子性と可視性が担保される場合でも、この順序性保証が無ければプログラムは期待通り動作しない。この順序性はいわゆる “最適化” の影響を受けやすく、「コンパイル時オプションを変えると思った通り動かない」「最適化レベルを上げるとプログラムが壊れる」事象として表面化するケースが多いが、いずれもvolatile変数を誤って利用したことによる未定義動作の結果に過ぎない。*4
→ volatile変数とマルチスレッドとの関係についての押し問答(後編)に続く。
関連URL
*1:“いつかは必ず” という表現に違和感を持つかもしれないが、一般化されたマルチスレッドシステムにおいて “即座に” や “ある一定期間内に” といった時間軸での制約条件は課されない。とはいえ、処理系において合理的なスレッドスケジューリングが行われる前提の下では、合理的な期間内には他スレッドから可視になると解釈してよい。
*2:N3337 1.9/p25: An implementation should ensure that the last value (in modification order) assigned by an atomic or synchronization operation will become visible to all other threads in a finite period of time.
*3:atomic変数は普通の変数と同様にメモリ上に配置される。C++11ではatomic変数とvolatile修飾は直交した概念であり、“volatileなatomic変数” を定義することも出来る(→id:yohhoy:20120701)。
*4:producer_thread での変数代入順序が逆転したり、consumer_thread での変数参照順序が逆転するかもしれないが、いずれもC++言語仕様の下では “妥当な” 実行結果である。