読者です 読者をやめる 読者になる 読者になる

かとじゅんの技術日誌

技術の話をするところ

並行処理におけるメモリの可視性保証について

並行処理プログラミングにおいてJavaのメモリモデルを深く掘り下げたことがなかったのでちょっと本気出してみます。しかし、間違っているところあるかもしれません。ツッコミいただければ適宜訂正させていただきます。

可変データへのアクセスを同期化

JavaでActorっぽいものを作ってみるで使っていた、canceledフラグはvolatileという修飾子を付けて宣言しています。

簡単にいうとと固有ロックを使わずに、フィールドを読み込むスレッドが、最後に書きこまれた値を見えるようにするためのvolatile修飾子です。

と、あまりに簡単に説明しているので、もう少し具体的に理解するために、まず、volatile修飾子を付けない通常の変数では、どうなるのかEffective Java第二版の「項目66 共有された可変データへのアクセスを同期する」の例で考えてみたいと思います。

Effective Java 第2版 (The Java Series)

Effective Java 第2版 (The Java Series)


以下のプログラムはどのぐらいの時間動作するでしょうか?

public class StopThread {
    private static boolean stopRequested;
    
    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(new Runnable() {
            public void run() {
                int i = 0;
                while (!stopRequested) {
                    i++;
                }
            }
        });
        backgroundThread.start();
        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }

}

スレッドが開始したら1秒後に終了するようなプログラムです。でも、このプログラムは終了しそうで終了しません。

みなさんは、このプログラムは約1秒動作して、その後、メインスレッドがstopRequestedをtrueに設定することで、バックグラウンドのスレッドのループが終了すると期待されるかもしれません。しかし、私のマシンでは、プログラムは決して終了しません。バックグラウンドのスレッドは永久にループし続けます。

「なんと!」と思うかもしれませんが、、同期なしでは、仮想マシンが次のコードに変更する*1ことは許容されています。

if (!stopRequested)
    while(true){
        i++;
    }

これでは確かに終了しませんね。

本書では解決策のひとつとして、以下のように固有ロック(synchronized)を使えばよいとしています。

// 適切に同期化された協調的スレッド終了
public class StopThread {
    private static boolean stopRequested;
    
    // stopRequestedに設定する。
    private static synchronized void requestStop() {
        stopRequested =  true;
    }

    // stopRequestedを取得する。
    private static synchronized boolean stopRequested() {
        return stopRequested;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(new Runnable() {
            public void run() {
                int i = 0;
                while (!stopRequested())
                    i++;
                }
            }
        });
        backgroundThread.start();
        TimeUnit.SECONDS.sleep(1);
        requestStop();
    }

}

stopRequestedの値の読み込みと書き込みはアトミックな操作なのに、固有ロック(synchronized)で同期化する必要があるの?と思うかもしれません。それと、書き込み時だけ同期化すればいいんじゃない?と思うのですが、そうではありません。本書でも、次のように書き込みだけではなく、読み込み時にもスレッドから値がきちんと見えるようにしなければならないと言及しています。

書き込みメソッド(requestStop)と読み込みメソッド(stopRequested)の両方が同期されていることに注意してください。書き込みメソッドだけを同期するだけでは十分ではありません。実際、読み込み操作と書き込み操作の両方が同期されていなければ、同期は何の効果もありません。

以下、その理由になりますが、長いので注意w

Java メモリモデルを知る

なぜ このようなことが起きるかというのは、Javaメモリモデル(Java Memory Model = JMM)を理解すれば理由がわかります。

そのJMMの正しい理解を得るには、かなりの難易度ですが、Java言語仕様第3版(JLS3)を読むのがよいです。

Java言語仕様 第3版 (The Java Series)

Java言語仕様 第3版 (The Java Series)


英語でよれけばこちら => The Java Language Specification

それと、以下の記事も参考になりますが、これも難易度が高い。
Javaの理論と実践: Javaメモリ・モデルを修正する 第1回
Javaの理論と実践: Javaメモリ・モデルを修正する 第2回
次のこれがわかるレベルになると、他人に並行処理プログラミングを任せられないレベルだとか。
The JSR-133 Cookbook

JMMがなぜ必要か

