Anarchy In the 1K

Javaにおけるスレッドの状態待ち

背景

マルチスレッドの処理において、排他制御の為にロックを取得し、期待する状態を待って後続処理を続行する。この様なよくある処理に関して、詳細を以下にまとめます。

実装例

早速ですが、スレッドの状態待ちにの実装例です。概要は以下の通りです。

  • ready, count: 各スレッドで共有する値です。
  • タスク1: readyステータスがtrueだった際に、countに10加算する。
  • タスク2: readyステータスをtrueに変更する。
public class ThreadSleepSample {
  // スレッド間で共有する値
  private static boolean ready = false;
  // スレッド間で共有する値
  private static int count = 0;
  // 各タスク内で呼び出しを行うインスタンスを生成する。
  private static ThreadSleepSample threadSleepSample = new ThreadSleepSample();
  // タスク1
  private static Callable<Void> addCountTask =
      () -> {
        threadSleepSample.addCount();
        return null;
      };
  // タスク2
  private static Callable<Void> changeReadyTask =
      () -> {
        TimeUnit.SECONDS.sleep(1);
        threadSleepSample.changeReady();
        return null;
      };

  public static void main(String[] args) {
    ExecutorService executorService = Executors.newFixedThreadPool(3);
    executorService.submit(addCountTask);
    executorService.submit(addCountTask);
    executorService.submit(changeReadyTask);
    executorService.shutdown();
  }

  // モニタロックを取得する。(*1)
  private synchronized void addCount() throws InterruptedException {
    while (!ready) {
      System.out.println(Thread.currentThread() + " is waiting.");
      // 状態待ちを行う。(*2)
      wait();
      System.out.println(Thread.currentThread() + " is not waiting.");
    }
    count += 10;
    System.out.println(count);
  }

  // モニタロックを取得する。(*1)
  private synchronized void changeReady() {
    ready = true;
    // 状態変更通知を行う。(*3)
    notifyAll();
  }
}

実行結果は以下の通りです。

Thread[pool-1-thread-2,5,main] is waiting.
Thread[pool-1-thread-1,5,main] is waiting.
Thread[pool-1-thread-2,5,main] is not waiting.
10
Thread[pool-1-thread-1,5,main] is not waiting.
20

解説

モニタロックの取得(*1)

モニタロックとは

モニタロックとは、各インスタンスに1つ用意されているロックのことです。排他制御にモニタロックを用いることで、あるインスタンスにおける特定処理の実行を、単一スレッドのみに制限しています。

モニタロックの取得方法

以下の通り記載することで、該当のメソッドを実行する前に、モニタロックの取得を試みます。

アクセス修飾子 synchronized 戻り値の型 メソッド名(...) {

実装例においては、threadSleepSampleインスタンスaddCountメソッド, changeReadyメソッドの実行は、モニタロックを取得した単一スレッドのみが可能です。

状態待ち(*2)

waitメソッドを呼び出し、期待する状態待ちを行います。

sleepメソッドでなく、waitメソッドを用いる理由

処理を停止するなら、sleepメソッドでも可能と考えるかもしれません。
ですが、sleepメソッドには以下特徴があります。

  • モニタロックをスレッドが保持し続ける。(状態更新にモニタロック取得が必須の場合、デッドロックになる。)
  • 期待した状態が満たされたか否かに関わらず、指定した時間停止する。

一方で、waitメソッドには以下特徴があります。

  • モニタロックをスレッドが手放す。
  • 期待した状態が満たされた可能性の通知(後述の状態変更通知を御覧下さい。)を受け取るまで、停止する。

上記をまとめると、両者の使い分けは以下の通りです。

  • sleepメソッド: 1つのスレッドにおける、処理間隔の調整に用いる。
  • waitメソッド: マルチスレッドにおける、状態待ちに用いる。

waitwhile文で囲う理由

waitで停止したスレッドは、後述の状態変更通知を受けて処理を再開します。 しかし、ここで問題になるのが、状態変更通知を受けても目的の状態が満たされたとは限らないということです。

というのも、状態変更通知はどの変数がどの様な状態になったか、という情報を含みません。例えば、異なる変数の状態待ちを行うスレッドが複数ある場合、その何れかが状態変更通知を受け取っても、それが自身が待ちを行っている変数に関する通知とは限らないわけです。

その為、waitメソッドをwhile文で囲うことで、処理が再開した後に再度期待する状態かチェックし、そうでない場合はもう一度待ちを行います。

状態変更通知(*3)

notifyAllメソッドを呼び出し、状態待ちを行っているスレッドに、期待した状態が満たされた可能性を通知します。

notifyメソッドでなく、notifyAllメソッドである理由

以下にnotifyメソッドとnotifyAllメソッドの違いをまとめました。 説明に当たり、以下用語を用います。

  • ウェイトセット: waitメソッドを呼び出し、状態待ちを行うスレッドの集合です。
  • エントリセット: 状態変更通知を受けて、モニタロックの取得を試みるスレッドの集合です。

各メソッドの挙動の違い

notifyメソッド

下図の通りの挙動になります。notifyAllメソッドとの違いは、1つのスレッドがウェイトセットからエントリセットに移動する点です。

f:id:fujiU:20200202163514p:plain
notifyメソッドの挙動

notifyAllメソッド

下図の通りの挙動になります。notifyメソッドとの違いは、全てのスレッドがウェイトセットからエントリセットに移動する点です。

f:id:fujiU:20200202164513p:plain
notifyAllメソッドの挙動

notifyメソッドの問題点

状態変更通知にnotifyメソッドを利用した場合、1つのスレッドのみがウェイトセットからエントリセットに移動する為、以下の問題点があります。

  • 移動したスレッドが該当の通知を待っていたスレッドではなかった場合、その通知を待っていた他のスレッドが起動する機会を永遠に失う可能性がある。
  • 選ばれなかった他のスレッドにモニタロック取得の機会が与えられず、スレッド毎の処理時間のバラつきが大きくなる。