クラスライブラリ応用

スレッド

今回は「Java言語プログラミングレッスン 下」を参照します。 この本に掲載されているサンプルプログラムは著者のサイトからダウンロードできますが、 文字コードが Shift JIS であるため、UTF-8 に変換したものを以下に置いておきます。

これまで、キュー、スタック、リストといったデータ構造を学んできました。 今回はデータ構造から離れ、プログラムの構造の話になります。

複数の処理を同時に

大きな仕事をプログラムでこなしたい時には、 たくさんの処理を実行しなければなりません。 いままで作成してきたプログラムは、 1つの処理が終わってから次の処理にとりかかるというのが当然で、 同時に複数の処理を実行することはできないものでした。

しかし世の中では、複数の処理を同時にこなすことが普通に行われます。

Computer の世界では、CPU レベル、OS レベル、そして Java VM (仮想マシン) のレベルで、 並列処理が可能になっています。

スレッド

スレッド(thread)とは、もともと一本の糸を表す語ですが、 Computer の世界では、「処理を実行する主体」のことを意味します。

これまで作成してきたプログラムでは、 「処理を実行する主体」は1つしか存在しなかったので、 同時に1つの処理しかできませんでした。 このようなプログラムをシングルスレッドのプログラムと言います。

逆に、「処理を実行する主体」が複数あれば、複数の処理が同時にできるというわけです。 「処理を実行する主体」が複数あることを想定したプログラムを、 マルチスレッドのプログラムと言います。

ところで、「処理を実行する主体」が複数存在するというのは、どういうことでしょうか。 CPUが複数あったり、CPUのコアが複数あったりしなければ、 同時に複数の処理はできないはずです。 Java には、CPUが複数あったりしなくても複数の処理を同時に進めることができるよう、 多くの処理を少しずつ実行する仕組みが組み込まれています。 よってプログラマは、CPUの構成を気にすることなく、 「処理を実行する主体」が複数あることを前提にプログラミングをすることができます。

Java におけるスレッドの利用方法

Java で新たなスレッドを作る方法には、2種類あります。

(B) の Runnable インタフェイスを実装する方法がお薦めです。 なぜなら (A) の方法では、別のクラスを継承することができなくなってしまうからです。 ただ、既存のプログラムを読む機会も想定し、どちらも知っておきましょう。

Runnable インタフェイスの実装方法

これまでのプログラムでは、main メソッドが処理の開始点となっていました。 新たなスレッドを作成する場合には、別の開始点が必要となります。 その開始点となるメソッドが、Runnable インタフェイスに属する run メソッドです。

public class CounterTo100Main {
    public static void main(String[] args) {
        // 別スレッドで動かす予定のオブジェクトを生成
        CounterTo100 counter = new CounterTo100();
        // そのオブジェクトを動かすスレッドを用意
        Thread thread = new Thread(counter);
        // スレッドを開始. ここで run メソッドが呼ばれる.
        thread.start();
        // このスレッドでも 100 まで数えてみる
        for(int i = 0; i < 100; i++)
            System.out.println("main: i = " + i);
    }
}

class CounterTo100 implements Runnable {
    // スレッドが開始されると呼ばれるメソッド
    public void run() {
        // 100 まで数える
        for(int i = 0; i < 100; i++)
            System.out.println(" run: i = " + i);
    }
}

スレッドを生成するには Thread クラスを利用します。

Thread クラスのコンストラクタの引数に、 別スレッドで動かしたい run メソッドを持つオブジェクトを指定します。 こうした上で、スレッドの実行を開始するには start メソッドを用います。 run メソッドを直接呼び出すのではないことに注意しましょう。 Thread クラスの start メソッドの内部で、 別スレッドで動かすオブジェクトの run メソッドが呼び出されます。

1つの Thread オブジェクトは、1つのスレッドに対応しています。 複数のスレッドを生成したいときには、 それぞれに対応する Thread オブジェクトが必要です。

run メソッドの実行が終了すれば、スレッド自体が終了します。 この場合、main メソッドとどちらが先に終了するかは不定です。

なお、main メソッドが先に終了した場合、変数 counter や thread はなくなりますが、 それらが参照していたオブジェクトはスレッドが終了するまで残り続けます。

