Javaの並行処理ライブラリにおける読込専用ロックReentrantReadWriteLockの仕組み

序論

これまでに解析したReentrantLockやSynchronizedは排他的ロックであり、一度に一つのスレッドしかリソースへのアクセスを許可しません。しかし、特定の状況では複数のスレッドが同時にリソースを読み込むことは可能だが、書き込みは一つのスレッドのみ許可する必要がある場合があります。このようなケースにおいて、ReentrantLockおよびSynchronizedは対応できません。幸いにも、JUC(Java Util Concurrent)にはこのような要件を満たす「読込専用ロック(Read-Write Lock)」であるReentrantReadWriteLockが実装されています。

  1. ReentrantReadWriteLock

ReentrantReadWriteLockは、JUCで提供される読込専用ロックの実装です。このロックは一度に複数の読み取りスレッドがリソースにアクセスすることを許可しますが、書き込み操作が行われている間は、読み取りも書き込みも同時に許可されません。

ReentrantReadWriteLockは内部的に2つのロックを管理しており、それぞれ読込ロック(ReadLock)と書込ロック(WriteLock)です。このロックの分離により、排他的ロックよりも高い並行性とパフォーマンスを実現できます。

1.1 ReentrantReadWriteLockの使用方法

ReentrantReadWriteLockはReadWriteLockインターフェースを実装しており、そのインターフェースには以下の2つのメソッドのみが定義されています。

  • Lock readLock():読込ロックを返します。
  • Lock writeLock():書込ロックを返します。

以下はReentrantReadWriteLockを使用したテスト例です。

 1 public class ReentrantReadWriteLockTest {
 2 
 3     private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
 4     private static Lock readLock = readWriteLock.readLock();
 5     private static Lock writeLock = readWriteLock.writeLock();
 6     private static SimpleDateFormat format = new SimpleDateFormat("mm:ss");
 7 
 8     public static void main(String[] args) throws Exception{
 9         /**
10          * 読込ロックは共有ロックであり、複数のスレッドが同時に読込可能だが書込不可
11          * 書込ロックは排他的ロックであり、他のスレッドによる読込・書込を禁止
12          * */
13 
14         /**2つのスレッドが同時に読込*/
15         Thread readThread1 = new Thread(new ReadThread());
16         Thread readThread2 = new Thread(new ReadThread());
17         readThread1.start();
18         readThread2.start();
19         Thread.sleep(5000L);
20         System.out.println("*******************2つのスレッド同時読込テスト終了*******************");
21 
22         /**2つのスレッドが同時に書込*/
23         Thread writeThread1 = new Thread(new WriteThread());
24         Thread writeThread2 = new Thread(new WriteThread());
25         writeThread1.start();
26         writeThread2.start();
27 
28         Thread.sleep(10000L);
29         System.out.println("*******************2つのスレッド同時書込テスト終了*******************");
30 
31         /**読込→書込→読込の順番*/
32         Thread readThread3 = new Thread(new ReadThread());
33         Thread writeThread3 = new Thread(new WriteThread());
34         Thread readThread4 = new Thread(new ReadThread());
35         readThread3.start();
36         writeThread3.start();
37         readThread4.start();
38         Thread.sleep(15000L);
39         System.out.println("*******************読込→書込→読込テスト終了*******************");
40 
41         /**書込→読込の順番*/
42         Thread readThread5 = new Thread(new ReadThread());
43         Thread writeThread5 = new Thread(new WriteThread());
44         writeThread5.start();
45         readThread5.start();
46         Thread.sleep(20000L);
47         System.out.println("*******************書込→読込テスト終了*******************");
48     }
49 
50     static class ReadThread implements Runnable{
51 
52         @Override
53         public void run() {
54             try {
55                 tryRead();
56             } catch (InterruptedException e) {
57                 e.printStackTrace();
58             }
59         }
60     }
61 
62     static class WriteThread implements Runnable{
63 
64         @Override
65         public void run() {
66             try {
67                 tryWrite();
68             } catch (InterruptedException e) {
69                 e.printStackTrace();
70             }
71         }
72     }
73 
74     public static void tryRead() throws InterruptedException {
75         System.out.println("スレッド:"+Thread.currentThread().getName() + "読込ロック取得開始:" + format.format(new Date()));
76         readLock.lock();
77         System.out.println("スレッド:"+Thread.currentThread().getName() + "読込ロック取得完了" + format.format(new Date()));
78         Thread.sleep(2000L);
79         readLock.unlock();
80         System.out.println("スレッド:"+Thread.currentThread().getName() + "読込ロック解放完了" + format.format(new Date()));
81     }
82 
83     public static void tryWrite() throws InterruptedException {
84         System.out.println("スレッド:"+Thread.currentThread().getName() + "書込ロック取得開始" + format.format(new Date()));
85         writeLock.lock();
86         System.out.println("スレッド:"+Thread.currentThread().getName() + "書込ロック取得完了" + format.format(new Date()));
87         Thread.sleep(2000L);
88         writeLock.unlock();
89         System.out.println("スレッド:"+Thread.currentThread().getName() + "書込ロック解放完了" + format.format(new Date()));
90     }
91 }

