Java入門|スレッドの同期と排他制御

仲間が増えるほど連携が大事になる。スレッドも同じで、同時に動くからこそ順番を守る工夫が必要になる。

スレッドは、複数の処理を同時に進められる便利な仕組みです。
これまで見てきたように、悟空が準備を進めるあいだに、ベジータやピッコロが別の修行を進めるような流れをJavaでも作ることができます。

ただし、スレッドは数を増やせばそれだけ便利になる、という単純な話ではありません。
複数のスレッドが同じデータ同じ資源を同時に使おうとすると、思わぬ食い違いが起こることがあります。

ドラゴンボールの世界でたとえるなら、悟空とベジータが同じ修行記録ノートに同時に書き込みをしようとする場面です。
先に書いた内容をもう一方が上書きしてしまったり、途中の値を見て計算してしまったりすると、本来の合計と違う結果になることがあります。
1人ずつ順番に書けば問題ないのに、同時に触ることで矛盾が起きるわけです。

Javaのスレッドでもこれと同じことが起こります。
とくに、複数のスレッドが同じフィールドを共有しているときは注意が必要です。
このような場面で重要になるのが、同期排他制御です。

ここでは、なぜ複数スレッドで矛盾が起きるのか、どのようにしてそれを防ぐのかを、ドラゴンボールの世界観に置き換えながらていねいに整理していきます。
スレッドの学習の中でも、この内容は実践につながるとても大切なテーマです。

複数スレッドで同じデータを扱うと何が起こるのか

スレッドが別々に動いているだけなら、それほど問題は起きません。
たとえば、悟空は修行メニューを表示するだけ、ベジータは気合いの声を表示するだけ、というように、お互いが独立した仕事だけをしているなら大きな衝突は起きにくいです。

でも、複数のスレッドが同じ場所にある値を更新しようとすると話が変わります。

たとえば、

  • 今の値を取り出す
  • そこに新しい値を足す
  • 結果を元の場所に戻す

という処理は、一見すると単純です。
ところが、この3つの途中に別のスレッドが割り込むと、正しい結果にならないことがあります。

これは、1つの処理が見た目では1文でも、内部ではいくつかの段階に分かれているからです。
その途中で別スレッドが同じ値を読んでしまうと、古い値をもとに計算してしまい、結果がずれてしまいます。

ドラゴンボール風に考える同期の必要性

ここでは、共通の修行ポイントを管理する場面で考えてみます。

悟空チームには、全員の修行成果を合計する「修行記録帳」が1冊だけあるとします。
ベジータもピッコロも、自分が増やした修行ポイントをそこへ書き込みます。

本来なら、

  1. 今の合計を見る
  2. 自分の得点を足す
  3. 新しい合計を書き戻す

という流れです。

でも、ベジータが今の合計を見た直後に、ピッコロも同じ合計を見てしまうと、2人とも同じ古い数字をもとに計算してしまいます。
すると、片方の加算が結果的に消えたような状態になり、最終的な合計が小さくなってしまいます。

これが、スレッドで共有データを扱うときに起こる典型的な問題です。

サンプルプログラム:矛盾が起こる例

ここでは、ドラゴンボール風に内容を置き換えたサンプルで見ていきます。
共通の修行ポイントを管理するクラスと、そのポイントを増やす戦士スレッドを用意します。

ファイル名:Sample7.java

// 修行ポイントを管理するクラス
class TrainingCenter
{
    private int total = 0;

    public void addPoint(int p)
    {
        int tmp = total;
        System.out.println("現在の合計ポイントは" + tmp + "です。");
        System.out.println(p + "ポイント増やします。");
        tmp = tmp + p;
        System.out.println("合計ポイントを" + tmp + "にします。");
        total = tmp;
    }
}

// 修行する戦士クラス
class Warrior extends Thread
{
    private TrainingCenter center;
    private String name;

    public Warrior(TrainingCenter c, String nm)
    {
        center = c;
        name = nm;
    }

    public void run()
    {
        for (int i = 0; i < 3; i++) {
            System.out.println(name + "が修行ポイントを加算します。");
            center.addPoint(50);
        }
    }
}

