あなたのプログラムはシングルスレッドではない

他の言語: English Español Français Deutsch 한국어 Português 中文

コードを書くとき、私たちは同期的に考える傾向があります。 一つの文を次々と書き、ランタイムはそれらを順番に実行します。 このメンタルモデルはスクリプトやコマンドラインツールには最適ですが、 フレームワークやサーバーアプリケーションを扱う際には危険なほど誤解を招く可能性があります。

スレッドを明示的に作成したり、並行処理プリミティブを使用したりしなくても、 Webアプリケーションはほぼ確実に並行してコードを実行しています。 フレームワークがそれを処理してくれるので便利ですが、それを念頭に置き、 フレームワークが提供するスレッドモデルと構成要素を正しく使用する必要があります。

この記事では、一見同期的なコードでシングルスレッドのメンタルモデルを誤って適用した場合に 発生する可能性のあるバグの種類を見ていきます。 原因を特定し、デバッガーを使用して再現し、いくつかの解決策を検討します。

問題

開発環境と本番環境で問題なく動作しているSpring Bootアプリケーションがあるとします。 長い間すべてが順調に動作していましたが、経理チームから深刻な問題が報告されました: 複数の請求書に同じ番号が付いているのです。これは大問題です。請求書番号は法的・会計上の理由から一意でなければなりません。 重複は一度だけ発生し、ローカルで問題を再現することはできません。

Tip icon

この演習をより興味深くするために、続きを読む前に自分で問題を見つけてみてください。 プロジェクトはGitHubで公開されています。

データベースを確認すると、同じ請求書番号を持つ別々のレコードが表示されます。 設定は問題なさそうですが、何かが明らかにおかしいです。 請求書作成フローをトレースすると、請求書番号を生成するサービスが見つかります。

コードは次のようになっています:

@Service
public class InvoiceService {
    private InvoiceNumberGenerator generator;

    public Invoice createInvoice(Order order) {
        if (generator == null) {
            generator = createGenerator();
        }
        String invoiceNumber = generator.nextNumber();
        return new Invoice(invoiceNumber, order);
    }

    private InvoiceNumberGenerator createGenerator() {
        // Reads last used number from database, initializes counter
        int lastNumber = invoiceRepository.findMaxInvoiceNumber();
        return new InvoiceNumberGenerator(lastNumber);
    }
}

これはあなたが書くようなコードではありません。 問題を見つけられますか?

何が問題だったのか?

作者のメンタルモデルはおそらく「Springが私のBeanを管理しているから安全だ」というものでした。 そしてそれは正しいです:Springは確かにBeanのライフサイクルを処理し、 InvoiceService が一度だけ作成され、適切に共有されることを保証します。 しかし、Springは自分で初期化するフィールドについては何も知りません。 generator フィールドはあなたの責任であり、ここに問題が潜んでいます。

イベントの順序

SpringのBeanはデフォルトでシングルトンです。 これは、 InvoiceService のインスタンスが1つだけ存在し、すべてのリクエストで共有されることを意味します。 複数のHTTPリクエストが同時に来ると、 それらは異なるスレッドで処理され、すべてが同じサービスインスタンスにアクセスします。

では、ジェネレーターがまだ初期化されておらず、 2つのスレッドがほぼ同時に createInvoice() を呼び出した場合に何が起こるかをトレースしてみましょう:

  1. スレッドAが generator == null をチェック、結果は true
  2. スレッドAが if ブロックに入り、ジェネレーターの作成を開始
  3. スレッドBが generator == null をチェック、まだ true (Aがまだジェネレーターを作成していない)
  4. スレッドBも if ブロックに入る
  5. 両方のスレッドがデータベースに findMaxInvoiceNumber() をクエリし、同じ値(例: 1000 )を取得
  6. 両方のスレッドが 1000 から始まる独自の InvoiceNumberGenerator を作成
  7. 両方が nextNumber() を呼び出し、 1001 を取得
  8. 同じ番号を持つ2つの請求書が作成される

遅延初期化はスレッドセーフではなく、 並行アクセス下では、両方のスレッドがデータベースから同じ「最後の請求書番号」を読み取り、 同じ次の番号を生成する可能性があります。

デバッガーで再現する

今見つけた問題のあるコードはシンプルなので、見ただけでイベントの順序を理解できるかもしれません。 より複雑なケースでは、正確に何が起こっているかを理解するために、バグをステップバイステップで再現したい場合があります。 また、不運なタイミングをシミュレートして、プログラムが負荷下で壊れないかテストしたい場合もあります。

これにはIntelliJ IDEAのデバッガーを使用します。個々のスレッドを制御できます - まさに必要なものです。

まず、 generator = createGenerator(); の行にブレークポイントを設定し、 右クリックしてサスペンド (Suspend)を スレッド (Thread)に設定します。 これにより、デバッガーはすべてのスレッドではなく、ブレークポイントにヒットしたスレッドのみを一時停止するようになります。

サスペンドポリシーがスレッドに設定されたブレークポイント設定ダイアログ サスペンドポリシーがスレッドに設定されたブレークポイント設定ダイアログ

デバッグモードでアプリケーションを実行し、以下のcurlコマンドを使用して 異なるターミナルから2つのリクエストを送信します:

curl -X POST http://localhost:8080/api/diagnostics/create-order