上記のコードは複数のシナリオをテストしています。

シナリオ1:2つの読込スレッドが同時に読込を行う場合、出力結果は以下のようになります:

1 スレッド:Thread-1読込ロック取得開始:40:48
2 スレッド:Thread-0読込ロック取得開始:40:48
3 スレッド:Thread-1読込ロック取得完了40:48
4 スレッド:Thread-0読込ロック取得完了40:48
5 スレッド:Thread-1読込ロック解放完了40:50
6 スレッド:Thread-0読込ロック解放完了40:50
7 *******************2つのスレッド同時読込テスト終了*******************

2つの読込ロックが同時に取得できることを示しています。

シナリオ2:2つの書込スレッドが同時に書込を行う場合、出力結果は以下のようになります:

1 スレッド:Thread-2書込ロック取得開始40:53
2 スレッド:Thread-3書込ロック取得開始40:53
3 スレッド:Thread-2書込ロック取得完了40:53
4 スレッド:Thread-2書込ロック解放完了40:55
5 スレッド:Thread-3書込ロック取得完了40:55
6 スレッド:Thread-3書込ロック解放完了40:57
7 *******************2つのスレッド同時書込テスト終了*******************

2つ目の書込ロックは、1つ目の書込ロックが解放されるまで待機してから取得されることを示しています。

シナリオ3:読込→書込→読込の順番で操作を行う場合、出力結果は以下のようになります:

 1 スレッド:Thread-4読込ロック取得開始:41:03
 2 スレッド:Thread-5書込ロック取得開始41:03
 3 スレッド:Thread-6読込ロック取得開始:41:03
 4 スレッド:Thread-4読込ロック取得完了41:03
 5 スレッド:Thread-4読込ロック解放完了41:05
 6 スレッド:Thread-5書込ロック取得完了41:05
 7 スレッド:Thread-5書込ロック解放完了41:07
 8 スレッド:Thread-6読込ロック取得完了41:07
 9 スレッド:Thread-6読込ロック解放完了41:09
10 *******************読込→書込→読込テスト終了*******************

読込ロックを取得した後、書込ロックは読込ロックが解放されるまで待機するため、書込ロックが待機中の間に別の読込ロックを取得しようとしても、既に書込ロックが待機しているため、書込ロックが解放されるまで待たなければならないことがわかります。

シナリオ4:書込→読込の順番で操作を行う場合、出力結果は以下のようになります:

1 スレッド:Thread-8書込ロック取得開始41:18
2 スレッド:Thread-7読込ロック取得開始:41:18
3 スレッド:Thread-8書込ロック取得完了41:18
4 スレッド:Thread-8書込ロック解放完了41:20
5 スレッド:Thread-7読込ロック取得完了41:20
6 スレッド:Thread-7読込ロック解放完了41:22
7 *******************書込→読込テスト終了*******************

書込ロックは非常に優先度が高く、取得されると他の読込ロックも取得できなくなることが確認できます。

1.2 ReentrantReadWriteLockの内部構造

ReentrantReadWriteLockの実装は、内部クラスであるReadLockとWriteLockによって行われています。これらの内部クラスは、AQS(AbstractQueuedSynchronizer)のサブクラスであるSyncクラスを通じて実現されています。

