21
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ReentrantLockでなぜ同期化ができるのか

Posted at

背景

JJUG CCC 2023 Spring にて、櫻庭祐一さんによる Virtual Threads - 導入の背景と、効果的な使い方 というセッションがあった。

このセッションの中で、 Virtual Thread を使う場合はなるべく synchronized を使わないようにすべきであること、またどうしてもロックが必要であれば ReentrantLock などのロッククラスを使うようにすることが説明されていた。

それを聞いていて、「え? ReentrantLock って synchronized を使わずに同期化を実現しているってこと? どうやって?」と疑問に感じたので、仕組みを調べてみた。
あと、 ReentrantLocksynchronized の使い分けとかもちょっとだけ調べた。

環境

> gradle --version

------------------------------------------------------------
Gradle 8.1.1
------------------------------------------------------------

Build time:   2023-04-21 12:31:26 UTC
Revision:     1cf537a851c635c364a4214885f8b9798051175b

Kotlin:       1.8.10
Groovy:       3.0.15
Ant:          Apache Ant(TM) version 1.10.11 compiled on July 10 2021
JVM:          17.0.5 (Eclipse Adoptium 17.0.5+8)
OS:           Windows 10 10.0 amd64

ReentrantLock とは

ReentrantLock (Java SE 17 & JDK 17)

synchronizedメソッドおよび文を使用してアクセスする暗黙の監視ロックと同じ基本動作およびセマンティックスを使用し、かつ拡張機能を持つ、再入可能な相互排他Lockです。

Java 1.5 で追加されたロックを制御するためのクラス。
synchronized と同じことができるうえに、拡張機能もあるらしい。

以下のような感じで使用する。

ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    // ...
} finally {
    lock.unlock();
}
  • lock() でロックを取得し、 unlock() でロックを解放する
  • 解放漏れを防ぐため、 finally 句で unlock() しておいたほうがいい

ReentrantLock は synchronized を使用していないのか

lock() メソッドのソースコード を見てみると、確かに synchronized は使われていないことが分かる(sync.lock() や、その先にも synchronized は存在しない)。

ReentrantLockのlockメソッドの実装
    public void lock() {
        sync.lock();
    }

synchronized の役割

Java の synchronized には、大きく以下の役割がある。

  • 排他制御
  • メモリの可視性の保証

排他制御

要するに、あるスレッドが処理している間は別のスレッドは同じ処理が出来ないように止めておくという話。
これは散々いろいろなところで説明されている話だと思うので、省略する。

メモリの可視性の保証

synchronized のもう1つの重要な役割として、メモリの可視性の保証がある。

Java では様々な理由により、あるスレッドで変数(メモリ)に書き込まれた値が別のスレッドからそのままの形で見れるとは限らない。
以下に、変数に書き込まれたはずの値が見れない(メモリの可視性が保証されない)現象の例を記載する。

スレッドごとのキャッシュ

以下のようなコードがあったとする。

Javaのvolatile修飾子の使い方を現役エンジニアが解説【初心者向け】 | TechAcademyマガジン のコードを参考にした。

package sandbox.async;

public class ThreadCacheSample {
    private static int a;

    public static void main(String[] args) throws Exception {
        final Thread t1 = new Thread(() -> {
            for (int i=0; i<5; i++) {
                a = i+1;
                System.out.println("[t1] a=" + a);

                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) { /* ignore */ }
            }
        });

        final Thread t2 = new Thread(() -> {
            int previousAValue = a;
            while (a < 5) {
                if (previousAValue != a) {
                    System.out.println("[t2] a=" + a);
                    previousAValue = a;
                }
            }
        });

        t1.start(); t2.start();
        t1.join(); t2.join();
    }
}
  • スレッド1(t1)では、変数 a に対して1秒ごとに1加算した値を代入しつつ、加算後の a の値を出力している
  • スレッド2(t2)では、ループをしつつ変数 a の値が変わったかどうかをチェックしていて、値が変わったことを検知したらその値を出力している
    • a の値が 5 以上になったらループを終える

これを実行すると、以下のようになる。

実行結果
[t1] a=1
[t1] a=2
[t1] a=3
[t1] a=4
[t1] a=5

この処理は、この表示のまま止まってプログラムは終了しなくなる。
t2 の出力が全くされていないことから、 t2 では変数 a の値が変わったことが検知できていないことが分かる(t2 からは、変数 a の値はずっと 0 のままに見えている)。

これは、スレッドごとに変数 a の値がキャッシュされることで発生している。

synchronized による可視性の保証

