並列処理の世界観です。
プログラミングにおいて、一度にひとつのコードを流れるように実行するだけではなく、複数のコードを並列して同時に動かしたいことがあります。
たとえば、クライアントと接続するサーバは、ひとつのクライアントだけと接続するのではなく、たくさんのクライアントと同時に接続します。
ですが、並列処理は、ネットワークだけのものではありません。たとえば、GUIアプリケーションなどは、プログラムの処理が行われている間にも、GUIツールキットのコントロールに機敏に反応しなければなりません。
また、ゲームプログラミングにおいては、ひとりのキャラクターが動いている間に、別のキャラクターが停止してしまったのではいけません。
このような時、並列処理を行うために、スレッドやプロセスの概念を使用します。
たとえば、サーバはクライアントの数だけスレッドを用意して、そのスレッドを同時に実行するでしょう。GUIアプリケーションは、GUIのイベントが発生したら、そのためのスレッドを作成するでしょう。また、ゲームの場合、キャラクターの数だけスレッドを作成するでしょう。
実際はもっと別のやり方をしているかもしれませんが、僕が今思いついた方法ではそのようにするかもしれません。
ですが、並列処理、あるいはマルチスレッドでは注意が必要です。マルチスレッドのプログラムを書くことは、シングルスレッドのプログラムを書くよりもはるかに難しいです。共有データを排他制御し、壊れないように管理しなければなりませんし、デバッグをすることも困難を極めます。
しかしながら、IntelのCPUがマルチコア化したように、時代はもはやマルチスレッドから逃げようとしても逃れることはできません。
並列性とは、UNIXのようなマルチタスクのOSにおいて、複数のプログラムや処理を同時に(並列して)動かすことのできる仕組み。
単純で開発しやすいモデルは、UNIXのコマンドラインシェルから実行されるプログラムのように、「小さなプログラム」がたくさんあって、そのプログラムを複数同時に実行させる、「プロセス」と呼ばれるモデル。
プロセスは、単に小さなプログラムを開発し、OSの側が並列で実行させるため、シンプルで、開発もしやすいが、同時に実行されるプロセス同士がどのように通信し合うか(プロセス間通信やCORBA/D-Busなどを使うことはできる)という課題がある。また、プロセスを作成・破棄させるためにオーバーヘッドが大きいという問題もある。
これに対して、ひとつのプロセスの中で、関数レベルで処理を並列実行させる「スレッド」と呼ばれる仕組みもある。これはオーバーヘッドも少なくパフォーマンスが向上するため、ApacheやJavaサーブレットなどのサーバーソフトウェアで多く採用されている。また、プログラムの中で、それぞれのスレッドが同じ情報にアクセスして通信し合うことも容易である。だが、同じデータに同時にアクセスしたり、読み書きを同時にしたりした時に、データに矛盾や破損が起きないように、ロックをかけて排他制御して「スレッドセーフ」な関数にする必要がある。
後日注記:実際のところ、プロセスとスレッドの大きな違いは、プロセスはそれぞれのプロセスに独立したメモリ空間を持つが、スレッドはひとつのプロセスの中で同じメモリ空間を各スレッドが共有する、ということ。また、プロセスは独立性が高いため、ひとつのプロセスが破壊しても別のプロセスに影響はでないが、スレッドでは同時に破壊されてしまう。スレッドの場合は同じデータを同時に読み書きすることで破壊される恐れがあるため、排他制御やロックを用いてスレッドセーフな関数にする必要がある。また、プロセスにおいても共有メモリによってメモリ空間を共有する(独立メモリと別に共有メモリを持つ)ことはできる。OracleなどのDBでは、キャッシュや表や索引など多くのデータを各DBプロセスが共有している。
ある意味、UNIX系のOSは、こうした「並列実行」がとても得意で、タスクをたくさん実行させても、落ちることなく負荷が高くなっても正常に実行し続ける。これはひとえにUNIXのカーネルとユーザーランドを分ける仕組みが安全であるからである。たとえば、UNIXではプログラムはハードウェアに直接アクセスすることは基本的にできず、カーネルを介す必要があるが、DOSではユーザーランドのプログラムがハードウェアに直接アクセスできる。UNIXは安全なやり方を提供するが、その代わり融通が利かないところがあり、DOSの方がゲームなどは作りやすい。また、歴史的にUNIXのカーネルはパブリックドメインで開発されており、「とても枯れた技術」であるため、簡単には壊れない「長年の蓄積」があると言える。これはBSDソケットなどのネットワークインターフェースシステムについても言え、UNIXには長年のネットワークの経験の蓄積がある。
イベント駆動を参照のこと。
自分の書いたブログ「神々とともに生きる詩人」2021/01/27より。
一般的に並列処理を行うには、スレッドセーフを考えた設計が重要になる。
スレッドセーフでは、データの競り合い状況が発生しないように、共有データを処理する間ロックして、処理した後でロックを解除する。
この仕組みのことをmutexと呼ぶ。mutexとは、mutual exclusion(相互排他)の略です。
C++には長らくスレッドの概念がなかったため、マルチスレッドコードはプラットフォーム依存のコードを書く必要があったが、Javaでは標準でスレッドの概念があり、ロックはsynchronizedメソッド・ブロックで可能となっており、スレッドの待機や割り込み(中断)もできるようになっている。
また、制御モデルにおいて、並列処理には3つのモデルがある。
まず、同じ処理を全てのスレッドで行う単純な並列処理モデル。
次に、マスター(主人)がスレーブ(奴隷)を従えるマスター・スレーブモデル。
最後に、プログラムの出力を次のプログラムの入力として次々に渡していく、パイプラインモデルである。
並列処理以外の制御モデルとしては、これ以外にも、コールバック関数をイベントループのリスナとして設定する、イベントドリブン(イベント駆動)モデルなどがある。
イベントドリブンは、Windowsのウィンドウプログラムのほか、Linuxカーネルのシステムコールなどにもみられる考え方である。
(一部の内容でCode Reading ~オープンソースから学ぶソフトウェア開発技法~ (プレミアムブックス版)を参考にしました。)
並列処理を行う上で、プロセスの競合状態を避ける必要がある。競合状態とは、複数のプロセスがひとつのリソースをどちらも欲しいというような状況のこと。
並列プログラムを実行する上で、実行中にプロセス切り替えを起こしてはいけないような重要な領域を、クリティカルリージョンあるいはクリティカルセクションと言う。
プロセス切り替えを禁止するために、割り込みを禁止することができる。プロセス切り替えはタイマー割り込みなどによって行われるため、割り込みを禁止することでクリティカルリージョンを持つプロセスがひとつだけ実行されることを保証できる。
mutex(相互排他)を実現するために、フラグを使う方法がある。フラグは、int mutex_flag = 0;とした上で、ロックをかける際にmutex_flag++とし、ロックを解除する際に--mutex_flagとすることで、フラグの上げ下げによって相互排他を実現できる。
このほか、セマフォによって排他制御を行う方法もある。セマフォは、同時に使える人数のうちあとどれくらいがリソースを利用できるかを管理するための整数値であり、OSによって管理される。
以上は以下の書籍・ページを参考に執筆しました。
Linuxカーネル(プロセス間通信)も参照のこと。
2023.05.18
CとLinuxで非同期処理やスレッドプログラミングを行うなら、NPTL(Native POSIX Thread Library)のpthread_create()という関数を使う。
後日注記:C++11でstd::threadが提供されるまで、長らくC/C++には標準のマルチスレッドAPIがなかったため、プラットフォーム依存のAPIを使う必要があった。UNIXやLinuxではPOSIX標準のpthreadを使うことが一般的。
C++(STL・ライブラリ)やLinux API(プロセス・メモリ)も参照のこと。
スレッド間の排他制御は、Win32ではCriticalSectionを利用すれば実現出来ます。
スレッド間の排他制御に対して、アプリケーション間の排他制御はMutexで行います。しかしながらOSレベルで排他を行うMutexはCriticalSectionよりもはるかに遅くなります。
詳しくはやねうらお氏による「Windowsプロフェッショナルゲームプログラミング」を参照してください。
やねうらお氏は、書籍「Windowsプロフェッショナルゲームプログラミング2」でタスクシステムやマイクロスレッド、マルチスレッドのデザインパターンを紹介されています。WindowsでマルチスレッドのC++プログラミングをされる方皆さんに読まれることをおすすめします。
標準のマルチスレッドAPIがなかったC/C++に対して、Javaでは早くからマルチスレッドのAPIを標準で提供していた。
スレッド間の排他制御は、Javaではsynchronizedをメソッドあるいはブロックにつけます。
詳しくは以下の書籍が参考になります。
Javaクラスライブラリ(マルチスレッド)も参照のこと。
また、以下の書籍は、マルチスレッドのさまざまな設計を、実際に実装する際にどのようにすればいいかの参考になります。
非同期処理を行う上で、「何かの処理がきちんと完了してから次の処理を行いたい」とか、「処理を行った結果を別の処理に渡したい」などとすることがあります。
これを同期処理で書くことは簡単ですが、非同期処理にすることで、長い時間がかかる処理を行っている間、CPUはそれに待たされることなく別の関係のない処理を行えます。
コールバック関数で実現することもできますが、コールバック地獄になることもあります。コールバックを複数使うと、ネストが深くなりすぎ、見通しも悪くなります。
JavaScriptでは、このような時に、Promiseと呼ばれる仕組みを使うことができます。
Promiseでは、関数の引数として、resolve(処理の成功)とreject(処理の失敗)の二つの関数をとります。まず、処理がきちんと完了した時点で、resolve()を実行します。ここで、処理は「解決」されます。これがreturnやyieldに相当します。処理が解決したら、then()に記述された次の関数の処理が実行されます。thenはいくらでも続けることができます(メソッドチェーン)。また、正しく処理が解決せず、不正に終了する場合はreject()を実行します。これをcatch()で捕捉します。catch()は処理中にthrowで吐かれた例外を捕捉することもできます。
また、このようなPromiseによる非同期処理を、もっと同期処理と同じような記述で書くことのできる仕組みとして、async/awaitがあります。asyncを関数定義につけると、その関数は非同期関数となります。awaitはasyncをつけた関数の中にしか記述できませんが、awaitが記述された時点で、awaitをつけた関数のPromiseの結果が返されるまで、非同期関数を一時的に停止して、Promiseの解決を待機します。そして、解決した時点で非同期関数の実行を再開します。
非同期処理をasync/awaitを用いて書く仕組みは、C#やPythonなどにも存在します。C#では、ジェネリックなTaskクラスをasync/awaitとともに記述することで、同期関数と同じように非同期関数を実行できます。ある意味、golangのgoルーチンに近いと思います。また、Pythonではイベントループとともにasync/awaitを実行できます。Pythonでは従来からジェネレータを使うことでコルーチンの作成が可能でしたが、それと区別するために、async/awaitのコルーチンのことをネイティブコルーチンと呼びます。
以下は参考文献。
自分の書いたブログ「神々とともに生きる詩人」2021/01/21より。
マルチスレッドや、externを使って複数のファイルで同じ変数を使う場合は、null参照に注意しましょう。
特に、共有オブジェクトの寿命があいまいな状況下での、マルチスレッド環境では、null参照が起こりやすくなります。
null参照とは、たとえばpがnullであるにもかかわらずp->meth()を実行するようなこと。
pにオブジェクトがあるかどうか分からない場合、たとえばpがほかのさまざまな関連する処理の中で動的に生成される場合などでは、これはやっかいなバグになります。
このような場合に例外処理をして処理を強制終了するのは簡単ですが、絶対に落ちてはならないシステムでは、どうにかしてnull参照を排除しなければなりません。
しかしながら、希望の光はあります。
Kotlinなどでは、そもそもnullそのものを許容せず、null値を代入したい場合には専用のnull許容型を使う必要があります。
これにより、null参照そのものがなくなります。
詳しくはKotlinを参照のこと。
以下の書籍が参考になります。
CPUやGPUやLinuxカーネル(プロセス)やLinuxカーネル(IPC)を参照のこと。
Linux API(プロセス・メモリ)を参照のこと。
Java(マルチスレッド)を参照のこと。
クラスタ・分散・高信頼システムを参照のこと。
プロセスはひとつひとつ独立したメモリ領域を持つ。
スレッドは同じメモリ領域を共有しながら並列処理ができる。
プロセスよりもスレッドの方が軽量。
スレッドセーフな関数を作るためには、別の関数からのアクセスによって壊れる可能性のあるデータは誰かがアクセスする間別の関数からアクセスできないようにロックする。