リクエストの1つがブレークポイントにヒットします:これは予想通りです。 2つ目のリクエストもブレークポイントにヒットすると、複数のスレッドが 同時にクリティカルセクションに入ることができることが証明されました - これがまさに問題のバグの定義です。

スレッド (Threads)タブを調べて、 両方のスレッドが同じ場所で待機していることを確認できます:

同じブレークポイント位置で停止している2つのスレッドを表示するスレッドタブ 同じブレークポイント位置で停止している2つのスレッドを表示するスレッドタブ

両方のスレッドが null チェックを通過しており、 解放すると、それぞれが独自のジェネレーターインスタンスを作成します。

あるいは、非一時停止ログブレークポイントを 小さな遅延とともに使用して、より遅い実行をシミュレートすることもできます:

遅い実行をシミュレートするThread.sleepを含む評価・ログ式付きブレークポイント 遅い実行をシミュレートするThread.sleepを含む評価・ログ式付きブレークポイント

このタイプのブレークポイントはデバッグログ用に設計されていますが、 すでに知っているように、 ほぼあらゆる副作用を導入するために使用できます。 この人工的な遅延により、リクエストが衝突する可能性が高いため、アプリケーションを一時停止する必要がありません。

問題の修正

要件に応じて、問題を修正する方法はいくつかあります:

オプション1:データベースを使用する

請求書番号に関しては、正しい解決策はデータベースに一意性を処理させることです:

@Service
public class InvoiceService {
    private final InvoiceRepository invoiceRepository;

    public InvoiceService(InvoiceRepository invoiceRepository) {
        this.invoiceRepository = invoiceRepository;
    }

    public Invoice createInvoice(Order order) {
        // Database generates the number via sequence or auto-increment
        return invoiceRepository.save(new Invoice(order));
    }
}

これにより、メモリ内カウンターが完全に排除されます。 データベースは複数のアプリケーションインスタンス間でも一意性を保証します。

オプション2:即時初期化

メモリ内ジェネレーターが必要な場合は、Spring Beanにします:

InvoiceNumberGenerator.java:
@Component
public class InvoiceNumberGenerator {
    private final AtomicInteger counter;

    public InvoiceNumberGenerator(InvoiceRepository invoiceRepository) {
        Integer maxNumber = invoiceRepository.findMaxInvoiceNumber();
        int startingNumber = (maxNumber != null) ? maxNumber : 0;
        this.counter = new AtomicInteger(startingNumber);
    }

    public String nextNumber() {
        int next = counter.incrementAndGet();
        return String.format("INV-%05d", next);
    }
}
InvoiceService.java:
@Service
public class InvoiceService {
    private final InvoiceRepository invoiceRepository;
    private final InvoiceNumberGenerator generator;

    public InvoiceService(InvoiceRepository invoiceRepository,
                          InvoiceNumberGenerator generator) {
        this.invoiceRepository = invoiceRepository;
        this.generator = generator;
    }

    public Invoice createInvoice(Order order) {
        String invoiceNumber = generator.nextNumber();
        Invoice invoice = new Invoice(invoiceNumber, order);
        return invoiceRepository.save(invoice);
    }
}

これはSpringの標準的なアプローチです。 シングルトンBeanは起動時に作成され、すべてのリクエスト間で安全に共有されます。

オプション3:@Lazyを使用する

本当に遅延初期化が必要な場合 (例えば、データのロードや接続の確立によりBeanの作成にコストがかかる場合)、 @Lazyを使用してSpringに安全に処理させることができます:

InvoiceNumberGenerator.java:
@Component @Lazy
public class InvoiceNumberGenerator {
    private final AtomicInteger counter;

    public InvoiceNumberGenerator(InvoiceRepository invoiceRepository) {
        Integer maxNumber = invoiceRepository.findMaxInvoiceNumber();
        int startingNumber = (maxNumber != null) ? maxNumber : 0;
        this.counter = new AtomicInteger(startingNumber);
    }

    public String nextNumber() {
        int next = counter.incrementAndGet();
        return String.format("INV-%05d", next);
    }
}
InvoiceService.java:
@Service
public class InvoiceService {
    private final InvoiceRepository invoiceRepository;
    private final InvoiceNumberGenerator generator;

    public InvoiceService(InvoiceRepository invoiceRepository,
                          @Lazy InvoiceNumberGenerator generator) {
        this.invoiceRepository = invoiceRepository;
        this.generator = generator;
    }

    public Invoice createInvoice(Order order) {
        String invoiceNumber = generator.nextNumber();
        Invoice invoice = new Invoice(invoiceNumber, order);
        return invoiceRepository.save(invoice);
    }
}

経験則として、遅延初期化、キャッシング、同期化、 または同様のパターンを手動で実装している場合は、フレームワークがすでに提供しているかどうかを確認してください。 自分で行うことは、フレームワークと戦うことを意味し、保守のオーバーヘッドや微妙なバグを導入することになります。

まとめ

スレッドを明示的に作成しなくても、アプリケーションは並行して実行されることがよくあります。 このパターンはSpringやWeb開発に限ったことではありません。 暗黙のスレッドモデルがあるところでは、リスクがあります: Webフレームワーク、UIツールキット、さらには標準ライブラリAPIでさえもバックグラウンドでスレッドを管理しています。

次回、アプリケーションで断続的で説明のつかない動作を見たときは、 自問してください:これはスレッドの問題かもしれない?ほとんどの場合、答えはイエスです。

ハッピーデバッギング!

all posts ->