どの開発者も一度は頭を抱えたことがあるバグがある。99回は正しく動くのに100回目だけ結果がおかしくなり、もう一度実行するとまた正常に戻る。明確なスタックトレースもなく、ローカルでは再現できないのに、多くのユーザーが同時にアクセスする本番環境では繰り返し発生する。それが競合状態(レースコンディション)であり、プログラミングにおいて最も見つけにくいバグの一つだ。
競合状態とは何か
競合状態は、並行して動く複数のプロセスやスレッドの実行順序によってプログラムの結果が変わるにもかかわらず、その順序が保証されていないときに発生する。簡単に言えば、二つ(またはそれ以上)のコードが共有リソースへのアクセスと変更を「競争」し、最終結果がどちらが先に「ゴール」するかで決まってしまう。そしてその順序は、実行のたびに同じになるとは限らない。
典型的な例は、二つのスレッドが共有カウンターに対してcount = count + 1を実行するケースだ。一つの操作に見えるが、機械レベルでは「現在値を読む、1を足す、書き戻す」の三段階になる。両方のスレッドがどちらも書き戻す前にcount = 5を読み込むと、両方とも6を計算して互いに上書きし、結果は7ではなく6になる。
なぜこれほど見つけにくいのか
競合状態はタイミング、つまりスレッドが交互に実行される正確な瞬間に依存しており、それを制御したり予測したりすることはほぼ不可能だ。アクセスの少ない開発者のマシンでは、二つのスレッドがちょうど良いタイミングで衝突してバグが表面化することはほとんどない。しかし数百、数千の同時リクエストがある本番環境では衝突の確率が大きく上がり、バグが現れ始める。ただし、毎回ではない。
そのため、競合状態は通常のデバッグではほとんど見えない。ブレークポイントを置いて一行ずつ追うと、そのスレッドの処理が遅くなり、偶然バグが消えてしまう。開発者が冗談で「ハイゼンバグ」(観察しようとすると消えるバグ)と呼ぶ現象だ。
イメージしやすい例:最後の1枚のチケット
残り1枚だけのチケット販売システムを想像してほしい。ユーザーAとBがほぼ同時に「購入」を押す。Aの処理が「チケットは残っているか」を確認し、「残り1枚」という答えを得る。その直後、Bの処理も確認し、同じ答えを得る。Aの処理がまだ在庫を0に減らしていないからだ。結果として、実際には1枚しかないのに、AもBも購入成功の確認を受け取る。チケット販売、予約、ECのシステムが、大規模セールのように最も多くの人が同時に購入するタイミングで「売り越し」を起こしやすいのはこのためだ。
検出と防止の方法
競合状態は必ずしも再現できないため、最も効果的なのは最初から正しく設計することだ。一般的な手法としては、ロックやミューテックスを使って一度に一つのスレッドだけが共有リソースにアクセスできるようにすること、カウンターの加算のような単純な操作にはアトミック操作(分割や割り込みができない操作)を使うこと、データベースのトランザクションで読み書きの操作を一つの単位にまとめること、そして本当に必要な場合を除き、スレッド間で変更可能な状態(ミュータブルな状態)を共有しないことが挙げられる。
テストについては、多数の同時リクエストを再現するストレステストや、ThreadSanitizerやGoのレース検出器といったツールが、コードを再実行してバグが出るのを祈るよりはるかに役立つ。コードレビューでは、複数のスレッドやリクエストから共有リソース(グローバル変数、ファイル、データベースのレコード)にアクセスする部分を、通常より注意深く確認する価値がある。
競合状態は、一人で手動テストして「動いた」コードが、ロジックとして本当に正しいとは限らないことを思い出させてくれる。多くのユーザーやスレッドが同時に触れたときに初めて真実が明らかになり、そしてそれはたいてい最悪のタイミングで起こる。