排他制御

なぜ必要か?

複数の Thread が同時に走るとまずい例として、よくある預金の例を紹介しておきます。

class Deposit {
    int deposit; // 預金額
    void addDeposit(int val) {
        int tmp = val + deposit; // 預金deposit に val を加えて、
        deposit = tmp;           // それを預金 deposit に書き戻す
    }
}

この例は一人で処理している場合は良いのですが、複数の人が同時に処理すると、うまくないことがおこります。

預金変化            Thread A             ThreadB
deposit: 10
                   A:addDeposit(5)
                                         B: addDeposit(7)
                   int tmp = 5 + 10;
                                         int tmp = 7 + 10;
deposit: 10->15    deposit = 15;
deposit: 15->17                          deposit = 17;

critical.png

というわけで、この様な部分は、critical section と呼ばれ、互いに他のthreadを 排他制御して行う必要があります。

なぜ必要か?2

実は、マルチスレッドプログラミングで排他制御が必要なもう一つの理由は、「データがいつメモリに書き込まれるか、それほど確定的ではない」ってところにあります。

例えば、

for(...) { this.shared += 10; }

というプログラムがあっても、コンパイラ(Javaの場合はVM)の最適化は「shared への書き込みなんて最後にまとめてやればいいんじゃね?(その間、変数=レジスタで処理しちゃえ)」って感じで、

val = this.shared;
for(...) { val += 10; }
this.shared = val;

的なことをしてくれます(しかねません)。

あと、処理系が書き込み命令を発行しても、キャッシュに書き込まれるだけで、本当に主メモリに書き出されるのは「ちょっと後」になるでしょう(参考:キャッシュメモリ@wikipediaのデータ更新部分)。別のスレッドに知らせるには、例えば、メモリバリア命令を発行すべきところです。

cache.png

排他制御ブロックをつかうと、この種の対応を内部で全部やってくれています。偉いですね(Java でなくても、ロックのタイミングでメモリバリアは普通おこなわれます)。 volatile 変数や、java.util.concurrent.atomic.AtomicInteger などが提供する atomic 命令などを使うこともできますが、ただの変数書き込みでは「いつ読み書きされるか、よくわからない」ものなんです。

こういうことに興味がある人は、Java Language Spec. (17.4 Memory Model) とか見てみるとよいかもです。メモリアクセス順所として、「どのような実行なら正しい」のか、その定義が規定されています。

Java における排他制御

Java では、synchronized 構文 をつかっておこなうことになります。

void addDeposit(int val) {
    synchronized(this) {
        int tmp = val + deposit; // 預金deposit に val を加えて、
        deposit = tmp;           // それを預金 deposit に書き戻す
    }
}
/* あるいは、method 自身に synchronized 修飾する。今回は、こちらの方がシンプルですな。
synchronized void addDeposit(int val) {
    int tmp = val + deposit; // 預金deposit に val を加えて、
    deposit = tmp;           // それを預金 deposit に書き戻す
}
*/

これで、synchronized block に突入する際に、この this object のロック(正しくはmonitor)を確保してから実行することになります。つまり、この thread がロックを保持している以上、他の thread は当該object に関する synchronized block には突入できなくなります。

このようにJava の synchronized は、synchronized を行うもの同士に対して、排他的実行をさせることができます。

他にも特徴としては、

  • 同じオブジェクトに対するネストした synchronized ブロック/メソッドが可能
    • 同じロック(monitor)を何度も取得(acquire)可能。すべて抜けた時点でロック(monitor) release
  • オブジェクト毎に、ロック(monitor)が存在
    • method に対して synchronized 宣言を行うと、 method 全体を synchronized(this) で囲ったのとおなじ効果がある。
    • 但し、static method の場合は Class オブジェクトに関する排他制御になる。
  • Exceptionなどで、synchronized block から通常でない形で抜け出す場合も、 lock は解放される。

などがあります。

利用上の基本原則

  • 複数スレッドから同時期にアクセスされるデータについては、排他制御して行う (あるいは、volatile 変数や Atomic クラスなどを用いる)

注意点:

  • 共有データにアクセスするすべての部位を排他的に実行するようにしなくてはいけない。
    • synchronized で囲まれないヤバイ奴(下図だとThread C)がいると、synchronized なメソッドも迷惑を受ける恐れあり。

syncX.png