【ITニュース解説】How Can a Program Produce Different Results Every Time You Run It?
2026年10月16日に「Dev.to」が公開したITニュース「How Can a Program Produce Different Results Every Time You Run It?」について初心者にもわかりやすく解説しています。
ITニュース概要
プログラムが同じコード、入力でも実行ごとに異なる結果を出すことがある。これは、複数の処理が同時に共有データへアクセスする際、実行順序が毎回変わるため「競合状態」が起きるためだ。これにより、意図しない結果になる。対策として、同期化やアトミック変数などを用いてデータの一貫性を保つことが重要だ。
ITニュース解説
プログラムが毎回異なる結果を出すという現象は、多くの開発者が直面する問題だ。コードや入力データが同じでも出力が変わる場合、一見するとコンパイラの不具合やハードウェアの問題、あるいは偶発的なバグのように思えるかもしれない。しかし、多くの場合、その原因はより根本的な部分にある。それは、複数の処理が実行される順序にプログラムが依存しているにもかかわらず、その順序が保証されていないことだ。この振る舞いは、並行処理、スレッドスケジューリング、共有状態、そして競合状態といった重要な概念と深く関連している。これらの概念の理解は、ソフトウェア開発で遭遇する最も厄介なバグのいくつかを解明するために不可欠である。
例えば、2つのスレッドが同じカウンター変数をそれぞれ10万回ずつインクリメントするプログラムを考える。期待される最終的なカウンターの値は20万のはずだが、実際には期待値よりも小さい値が出力される場合がある。なぜこのようなことが起こるのだろうか。その理由は、両方のスレッドが同じ変数「カウンター」を同時に変更しようとしているからだ。counter++という処理は一行のコードに見えるが、内部的には「現在のカウンターの値を読み込む」「その値に1を足す」「結果をカウンターに書き戻す」という複数のステップから構成されている。これらのステップは、異なるスレッド間で重なって実行される可能性がある。問題は、どちらかのスレッドが間違った命令を実行しているわけではない。問題の本質は、スレッド間の操作が互いに干渉し合うことにある。
並行処理とは、複数のタスクが時間的に重なる期間にわたって進行することを意味する。システムによっては、一つのプロセッサ上で交互に実行されたり、複数のプロセッサコア上で実際に同時に実行されたりする。スレッドが共有メモリにアクセスする際に、この問題が発生する。例えば、スレッドAがカウンターの値を10と読み込み、その直後にスレッドBもカウンターの値を10と読み込んだとする。次にスレッドAが計算結果の11を書き込み、続けてスレッドBも計算結果の11を書き込む。この場合、最終的なカウンターの値は11となるが、実際には2回のインクリメントが試みられたにもかかわらず、1回の更新が失われたことになる。このような現象は「更新の喪失」と呼ばれ、競合状態の典型的な例である。
オペレーティングシステムに搭載されているスケジューラは、実行可能なスレッドのどれにCPU時間を割り当てるか、そしていつ割り当てるかを決定する。そのため、スレッドは様々な時点で一時停止され、再開される可能性があり、その実行順序はプログラムが実行されるたびに変わりうる。ソースコードは一切変更されていないにもかかわらず、操作が重なる順序が変わるだけで結果が異なるのだ。これが、並行処理のバグが再現しにくい一因である。プログラムは、あるテストでは正しく動作するが、わずかに異なる負荷や別のマシン、システムが高負荷状態になったときに予期せず失敗することがある。スケジューリング自体が自動的にバグを引き起こすわけではない。プログラムが保証されていない実行順序に依存している場合に、そのバグを露呈させるのである。
競合状態とは、プログラムの正しさが、時間や操作の順序に依存し、それらが重なったり交互に実行されたりすることで、予期せぬ結果を引き起こす状況を指す。特に、複数のスレッドが共有された可変データにアクセスし、そのうち少なくとも一つが適切な同期なしにデータを変更する場合に危険となる。例えば、残高1000円の銀行口座から、2つの取引がほぼ同時に700円を引き出そうとする場合を考える。両方の取引が、残高が十分であるかどうかをチェックするために、残高を読み込む。もし両方が、どちらも残高を更新する前に1000円と読み込んでしまうと、両方とも引き出しを許可可能だと判断してしまうかもしれない。実装によっては、システムは両方の引き出しを受け入れたり、一方の更新が他方を上書きしたりする可能性がある。いずれの結果も、本来のビジネスルールに違反することになる。これは単なる理論上の問題ではない。同様の問題は、在庫管理、決済処理、チケット予約、リクエストカウンター、口座残高といった様々なシステムで発生しうる。したがって、競合状態は単に異なる数値が得られるというだけでなく、更新の喪失、重複したアクション、データの一貫性の欠如、あるいは誤ったビジネス上の意思決定につながる可能性があるのだ。
前述の通り、counter++という表現は一見単純だが、実際には「読み込み-変更-書き込み」という一連の操作である。概念的には「現在のカウンターの値を読み込む」「その値に1を加える」「更新された値を保存する」というステップで動作する。もし2つのスレッドがこれらのステップを同時に実行した場合、両方が同じ古い値を読み込んでしまい、お互いの更新を上書きしてしまう可能性がある。この問題の解決策は、その操作を「アトミック」にすること、つまり分割不可能な単一の操作として扱うか、共有状態を同期メカニズムで保護することだ。Javaではsynchronizedキーワードを用いることで、特定のメソッドやブロックを同時に実行できるスレッドを一つに制限し、競合を防ぐ。これにより、カウンターのインクリメント操作が保護され、最終結果は期待通りの20万になる。同期は、クリティカルセクションの同時実行を防ぐだけでなく、同じモニターを使用する同期操作間でメモリの可視性も保証する。
さらに、ロックはリソースへのアクセスを制御する同期メカニズムの一つだ。スレッドは保護された操作を実行する前にロックを取得し、完了後にロックを解放する。JavaのReentrantLockは、より明示的にロックを制御するために使用できるが、ロックの取得と解放を忘れずに行う必要がある。ロックを解放し忘れると、他のスレッドは無期限に待機してしまう可能性がある。また、複数のロックを異なる順序で取得しようとすると、デッドロック(複数のスレッドがお互いのロック解除を待ち続け、永遠に処理が進まなくなる状態)を引き起こす可能性もある。目標は全てをロックすることではなく、コードの最小限の適切なセクションを保護し、プログラムの正しさを保つことだ。
場合によっては、完全なロックが不要なこともある。JavaにはAtomicIntegerのようなアトミッククラスが提供されており、これらは個々の値に対してスレッドセーフな操作をサポートする。アトミックなインクリメント操作は、元の例で発生した更新の喪失を防ぐことができる。アトミック変数はカウンター、フラグ、単純な共有値に特に有用だが、複数の関連する操作のシーケンス全体をアトミックにするわけではないため、より複雑なケースでは依然としてロックや異なる設計が必要となる場合もある。
プログラムの非決定性は、並行処理だけでなく、乱数生成、現在時刻、外部イベント、ネットワーク応答時間、ファイルシステムやデータベースの状態、分散システムがメッセージを受信する順序などにも依存して異なる結果を出すことがある。競合状態は、制御されていない実行順序がプログラムの意図する動作に違反する場合の、正しさに関する問題だ。異なる出力が出たからといって、自動的に競合状態であるとは限らない。重要なのは、観察された変動が期待されるものか、それとも必要な保証を破っているかという点だ。
並行処理のバグは、しばしば断続的に発生し、再現が難しい。ログ出力を追加したり、デバッガを実行したり、マシンのパフォーマンスを変更したりすると、スレッドのタイミングが変わり、一時的に問題が消えてしまうことがある。これは「ハイゼンバグ」と呼ばれる現象で、観察や調査を試みると動作が変わるバグのことだ。例えば、ロギングステートメントによって、あるスレッドが十分に遅くなり、問題となるような形で2つの操作が重なるのを防いでしまう場合がある。コードは動作しているように見えるが、根本的な競合状態は残っているのだ。負荷の下で問題を再現すること、共有可変状態を注意深く調査すること、並行処理対応のテストツールを使用すること、可能であれば決定論的なテストを優先すること、そして同期設計をレビューすることが、これらのバグに対処するための有効な技術である。最も重要なのは、もう再現できないからといってバグが修正されたと安易に決めつけないことである。
競合状態を防ぐ最も効果的な戦略は、コードの正しさが偶発的な実行順序に依存しないように並行コードを設計することだ。いくつかの実践が特に役立つ。共有される可変状態を最小限に抑えること。スレッドが可変データを共有しなければ、多くの競合状態は発生しない。次に、スレッドセーフな抽象化を利用すること。手動で各操作を同期させるよりも、十分にテストされた並行コレクションやアトミッククラスを優先して使用する。クリティカルセクションは小さく保つべきだ。関連する状態の変更を保護しつつ、無関係な作業を不必要にブロックしないようにする。可能であれば、不変データを使用する。作成後に変更できないオブジェクトは、安全に共有しやすい。特定の期待されるスレッド順序に依存しないこと。ある操作が別の操作の前に実行されなければならない場合、その関係を明示的に確立する。そして、現実的な並行処理の状況下でテストを行うことが重要だ。例えば、Webアプリケーションで共有されるインメモリカウンターは、ローカル開発中には問題なく見えるかもしれないが、多数の同時リクエストが発生すると正しく動作しないことがある。同様に、同じデータベースレコードを更新する2つのAPIリクエストは、データベース操作が必要な一貫性を保証していなければ、互いに上書きし合う可能性がある。データベーストランザクション、アトミックなSQL更新、楽観的ロック、一意性制約などは、データ層でビジネスルールを強制するのに役立つ。
結論として、プログラムはコードと入力が変わらなくても、実行ごとに異なる結果を出すことがある。これは、並行処理される操作が異なる順序で実行される可能性があり、プログラムが共有状態について持っている仮定が露呈するためだ。これらのタイミングの違いがプログラムの正しさに影響を与えるときに、競合状態が発生する。スレッドスケジューリングは操作がいつ実行されるかに影響を与え、ロック、同期、アトミック変数、そして慎重な状態管理は、最終結果が正しく保たれるようにするのに役立つ。より深い教訓は、並行プログラミングが単にタスクを高速化することではないということだ。それは、操作の実行順序が変わってもシステムが正しく動作するように、それらを調整することである。並行ソフトウェアを記述する際には、たまたま観察した実行順序に決して依存せず、プログラムが実際に必要とする保証を確立すべきだ。