【読書メモ】『Grokking Concurrency』を読んだ
最近、Swift Concurrency に注目して学習を進めている。その一環として、並行処理全般について解説した『Grokking Concurrency』を読んだ。本記事では、本書の内容を整理しつつ、自分なりの理解や感想をまとめる。
なお、私は英語の原著 (O'reilly読み放題対象) を読んだが、『なっとく!並行処理プログラミング』という和訳版も出版されている。
読んだ感想
並行処理や非同期処理の基礎を徹底的に解説しつつも、教科書のような学問としての解説ではなく、実務への応用を見据えた実践的な内容となっている点が良い。オペレーティングシステムの基礎知識と、現場での活用を橋渡しする一冊と言える。
内容は基礎的なため、特にジュニアから中堅レベルのソフトウェアエンジニアで、並行処理やオペレーティングシステムの知識が十分に身についていないと感じる方には、ぜひ一読をおすすめしたい。英文も比較的平易で読みやすい。
内容のまとめ
全部で13章あり、3つのPartに大別されている。
Pythonコードをサンプルコードとして説明しているものの、プログラミング一般に通ずる内容となっている。
Part 1
Part 1では、並行処理の基礎知識や、並行処理を支える基盤となる技術が解説されている。
Chapter 1: 並行処理
序章として、並行処理の重要性について簡単に触れている。
- 並行処理の重要性が増している背景として、プロセッサ単体の性能向上が頭打ちになりつつあることが挙げられる。
- システムの性能を測る指標として、レイテンシとスループットが重要であり、並行処理はこれらを改善する有効な手段の一つである。
- システムをスケールさせる方法として、垂直スケーリング (scaling up) と水平スケーリング (scaling out) の二つがある。垂直スケーリングの限界を補う手法として、水平スケーリングが用いられ、並行処理はその実現手段の一つである。
- システムの疎結合化が重要であり、その実現のために並行処理が活用される。
- 並行処理は、アプリケーション、オペレーティングシステム、ハードウェアといった様々なレイヤーで実装されている。
Chapter 2: 直列実行と並列実行
命令の直列実行・並列実行について解説されている。
- プロセッサは、命令を直列に (シリアル実行: Serial Execution) または並列に (パラレル実行: Parallel Execution) 実行する。
- 並列処理 (Parallel Processing) とは、複数の処理を物理的に同時に実行することである。一方、並行処理 (Concurrency) とは、複数のタスクが同時に進行しているように見えることを指し、必ずしも物理的に同時に実行されるとは限らない。並行処理は、内部的には直列に実行される場合がある。また、単一のタスクを並列実行することも可能である。
- アムダールの法則 (Amdahl's Law) により、あるプログラムを並列化した場合の性能向上の上限や限界を見積もることができる。
- アムダールの法則が問題の規模を固定して並列化の限界を示すのに対し、グスタフソンの法則 (Gustafson's Law) は、問題の規模を拡大することで並列化による性能向上が可能であることを示している。
Chapter 3: コンピュータの仕組み
ハードウェアレベルでの並行化を理解する上で必要となるコンピュータアーキテクチャの基礎が解説されている。
- ハードウェアレベルでの並列実行は、マルチコアやマルチプロセッサといったハードウェアのアーキテクチャに依存する。
- コンピュータの並列処理アーキテクチャを分類する方法として、Flynnの分類が有名であり、命令とデータの方式に基づいて、SISD、MISD、SIMD、MIMDの4種類に大別される。
- GPUは、SIMDを採用しており、同じ命令を多数のスレッドに適用して並列処理を行う。GPUは、CPUと比較してクロック周波数は低いものの、膨大な並列処理ユニットを持つため、大規模なデータ並列処理を高速に実行できる。
- マルチコアやマルチプロセッサのCPUは、MIMDの一例であり、複数の独立した命令が複数のデータに並列に実行される。CPUは、GPUと比べてクロック周波数が高く、より多様な命令セットをサポートしている。
- CPUがプログラムの実行主体であるが、通常、プログラムが直接CPUに命令を出すことは少なく、代わりにオペレーティングシステムやランタイムシステムを通じてハードウェアを抽象化したレイヤーとやりとりを行う。
Chapter 4: 並行処理の構成要素
オペレーティングシステムレベルでの並列実行を支える、プロセスとスレッドについて解説している。
- プロセスは、端的には、実行されているプログラムのインスタンスと言える。各プロセスは、一つ以上のスレッドを持つ。また、スレッドはプロセスなしに単独で存在することはない。
- スレッドは、端的には、演算の実行単位であり、特定の処理を行うための命令の一覧を持つ。スレッドはオペレーティングシステムによって管理され、プロセス内のリソースを共有しながら並行に実行される。
- 単一のプロセス内に、複数のスレッドが存在することができ、プロセス内のリソースを共有することができる。他方、一般に複数のプロセスは互いに独立して実行されるが、プロセス間通信(IPC)を介してデータをやり取りすることも可能である。
- プロセスと比べて、スレッドはコンテキストスイッチが低コストであるため、並行処理の実装に適している。また、スレッド間でアドレス空間を共有できるため、より高速に共有データにアクセスすることもできる。ただし、データ競合のリスクも存在するため、アクセス制御や同期が必要となる。
Chapter 5: プロセス間通信
プロセス間通信 (IPC) を解説している。
- プロセス間通信 (IPC) とは、複数のプロセスまたはスレッド間でデータをやり取りし、状態を同期するための仕組みを指す。IPC には様々な手法があり、それぞれにメリット・デメリットが存在するため、用途に応じて使い分けられる。
- 共有メモリ (Shared Memory) は、プロセス間で大量のデータを高速にやり取りする手法の一つである。ただし、複数のプロセスが同時にアクセスすることで競合が発生しやすいため、適切な排他制御 (e.g. ミューテックス、セマフォ) を行う必要がある。
- パイプ (Pipe) には、親子プロセス間の一時的な通信に用いられる匿名パイプ (Unnamed Pipe) と、異なるプロセス間での永続的な通信に使用される名前付きパイプ (Named Pipe) の2種類がある。パイプは、カーネル内部のメモリ空間に FIFO バッファとして実装される。
- メッセージキュー (Message Queue) は、プロセス間またはシステム間で非同期的にデータをやり取りするための手法であり、システムの疎結合化を実現するために広く用いられる。
- ソケット (Socket) は、双方向通信に用いられ、同一ホスト内でのローカル通信 (e.g. Unixドメインソケット) あるいは異なるホスト間でのネットワーク通信 (e.g. TCP/IP) の両方に対応している。通信はネットワークスタックを介してソケットインタフェースを通じて行われる。多くのケースにおいて、ソケットはパフォーマンス・スケーラビリティ・利用のしやすさを満たした良い選択肢となる。
- スレッドプール (Thread Pool) は、プロセスやスレッド管理の一環として、タスクを複数スレッド上で効率的に処理する仕組みである。スレッドプール内のスレッドは、あるタスクが完了したら、再利用されて別のタスクに着手できるよう設計されている。これにより、スレッドを作成・破棄するオーバーヘッドを軽減し、過剰なスレッド作成を防ぐことができる。
Part 2
Part 2では、マルチタスク・分解・同期について解説されている。
Chapter 6: マルチタスク
オペレーティングシステムのマルチタスクの仕組みが解説されている。
- マルチタスク (Multitasking) とは、複数のタスクを並行して実行することである。これは主にオペレーティングシステムの機能として提供される。
- タスクの実行におけるボトルネックは、主に2種類に大別される。
- CPU-bound:計算処理が主なボトルネックとなり、プロセッサの処理能力に依存する。
- I/O-bound:入出力処理の待ち時間がボトルネックとなり、ディスクやネットワークの速度に依存する。
- コンテキストスイッチ とは、あるタスクを実行中の状態から、別のタスクを実行するために、CPUのレジスタやプログラムカウンタ、メモリマッピング情報などを保存・復元するプロセスである。
- オペレーティングシステムは、コンテキストスイッチを繰り返すことで、単一のプロセッサ上でもマルチタスクを実現することができる。
- I/O-boundなタスクの待ち時間中に、別のタスクへコンテキストスイッチすることで、プロセッサを有効活用することができる。一方、コンテキストスイッチにはオーバーヘッド(例:キャッシュのフラッシュ、メモリアクセスによる遅延)が発生するため、過剰なコンテキストスイッチはパフォーマンスの低下を招く可能性がある。
- オペレーティングシステムのスケジューラによって、タスクのコンテキストスイッチが管理される。スケジューラがタスクを強制的に切り替える方式はプリエンプティブスケジューリングと呼ばれ、現在のオペレーティングシステムにおいて主流となっている。対照的に、協調的なスケジューリングでは、タスクが明示的に制御を手放す必要がある。
Chapter 7: 分解
パイプライン処理、fork/joinパターン、map/reduceパターンといった、タスクを分解することによる並列化について解説する。
- 一つの課題を並列処理する方法は、大きく データ分割(data decomposition) と タスク分割(task decomposition) の2種類に分類される。
- タスク分割(task decomposition) の代表的な例としてはパイプライン処理が挙げられる。これは、各処理が依存関係を持ちながら順次実行される場合に適しており、特に共有リソースの数が限られている環境で有効である。
- 一方、独立したデータのチャンクに対して並列に処理を実行できる場合は、fork/joinパターン や map/reduceパターン などの データ分割(data decomposition) を活用することが可能である。
Chapter 8: 並行処理問題の解決:競合状態と同期
本章では、競合状態(Race condition)とは何か、ならびにデータ競合を防止するための基本的な仕組み(ロック、セマフォなど)について解説する。
- 並行処理において、複数のプロセスやスレッドが共有リソースにアクセスする場合、競合状態による想定外の動作が発生する可能性がある。
- クリティカルセクションとは、複数のスレッドが同時に実行した場合に整合性の問題が生じる可能性のあるプログラムの部分を指す。競合状態を防ぐためには、各スレッドが排他的にクリティカルセクションに入るよう制御する必要がある。
- 排他制御の最も基本的な方法の一つとして、クリティカルセクション内の操作を他のスレッドやプロセスの影響を受けずに完了できるアトミックな操作にする方法がある。アトミック操作は、ハードウェアやランタイム環境のサポートに依存する。
- ロックを用いることで、共有リソースへのアクセスを一度に1つのスレッドのみに限定することができる。
- セマフォはロックと同様に共有リソースへのアクセスを制御できるが、カウント機能を持つことで、複数のスレッドによる同時アクセスを一定数まで許可することが可能である。
Chapter 9: 並行処理問題の解決:デッドロックと飢餓状態
デッドロックや飢餓状態といった排他制御に伴う課題について解説している。
- デッドロックとは、複数のタスク(スレッドやプロセス)が相互にリソースを占有し合い、互いにその解放を待機し続けることで、永久に処理が進行しなくなる状態を指す。
- 飢餓状態とは、スレッドやプロセスが必要なCPU時間や共有リソースを適切に確保できず、特定のタスクの進行が著しく遅くなる、または停止する状態である。これは、スケジューリングの不公平さやリソースの優先度の偏りによって引き起こされる。
- 並行処理が用いられる典型的なパターンとして、Producer-Consumer問題やReaders-Writers問題があり、これらの問題を解決するための手法として、ロック、セマフォ、条件変数などを活用したベストプラクティスが存在する。
Part 3
Part 3では、non-blocking IO、イベントベースの並行処理、そして非同期通信について解説されている。
Chapter 10: non-blocking IO
non-blocking I/Oの仕組みと有効性について解説している。
- クライアント-サーバー型のアプリケーションでは、複数のクライアントやサーバーがネットワークを介して通信し、並行してメッセージの送受信を行っている。
- I/O-boundなコードが実行される際、I/O処理が完了するまでプロセッサは他の処理を実行できず、待機状態に入ることがある。このため、I/O処理の待機時間がシステムのパフォーマンスに影響を及ぼす。
- Blockingなインタフェースでは、タスクが完了するまで制御が呼び出し元に返されない。一方、non-blockingなインタフェースでは、タスクの実行を開始し、即座に制御を呼び出し元に返すため、他のタスクを並行して進めることが可能となる。
- スレッドの数を増やすことには、コンテキストスイッチのオーバーヘッドやメモリ消費、カーネルリソースの管理コストなどの制約が存在する。これを解決する手段として、non-blockingな処理を活用することで、少数のスレッドで複数のタスクを効率的に処理することができる。
Chapter 11: イベントベースの並行処理
NginxやNode.jsで用いられる、イベントベースの並行処理について解説している。
- イベントベースの並行処理(Event-based concurrency) は、I/O待ち時間が多いアプリケーション(例:Webサーバー、リアルタイムシステム)に適しており、少ないリソースで高いスケーラビリティを実現できる手法である。適切な設計のもと、少数のスレッドで数千~数万の同時接続に対応することが可能である。
- イベントベースの並行処理を実装する方法の代表例として、リアクターパターン(Reactor pattern) がある。このパターンでは、イベントループがキューに蓄積されたイベントを監視し、適切なコールバック関数を呼び出して処理を進める。
- 同期通信(Synchronous communication) では、データの送受信中にプログラムの実行がブロックされ、送信側は受信側からの応答を待つ必要がある。そのため、待機時間が長くなると、システム全体のパフォーマンスに影響を与える。
- 非同期通信(Asynchronous communication) では、データの送受信中にプログラムの実行がブロックされず、送信側と受信側の同期を取る必要がない。送信後、プログラムは他の処理を継続し、完了通知(例:コールバック、イベント、Promise/Future)を受け取ることでデータを処理できる。
Chapter 12: 非同期処理
非同期処理の重要性と、代表的な実装パターンであるCoroutineやFutureについて解説している。
- OSのタスクスケジューラでは、プリエンプティブマルチタスクが主流であり、OSが一定時間ごとにスレッドの切り替えを自動的に制御する。一方、ユーザーレベルのスレッド(例えば、CoroutineやFiberなど)では、協調的マルチタスクが採用され、タスク自身が明示的に制御を他のタスクに委譲する必要がある。
- ユーザーレベルのスレッド(Coroutineなど)は、OSスレッドと比較して非常に軽量であり、少数のOSスレッドで多数のタスクを効率的に処理できる。ただし、各コルーチンの実行タイミングはプログラムが管理する必要があり、適切なスケジューリングが求められる。
- 非同期処理の代表的な実装パターンとして、Coroutine と Future がある。
- Coroutineは、特定のポイントで処理を一時中断し、後続の処理を非同期的に再開できる関数であり、状態を維持しながら効率的なタスク管理を可能にする。
- Futureは、非同期処理の結果を格納するオブジェクトであり、将来完了する処理の結果やエラーを管理するための仕組みである。JavaScriptのPromiseなどが代表的な実装例である。
Chapter 13: 並行処理アプリケーションを作成する
この章は応用にあたり、行列乗算の並列化や文章中の単語数の集計処理を例に、プログラムを並列化するステップの実例を紹介している。
Discussion