並行処理の落とし穴 – 複数スレッドからの安全なアクセス

docs

前回の記事では、Javaで複数の処理を同時に動かす「並行処理」の基本的な考え方について学んだね。

Javaにおけるスレッドの基本:並行処理の導入 | ToolDocs

今回はその続き!並行処理にはとっても便利な面がある一方で、注意しないとハマっちゃう落とし穴もあるんだ。それが「複数のスレッドからの安全なアクセス」という問題。

「え、どういうこと?」って思うよね。具体例で見ていこう!

複数のスレッドが同じものに触ると…?

想像してみて。君が友達と2人で「貯金箱」にお金を入れるゲームをしているとしよう。

ケース1:順番に入れる

君が100円入れて、その後に友達が100円入れる。これなら合計200円でバッチリだよね。これが前回の記事で話した「順番に処理される」イメージに近いかな。

ケース2:同時に触っちゃう!

さあ、ここで問題発生!君と友達が「同時に」貯金箱に100円を入れようとしたらどうなるだろう?

  • 君が100円を入れようと貯金箱を開ける。
  • 友達も100円を入れようと貯金箱を開ける。(あ!同時に開けちゃった!)
  • 君が100円を入れて貯金箱を閉める。
  • 友達が100円を入れて貯金箱を閉める。

結果、貯金箱にはいくら入っていると思う?200円って思うよね?でも、もしかしたら100円しか入っていないかもしれないんだ!

なんでかって?それは、貯金箱に「今いくら入っているか」を確認して、そこに100円を足して、最終的な金額を貯金箱に戻す、という一連の作業が「アトミックじゃない」からなんだ。アトミックっていうのは、「これ以上分割できない最小単位の処理」って意味だよ。

今回の例だと、

  1. 貯金箱の残高を読み込む
  2. 残高に100円を足す
  3. 新しい残高を貯金箱に書き込む

っていう3つのステップがあるよね。もし、君が1番目の「残高を読み込む」をした直後に、友達も1番目の「残高を読み込む」をしちゃったら…?2人とも同じ古い残高を読み込んで、それぞれ100円足して書き込んじゃうから、片方の追加分が上書きされちゃう、なんてことが起きるんだ。これが並行処理の怖いところ!

Javaの世界でも全く同じことが起こるんだ。複数のスレッドが同じデータ(今回の貯金箱みたいなもの)を同時に変更しようとすると、データの整合性が崩れちゃうことがあるんだ。これを「競合状態(Race Condition)」って呼ぶよ。

同期処理とロックで安全を確保!

じゃあ、どうすればいいんだろう?ここで登場するのが「同期処理」と「ロック」という考え方だ!

さっきの貯金箱の例で言うと、こうすれば安全だよね?

「貯金箱にお金を入れるときは、誰か一人が使っている間は、他の人は使っちゃダメだよ!」

これをJavaで実現するのが「synchronized」キーワードと「java.util.concurrent.locks」パッケージだよ。

synchronized キーワードで簡単ロック!

一番手軽に同期処理を実現できるのが synchronized キーワードだ。これを使うと、特定のコードブロックやメソッドを「一度に一つのスレッドしか実行できない」ようにすることができるんだ。

Java

public class SavingsAccount {
    private int balance; // 貯金箱の残高

    public SavingsAccount(int initialBalance) {
        this.balance = initialBalance;
    }

