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

仲間が同時に動くほど、共有する記録は慎重に守る必要がある。スレッドの同期と排他制御を理解すると、複数の処理が同じデータを扱っても、正しい結果を保てるようになります。

スレッドは、複数の処理を並行して進められる便利なしくみです。

これまで、Thread クラスを継承する方法や、Runnable インターフェイスを実装する方法を使って、main とは別の処理の流れを作る方法を学んできました。

ドラゴンボール風にたとえると、戦士本部の任務で、BulmaSupport が修行場の準備を進めているあいだに、GokuWarrior が重力修行を進め、VegetaWarrior が戦闘フォームを整えるような流れです。

複数の戦士が同時に動けるようになると、任務全体を効率よく進められます。

しかし、スレッドは増やせば増やすほど単純に便利になる、というものではありません。

複数のスレッドが、それぞれ完全に独立した処理だけをしているなら、大きな問題は起きにくいです。

たとえば、GokuWarrior スレッドは修行メッセージを表示するだけ、VegetaWarrior スレッドは別のメッセージを表示するだけ、というように、互いに共有するデータがない場合です。

ところが、複数のスレッドが 同じデータ を読み書きし始めると、注意が必要になります。

たとえば、戦士本部に「共通の修行記録帳」が1冊だけあるとします。

GokuWarrior も VegetaWarrior も、自分の修行ポイントをその記録帳に加算します。

本来なら、次の流れで正しく合計が増えていくはずです。

手順内容
1今の合計ポイントを見る
2自分の修行ポイントを足す
3新しい合計ポイントを書き戻す

しかし、2人が同じ記録帳をほぼ同時に見てしまうと、古い合計値をもとに別々に計算してしまうことがあります。

その結果、片方の加算が反映されず、合計ポイントが本来より小さくなる可能性があります。

このような問題を防ぐために必要になるのが、同期排他制御 です。

用語意味
同期複数スレッドの処理タイミングを整えるしくみ
排他制御同じ共有資源に同時に1つのスレッドだけが入れるようにするしくみ
synchronizedJavaで排他制御を行うために使える指定

この記事では、複数スレッドで同じデータを扱うと何が起こるのか、なぜ synchronized が必要なのか、どの範囲を守る必要があるのかを、Sample7.java を使ってていねいに整理していきます。

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

スレッドが別々の仕事をしているだけなら、問題は比較的起きにくいです。

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

スレッド処理
GokuWarrior スレッドGokuWarrior が修行メニューを表示する
VegetaWarrior スレッドVegetaWarrior が戦闘フォーム確認を表示する
main スレッドBulmaSupport が準備を表示する

この場合、それぞれが別々のメッセージを出しているだけなので、表示順は混ざるかもしれませんが、共有データの値が壊れるような問題は起きにくいです。

しかし、次のような場合は注意が必要です。

状況問題になりやすい理由
同じ合計値を複数スレッドが更新する古い値をもとに計算する可能性がある
同じ在庫数を複数スレッドが減らす在庫数が実際とずれる可能性がある
同じファイルに複数スレッドが書き込む内容が混ざったり上書きされたりする可能性がある
同じオブジェクトの状態を複数スレッドが変更する状態の整合性が崩れる可能性がある

ここで大切なのは、値を増やす処理が見た目では単純でも、内部では複数の段階に分かれているということです。

たとえば、合計ポイントに50を加える処理を考えます。

total = total + 50;

この1行は、見た目には1つの処理に見えます。

しかし、考え方としては次の流れに分けられます。

段階内容
1total の現在値を読む
2読んだ値に 50 を足す
3計算結果を total に書き戻す

この途中で別のスレッドが同じ total を読んでしまうと、古い値をもとに計算してしまうことがあります。

ドラゴンボール風にたとえると、GokuWarrior が修行記録帳を開いて現在の合計を見る。
まだ新しい合計を書き終わっていないのに、VegetaWarrior も同じ記録帳を見てしまう。

すると、2人とも同じ古い合計を見て、それぞれ修行ポイントを足してしまいます。

その結果、どちらかの記録が上書きされたようになり、正しい合計になりません。

共有資源とは何か

共有資源とは、複数のスレッドから共通で利用されるデータや対象のことです。

今回の例では、修行ポイントの合計を表す total が共有資源です。

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

