ミュータブルオブジェクト管理の難しさ
このように非同期処理をマルチスレッド環境で行えば処理全体の効率を向上できます。しかし、マルチスレッド環境でミュータブルオブジェクトを管理するには困難を伴います。なぜなら、注意深く実装しないとオブジェクトが不正な状態になりうるからです。この問題を理解するために、Webサーバ内で、あるデータ構造をキャッシュする場合を例にあげて考えましょう。現代のWebアプリケーションには応答速度が求められるので、Webサーバ内でデータをキャッシュすることは珍しくありません。全てのデータをデータベースなどの永続ストレージに置くのではなく、一部をWebサーバ内のランダムアクセスメモリに持ち、必要に応じて永続ストレージと同期する手法です。

さらに近年のWebサーバ・フレームワークはマルチスレッド環境でクライアントからのリクエストを処理するものが多いです。こういった場合、Webサーバでキャッシュされるようなデータは寿命が長い、つまりクライアントからのリクエストを受けてレスポンスを返すまでの期間を超えてサーバ側で保持し続けます。よって、必然的にキャッシュされたデータは多くのスレッドから同時にアクセスされます。この時、キャッシュされたデータがミュータブルオブジェクトであれば、同時に複数のリクエストがオブジェクトを書き込んでも不正なオブジェクトにならないよう実装しなくてはなりません。

ここで、マルチスレッド環境で不正なオブジェクトが生み出す問題のいくつかを紹介しましょう。Javaに関しての例のみになりますがOracleのJava TutorialsのConcurrencyの章から一部を抜粋して紹介します。
最初は「スレッド干渉」と呼ばれる問題です。これは複数のスレッドから同時に同じミュータブルオブジェクトに対して処理を実行し、処理同士がお互いに干渉し合う時に起きます。2つのスレッドで同時にInt値をインクリメント、デクリメントする場合を考えましょう。Java Virtual Machineを含む多くのプログラム実行環境ではInt値のインクリメント、デクリメントはスレッドセーフではありません。Int値のインクリメントやデクリメントは内部で複数ステップに分かれていて、以下のように2つのスレッドからほぼ同時に行われると結果が不正になります。
class Counter {
private int c = 0;
public void increment() {
c++;
}
public void decrement() {
c--;
}
public int value() {
return c;
}
}
- スレッド 1: int c 取得
- スレッド 2: int c 取得
- スレッド 1: 取得したInt値をインクリメント
- スレッド 2: 取得したInt値をデクリメント
- スレッド 1: cに1を書き込み
- スレッド 2: cに-1を書き込み
(6が5を上書き。インクリメントとデクリメントがそれぞれ1回ずつ行われたのにcが0ではなく-1になる)
もしInt値でWebページの閲覧回数を保存していたら、同時にリクエストが来たことで閲覧回数が実際より少なくカウントされるかもしれません。Webページの閲覧なら影響は少ないでしょうが、これが金額に関わるデータだったらどうでしょう。さらにIntではなく、複雑なデータ構造を持つミュータブルオブジェクトをマルチスレッド環境で管理すると、問題は深刻です。最悪の場合オブジェクトが破壊されて意味をなさなくなり、破壊された後のオブジェクトに対する操作の結果が予想できなくなります。
私の経験ですが、以前あるリアルタイムなオンライン取引のアプリケーション作成に携わっていました。そのアプリケーションは顧客側と会社側から同時に書き込む複雑なミュータブルオブジェクトを持ち、オブジェクトは深い階層を持ったツリー状のデータ構造になっていました。ツリーの枝葉部分の書き込みをマルチスレッド環境で正しく行うには慎重さを要し、不正な書き込み処理を行えばリアルタイム取引の最中に取引データが破壊され、失注や会社の損失につながるものでした。
スレッド干渉を避けるためには適切なロックを施すことが有効です。ロックは1つのスレッドが処理を行っている間は、別のスレッドが同じ処理もしくは関連する処理を開始できず「待つ」状態にします。ロックによってスレッド干渉を引き起こすような別スレッドからの割り込みを防げます。

しかしロックは扱いが難しく、使い方を間違えれば別の問題を引き起こす可能性があります。それが、2番目に紹介する問題「デッドロック」です。初歩的なデッドロックは2つの変数に対してロックを施す際に発生します。下図のように片方のスレッドが1つの変数のロックを、もう片方のスレッドが他方の変数のロックを施し、スレッド同士がお互いのロック開放を待って身動きが取れなくなります。

このように初歩的でわかりやすいデッドロックならコードレビューなどの際に簡単に気づいて防げるでしょう。しかし実際のデッドロックは複雑な条件下で発生し、さらに悪いことに本番環境で発生すると突然処理が止まり、かついつもデッドロックが起こるとは限らないため問題の原因を特定するのが非常に困難です。
次に、3番目の問題として「メモリ矛盾」についてお話します。ソースコード上は同じ変数でもスレッドが異なれば異なる値が保持される可能性があります。この時、スレッド間で(書き込みを含む)処理の順序が定義できないため、どちらのスレッドの状態が正しい最新の状態かわからなくなってしまいます。
例として、2つの異なるスレッドが同じミュータブルオブジェクトの参照を保持しているケースを考えます。ある時点で2つのスレッドはこのミュータブルオブジェクトに対して読み出しを行い、同じ状態を保持しています。次にスレッド1からオブジェクトに書き込みます。しかし、異なるスレッドは同じオブジェクトに対して別の状態を保持しうるので、スレッド1による書き込みはスレッド2からはまだ観測されない可能性があります。次にスレッド2からこのオブジェクトの読み出しを行うと、スレッド1の書き込みより後の時点であるにもかかわらず、スレッド1からの書き込みが反映されないオブジェクトの状態を読み出してしまいます。


この問題も、いつ起こるかわからない、必ず起きるとは限らない問題のため本番環境で発生すると「なぜかデータの書き込みが反映されない」という不思議な現象が観測され、原因を特定するのが困難になります。
ここまで紹介した以外にもマルチスレッド環境での非同期処理はさまざまな問題を引き起こす可能性があり、その多くがミュータブルオブジェクトに関わるものです。その原因はデータ・レースと呼ばれる「2つ(以上)のスレッドから、少なくとも一方が書き込みであるアクセス」を行うパターンによって引き起こされます。データ・レースによる問題は「happens-before関係」と呼ばれるマルチスレッド環境での処理の順番の定義づけを行うことによって解決され、Javaにおけるデータ・レースの扱いやhappens-beforeの実現についてはJava Memory Modelの仕様書に詳しく書かれています。ここまで紹介してきたOracleのJava TutorialsのConcurrencyの章と併せて読むと、より非同期処理に対する理解が深まるはずです。
