C#のvolatileって結局なんの効果があるの?

volatile 修飾子

C#にはvolatileという修飾子がある。MSDNのC# リファレンスでは以下のように説明している。

volatile キーワードは、同時に実行中の複数のスレッドによってフィールドが変更される可能性があることを示します。 volatile と宣言されているフィールドは、シングル スレッドによるアクセスを前提とする、コンパイラの最適化の対象にはなりません。このため、フィールドには常に最新の値が含まれます。

volatile 修飾子は、通常、アクセスをシリアル化する lock ステートメント (C# リファレンス) ステートメントが使用されない場合に複数のスレッドによりアクセスされるフィールドに対して使用します。

まず、前半部分は他のスレッドより変更される可能性があるフィールドに対して最適化を禁止するためのもの、これはまあまあ想像できる。以下のようなコードでコンパイラが_shouldStopがループ内で使用されていないのと判断してwhile (true)と最適化すると無限ループになる。これを抑制するために_shouldStop にvolatile を指定するということであろう。

public void DoWork()
{
    _shouldStop = false;
    while (!_shouldStop)
    {
        Console.WriteLine("worker thread: working...");
    }
    Console.WriteLine("worker thread: terminating gracefully.");
}

後半はどうだろうか、volatile 修飾子をつけるとlockを使用しないで排他可能と読めるのであるが、これはどういうことか?
実はvolatile は参照型や整数型など利用できる型が決まっている。この制限はCLRが代入時の原子性*1を保証する単位に一致するのでその特性を利用して排他できることを言っているのではないか。ただ、この特性はvolatileを宣言する/しないにかかわらず、lockせずに排他(≒原子性)が保障されている。だとすると、この説明は不要なような感じるのだが、わざわざ記述している意図は何かあるのではと疑問が浮かぶ。

volatileはlock不要な排他メカニズム?

調べてみるとvolatileについていろいろ資料があるようだ。特に
Understand the Impact of Low-Lock Techniques in Multithreaded AppsNonblocking Synchronization の記事が詳しくまとめられている。

前者は、スレッドの共有メモリにどのようにアクセスするかの仕様(メモリモデル)について書かれている。メモリモデルにはいくつかある。この歴史的な経緯なども興味深いが、基本のECMAのモデルの説明を読むとvolatileに対するアクセスについていくつかの最適化の制限が付けられることが記述されている。さらに、volatile変数アクセス時にプロセッサ(CPU)キャッシュの無効化とフラッシュを要求している。これによってキャッシュとメインメモリが同期される。排他を行う上でこの同期は必要でvolatileの修飾子をつけると変数の読み書き時には自動的に同期されるというのようだ。これで先ほどの疑問も解決である。

ちなみに後者の記事では実際にvolatileをつけないと問題のあるコードが記載されている。以下のコードをReleaseモードでビルドして、デバッグなしで実行すると終了しない。volatile をつける(static volatile bool complete = false;)と正しく終了する。

class Program
{
    static bool complete = false;
    static void Main()
    {
        var t = new Thread(() =>
        {
            bool toggle = false;
            while (!complete) toggle = !toggle;
        });
        t.Start();
        Thread.Sleep(1000);
        complete = true;
        t.Join();        
    }
}

volatileはlock不要なメモリアクセス機能を提供する

結局volatileの効果としては、マルチスレッドでlockなしにメモリをアクセスできる仕組みを提供しているということであろう。これを実現するために、.NETではコンパイラの最適化機能に対していくつかの制限を加えたり、プロセッサ(CPU)キャッシュの同期化を強制するようになってる。ただいろいろ制限や癖もあるので、余程パフォーマンス的な要求がない限りlockを明示するほうがお勧めのようだ。

*1:ECMA C# language specificationに記載されいるらしい