共有資源があるとき、複数スレッドが同時に読み書きすると、値の食い違いが起きる可能性があります。

特に、次のような処理は注意が必要です。

処理注意点
読むまだ更新前の古い値を読んでしまうことがある
計算する古い値をもとに計算してしまうことがある
書き戻す別スレッドの更新を上書きしてしまうことがある

ドラゴンボール風に言えば、共通の修行記録帳は1冊しかありません。

GokuWarrior も VegetaWarrior もその帳面を使います。

だからこそ、誰かが書いている途中に、別の戦士が同じページを開いて書き込むと、記録が乱れてしまいます。

図:共有データを同時に更新すると矛盾が起きる

この図が示していること

この図は、GokuWarrior スレッドと VegetaWarrior スレッドが、同じ total をほぼ同時に読んでしまう状態を表しています。

本来なら、GokuWarrior が50を足し、VegetaWarrior も50を足せば、合計は100になるはずです。

しかし、2人が同じ古い値 0 を読んでから、それぞれ 50 を計算して書き戻すと、結果が 50 のままになる可能性があります。

図の要素意味
修行記録帳 total複数スレッドが共有するデータ
GokuWarrior スレッドtotal を読み、50を足して書き戻す
VegetaWarrior スレッド同じ total を読み、50を足して書き戻す
赤い矢印同時アクセスによる衝突
結果が50になる可能性片方の更新が失われる状態

この図から分かることは、問題は足し算そのものではなく、読み取りから書き戻しまでの一連の処理が途中で割り込まれてしまうことにある、という点です。

矛盾が起こる例を見る

ここでは、共通の修行ポイントを管理するクラスと、そのポイントを増やす戦士スレッドを用意します。

GokuWarrior と VegetaWarrior が、同じ TrainingCenter オブジェクトを共有し、それぞれ修行ポイントを加算します。

ファイル名: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 WarriorWorker extends Thread
{
    private TrainingCenter center;
    private String name;

    public WarriorWorker(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();

        WarriorWorker worker1 = new WarriorWorker(tc, "GokuWarrior");
        worker1.start();

        WarriorWorker worker2 = new WarriorWorker(tc, "VegetaWarrior");
        worker2.start();
    }
}

プログラムの登場人物

このプログラムには、3つのクラスが登場します。

クラス役割ドラゴンボール風のイメージ
TrainingCenter修行ポイントの合計を管理する戦士本部の共通修行記録帳
WarriorWorker修行ポイントを加算するスレッド修行成果を記録する戦士
Sample7実行用クラス本部で任務を開始する指令役

TrainingCenter には、修行ポイントの合計を表す total フィールドがあります。

private int total = 0;

この total が、今回の共有資源です。

GokuWarrior スレッドも VegetaWarrior スレッドも、同じ TrainingCenter オブジェクト tc を共有します。

TrainingCenter tc = new TrainingCenter();

WarriorWorker worker1 = new WarriorWorker(tc, "GokuWarrior");
WarriorWorker worker2 = new WarriorWorker(tc, "VegetaWarrior");

つまり、2人は同じ修行記録帳にポイントを書き込むことになります。

addPoint メソッドの処理

TrainingCenter クラスの addPoint メソッドを見てみましょう。

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;
}

このメソッドは、次の流れで合計ポイントを更新しています。

順番処理
1total の値を tmp に取り出す
2現在の合計を表示する
3加算するポイントを表示する
4tmp に p を足す
5新しい合計を表示する
6tmp を total に戻す

1人だけが使うなら、この流れで問題ありません。

しかし、複数スレッドが同時に addPoint に入ると、途中で別スレッドが同じ total を読んでしまう可能性があります。

本来どうなるはずなのか

このプログラムでは、GokuWarrior と VegetaWarrior が、それぞれ3回ずつ 50ポイント を加算します。

つまり、計算上は次のようになります。

戦士加算回数1回あたり合計
GokuWarrior3回50150
VegetaWarrior3回50150
全体6回50300

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

しかし、addPoint メソッドが synchronized で守られていない場合、実行タイミングによっては 300 にならない可能性があります。

なぜ正しい合計にならないことがあるのか

理由は、addPoint の処理が途中で割り込まれる可能性があるからです。

たとえば、次のような流れが起きることがあります。