Java並行処理プログラミング」にもJMMの解説があります。こっちがわかりやすいかもしれません。

Java並行処理プログラミング ―その「基盤」と「最新API」を究める―

Java並行処理プログラミング ―その「基盤」と「最新API」を究める―


そもそもなんで、JMMが必要か。P381の「16-1-1・プラットホームのメモリモデル」から引用。

Javaは、アーキテクチャごとのメモリモデルの違いからデベロッパを隔離し保護するために、独自のメモリモデル(JMM) を提供し、JVMJMMとプラットホームのメモリモデルの違いを吸収するためにメモリバリヤを随所に挿入します。

ハードウェアアーキテクチャ固有のメモリモデルというのはそれぞれに違いがあります。その違いをOSやコンパイラ、ランタイム、プログラム*2が違いを吸収し、スレッドセーフを実現しなければなりません。しかし、JavaではJMMがその差異を吸収してくれるわけです。
JMMは、キャッシュのような存在に見えますが、実際にはキャッシュだけはなく、レジスタ、その他ハードウェア、コンパイラによる最適化などを含めた抽象的な概念を指します。ローカル・メモリとか、ローカル・キャッシュとかいうようです。

加えて、メモリバリヤ(以下、メモリバリア)とは何かということですが、以下がわかりやすいかも。

CPUには、性能最適化策としてアウト・オブ・オーダー実行を行うものがあり、メモリのロード命令やストア命令を含めて順序を入れ替えて実行する。この命令の並べ替えは、ひとつのスレッドの中で一般に暗黙のうちに行われるが、マルチスレッドプログラムやデバイスドライバでは慎重に制御しない限り予測不能の動作を生じる原因となる。順序性の制限の方法はハードウェア依存であり、そのアーキテクチャによって定義される。アーキテクチャによってはいくつかのバリアを用意して、それぞれ異なった順序性制限を実現している場合がある。

そもそもCPUは性能の最適化でメモリのロード(読み込み)やストア(書き込み)の順序を入れ替えて実行するが、並行処理では問題が生じるので、メモリバリアという順序の制限を掛けることをしているということですね。JMMのメモリバリアについては、順序性の制限だけでなく、キャッシュのフラッシュまたは無効化、ハードウェアの書き込みバッファのフラッシュや実行パイプラインの停止などの、可視性保証も行うようです。詳しいことは、JSR-133の仕様をThe JSR-133 Cookbookで読むとよいです。

それでは、JMMがどのように機能するか みてみましょう。
Java並行処理プログラミング」の「16-1 メモリモデルはなぜ必要か?」で、以下のような説明があります。

あるスレッドが、変数aVariableに値を代入するとします:
aVariable = 3;
このときメモリモデルは、次のような疑問に答えます:aVariableをリードするスレッドに値3が見える(スレッドが確実に値3をリードする)ためにはどんな条件が必要か?

通常の変数への代入です。これを同期化なしでこの変数を別のスレッドから値が見えるかどうかという質問です。

これは馬鹿げた疑問のように見えますが、しかし同期化が不在なら、
いろんな理由で、あるスレッドがほかのスレッドの操作結果をすぐには見られない、または永遠に見られないことがあります。
コンパイラが、ソースコードに書かれている常識的な順序ではない順序で命令をつくり出すことがあります。
変数をメモリではなくプロセッサのレジスタに保存することもあります。
プロセッサは複数の命令を並行に実行したり、コンパイラが作ったコードとは違う順序で命令を実行することがあります。
キャッシュの介在によって、変数へのライトが主記憶にコミットされる順序が変わることがあります。
また、プロセッサローカルなキャッシュに保存された値がほかのプロセッサから見えないこともあります。
こういった要因によって、変数の最新の値がスレッドから見えなかったり、ほかのスレッドで起きているメモリアクションが実際とは違う順序で見えたりします。ーいずれも、適切な同期化をしなかった場合です。

同期化しない場合は、メモリモデルによって、スレッド間で変数が見えるか(可視性)どうか、変数に対する命令(ロード/ストア)の順序変更(リオーダー)が入れ替わるなど、が起こり得るということですね。

固有ロック(synchronized)

長くなりましたが、以下のStopThreadの固有ロック(synchronized)をかけた二つのメソッドに話を戻します。