ReadLockの実装原理

  1 public void lock() {
  2         sync.acquireShared(1);
  3     }
  4 
  5     protected final int tryAcquireShared(int unused) {
  6         /*
  7          * 実行フロー:
  8          * 1. 他のスレッドが書込ロックを保持している場合は失敗
  9          * 2. それ以外の場合、現在のスレッドは状態に対してロックを取得できる
 10          *    ただし、キューのポリシーによりブロックするか判断
 11          *    ブロックしない場合はCASで状態を更新し、カウントを増加
 12          *    注意: 再入のチェックは完全版で行う
 13          * 3. CAS失敗時はリトライループで再度試行
 14          */
 15         Thread current = Thread.currentThread();
 16         /**1. 同期状態を取得*/
 17         int c = getState();
 18         /**2. 書込ロックが他のスレッドに占有されているか確認; exclusiveCountは書込ロックの占有回数を返し、0でない場合、他のスレッドが占有している*/
 19         if (exclusiveCount(c) != 0 &&
 20                 getExclusiveOwnerThread() != current)
 21             return -1;
 22         /**3. 読込ロックの占有回数を取得*/
 23         int r = sharedCount(c);
 24         /**
 25          * 4. 条件判定
 26          * readerShouldBlock(): 現在のスレッドがブロックすべきかどうか
 27          * 公平モード: キューの先頭ノードの次のノードならブロックしない
 28          * 非公平モード: 先頭ノードが排他的ロックならブロックする
 29          *
 30          * r < MAX_COUNT: 読込スレッド数が最大値未満か確認
 31          * 最大値は2^16 - 1 = 65535
 32          *
 33          * 両方が満たされればCAS操作で同期状態を変更し、読込ロックを取得
 34          * */
 35         if (!readerShouldBlock() &&
 36                 r < MAX_COUNT &&
 37                 compareAndSetState(c, c + SHARED_UNIT)) {
 38             // r == 0 の場合、初めて読込ロックを取得したことを意味
 39             if (r == 0) {
 40                 firstReader = current;
 41                 firstReaderHoldCount = 1;
 42             } else if (firstReader == current) {
 43                 // 同じスレッドが最初の読込ロックを取得していた場合、カウントを増やす
 44                 firstReaderHoldCount++;
 45             } else {
 46                 // キャッシュにスレッドの再入回数を記録
 47                 HoldCounter rh = cachedHoldCounter;
 48                 if (rh == null || rh.tid != getThreadId(current))
 49                     cachedHoldCounter = rh = readHolds.get();
 50                 else if (rh.count == 0)
 51                     readHolds.set(rh);
 52                 rh.count++;
 53             }
 54             return 1;
 55         }
 56         /**CASが失敗した場合、ループによる再試行を行う*/
 57         return fullTryAcquireShared(current);
 58     }
 59 
 60     final int fullTryAcquireShared(Thread current) {
 61         /*
 62          * このコードはtryAcquireSharedと一部重複するが、保持カウントの遅延読み取りなど
 63          * を避けることでよりシンプルに設計されている
 64          */
 65         HoldCounter rh = null;
 66         for (;;) {
 67             int c = getState();
 68             /**
 69              * 書込ロックが存在し、かつ現在のスレッドが所有していない場合、即座に失敗
 70              * */
 71             if (exclusiveCount(c) != 0) {
 72                 if (getExclusiveOwnerThread() != current)
 73                     return -1;
 74                 // すでに排他的ロックを保持している場合はデッドロックを防ぐためにブロックしない
 75             } else if (readerShouldBlock()) {
 76                 // 読込ロックの再入を確認
 77                 if (firstReader == current) {
 78                     // assert firstReaderHoldCount > 0;
 79                 } else {
 80                     if (rh == null) {
 81                         rh = cachedHoldCounter;
 82                         if (rh == null || rh.tid != getThreadId(current)) {
 83                             rh = readHolds.get();
 84                             if (rh.count == 0)
 85                                 readHolds.remove();
 86                         }
 87                     }
 88                     if (rh.count == 0)
 89                         return -1;
 90                 }
 91             }
 92             if (sharedCount(c) == MAX_COUNT)
 93                 throw new Error("最大ロックカウント超過");
 94             if (compareAndSetState(c, c + SHARED_UNIT)) {
 95                 if (sharedCount(c) == 0) {
 96                     firstReader = current;
 97                     firstReaderHoldCount = 1;
 98                 } else if (firstReader == current) {
 99                     firstReaderHoldCount++;
100                 } else {
101                     if (rh == null)
102                         rh = cachedHoldCounter;
103                     if (rh == null || rh.tid != getThreadId(current))
104                         rh = readHolds.get();
105                     else if (rh.count == 0)
106                         readHolds.set(rh);
107                     rh.count++;
108                     cachedHoldCounter = rh; // 解放時にキャッシュ
109                 }
110                 return 1;
111             }
112         }
113     }

WriteLockの実装原理

public void lock() {
        sync.acquire(1);
    }

    protected final boolean tryAcquire(int acquires) {
        /*
         * 実行フロー:
         * 1. 読込カウントまたは書込カウントが0でなく、かつ所有者が異なるスレッドの場合失敗
         * 2. カウントが最大値を超える場合失敗
         * 3. それ以外の場合、再入またはキューのポリシーが許可する限りロックを取得
         *    状態を更新し、所有者を設定
         */
        Thread current = Thread.currentThread();
        int c = getState();
        int w = exclusiveCount(c);
        if (c != 0) {
            // 独占ロックが他のスレッドに占有されており、現在のスレッドではない場合失敗
            if (w == 0 || current != getExclusiveOwnerThread())
                return false;
            if (w + exclusiveCount(acquires) > MAX_COUNT)
                throw new Error("最大ロックカウント超過");
            // 再入の場合、状態を直接更新し、CAS不要
            setState(c + acquires);
            return true;
        }
        // 現在ロックなしの場合、ブロックする必要があるか確認
        if (writerShouldBlock() ||
                !compareAndSetState(c, c + acquires))
            return false;
        // 成功した場合、現在のスレッドを排他的ロックの所有者に設定
        setExclusiveOwnerThread(current);
        return true;
    }

WriteLockのロジックは比較的単純で、AQSの排他的ロック取得メカニズムに基づいています。

タグ: Java concurrent ReadWriteLock ReentrantLock AQS

7月19日 01:33 投稿