Last Update: volatile.aspx 2007/11/23 2:19 JST
CooS

volatileの本当の働き

volatileはC言語の修飾子です。こんな風に書かれます。

volatile int flag = 0;

目的は主に2つくらいあって、具体的には

  1. マルチスレッドの場合の共有変数につける
  2. ハードウェアのレジスタをアクセスする場合につける
だと思います。

しかしながら、1番目については、別にvolatileを付ければマルチスレッドがうまく動くわけではありません。それどころか、付けなくても良い場合もあり、さらにいうと付けない方が性能が上がるときもあります。また、2についても同様であって、こちらは基本的に付けないと上手くいかないことが多いのですが、付けなくても動くこともあったり、厳密にはvolatileだけで完璧になるわけではありません。

結局のところ、volatileはなにをしてくれて、結果としてどうなるのかが非常に理解しにくくなっています。そのため、「どうやらvolatile変数に関してコンパイラは最適化をしないらしい」という表層の効果だけが一人歩きしてしまい、残念なことに最適化抑制修飾子と捉えられてしまっているのが世の中の大勢のようです。

本ドキュメントは、そんな可哀想なvolatileの誤解を解き、コンパイラが無用な中傷を受けることが少なくなることを願って書くものです。

volatileの本当の効果

volatileはそもそもなにをするものでしょうか。MSDNの定義によると、

The volatile type qualifier declares an item whose value can legitimately be changed by something beyond the control of the program in which it appears, such as a concurrently executing thread.

volatile型修飾子は、それが現れたプログラムの制御を超えて、誰か(たとえば別のスレッド)がその要素の値を変更しうることを宣言します。

となっています。まあよく分からないので、こう考えてください。

すべてはレジスタに格納される

我々はコンピュータについて学習するときに、「プロセッサ」と「メモリ」について理解し、プログラムについて学習するときに、「変数とはメモリの一部の別名である」など教えられます。だから、まっとうなプログラマーは、変数はメモリにあるものだと理解しています。

これは実際には、特にvolatileについて理解するときには、まったく正しくありません。より良く理解するために、いまここで「すべての変数はレジスタに割り当てられる」と覚え直してください。

レジスタとは、プロセッサにある特別な記憶域のことですが、OSがうまいこと切り替えてくれるので、スレッドごとにレジスタがあると思って差し支えありません。スレッドはそれぞれ独自にレジスタの値を持っていて、一部が共通であったりはしません。これは一つめに重要なポイントです。

さて、次にコンパイラの話です。

コンパイラは、プログラムをなるべく速く動くような機械語に変換しようとします。そのため、あるデータを格納するために、メモリよりもレジスタを出来るだけたくさん使うように頑張ります。なぜなら、メモリよりレジスタのほうがアクセス速度が速いからです。

ここで、コンパイラの気持ちになってみましょう。実は、コンパイラとしては、メモリなんか使いたくないんです。レジスタが無限個あって、すべてがレジスタに格納されてしまうのが一番速い。でもまあ、レジスタはたかだかバイトオーダーの容量なのに対し、メモリはギガオーダーなので、仕方なくメモリも使う。しかしあくまでメモリはレジスタが足りないときの退避場所であって、「変数はメモリにある」というのはコンパイラとしては都合の悪い事実です。「変数はレジスタに置く。残念なことにレジスタで足りなければメモリを使う」。これが二番目に大切なポイントです。

さてさて、本題の変数について議論しましょう。

コンパイラとしては、変数はレジスタにあってほしいということは先ほど説明しました。だから、一般のコンパイラは、プログラムの変数の扱いについてこんな風に約束しています。

――どんな変数であっても、それはレジスタに割り当てられるかもしれないし、メモリに格納されるかもしれない。もしくは、最適化によって消してしまうかもしれない。

つまり、「ある変数が具体的にどうなるかなんて細かいことは俺がやる。全部任せてくれ。そうすればプログラムの実行速度を上げてやろうじゃないか。」ということです。だから、プログラマは、すべての変数がレジスタに割り当てられるものとしてコーディングしなければなりません。変数への読み書きでメモリにアクセスすると考えてはいけません。

そろそろ結論です。

「あらゆる変数はレジスタかもしれないと思え」ということですが、しかし、マルチスレッドやハードウェアへのアクセスではそれでは困ります。スレッド同士はレジスタを共有していませんから、データを受け渡すためにはどうしたってメモリにアクセスして貰わなければ困ります。ハードウェアについても、実際にアクセスしてバスに出ていかないとハードウェアには分かりません。

そこで、とうとうvolatileの出番です。

コンパイラは、volatileが付けられた変数については必ずメモリへ割り当てます。そして、一回の(ソースコード上の)読み込みや書き込みが、メモリへの実際の読み書きと対応するように、機械語を生成します。