public class StopThread {
    private static boolean stopRequested;
    
    // stopRequestedに設定する。
    private static synchronized void requestStop() {
        stopRequested =  true;
    }

    // stopRequestedを取得する。
    private static synchronized boolean stopRequested() {
        return stopRequested;
    }
// ...
}

Java言語仕様の8.3.1.4のvolatile修飾子の解説で、固有ロック(synchronized)について以下のように触れています。

One way to prevent this out-or-order behavior would be to declare methods one and two to be synchronized (8.4.3.6):
class Test {
static int i = 0, j = 0;
static synchronized void one() { i++; j++; }
static synchronized void two() {
System.out.println("i=" + i + " j=" + j);
}
}
This prevents method one and method two from being executed concurrently, and furthermore guarantees that the shared values of i and j are both updated before method one returns.

こういった無秩序な振る舞いを避けるには,メソッドone,twoをsynchronizedとして宣言(38.4.3.6)する方法がある:
(中略)
これにより,メソッドoneとメソッドtwoの並列実行が抑止され,メソッドoneが制御を返す前にi,jの共有値が双方とも更新されることを保証できる。

この前後の文章を読んでもらうとわかるのですが、volatile修飾子には値を読み込むスレッドが、最後に書きこまれた値を見えるようにする効果があります。それと同じ効果が固有ロック(synchronized)にもあると記述されています。
以下のドキュメントの方がもっと分かりやすいかもしれません。固有ロック(synchronized)はモニターという同期化の仕組みを利用していますが、そのモニターの機能の一部として、「synchronizedのブロックに出る際に、ローカルキャッシュをメインメモリに吐き出すことを要求し、同期化ブロックに入る際にはローカルキャッシュを無効化し、メインメモリを見にいく」動作を行うようです。つまり、JMMのメモリバリアのことですね。

関連モニター解放の一部としてスレッドが同期化ブロックから出る時には、JMMはローカル・プロセッサーのキャッシュをメイン・メモリに吐き出すように要求します。(中略) 同じように、同期化ブロックに入る際のモニター取得の一部として、(次に行われる読み込みがローカル・キャッシュではなく直接メイン・メモリに行くように)ローカル・キャッシュは無効化されます。
あるスレッドが、対象のモニターに保護された同期化ブロック期間中に変数を書き込み、同じモニターに保護された同期化ブロック期間中に別のスレッドがその変数を読み出す時には、その変数への書き込みは読み取り側のスレッドから見えることが、この過程によって保証されるのです。
同期化がない場合には、JMMはこの保証をしません。複数のスレッドが同じ変数にアクセスする時には必ず同期化(またはその弟分のvolatile)を使う必要があるのはこの理由からです。

前述したように、requestStopメソッドでstopRequestedメソッドの両方で、synchronizedを指定しなければ最新の値を見ることができないのが、この理屈からわかります。同期化だけの目的だけなら、この二つのメソッドのコードはアトミックに動作するので同期化は不要なのですが、スレッド間で変数の値が見えるようにする、つまり可視性を保証するために固有ロック(synchronized)使うということですね。

volatile修飾子

それでは、固有ロック(synchronized)の弟分の、volatile修飾子を使った例も見てみましょう。そのコードが以下です。

// 適切に同期化された協調的スレッド終了
public class StopThread {
    private static volatile boolean stopRequested;
    
    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(new Runnable() {
            public void run() {
                int i = 0;
                while (!stopRequested)
                    i++;
                }
            }
        });
        backgroundThread.start();
        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

最初の例のstopRequestedフィールドにvolatile修飾子を付加しただけです。
Java言語仕様において、そのvolatile修飾子が持つ意味は以下です。

A field may be declared volatile, in which case the Java memory model (17) ensures that all threads see a consistent value for the variable.

フォールドをvolatileとして宣言することによって, Javaのメモリ・モデル(17)はすべてのスレッドから見てその変数の値の整合性が保たれることを保証する。

スレッドからの見て、「変数への書き込みや読み込みの整合性が保証される」のがvolatile修飾子の特徴です。つまり、メモリバリアが機能するわけです。
volatile修飾子を付けた変数を揮発性変数とも呼びますが、「Java並行処理プログラミング」のP44「3-1-4・揮発性変数」にも解説があります。