時点GokuWarrior スレッドVegetaWarrior スレッドtotal
1total を読む → 00
2total を読む → 00
30 + 50 = 500
40 + 50 = 500
5total に 50 を書く50
6total に 50 を書く50

本来は、GokuWarrior が50を足し、VegetaWarrior がさらに50を足して、合計100になってほしい場面です。

しかし、2人とも最初に 0 を読んでしまったため、どちらも 50 を書き戻します。

その結果、合計は 100 ではなく 50 のままになる可能性があります。

このように、片方の更新が消えてしまうような状態を、更新の競合として考えることができます。

処理の途中に割り込まれることが問題

ここで大切なのは、addPoint 全体を1つのまとまりとして完了させたい、という点です。

addPoint の中では、次の流れがあります。

int tmp = total;
tmp = tmp + p;
total = tmp;

この一連の処理は、途中で別スレッドに入られると困ります。

ドラゴンボール風に言えば、修行記録帳への書き込みは、次の3つが終わって初めて1回分の記録です。

手順記録係の作業
1今の合計を見る
2自分のポイントを足す
3新しい合計を書く

GokuWarrior がこの作業をしている途中に、VegetaWarrior が同じページを見てしまうと、古い合計をもとに計算してしまいます。

だから、1人が addPoint を実行している間は、もう1人は待つ必要があります。

このように、共有資源を扱う一連の処理を守るために使うのが、同期と排他制御です。

同期とは何か

同期とは、複数スレッドの処理のタイミングを調整して、順番を守らせるしくみです。

今回の例では、次のような状態を作りたいわけです。

状況望ましい動き
GokuWarrior が addPoint を実行中VegetaWarrior は addPoint に入らず待つ
VegetaWarrior が addPoint を実行中GokuWarrior は addPoint に入らず待つ
addPoint が終わった次のスレッドが addPoint に入れる

つまり、修行記録帳に書き込むときは、1人ずつ順番に作業してほしいのです。

ドラゴンボール風に言えば、記録帳の前に「記入中は次の戦士は待機」という札を立てるようなものです。

これによって、途中の値を別の戦士が見てしまうことを防げます。

排他制御とは何か

排他制御とは、ある共有資源に対して、同時に1つのスレッドしか入れないようにする考え方です。

同期は広い意味で「タイミングを整えること」です。

その中でも、排他制御は「同時に使わせないこと」に焦点を当てた考え方です。

用語内容
同期スレッドどうしの処理タイミングを整える
排他制御共有資源に同時に1つのスレッドだけが入れるようにする
synchronized排他制御を行うためにメソッドなどに付ける指定

今回の addPoint では、total を扱う一連の処理を同時に実行させないことが大切です。

つまり、addPoint 全体を排他的に守る必要があります。

synchronized を使うとどうなるのか

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

addPoint に synchronized を付けると、次のようになります。

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

これにより、あるスレッドが addPoint を実行している間、同じオブジェクトに対して別のスレッドは addPoint に入れなくなります。

synchronized なしsynchronized あり
GokuWarrior と VegetaWarrior が同時に addPoint に入る可能性がある1人ずつしか addPoint に入れない
古い値を同時に読んでしまう可能性がある前の更新が終わってから次が読む
合計がずれる可能性がある合計が正しく積み上がりやすい

ドラゴンボール風に言えば、synchronized は修行記録帳の前に置く「記入中の戦士以外は入室禁止」の結界のようなものです。

GokuWarrior が書いている間、VegetaWarrior は待ちます。
GokuWarrior が書き終えて記録帳を閉じたあと、VegetaWarrior が入って続きを書きます。

図:synchronized による排他制御

この図が示していること

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

GokuWarrior スレッドが addPoint を実行している間、VegetaWarrior スレッドは待機します。

GokuWarrior の処理が終わり、total への書き戻しまで完了してから、VegetaWarrior が addPoint に入ります。

図の要素意味
synchronized addPoint排他的に守られたメソッド
青い結界同時侵入を防ぐ排他制御
GokuWarrior スレッド先に addPoint に入っているスレッド
VegetaWarrior スレッドGokuWarrior の処理完了を待つスレッド
更新後の total次のスレッドが読む正しい値

この図から分かることは、synchronized は処理を単に遅くするためのものではなく、共有データの整合性を守るために、処理の順番を整えるしくみだということです。

synchronized を付けた処理の確認