この結果が、まるで最適化を抑制したように見えるから、volatileは最適化抑制修飾子のように言われてしまいますが、真実は「メモリへのアクセスを約束する修飾子」です。決して最適化を止める意図ではないので、実際、メモリへのアクセスを行うという約束を守ったまま、それ以外の最適化を行うこともあります。

以上が、volatileの、真実そのものではないにしろ真実に近い解釈だと思います。以降は、よく聞く間違った説明について述べます。

volatileを付ければマルチスレッドで動く

嘘です。

確かに、ある共用資源について、ふたつのスレッドからアクセスするとき、その順序が予め決まっていれば、volatileだけによって同期や排他制御をすることが可能です。しかし、そのようなケースはレアケースなので、一般的にはvolatileだけで同期や排他制御を行うことは出来ません。

「マルチスレッドで困ったらとりあえずvolatile」というのは、原因のうち一割くらいはそれで本当に解決しているかもしれませんが、残りは単に再現しづらくなった――問題を悪化させただけだと思います。また、そういう風にのたまう人は、たぶんマルチスレッドプログラミングとvolatileについて大いなる誤解をしていると判定されるでしょう。

同期機構を使っていればvolatileは不要

mutex, critical sectionなどの同期機構を用いていれば、volatileが不要というのも真っ赤な嘘です。

最初に説明したように、プログラマは「あらゆる変数がレジスタに割り当てられる」と思っている必要があります。したがって、共有しようとするデータについては少なくともvolatile指定が必要なはずで、それは同期機構とは 別次元の話です。

同期機構が必要となるのは、たとえば、アトミックに操作できない共用資源について排他制御を行うときです。たとえば、ある構造体の複数のメンバ変数を順番に更新するときなどがこれに当たります。逆に、境界整列された32bit整数なんかは排他制御をしなくても普通はアトミックにアクセスできるので、排他制御は必要ではないこともあります。

上のいずれの場合(構造体・32ビット整数)についても、それがスレッドをまたがって参照されるのであれば、volatileが必要です。

volatileは性能を劣化させるから使用するな

劣化させること自体は真実ですが、それはコンパイラとのインターフェイスによる制約であって、volatileは使用しなければなりません。

volatile指定してしまうと、その変数について必ずメモリアクセスしてしまうため、本来はレジスタに最適化可能なところもメモリアクセスになってしまいます。これを嫌って、コンパイラの動作を推測しながらコーディングし、volatileを外してしまうことがあるようです。

しかしこれは、性能以外のあらゆる品質を劣化させる最悪の手のように思えます。(ですから、性能が至上命題という極限られた領域のプログラムでは許されるでしょう。しかしそれは普通は避けるべき行いです。) 僕のお薦めとしては、変数はあくまでvolatile付きとして宣言しておき、最適化可能である区間だけ、volatileを外したポインタでアクセスするか、テンポラリ変数を用意する方法です。

つまり、

//volaitle int flag;
mutex_lock(&lock);
int* p = (int*)&flag;
f(p);
g(p);
mutex_unock(&lock);
とやるか、
//volaitle int flag;
mutex_lock(&lock);
int tmp = flag;
// process about tmp
flag = tmp;
mutex_unock(&lock);

とする方法です。

特に後者の方法はvolatileを活かしながらきちんと最適化が効きますので、やり方としてはキレイだと思います。

補足:キャッシュについて

ところで、いままで述べた「メモリ」という単語は、実のところこれまた正確ではありません。プロセッサには普通キャッシュがついているので、コンパイラがメモリアクセスする命令を書いたからと言って、本当にそれがメモリモジュールへのアクセスになるかどうかはキャッシュの状態を見てみないと判断できないのです。

でもまあ、キャッシュについてはプロセッサとプログラマの関係であって、コンパイラとプログラマの関係とはあんまり関係ない。だから、コンパイラの基本的なお話をするときには、とりあえずキャッシュのことは忘れて大丈夫だと思います。

あとそうそう、volatileによって、コンパイラがメモリアクセスをちゃんとするっていうのは保証されます。でも、プロセッサがコンパイラの並べた命令順序通りに実行するかというのは別問題。並んでいる命令の順番を並べ替えて実行することをアウトオブオーダー実行などというはずですが、これがあるプロセッサでは、依存の無さそうなメモリアクセスを並べ替えることがあって、そうするとハードウェア操作なんかはずたぼろになってしまう。

だから、OoEプロセッサでは、volatileを付けた変数へのアクセスの順番まで決めたいときは、さらにその間にメモリバリアというものを挟む必要があります。これを置いてあると、コンパイラはプロセッサに「ここにメモリバリアがあるよ」って教えるし、プロセッサはメモリバリアを跨いで命令順序を入れ替えるようなことをしなくなります。これも、volatileとは別の話なので、これはこれで覚えておく必要のあることです。

奥が深いなあ。