class Sample7
{
    public static void main(String[] args)
    {
        TrainingCenter tc = new TrainingCenter();

        Warrior warrior1 = new Warrior(tc, "ベジータ");
        warrior1.start();

        Warrior warrior2 = new Warrior(tc, "ピッコロ");
        warrior2.start();
    }
}

このプログラムで本来どうなるはずなのか

このプログラムでは、ベジータとピッコロの2人が、それぞれ3回ずつ 50ポイント を加算します。

つまり計算上は、

  • 2人
  • 3回ずつ
  • 1回50ポイント

なので、最終的な合計は 300ポイント になるはずです。

表にするとこうです。

戦士加算回数1回あたり合計
ベジータ3回50150
ピッコロ3回50150
全体6回50300

ところが、実際にはその通りにならないことがあります。

なぜ正しい合計にならないのか

理由は、TrainingCenter クラスの addPoint() メソッドにあります。
このメソッドでは、次のような流れで値を更新しています。

  1. total を tmp に取り出す
  2. tmp に p を足す
  3. tmp の値を total に戻す

これは1人だけで使うなら問題ありません。
でも2つのスレッドが同時に実行すると、次のようなことが起こりえます。

時点ベジータ側ピッコロ側total
1total を読む → 00
2total を読む → 00
30 + 50 = 500
40 + 50 = 500
5total に 50 を書く50
6total に 50 を書く50

本来は 100 になってほしい場面なのに、結果は 50 のままです。
2人とも同じ 0 を見て計算してしまったからです。

つまり、片方の更新が実質的に打ち消されたような形になっています。

これは処理の途中に割り込まれている問題

ここで大切なのは、addPoint() 全体を1つのまとまりとして終わらせたいのに、その途中で別スレッドが入ってしまっていることです。

ドラゴンボールの世界でいえば、ベジータが修行記録帳を開いて今の合計を確認し、まだ書き終わっていないのに、ピッコロもその帳面をのぞいて同じ古い数字を見てしまっている状態です。
この時点で、もう整合性が崩れ始めています。

つまり問題の本質は、共有資源を複数スレッドが同時に触っていることです。

共有資源とは何か

共有資源とは、複数のスレッドから共通で利用されるデータや対象のことです。
今回の例なら、TrainingCenter の total フィールドがそれにあたります。

こうした共有資源の代表例としては、次のようなものがあります。

共有資源の例具体例
フィールド残高、合計値、在庫数
ファイル同じログファイル、設定ファイル
データベース同じレコード、同じテーブル
オブジェクト共通の設定情報、共通カウンタ

共有資源があるときは、複数スレッドが勝手に同時アクセスすると、値の食い違いが起きやすくなります。

同期とは何か

こうした矛盾を防ぐために使う考え方が同期です。

同期とは、スレッドどうしの処理のタイミングを調整して、順番を守らせる仕組みのことです。
今回の例でいえば、1人の戦士がポイント加算をしているあいだは、もう1人は addPoint() に入れないようにする必要があります。

つまり、

  • ベジータが addPoint() を実行中なら、ピッコロは待つ
  • ピッコロが addPoint() を実行中なら、ベジータは待つ

という状態にするわけです。

排他制御とは何か

ここであわせて覚えておきたいのが排他制御です。

排他制御とは、ある共有資源に対して、同時に1つのスレッドしか入れないようにする考え方です。
同期という広い考え方の中でも、「同時利用を禁止する」部分が排他制御だと考えるとわかりやすいです。

今回の例では、addPoint() の処理を1人ずつしかできないようにすることが排他制御にあたります。

表にすると、こう整理できます。

用語意味
同期スレッドどうしのタイミングを整える仕組み
排他制御同時に同じ資源を使えないようにする仕組み

synchronized を使うとどうなるのか

Javaでは、メソッドに synchronized を付けることで、そのメソッドを同時に複数スレッドが実行できないようにできます。

つまり、addPoint() に synchronized を付けると、

public synchronized void addPoint(int p)
{
    ...
}

このメソッドをあるスレッドが実行している間、ほかのスレッドはこのメソッドに入れなくなります。

これによって、addPoint() の処理がひとかたまりとして守られます。
途中で別スレッドが割り込めなくなるので、値の矛盾が起きにくくなります。