addPoint に synchronized を付けると、共有データ total を扱う処理を1人ずつ行えるようになります。

ファイル名:Sample7.java(変更後)

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

    public synchronized 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 WarriorWorker extends Thread
{
    private TrainingCenter center;
    private String name;

    public WarriorWorker(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();

        WarriorWorker worker1 = new WarriorWorker(tc, "GokuWarrior");
        worker1.start();

        WarriorWorker worker2 = new WarriorWorker(tc, "VegetaWarrior");
        worker2.start();
    }
}

synchronized を付けたときの流れ

synchronized を付けると、addPoint の流れは次のように守られます。

順番処理
1GokuWarrior が addPoint に入る
2GokuWarrior が total を読む
3GokuWarrior が 50 を足す
4GokuWarrior が total に書き戻す
5GokuWarrior が addPoint を出る
6VegetaWarrior が addPoint に入る
7VegetaWarrior が更新後の total を読む
8VegetaWarrior が 50 を足す
9VegetaWarrior が total に書き戻す

このように、addPoint の処理が1回ずつきちんと完了してから次のスレッドが入ります。

そのため、合計ポイントが正しく積み上がりやすくなります。

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

今回のポイントは、total を読む部分だけを守ればよいわけではない、ということです。

addPoint では、次の流れ全体が1つの意味を持っています。

処理役割
total を tmp に読む現在の合計を確認する
tmp に p を足す新しい合計を計算する
total に tmp を戻す合計を更新する

この3つは、途中で分断されてはいけない一連の処理です。

途中で別スレッドが入ると、古い total を読んでしまう可能性があります。

だから、addPoint 全体に synchronized を付けて、読み取りから書き戻しまでをまとめて守ります。

ドラゴンボール風に言えば、修行記録帳への書き込みは、ページを見るだけでは終わりません。

現在の合計を見る。
自分の点数を足す。
新しい合計を書き込む。

ここまで終わって、1回分の記録です。

だから、途中で別の戦士が入ってきてはいけません。

synchronized で改善されること

synchronized を付けると、次のような改善が期待できます。

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

今回の例では、GokuWarrior と VegetaWarrior がそれぞれ3回ずつ50ポイントを加算します。

synchronized によって addPoint が1回ずつ守られれば、合計は期待どおり 300 に近づきます。

戦士加算回数1回あたり合計
GokuWarrior3回50150
VegetaWarrior3回50150
全体6回50300

ただし、表示の順番そのものは固定されません。

synchronized が守るのは、addPoint の中へ同時に入らせないことです。

GokuWarrior の表示が先に出るか、VegetaWarrior の表示が先に出るかまでは、スレッドの実行タイミングによって変わります。

synchronized が守ることsynchronized が固定しないこと
addPoint の同時実行を防ぐスレッド全体の表示順
total の更新の整合性を守るGokuWarrior と VegetaWarrior の実行順すべて
共有データの更新を1回ずつ完了させるどちらが先に何回動くか

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

同期と排他制御は、学習用の加算処理だけの話ではありません。

実際の開発でも、共有データを複数スレッドで扱う場面はたくさんあります。

場面同期しないと起こりうる問題
在庫数の更新同時注文で在庫数がずれる
口座残高の更新入金や出金の結果が狂う
ログイン回数の集計カウント漏れが起きる
ファイル更新内容の上書きや混在が起きる
データベース更新一部の更新が失われる
共有カウンタ実際より少ない値になる

たとえば、在庫が1つしかない商品に対して、2つの注文処理が同時に入った場合を考えます。

どちらの処理も「在庫が1つある」と読んでしまうと、2人に売れてしまう可能性があります。

これは、修行記録帳の total と同じ構造です。

修行記録の例実務の例
total を読む在庫数を読む
50を足す1つ減らす
total に戻す在庫数を書き戻す
古い値を同時に読むと合計がずれる古い在庫数を同時に読むと販売数がずれる

このように、共有データの読み取り、計算、書き戻しがある場面では、同期がとても重要になります。

図:共有資源を守る考え方

この図が示していること

この図は、複数スレッドが共有資源を扱うときに、同期と排他制御で守る必要があることを表しています。

共有資源には、修行ポイントの合計だけでなく、在庫数、口座残高、ログファイル、共有カウンタなどがあります。

これらを複数スレッドが同時に更新すると、値がずれたり、更新が失われたりする可能性があります。