Java でマルチスレッドのプログラムを書いた場合、上記のようにメモリの可視性が保証されないケースが起こりえる。
synchronized には、このメモリの可視性を保証してくれる役割がある。

synchronized は、ロックを取得するときにロックオブジェクトを指定する。
あるスレッドαがロックオブジェクトAでロックを取得して、そのスレッド内でいくつかのメモリに値を書き込んだとする。
スレッドαがロックを解放し、次に別のスレッドβが同じロックオブジェクトAでロックを取得したとする。
このとき、スレッドβからはスレッドαで書き出されたメモリの状態(ロックオブジェクトAによるロックが解放されるまでにメモリに書き出された値)が見えることが保証されるようになっている。

image.png

試しに、先ほどのコードに synchronized を導入してみる。

package sandbox.async;

public class ThreadCacheSample {
    private static int a;

    public static void main(String[] args) throws Exception {
        final Thread t1 = new Thread(() -> {
            for (int i=0; i<5; i++) {
                synchronized (ThreadCacheSample.class) { // ★
                    a = i+1;
                    System.out.println("[t1] a=" + a);
                }

                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) { /* ignore */ }
            }
        });

        final Thread t2 = new Thread(() -> {
            int previousAValue = a;
            while (a < 5) {
                synchronized (ThreadCacheSample.class) { // ★
                    if (previousAValue != a) {
                        System.out.println("[t2] a=" + a);
                        previousAValue = a;
                    }
                }
            }
        });

        t1.start(); t2.start();
        t1.join(); t2.join();
    }
}
  • スレッド1(t1)で変数 a に値を書き込んでいるところと、スレッド2(t2)で変数 a の値を読み込んでいるところを、それぞれ synchronized で囲った
  • ロックオブジェクトとしては、いずれも ThreadCacheSample.class を指定した

これを動かすと、以下のようになる。

実行結果
[t1] a=1
[t1] a=2
[t2] a=2
[t1] a=3
[t2] a=3
[t1] a=4
[t2] a=4
[t1] a=5
[t2] a=5

t2 で変数 a の値が変わっていることが検知できるようになり、処理は終了した。

揮発性変数

synchronized 以外に Java に用意されている同期化に関する仕組みとして、揮発性変数(volatile variable)というものがある(volatile は、カタカナ英語で「ボラタイル」)。

揮発性変数は、クラスのフィールドに volatile 修飾子を付けることで宣言できる。

public class Hoge {
    private volatile int n; // n は揮発性変数になる
}

変数を揮発性変数にすると、その値はスレッドにキャッシュされなくなる。
これにより、揮発性変数から読み取った値は、常に最後にその揮発性変数に書き出された値であることが保証されるようになる(つまり、あるスレッドが揮発性変数に書き出した値は、他のスレッドからも確実に見ることができる)。

試しに、前節のコードの変数 a を揮発性変数に書き換えてみる。

package sandbox.async;

public class ThreadCacheSample {
    private static volatile int a; // ★ a を揮発性変数に変更

    public static void main(String[] args) throws Exception {
        final Thread t1 = new Thread(() -> {
            for (int i=0; i<5; i++) {
                a = i+1;
                System.out.println("[t1] a=" + a);

                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) { /* ignore */ }
            }
        });

        final Thread t2 = new Thread(() -> {
            int previousAValue = a;
            while (a < 5) {
                if (previousAValue != a) {
                    System.out.println("[t2] a=" + a);
                    previousAValue = a;
                }
            }
        });

        t1.start(); t2.start();
        t1.join(); t2.join();
    }
}
実行結果
[t1] a=1
[t1] a=2
[t2] a=2
[t1] a=3
[t2] a=3
[t2] a=4
[t1] a=4
[t2] a=5
[t1] a=5

a の値がスレッドにキャッシュされなくなり t2 スレッドからは常に最新の a の値が見れるようになったため、問題なく処理が完了した。

改めて ReentrantLock はどうやって同期化を実現しているのか

synchronized には、以下の2つの役割がある。

  • 排他制御
  • メモリの可視性の保証

ReentrantLocksynchronized を使わずに、これらの役割を実現していることになる。
一体どうやって実現しているのか?

排他制御の実現

まずは排他制御をどうやっているのか見てみる。

以下のようなコードを書いて動かしてみる。