    // お金を入れるメソッド
    public synchronized void deposit(int amount) {
        // synchronized をつけることで、このメソッドは一度に一つのスレッドしか実行できない
        int currentBalance = this.balance; // 今の残高を読み込む
        System.out.println(Thread.currentThread().getName() + ": 残高 " + currentBalance + " に " + amount + " を追加します。");
        try {
            Thread.sleep(10); // わざと少し時間を置く(競合を再現しやすくするため)
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        this.balance = currentBalance + amount; // 新しい残高を書き込む
        System.out.println(Thread.currentThread().getName() + ": 新しい残高は " + this.balance + " です。");
    }

    public int getBalance() {
        return balance;
    }

    public static void main(String[] args) {
        SavingsAccount account = new SavingsAccount(0); // 初期残高0円の貯金箱

        Runnable depositor = () -> {
            for (int i = 0; i < 500; i++) {
                account.deposit(1); // 1円ずつ500回入れる
            }
        };

        Thread thread1 = new Thread(depositor, "太郎");
        Thread thread2 = new Thread(depositor, "花子");

        thread1.start();
        thread2.start();

        try {
            thread1.join(); // 太郎スレッドが終わるのを待つ
            thread2.join(); // 花子スレッドが終わるのを待つ
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        System.out.println("最終的な貯金箱の残高: " + account.getBalance());
    }
}

このコードでは、deposit メソッドに synchronized をつけているよね。こうすると、deposit メソッドを「太郎」スレッドが実行している間は、「花子」スレッドは deposit メソッドの実行が終わるまで待たされるんだ。これで、さっきの「同時に貯金箱に触っちゃう」問題が解決するわけ!

もし synchronized を外して実行してみると、最終的な残高が1000にならない(900台になったり、バラバラになったりする)ことがあるはずだよ。ぜひ試してみて!

ロックオブジェクトを使ったより柔軟な制御

synchronized は便利だけど、ロックする範囲を細かく制御したいときや、より複雑な同期処理をしたいときには、「java.util.concurrent.locks」パッケージが提供する Lock インターフェースを使うと便利だよ。

代表的な実装クラスに ReentrantLock がある。

Java

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class SavingsAccountWithLock {
    private int balance;
    private final Lock lock = new ReentrantLock(); // ロックオブジェクトを準備

    public SavingsAccountWithLock(int initialBalance) {
        this.balance = initialBalance;
    }

    public void deposit(int amount) {
        lock.lock(); // ロックを取得
        try {
            // ロックが取得できたスレッドだけがこのブロックを実行できる
            int currentBalance = this.balance;
            System.out.println(Thread.currentThread().getName() + ": 残高 " + currentBalance + " に " + amount + " を追加します。(Lock)");
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            this.balance = currentBalance + amount;
            System.out.println(Thread.currentThread().getName() + ": 新しい残高は " + this.balance + " です。(Lock)");
        } finally {
            lock.unlock(); // ロックを解放(★これが超重要!忘れずに!)
        }
    }

    public int getBalance() {
        return balance;
    }

    public static void main(String[] args) {
        SavingsAccountWithLock account = new SavingsAccountWithLock(0);

        Runnable depositor = () -> {
            for (int i = 0; i < 500; i++) {
                account.deposit(1);
            }
        };

        Thread thread1 = new Thread(depositor, "ジロウ");
        Thread thread2 = new Thread(depositor, "サチコ");

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        System.out.println("最終的な貯金箱の残高: " + account.getBalance());
    }
}

ReentrantLock を使う場合は、lock.lock() でロックを取得して、try-finally ブロックで囲んで lock.unlock() で確実にロックを解放するのがお作法だよ。もし unlock() を忘れると、他のスレッドがいつまで経ってもロックを取得できなくなり、プログラムが停止してしまう「デッドロック」という状態に陥る可能性もあるから注意が必要だ!

同期処理のメリット・デメリット

同期処理を使えば、複数のスレッドからの安全なアクセスが保証される。これはとっても良いことだよね!でも、いいことばかりじゃないんだ。

メリット:

  • データの整合性: 複数のスレッドから同時にデータが変更されても、データが壊れたり、意図しない値になったりするのを防ぐことができる。
  • 安全性の確保: 複雑な並行処理でも、正しく動くように保証できる。

デメリット:

  • パフォーマンスの低下: ロックを使うということは、他のスレッドを待たせるということ。これは処理の並行性を犠牲にするということだから、場合によっては処理速度が遅くなることがある。
  • デッドロックのリスク: 複数のロックを扱う場合、お互いのロックを待ち合ってしまい、プログラムが停止してしまう「デッドロック」という状態になるリスクがある。

だから、同期処理は「必要なところにだけ」使うのが鉄則だよ。なんでもかんでもロックすれば良い、というわけじゃないんだ。

前回の記事のおさらいと次回予告

前回の記事では、Thread クラスや Runnable インターフェースを使って、Javaでスレッドをどうやって作るかを学んだよね。まだ読んでない人は、ぜひこっちもチェックしてね!

Javaにおけるスレッドの基本:並行処理の導入 | ToolDocs

今回は、そのスレッドたちが安全にデータを共有する方法について深掘りしたよ。これで、君は並行処理の強力さと、それを安全に使うための基礎を身につけたことになる!

次回の記事では、今回少し触れた「デッドロック」について、もっと詳しく見ていこうと思う。デッドロックは並行処理のバグの中でも見つけにくい厄介なやつだから、しっかり対策を学んでおこうね。お楽しみに!

まとめ

  • 複数のスレッドが同じデータを同時に変更しようとすると、「競合状態」になり、データの整合性が崩れる可能性がある。
  • これを防ぐために「同期処理」と「ロック」を使う。
  • synchronized キーワードは、メソッドやコードブロックを一度に一つのスレッドしか実行できないようにする。
  • java.util.concurrent.locks.Lock インターフェース(例: ReentrantLock)を使うと、より柔軟なロック制御が可能になる。lock() でロックを取得し、unlock() で解放することを忘れずに!
  • 同期処理はデータの整合性を保つ上で重要だが、パフォーマンスの低下やデッドロックのリスクもあるため、適切に使う必要がある。

並行処理は奥が深くて難しいけど、一つずつ理解していけば、よりパワフルなプログラムが書けるようになるよ!頑張っていこうね!

コメント

タイトルとURLをコピーしました