synchronized を付けた場合のイメージ

同期された addPoint() では、流れは次のようになります。

  1. ベジータが addPoint() に入る
  2. total を読み、50 を足し、書き戻す
  3. addPoint() を出る
  4. そのあとでピッコロが addPoint() に入る
  5. 更新後の値をもとにさらに50を足す

このように、処理が順番に進むので、合計値は正しく積み上がります。

図で見る矛盾の起きる状態

この図が示していること

この図では、ベジータスレッドとピッコロスレッドが、同じ total の値をほぼ同時に読み取ってしまい、それぞれが同じ古い値をもとに計算していることを表しています。

この図から分かるのは、問題が「足し算そのもの」にあるのではなく、読み取りから書き戻しまでの一連の流れが守られていないことにある、という点です。
途中で別スレッドが割り込めると、共有データは簡単におかしくなります。

図で見る synchronized による排他制御

次に、同期したときの状態を図で見てみましょう。

この図が示していること

この図では、synchronized が付いたメソッドに対して、同時に1つのスレッドしか入れないことを表しています。

ここから分かるのは、synchronized が単に処理を遅くするためのものではなく、共有データの整合性を守るために順番を整えているということです。
ベジータの処理が終わるまではピッコロは待機し、終わったあとで次の処理に進むので、合計値は正しく更新されます。

synchronized を付けると何が改善されるのか

addPoint() に synchronized を付けると、各スレッドの加算処理が1回ずつきちんと完結してから次に進みます。
そのため、最終的な total は期待どおり 300 になります。

この改善をまとめると、次のようになります。

状態結果
synchronized なし値の食い違いが起こる可能性がある
synchronized あり共有データの更新が順番に行われやすくなる

なぜ addPoint() 全体を守る必要があるのか

今回の例では、

  • total を読む
  • 加算する
  • total に戻す

という流れ全体が1つの意味を持っています。
だから、途中の一部だけを見ていては不十分です。
この一連の流れ全部をまとめて守る必要があります。

ドラゴンボールのたとえなら、修行記録帳に書く作業は、

  • 今の合計を見る
  • 自分の得点を足す
  • 新しい合計を書き込む

まで全部終わって初めて1回分の記録です。
その途中に別の人が入り込んではいけません。

実務で同期が重要になる場面

この話は学習用の加算処理だけの問題ではありません。
実際の開発では、もっと重要な場面で同期が必要になります。

たとえば次のような場面です。

場面何が問題になるか
在庫数の更新同時注文で在庫数がずれる
口座残高の更新入出金結果が狂う
ログイン回数の集計カウント漏れが起きる
ファイル更新内容の上書き競合が起きる
データベース更新一部の更新が失われる

このように、1つしかない資源や、みんなで共有しているデータを扱うときは、スレッドの同期がとても重要になります。

この範囲で押さえておきたいポイント

今回の内容で特に大切なのは、次の点です。

ポイント内容
複数スレッドは便利ただし共有データには注意が必要
矛盾の原因複数スレッドが同じフィールドを同時に更新すること
同期スレッドどうしのタイミングを調整する仕組み
排他制御同時に1つのスレッドしか処理に入れないようにすること
synchronizedメソッドを排他的に実行させるための指定
守る対象共有データを扱う一連の処理全体

ドラゴンボール風に整理すると

今回のテーマをドラゴンボールの世界で言い換えると、こうなります。

悟空チームには共通の修行記録帳が1冊しかありません。
ベジータもピッコロも、その帳面に修行成果を書き込みます。
もし2人が同時に同じページへ書き始めると、どちらかの内容がうまく反映されず、合計が狂ってしまいます。

だからこそ、

  • 1人が書いているあいだは、もう1人は待つ
  • 書き終わってから次の人が書く

というルールが必要になります。
これが同期であり、排他制御です。

スレッドは、ただ複数の流れを作るだけの機能ではありません。
複数の流れが同じものを触るときにどう整えるかまで考えて、初めて安全に使えるようになります。

ここを理解できると、スレッドは難しいだけの仕組みではなく、現実のプログラムでデータの正しさを守るための大切な技術として見えてくるようになります。