復習 Webページを取得するプログラムを作成しましょう。 取得するWebページは、1行ずつ逐次表示してください。

問題 複数のWebページを同時に取得するプログラムを作成しましょう。

スレッドの待ち合わせ

複数のスレッドに仕事を分配して、同時に処理をすることを考えてみましょう。 それぞれ勝手に仕事を完了させればよいのであれば、 流れ解散でかまいません。 言い換えれば、終了時刻の同期を取る必要はありません。

しかし、分配可能な仕事の次に、分配できない仕事が待っている場合があります。 例えば、演習問題を数人で手分けしてやることにしましょう。 それぞれが自分の担当分をやるだけでは、情報が集まりません。 全員が終わったあとに、情報を集めて共有するという作業が必要です。

それには、まず全員が終わったことを、 すなわちスレッドが終了したことを検知する必要があります。 それには join メソッドを利用します。

public class CounterTo100Main {
    public static void main(String[] args) {
        // 別スレッドで動かす予定のオブジェクトを生成
        CounterTo100 counter = new CounterTo100();
        // そのオブジェクトを動かすスレッドを用意
        Thread thread = new Thread(counter);
        // スレッドを開始. ここで run メソッドが呼ばれる.
        thread.start();
        // このスレッドでも 100 まで数えてみる
        for(int i = 0; i < 100; i++)
            System.out.println("main: i = " + i);
        // 別スレッドが終了するのを待つ
        try {
            thread.join();
        }
        catch(InterruptedException e) {
            System.err.println(e);
        }
        System.out.println("=== 全スレッド終了 ===");
    }
}

class CounterTo100 implements Runnable {
    // スレッドが開始されると呼ばれるメソッド
    public void run() {
        // 100 まで数える
        for(int i = 0; i < 100; i++)
            System.out.println(" run: i = " + i);
    }
}

join メソッドでは例外 InterruptedException が発生する可能性があるので、 catch ブロックを用意する必要があります。

引数なしの join メソッドは、そのスレッドが終わるまで待ちつづけます。 引数ありの join メソッドでは、タイムアウトするまでの時間を指定できます。

問題 複数のWebページからリンク先のURLを取り出し、 その一覧を表示するプログラムを作成しましょう。 main メソッドでスレッドの数だけリストを用意し、 それを各スレッドに渡してください。


複数のスレッドが1つのオブジェクトを同時に扱うと?

複数のスレッドがお互いに関連のない処理を行う場合、それらの各スレッドは、 他のスレッドのことを意識することなく自分の処理を進めることができます。 このような場合、スレッドを複数にすることによるデメリットはほとんどないので、 安心してマルチスレッドのプログラムを作成することができます。

しかし、実際には複数のスレッドが同一の対象を処理するなど、 関連のある処理を分担していることも多く、 うまく複数のスレッドが協調して動作しないと、問題を生ずることがあります。

2人で料理

例によって、実世界の例で考えてみましょう。

友達の家に遊びに行き、なぜか二人で焼きそばを作ることになりました。 この場合、料理人が二人いることは、2つのスレッドが同時に動くことに相当します。 処理対象である焼きそばオブジェクトは 2人分をまとめて作るので、 共通のオブジェクトが1つあると考えます。 つまり、複数のスレッドが同時に1つのオブジェクトに対して処理をする状況です。

さて、ソース焼きそばはソースを全部入れると味が濃いので、 味見をしながら少しづつソースを入れることにしました。 この一回の手順は次のようになります。

  1. 焼きそばを一本取る
  2. 味見をする
  3. 味の薄さに応じて、ソースを適量追加する

この手順には何も問題はありません。 しかし、複数の料理人が協調せずにこのプロセスを実行しようとすると、 非常に味の濃い焼きそばを食べることになる可能性があります。

1人の料理人が焼きそばを一本取り、味見をしているとしましょう。 この料理人がソースをどのくらい足そうか考えているうちに、 もう1人の料理人も焼きそばを一本取り、味見を始めてしまう可能性があります。 どちらもソースを足す前の状態から判断して、 追加すべきソースの量を決めますから、 結果として適量 x 2 のソースが追加されます。