図の要素意味
共有資源複数スレッドから使われるデータや対象
synchronized の結界同時アクセスを防ぐ排他制御
GokuWarrior スレッド共有資源を更新したいスレッド
VegetaWarrior スレッド同じ共有資源を更新したい別スレッド
待機ほかのスレッドの処理完了を待つ状態

この図から分かることは、スレッドを安全に使うには、処理の流れを増やすだけでなく、共有資源をどう守るかまで考える必要があるということです。

synchronized はどこに付けるべきか

synchronized は、共有データを扱う一連の処理に付ける必要があります。

今回なら、total を扱う addPoint メソッドです。

public synchronized void addPoint(int p)

ここに synchronized を付ける理由は、addPoint の中に total の読み取り、計算、書き戻しがすべて含まれているからです。

守るべき処理理由
total を読む古い値を同時に読ませないため
tmp に p を足す読んだ値をもとに計算するため
total に戻す更新結果を確実に反映するため

一部だけを守っても、別の部分で割り込まれれば矛盾が起きる可能性があります。

そのため、意味のある処理のまとまり全体を守ることが大切です。

ドラゴンボール風に言えば、修行記録帳を開く瞬間だけ守っても不十分です。

合計を見る。
点数を足す。
新しい合計を書く。

この一連の記録作業が終わるまで、ほかの戦士が割り込まないようにする必要があります。

synchronized を使うときの注意

synchronized は便利ですが、何でもかんでも付ければよいわけではありません。

排他制御を行うと、同時に入れるスレッド数を制限することになります。

そのため、必要な範囲を正しく見極めることが大切です。

注意点内容
必要な共有データを守る共有される値や資源を中心に考える
守る範囲を広げすぎない不必要に待ち時間が増える可能性がある
一連の更新処理はまとめて守る読む、計算する、書き戻すを分断しない
表示順の固定とは別問題synchronized は主に共有データの整合性を守る

今回の addPoint のように、共有フィールド total を更新する処理では、synchronized を付ける意味が分かりやすいです。

しかし、単にメッセージを表示するだけの処理など、共有データを壊す可能性が低い場所まで過剰に守る必要はありません。

同期と排他制御の関係

同期と排他制御は、似た場面で出てくる言葉です。

学習段階では、次のように整理すると分かりやすいです。

用語役割ドラゴンボール風のイメージ
同期スレッドどうしのタイミングを整える戦士たちの行動順をそろえる
排他制御同じ場所に同時に入れないようにする記録帳への記入を1人ずつにする
synchronized排他制御を実現するための指定記録帳を守る結界

同期は広い意味で、スレッドのタイミングを調整することです。

排他制御は、その中でも「同時に使わせない」という考え方です。

今回の synchronized は、addPoint に同時に1つのスレッドしか入れないようにするため、排他制御の例として理解できます。

スレッドの同期と排他制御で押さえたいこと

スレッドの同期と排他制御を学ぶときは、次の点を押さえると理解しやすくなります。

ポイント内容
複数スレッドは便利複数の処理を並行して進められる
共有データには注意が必要同じ値を同時に更新すると矛盾が起きる可能性がある
問題の原因読む、計算する、書き戻すの途中で別スレッドが割り込むこと
共有資源複数スレッドから使われるデータや対象
同期スレッドどうしのタイミングを整えるしくみ
排他制御同じ共有資源へ同時に入れないようにするしくみ
synchronizedメソッドなどを排他的に実行させるための指定
守る対象共有データを扱う一連の処理全体

スレッドは、ただ複数の流れを作るだけでは安全に使えるとは限りません。

複数の流れが同じデータを触るときは、そのデータをどう守るかまで考える必要があります。

ドラゴンボール風に言えば、複数の戦士が同じ修行記録帳に書き込むなら、1人ずつ順番に書くルールが必要です。

GokuWarrior が記録している間は、VegetaWarrior は待つ。
GokuWarrior が書き終わったら、VegetaWarrior が更新後の合計を見て書く。

この順番を守ることで、修行ポイントの合計が正しく積み上がります。

Javaでは、そのための基本的な方法として synchronized を使えます。

この考え方が分かると、スレッドは「同時に動く便利なしくみ」だけではなく、「共有データの正しさを守りながら動かす必要があるしくみ」として理解できるようになります。