『Java 並行処理プログラミング』読書メモ
1章 はじめに
- スレッドを使用する利点
- マルチプロセッサの有効利用
- 設計の単純化
- 非同期イベントの処理を単純化
- 応答性の良いユーザインタフェイス
- スレッドを使用するリスク
- 安全性 (safety) の危機
- 安全性: ざっくりいうと「悪いことが起こらない」
- オブジェクト指向で考えればクラスの不変条件やメソッドの事後条件が満たされること、といえるか
- e.g. 競り合い状態 (race condition) は安全性を侵害するエラー
- 生存 (liveness) の危機
- 生存: ざっくりいうと安全性に加え、「最後まで正しく動いていい結果を生む」
- e.g. デッドロック、飢餓状態、ライブロックは生存に関するエラー
- 実行性能の危機
- e.g. コンテキストスイッチによるオーバヘッド
- 安全性 (safety) の危機
2章 スレッドセーフ
- スレッドセーフなクラス
- 複数のスレッドからアクセスされたときに、ランタイムにおける各スレッドのスケジューリングに関わらず、また呼び出し元による追加同期処理を行わなくても、安全性が維持されるクラス
- アトミック性
- スレッドセーフなクラスを作るためにはある操作についてはアトミックでなければいけないことが多い
- e.g. check-then-act な操作。ファイルがあるか確認し、無ければ作る。
- ロック
- クラスが複数のフィールドから状態を作る場合、それらのフィールドへのアクセス全てがアトミックでなくてはならない
- それらの変数への全てのアクセスを同じロックで保護するべき
3章 オブジェクトを共有する
- 可視性 (visibility)
- 安全性を保証するためにはアトミック性だけではなく可視性も重要
- Java の提供する固有のロック (synchronized キーワード)、明示的なロック (Lock interface) はアトミック性だけではなく可視性も保証する
- volatile 変数も可視性を保証するためのもの
- 詳細は 16章
- 安全な共有
- 複数スレッドでオブジェクトを共有する場合、役に立つポリシーは以下の 4 つ
- スレッド拘束
- 一つのスレッドでしか使われないなら安全 (実質共有とはよべないけど)
- e.g. ThreadLocal の使用
- リードオンリーの共有
- オブジェクトの状態が (実質的に) 不可変なら安全
- スレッドセーフな共有
- スレッドセーフなオブジェクトならば使用側が追加で同期処理を行わなくても安全
- ガード
- 特定のロックで適切に同期化されているならば安全
- スレッド拘束
- 複数スレッドでオブジェクトを共有する場合、役に立つポリシーは以下の 4 つ
4章 オブジェクトを組み立てる
- スレッドセーフなクラスの設計
- オブジェクトの状態を構成する変数 (ステート変数) を同定
- ステート変数の値などを制約する不変項を同定する
- オブジェクトのステートへの並行アクセスを管理するためのポリシーを確立する
- スレッドセーフなクラスを実装する上で使いやすい 2 パターン
- モニタパターン
- スレッドセーフの移譲
5章 並行処理の構築部材
- Java 標準ライブラリの提供する並行処理のためのクラスの紹介
6章 タスクの実行
- 理想的なタスク
- 仕事の抽象的で独立した単位
- ほかのタスクのステートや結果や副作用に依存しない
- サーバアプリケーションの場合、個々のリクエストがそうなることが多い
- プログラムの仕事量を複数のタスクに分割して本当に実行性能が上がるのは、それらが並行的に処理できる、互いに独立した、同質のタスクである場合
- 単純なタスクスケジューリングポリシー
- シングルスレッドでの逐次的実行
- 応答性、スループットに難あり
- タスク毎にスレッド作成
- 資源管理に難あり
- シングルスレッドでの逐次的実行
- Executor フレームワーク
- 直接スレッドでタスクを実行させるのではなく Executor に依頼する (タスクの依頼と実行の分離)
- Executor はタスクの実行のされ方を定義する
- タスクをどのスレッドで実行するか
- タスクをどんな順序で実行するか (e.g. FIFO, LIFO)
- 並列に実行できるタスクはいくつか
- キューに並ぶタスクの実行数はいくつか
- etc.
- 実際は Executor を継承しライフサイクル管理のためのメソッドを追加した ExecutorService インタフェースが使われることが多い
- ExecutorService の実装としては大きく分けて ThreadPoolExecutor と ForkJoinPool がある
7章 キャンセルとシャットダウン
- 独自に実装するには複雑な問題だけれど、可能であれば Future と Executor を使用するのが楽
- Java では interruption でキャンセルを実装するべき
- タスクは普通自分が実行されるスレッドを知らないので、能動的にスレッドに対して interrupt をかけるべきではない
- ただし InterruptedException を呼び出し側に広めるために使用するのはあり
- InterruptedException に対し、タスクは自身の後処理をしたあと、InterruptedException を投げ直すか
Thread#interrupt
でインタラプテッドステータスを復元する
- 標準ライブラリ中のブロックするメソッドでも interruptioin に対応しないものがある
- 例えば固有のロック取得待ちのスレッドは反応しない
- もしそれが困るなら
Lock#lockInterruptibly
を検討するべき
- もしそれが困るなら
- 例えば固有のロック取得待ちのスレッドは反応しない
8章 スレッドプールを利用する
- スレッドプールのサイズを決めるための科学的な方法は無いが、大きすぎることと小さすぎることを避ければ十分
- 動作の性質が異なるタスクに対してはそれぞれにプールを用意して調整するのがよい
- スレッド数の目安
- 計算集約的なタスクではプロセッサ数 + 1 をプールサイズにするのがよい
- DB コネクションのような資源に制約があるならば、その数をプールサイズにするのがよい
newCachedThreadPool
ファクトリメソッドは Executor を選ぶときのデフォルトとして適している- サーバアプリケーションはバースト時のような過負荷に対応するため固定サイズのプールの方がよい
- スレッドプールやワークキューのサイズを制限していいのはタスクが独立しているときだけ
- この文面を見て、
scala.concurrent.ExecutionContext.Implicits.global
を複数のFuture
から結果を得るようなことに何も考えずに使うとまずいのか、と一瞬思ったけどこの実装はForkJoinPool
を使用しているようで問題が無さそう (参考)
- この文面を見て、
9章 GUIアプリケーション
- 今日の GUI フレームワークは GUI のイベントをシングルスレッドで処理する
- このスレッドはイベントディスパッチスレッド (EDT) と呼ばれる
- 長時間のタスクは EDT 上ではなく別スレッドで実行させるべき
10章 生存事故を防ぐ
- データベースシステムはデッドロックを検出して回復できるように設計されている
- デッドロックを起こしているトランザクションをアボートし、再試行させる
- ただ再試行までシステムで自動的に行うようにできるのか疑問。例えば RDB の場合どう解決させたいかはクエリによるのでは
- MySQL の場合 InnoDB Error Handling が該当するマニュアルになりそうだけれど、アプリケーションでうまくリトライのロジックを書くことを要請しているように読める
- JVM はデッドロックを解決する能力は持っていない
- オープンコール
- ロックを保有しないでメソッドを呼び出すこと
- ロックを保持したまま (他のオブジェクトの) メソッドを呼び出すプログラムに比べ、デッドロックの可能性に関する分析が容易になる
- デッドロックの種類
- ロック順デッドロック
- 静的なもの
- 動的なもの (e.g. 引数に渡された 2 つのオブジェクト両方のロックを取る場合)
- オブジェクト間のデッドロック
- オープンコールでできるだけ回避する
- 資源デッドロック
- e.g. 2 つの異なるデータベースのコネクションを同時に必要で、かつ取得順が統一されないとき
- e.g. スレッド数が制限されたプールで他のタスクの結果に依存するタスクを実行するとき
- ロック順デッドロック
- デッドロックの防止と診断
- オープンコールを使用するようにリファクタリング
- 時間制限つきのロック
- スレッドダンプ
- その他の生存事故
- 飢餓状態
- スレッドが前進のために必要な資源へのアクセス (e.g. CPU) をずっと断られている状態
- 応答性の劣化
- ライブロック
- ブロックしていないスレッドが何度やってもエラーになる操作を再試行して前進できない状態
- 飢餓状態
11章 実行性能とスケーラビリティ
- ここでの「実行性能」という用語は以下の 2 つに着目している
- サービス時間や遅延時間という「速さ」
- 処理能力やスループットという一定の計算資源で実行できる仕事の「量」
- 実行性能の向上を目指して並行処理を使う目的
- 既存の処理資源をより有効に利用する
- 処理資源を増やせばそのぶん処理能力をアップできるようにする (スケーラブル)
- 並行処理は単純なシングルスレッドの処理に比べ速さは劣るかも。代わりにスケーラビリティを上げる
- すべての並行アプリケーションには、何らかの直列化の部分がある
- アムダールの法則ではアプリケーション内の直列化部分の割合とプロセッサ数、スピードアップの量の関係を表している
- スレッドがもたらす費用
- コンテキストスイッチ
- いま現在 CPU を使っているスレッドの実行コンテキストをセーブして、スケジューリングにより次に CPU をもらうスレッドの実行コンテキストをリストアすること
- メモリの同期化
- ブロッキング
- コンテキストスイッチ
- スレッドがロックを待ってブロックするとき、JVM は通常、そのスレッドをスケジューリングの対象から外す
- 実際のところ JVM のブロックの実装は以下の 2 通り
- スピンウェイト (成功するまで何度もロックの取得をトライすること)
- サスペンド (OS の助けを借りてスレッドを一時停止すること)
- こちらが主流らしい
- ブロックの多いプログラムは、コンテキストスイッチが多くなり、スケジューリングのオーバーヘッドが増えてスループットを落とす
- ロックの争奪を減らすには
- ロックの保持時間を短くする
- ロックのリクエストの頻度を少なくする
- 排他的ロックではない別の調停の仕組みを使って並行性を向上させる
- スケーラビリティを試験するときの目標は、プロセッサの稼働率を高く維持すること
- vmstat, mpstat で測定できる
- I/O 束縛かどうかは iostat で
- ロックの争奪の激しさを確認するには何らかのプロファイリングツール、またはスレッドダンプとか
- スレッドダンプの場合、間隔をとって取得した場合でも争奪の激しいロックは waiting しているスレッドがたくさんあるはず
12章 並行プログラムを試験する
- 基本ユニットテスト
- 並行性を気にせずまずは逐次的な文脈で仕様通りの動作を行うか確認する
- ブロックする操作を試験する
- 難しいけどやるなら十分な時間経過後 interrupt をかけて復帰するのを見るようなテスト
- 安全性を試験する
- 例えばキューのテストならば、一つの方針としては put した要素と get した要素のチェックサムを比較して安全性を確かめる
- テストの並行性をできるだけ保つために
CountDownLatch
やCyclicBarrier
を使用してスレッドが同時に動くようにするべき - 乱数を生成するクラスが内部で同期化を行っているかも
- テストで使うときは各スレッドで生成できる単純なもの (e.g. xorShift 関数) を使うべき
- マルチプロセッサ上でテストを行うべき
- アクティブなスレッド数が CPU の数よりも多くなっているべき (予測可能性を減らすため)
- 資源管理を試験する
- コールバックを使う
- 例えば
ThreadPoolExecutor
の試験でThreadFactory#newThread
を利用すれば作成されたスレッド数等の試験ができる
- 例えば
- スレッドの交代を頻繁にする
Thread.yield
の使用- ちょっと手間なわりに効果が確実ではない気が
- 試験の落とし穴
- ガーベッジコレクション
- 動的コンパイル
- コードパスの非現実的な拾い方
- 争奪の非現実的な激度
- デッドコードの排除
13章 明示的なロック
- 明示的なロック (
Lock
インタフェース) は固有のロックと違い以下の機能を提供するのに使える- ロックの無条件入手 (これは固有のロックと同じ)
- ポーリングによる入手
- 時間制限つきの入手
- インタラプト可能な入手
- 実装クラスとしては
ReentrantLock
- synchronized と同じ相互排他性とメモリ可視性を保証
- synchronized でサポートできない機能が欲しいときのみ
Lock
を使用するべき - 公平性
- ロックを求めた順に入手ができるなら公平
- 不公平の方がパフォーマンスは良い
- デフォルトは不公平
- JVM の synchronized に対する実装も不公平
- 他に
ReadWriteLock
というのもある- Read が多いならこちらの方が
Lock
よりもパフォーマンスが良くなる
- Read が多いならこちらの方が
14章 カスタムシンクロナイザを構築する
- 逐次プログラムでは事前条件が偽だった場合、待っていても真になることはない
- 並行プログラムの場合はあり得る
- 並行プログラムでは事前条件が満たされなかった場合の対応がいくつか考えられる
- 失敗を呼び出し側に広める
- 呼び出し側はポーリングするような形になる
- ポーリングの間隔が短いと CPU を無駄に消費するし、長いと応答性が犠牲になる
- 事前条件が満たされるまでブロックする
- 標準ライブラリにあるものを使用する (e.g. Latch)
- 条件キューを使う
- 固有の条件キューに関するAPI:
Object#wait
,Object#notify
,Object#notifyAll
- 固有の条件キューは不公平
- 固有の条件キューに関するAPI:
- 失敗を呼び出し側に広める
- 条件キューの使い方
- 条件キューを正しく使うためには、ある操作に対する事前条件 (条件述語) を正しく同定することが大事
- wait の呼出は必ず何らかの条件述語と結びついている
- wait を呼び出すとき、呼び出し側はすでに条件キューに結びついているロックを保持している必要が有る
- そのロックは、条件述語を構成しているステート変数をガードしていているもの
- コーディングレベルで色々気をつけるべきことはあるので本参照
- 明示的な条件オブジェクト
- 固有キューではない明示的な条件キューがほしいときには
Condition
を使用できる - 固有ロックに対する
Lock
のようにより多機能
- 固有キューではない明示的な条件キューがほしいときには
- AbstractQueuedSynchronizer (AQS)
- 標準の並行ライブラリの多くがロックとシンクロナイザのために AQS を使用している
15章 アトミック変数とノンブロッキング同期化
- スレッドのエラーや中断がほかのスレッドのエラーや中断を起こさないなら、そのアルゴリズムはノンブロッキングである
- 各ステップで必ずどれかのスレッドが前進できるのならば、そのアルゴリズムはロックフリーである
- お互い関係していそうな気がするんだけど、書き方的には独立した概念のよう
- コンペア・アンド・スワップ (CAS) のような機械語を使用して実装される
- CAS 命令のオペランド: 捜査対象のメモリ番地 V、古い値の期待値 A, 新しい値 B
- V の値が実際に A であれば B に更新する
- Java ではアトミック変数がその操作を抽象化した API を提供している
- 現実的な争奪の範囲ではロックよりも性能がよい
- ロックを使わないのでロックに関わる生存事故は起こり得ない (e.g. デッドロック)
16章 Java のメモリモデル
- Java の言語仕様では、JVM がスレッド内部で直列的なセマンティクスを維持することを要求している
- プログラムの結果が、そのプログラムを厳密に逐次的な環境中のプログラム順序で実行したときと同じ結果になるなら、環境は何をやってもよい
- コンパイラや JVM が最適化を行う余地ができるが、マルチスレッドの環境では想定外の挙動につながることもある
- Java のメモリモデルは、スレッドが行うメモリ上のアクションの、ほかのスレッドからの可視性を保証できる状況を指定する
- 複数の操作が事前発生と呼ばれる半順序によって順序化することで可視性を保証できる
- e.g. 揮発性フィールドへのライトが、同じフィールドのその後のリードよりも事前発生する(リードは必ず事前にライトされた値を読む)