...揮発性変数へのアクセスはロックを実行せずスレッドをブロックしないので、synchronizedに比べると軽い同期化の仕組みです。

volatileには排他制御がないので固有ロック(synchronized)より軽量です。

今日のほとんどのプロセッサのアーキテクチャの上では、揮発性変数のリード(変数から読む)は不揮発性変数のリード(最適化されている場合主にレジスタやキャッシュから読む)よりも、ほんのわずかに高価な程度です。

揮発性変数と不揮発性変数(finalでもvolatileでもない)の値を読み込むコストは、揮発性変数の「ほんのわずかに高価という程度」とのことです。
ちなみに、最近発売された「Javaルールブック」の P146「あまり利用するケースのない修飾子」にもvolatile修飾子について簡単な説明がありました。わかりやすい説明だと思いました。

Javaルールブック ?読みやすく効率的なコードの原則

Javaルールブック ?読みやすく効率的なコードの原則

固有ロック(synchronized)とvolatile修飾子のどちらを使えばよいのか

可視性を保証するために、固有ロック(synchronized)とvolatile修飾子のどちらを使えばよいのでしょうか。
固有ロック(synchronized)はロックの争奪が伴うのですが、volatileでは排他制御が伴わないのでロックの争奪とは無縁です。この点はメリットです。しかし、「Java並行処理プログラミング」で、揮発性変数の可視性についてこんな言及があります。

揮発性変数の可視性効果の及ぶ範囲は、その揮発性変数自身より広いです。スレッドAが揮発性変数に書き込みをして、次にスレッドBがその変数を読むと、Aがその揮発性変数に書き込む前にAにとって可視だったすべての変数の値が、Bにとって可視になります。
そこでメモリの可視性という点では、揮発性変数に書き込むことはsynchronizedブロックを出ることに似ていて、それを読むことはsynchronizedブロックに入ることに似ています。でも可視性に関して、揮発性変数にあまり頼りすぎないほうがいいでしょう。
あるステートの可視性をたまたま揮発性変数に頼っているコードは、明確にロックを使っているコードに比べて、不注意なリライトやメンテナンス作業で壊れやすく、読んでも分かりづらくなります。

確かに揮発性変数がオブジェクトへの参照だった場合は、可視性の範囲が固有ロック(synchronized)より比較的に広くなりそうです。それでいて、その変数に読み書きすることは、可視性という点において、synchronizedブロックに入ったり出たりするのと同等の効果が発生するわけですね。ロックの争奪の影響を受けない代わりに、揮発性変数の扱いを注意深くする必要があります。
揮発性変数を採用する基準は以下のようにするとよいとしています。

揮発性変数は、次の条件をすべて満たすときだけ使ってください:

  • その変数への書き込みが変数の現在値に依存しない。または一つのスレッドだけが値を更新する。
  • その変数がほかのステート変数とともに不変項に関与していない。
  • その変数がアクセスされるとき、ほかの理由でロックが必要とされない。

Javaの理論と実践: volatile を扱うの、「volatile を正しく使うための条件」でも同様のことがが示されています。使い方としてはやはり限定的なのかなという印象。
Java並行処理プログラミング」P45から引用

揮発性変数は、それを使ったほうが同期化ポリシーの実装と検証をしやすいときだけ使ってください。正しさを検証するために可視性に関するややこしい推理を要するときには、揮発性変数を使わないようにしましょう。

ということなので、原則的には固有ロック(synchronized)で検討し、ロック争奪で性能が劣化する場合や、volatileを使ったほうが変数の可視性がわかりやすい時に使うとよいのではないかと思います。

まとめると、マルチスレッドで、固有ロック(synchronized)はアトミック性と可視性を、volatile修飾子の揮発性変数は可視性だけを保証するということです。

次回のエントリ(若干先になるかも)では、JSR-133のメモリバリアとfinalフィールドについて掘り下げてみたいと思います。

*1:HotSpot Server VMが行う巻き上げという最適化です。

*2:PC OS上のC言語のプログラムでは、そこまで物理的なメモリモデルを意識したことがないけど、組み込み機器や、PCでもアセンブラとかならあり得るかな。