以上より得られる教訓は次のようなものです: 焼きそばを一本取ってからソースを追加するまでの一連の処理の間、 焼きそばオブジェクトを専有していなければならない。

ロックと synchronized

Java では、1つのスレッドでオブジェクトを専有し、 他のスレッドに手出しをさせないよう、 オブジェクトをロックすることができます。

// 焼きそば
class FriedNoodle {
    private int sumSauce;  // 加えられたソースの量の合計
    public FriedNoodle(int sauce) {
        sumSauce = sauce;  // ソースの量の初期値
    }
    public synchronized void addSauce() {
        // 焼きそばを一本取る
        // 味見をする
        int difference = 100 - sumSauce;  // 理想の量(100)との差を求める
        // 考える(適当な長さ)
        try {
            Thread.sleep((int)(Math.random() * 1000));
        }
        catch(InterruptedException e) {
            System.err.println(e);
        }
        // ソースが不足していると判断したら、適量になるよう追加する
        if(difference > 0) {
            sumSauce = sumSauce + difference;
            System.out.println("add " + difference);
        }
    }
    public int getSumSauce() {
        return sumSauce;
    }
}

// 焼きそばにソースを追加するだけの料理人
class Cook implements Runnable {
    FriedNoodle noodle;
    public Cook(FriedNoodle noodle) {
        this.noodle = noodle;
    }
    public void run() {
        noodle.addSauce();
    }
}

public class CookFriedNoodle {
    public static void main(String[] args) {
        FriedNoodle noodle = new FriedNoodle(30);  // ソースの量の初期値が30
        Cook cook1 = new Cook(noodle);
        Cook cook2 = new Cook(noodle);
        Thread thread1 = new Thread(cook1);
        Thread thread2 = new Thread(cook2);
        thread1.start();
        thread2.start();
        try {
	    thread1.join();
	    thread2.join();
	}
	catch(InterruptedException e) {
	    System.err.println(e);
	}
        System.out.println("amount of sauce: " + noodle.getSumSauce());
    }
}

メソッドについている修飾子 synchronized がポイントです。 あるオブジェクトに対して 1 つのスレッドが synchronized のついたメソッドの 実行を始めると、そのオブジェクトにロックがかかります。 そうすると、他のスレッドはそのオブジェクトに対して、 synchronized のついたメソッドを実行することができなくなり、 待たされることになります。 最初にロックをかけたスレッドがメソッドの処理を終えるとロックが解除となり、 他のスレッドが synchronized のついたメソッドを実行できるようになります。

同じオブジェクトのメソッドでも、 synchronized がついていないメソッドであれば、いつでも実行可能です。 ロックがかかっていようと関係ありません。 同時に複数のスレッドにより実行されることが可能です。

同時に実行してはいけない処理に対して synchronized をつけることは重要ですが、 逆に他のスレッドを待たせる必要がないのに synchronized をつけてしまうと、 マルチスレッドの利点が削がれてしまいます。

スレッドセーフ

必要なメソッドに synchronized をつけるなど、 マルチスレッドにおいても正しく動くよう設計されたクラスを、 スレッドセーフ (thread safe) なクラスと呼びます。

Java の標準クラスライブラリに含まれているクラスは、 thread safe とは限らないことに注意が必要です。 例えば、ArrayList、LinkedList は thread safe ではありません。 これは、マルチスレッドでの実行までを想定したコードにすると、 逆にシングルスレッドでは速度が低下するからです。 ArrayList、LinkedList のオブジェクトの構造 (例: 要素数) を複数のスレッドで同時に変更したい場合には、Collections.synchronizedList メソッドを使います。

    List list = Collections.synchronizedList(new ArrayList(...));

なお、Iterator を使って繰り返しの処理をする場合には、 synchronized ブロックを使って手動で同期をとる必要があります。

  synchronized(list) {
      Iterator i = list.iterator(); // Must be in synchronized block
      while (i.hasNext())
          foo(i.next());
  }

デッドロック

2つ以上のスレッドが互いの終了を待つ状態に陥ると、 プログラムが停止してしまいます。 これをデッドロック(dead lock)と言います。 デッドロックを起こさないようなプログラム設計が必要です。