package sandbox.async;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Hoge {

    public static void main(String[] args) throws Exception {
        Lock lock = new ReentrantLock();

        final Thread t1 = new Thread(() -> {
            lock.lock();
            try {
                TimeUnit.MINUTES.sleep(60);
            } catch (Exception e) {
                // ignore
            } finally {
                lock.unlock();
            }
        });
        t1.setName("t1");

        final Thread t2 = new Thread(() -> {
            try {
                lock.lock();
            } finally {
                lock.unlock();
            }
        });
        t2.setName("t2");

        t1.start(); t2.start();
        t1.join(); t2.join();
    }
}
  • 片方のスレッド(t1)でロックをとった状態で止めておき、もう片方のスレッド(t2)でロック解放待ちを発生させようとしている

これを動かして t2 スレッドがロック解放待ちになったのを確認したら、 jconsole で接続して t2 スレッドのスタックトレースを確認してみる。

image.png

どうやら、 LockSupport.park() というメソッドでスレッドを待機状態にしているっぽい(Unsafe が使われているので、これ以上先は首を突っ込まないことにする)。

park() を呼び出している元の AbstractQueuedSynchronizeracquire メソッドを確認してみる。

AbstractQueuedSynchronizerの実装(一部)
    private transient volatile Node head;
    ...

    abstract static class Node {
        volatile Node prev;       // initially attached via casTail
        volatile Node next;       // visibly nonnull when signallable
        Thread waiter;            // visibly nonnull when enqueued
        volatile int status;      // written by owner, atomic bit ops by others
    ...

    final int acquire(Node node, int arg, boolean shared,
                      boolean interruptible, boolean timed, long time) {
        Thread current = Thread.currentThread();
        byte spins = 0, postSpins = 0;   // retries upon unpark of first thread
        boolean interrupted = false, first = false;
        Node pred = null;                // predecessor of node when enqueued

        for (;;) {
            if (!first && (pred = (node == null) ? null : node.prev) != null &&
                ...
            }
            if (first || pred == null) {
                ...
            }
            if (node == null) {                 // allocate; retry before enqueue
                ...
            } else if (pred == null) {          // try to enqueue
                ...
            } else if (first && spins != 0) {
                --spins;                        // reduce unfairness on rewaits
                Thread.onSpinWait();
            } else if (node.status == 0) {
                node.status = WAITING;          // enable signal and recheck
            } else {
                ...
                if (!timed)
                    LockSupport.park(this);
                else if ((nanos = time - System.nanoTime()) > 0L)
                    LockSupport.parkNanos(this, nanos);
                else
                    break;
                ...
            }
        }
        return cancelAcquire(node, interrupted, interruptible);
    }

細かい判定の条件とかは置いておくとして、多分要するに for(;;) で無限ループしつつ適宜状態を見て LockSupport.park() でスレッドを待機させたりしているのかなと思う(適当)。

ポイントは、条件判定に使っている変数たち(Node や、そのインスタンス変数など)が揮発性変数で宣言されている点かなと。
これにより、マルチスレッドで動いても問題なく条件判定が働いているのだと思う。

メモリの可視性の保証の実現

ここからが本題。

冒頭の「え? ReentrantLock って synchronized を使わずに同期化を実現しているってこと? どうやって?」と思った要因はここで、メモリの可視性の保証を synchronized を使わずにどうやって実現しているのかが分からなかった。

当初の自分の揮発性変数に対する理解は、その揮発性変数だけの可視性が保証される、というものだった。
このため、揮発性変数以外の変数の可視性の保証がどうやって実現されているのかが分からなかった。

synchronized の代わりになるためには、以下の図のように、直前のロックの最中にメモリに書き出された状態は次のロック開始後は全て可視になる必要がある(揮発性変数以外も)。

image.png

この仕組みを理解するためには、事前発生(happens-before)を理解する必要がある。

事前発生(happens-before)

スレッドの処理の中に登場する個々の処理(変数のリード・ライト、モニタのロック・アンロック、etc...)は、アクションと呼ばれる。
この、個々のアクションの順番を定めるルールの1つとして 事前発生(happens-before) というものが存在する。

あるアクションAとBがあり、AがBより事前発生している(A happens-before B)場合、Aのアクションで書き出された値はBから可視になる。

17.4.5. Happens-before Order
Two actions can be ordered by a happens-before relationship. If one action happens-before another, then the first is visible to and ordered before the second.

(訳)2つのアクションは happens-before 関係によって順序付けることができる。
もし、あるアクション(one action)が他のアクション(other action)より事前発生する場合、最初のアクション(one action)は2つ目のアクション(other action)から可視であり、2つ目のアクションの前に順序付けられる。

Chapter 17. Threads and Locks | Java Language Specification

なんかややこしいことを言っているようだが、要するに A happens-before B の関係が成り立つなら、 A で書き出された値は B から見える、ということを言っている。

例えば以下のようなコードがあったとする。

int a = 0;  // A
int b = a;  // B

アクションAは、変数 a0 を代入している。
アクションBは、変数 ba の値を代入している。

A と B の間には A happens-before B の関係が成り立つことが言語仕様で決められている。
このため、アクション A で変数 a に書き出された値はアクション B から見えることになる(当たり前の話)。

事前発生の関係は推移的

A happens-before B かつ B happens-before C が成り立つとき、 A happens-before C も成り立つ。

もうちょっと分かりやすくするため、A happens-before B を以下のように表現したとする。

image.png

すると、 A happens-before B かつ B happens-before C は次のように表現できる。

image.png

このとき、 C からは B だけでなく A も可視となる。
つまり、矢印を辿ってさかのぼれる分は、すべて可視となる。

事前発生が適用されるケース

事前発生が適用されるケースは、Javaの言語仕様でいくつか定められている。

プログラム順序のルール

同じスレッド内のアクションは、プログラムで書かれているそのままの順序で事前発生が適用される。

どういうことかというと、

public void method() {
    int a = 0; // A
    int b = a; // B
}

とプログラム上に書かれていたら、その順番のまま事前発生が適用される。
したがって、 A happens-before B となる(当たり前の話)。

言語仕様には以下のように書かれている。

17.4.5. Happens-before Order
If we have two actions x and y, we write hb(x, y) to indicate that x happens-before y.

  • If x and y are actions of the same thread and x comes before y in program order, then hb(x, y).

(訳)
2つのアクション x, y に関して hb(x, y) と表現したとき、これは x happens-before y を表している。

  • もし x と y が同じスレッド内のアクションであり、かつ x が y よりもプログラム順序で前にある場合、 hb(x, y) である

Chapter 17. Threads and Locks | Java Language Specification

スレッドが異なれば事前発生は適用されない

前述の言語仕様に「同じスレッド内のアクション」とあるように、スレッドが異なる場合はそのままでは事前発生が適用されない。

int a, b;
int x, y;

public void method1() {
   a = 1;
   x = b;
}

public void method2() {
   b = 1;
   y = a;
}

method1method2 が別々のスレッドで実行される場合、事前発生の関係は以下のようになる。

image.png

それぞれのメソッドの中のアクションは同じスレッドで実行されるので事前発生の関係がある(矢印で結ばれている)。
しかし、お互いのメソッド間には事前発生の関係がないので矢印は結ばれない。

ここでは、 a = 1;y = a; の間には事前発生の関係はないことになる。
したがって、仮に時系列上は a = 1; のアクションが y = a; のアクションより先に実行されていたとしても、 y = a; の時点で a の値が 1 で見れる保証はない。

image.png

スレッド間でメモリの可視性を保証するためには、このスレッド間に事前発生の関係を結んであげる必要がある。

モニタロックのルール

同じロックオブジェクトを使ったロックとアンロック(モニタロック)には、「アンロック happens-before その後のロック」という事前発生の関係が適用される。

ここで言っているモニタロックとは、 synchronized のことを指している(ReentrantLock のことではない)。

17.4.5. Happens-before Order

  • An unlock on a monitor happens-before every subsequent lock on that monitor.

(訳)

  • あるモニタのアンロックは、その後のそのモニタのロックより事前発生する

Chapter 17. Threads and Locks | Java Language Specification

このモニタロックのルールを用いることで、スレッド間に事前発生の関係を持たせることができるようになる。

image.png

それぞれのスレッドで同じロックオブジェクトを使ったモニタロック(synchronized)を行うようにすると、アンロックとロックの間で事前発生の関係が結ばれるようになる。
これにより、スレッド間のアクションが事前発生で一本につながるようになる。
この結果、直近アンロックするまでにメモリに書き出した情報は、次にロックを取得したスレッドからは全て可視になることが保証されるようになる。

上記図はスレッド1が先にロックを取得できた場合の話なので、もしスレッド2が先にロックを取れていたら次のようになる。

image.png

揮発性変数のルール

揮発性変数への書き込みは、その後に行われる同じ揮発性変数からの読み込みよりも事前発生する(揮発性変数への書き込み happens-before その後の同じ揮発性変数からの読み込み)。

17.4.5. Happens-before Order

  • A write to a volatile field (§8.3.1.4) happens-before every subsequent read of that field.

(訳)

  • volatile フィールドへの書き込みは、その後の同じフィールドの読み込みより事前発生する

Chapter 17. Threads and Locks | Java Language Specification

図にすると以下のような感じ。

image.png

揮発性変数vがあったとして、スレッド1でそこに値を書き込んだとする。
次にスレッド2で同じ揮発性変数vから値を読み込む。
すると、2つのアクションの間には事前発生の関係が結ばれるようになる。

これにより、モニタロックのときと同じようにスレッド間のアクションが一本の事前発生の関係で結ばれるようになる。

同期化の相乗り

揮発性変数は、自身の変数の可視性を保証するのが本来の役割となっている。
しかし、上述のように事前発生のルールを利用することで他の変数に関する可視性を保証することができるようになる。
このように他の同期化の仕組みを利用して別の部分で同期化を実現するテクニックを、同期化の相乗りと呼ぶ。

この方法は synchronized を使わないので、より低いコストでメモリの可視性の保証ができるようになる(※Java 1.6 で synchronized の内部の仕組みが改善されており、 synchronized でもそんなに速度は遅くならないらしい)。
しかし、相乗りのコードを正確に記述しないと正しく同期化ができないので、実際に使う場合は細心の注意が必要となる(というか危険なので自前で実装すべきではない)。

Java の標準ライブラリには、この同期化の相乗りを安全に利用するためのクラスが用意されている。
それが java.util.concurrent パッケージ 配下で提供されているロックに関するクラスたちであり、 ReentrantLock もその1つとなる。

ReentrantLock の同期化の相乗り

ようやく疑問の答えにたどり着いた。

ReentrantLock は同期化の相乗りを利用することで synchronized を使わずに synchronized と同等のメモリの可視性の保証を実現している。

ReentrantLock は、次のような構成になっている。

ReentrantLock の内部に Sync というクラスがあり、その親クラス(AbstractQueuedSynchronizer)に state と呼ばれる揮発性変数が定義されている。

そして、 lock()unlock() を呼ぶと、おおざっぱに内部で以下のようなことが行われている。

lock() 時は揮発性変数の state から値を読み込み、 unlock() のときに値が書き込まれる。
これにより、同期化の相乗りが起こりメモリの可視性が保証されるようになる。

以上で、 ReentrantLocksynchronized と同等の同期化ができるようになる。

ReentrantLock と synchronized の違い

最後に、 ReentrantLocksynchronized の違いを軽くまとめる。

ReentrantLock(Lock) にだけできることとかメリットとか

  • ロックの待機にタイムアウトを設定できる
    • tryLock(long, TimeUnit) を使えば、ロックの解放待ちにタイムアウトを設定できる
    • synchronized の場合は永遠に待ち続けるので実装順序をミスるとデッドロックになる
  • ロックの待機中にインタラプト(割り込み)ができる
    • synchronized によるロック待ちはインタラプト(割り込み)ができない
    • tryLock(long, TimeUnit) でロックを待機していれば、ロック待ちに対してもインタラプトができる
  • 公平なロックができる
    • 公平なロックというのは、ロックの解放を待っているスレッドが複数存在したときに、先に待機を開始したスレッドの方が優先的に次のロックを取得できることを意味する
    • 不公平なロックの場合は、次にロックを取得できるのはロックが解放された後で最初にロックの取得を試みたスレッドになるので、後から待機し始めていたスレッドがロックを取れる可能性がある
    • synchronized によるロックは不公平なロックのみだが、 ReentrantLock の場合はいずれかを選択できる
    • 一見すると不公平よりは公平なほうがよさそうに思えるが、公平なロックを実現するためには待機スレッドのキュー管理が必要になるため、その分処理のオーバーヘッドが発生する
    • 実際のところ、多くのケースは不公平なロックで問題無く、またスループットも出るため、 ReentrantLock もデフォルトは不公平なロックとなっている
    • 公平なロックでないと困るアルゴリズムなときだけ、公平なロックを使うようにしたほうがいい

ReentrantLock(Lock) のデメリットとか

  • unlock() を明示的に行う必要がある
    • synchronized ならロックの解放漏れとかはないが、 ReentrantLock (Lock) を使う場合はロックの解放(unlock()) を明示的に行わなければならない
    • unlock() を忘れると永遠にロックが解放されないことになるので注意が必要
    • FindBugs とかで解放漏れがないか静的チェックができるらしい

性能差

  • ReentrantLocksynchronized では、そんなに性能差はでないらしい
    • ReentrantLock が出た当時の Java 1.5 の頃は、 synchronized と比較して ReentrantLock の方が実行性能が何倍も高い状態だった
    • しかし、 Java 1.6 で synchronized の内部の仕組みが改善されたことにより、 ReentrantLocksynchronized の間にそこまで大きな性能差はでなくなったらしい(Java 並行処理プログラミングの「13-2 実行性能への配慮」より)

参考

21
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?