普段Asyncのプログラムは書いていて、非同期処理のコンセプトもふわっとしっているので、いざとなれば調べたらトラブルシュートもできるのだが、プログラマとしてレベルを上げるために、複雑な非同期処理を何も見ることなくがっつりコードが書けるようになりたいと思い立ち、基礎の基礎からコードを書いてみることにしました。基本的にいつもお世話になっている岩永先生のサイトや、他で学んだ内容を自分なりに理解しながら、整理するだけですので、本家のサイトのほうがいろいろ有用と思います。
一つだけこのシリーズで気を付けたいのは、「コンファタブル」なレベルをキープすることです。例えば、私は最初にコンカレントキューから始めようと思ったのですが、通常のキューとの、Enqueueの挙動の違いが気になってコードを読んで、volatile というキーワードが出てきたり、他のライブラリが出てきたりして一向に終わりませんでした。今の自分は達人ではないので、達人のように一気になろうとするのは問題です。だから、自分が楽に理解できるレベルをキープしながら一歩一歩進めたら、きっと、コンカレントライブラリの実装も全部読めるようになるでしょう。でもそれまでは、自分が難しいと思ったらそこには何か問題があるはずので、自分が楽に理解できるレベルをキープすることを念頭においてやってみたいと思います。
今回は
- マルチスレッド の内容を学びます。
マルチスレッドが必要な理由
マルチスレッドはコンピューティングの様々な場面で必要です。代表的なものがGUIのプログラミングです。何か時間がかかる処理、例えばダウンロードを行っている間に、GUIが一切反応しなくなってしまったら相当利便性が低くなります。そういった時に、コンピュータが並列で処理ができると、ダウンロードを行っている間に、キーボードからの入力を受け付けて、さらに、バックグラウンドで別のクリーンアップ処理を走らせておくといったことが可能になります。特に実行時間が長くかかるような処理は、平行で処理をして、終了を待っている間、別の処理やイベントを済ませておく方が良いでしょう。そういったときに使えるテクニックです。
スレッドとは
スレッドは1連の処理の流れのことです。プログラムが上から下まで実行されるときのイメージです。マルチスレッドはそれが複数同時に進行するイメージになります。
スレッドプールとは
スレッドプールとは、スレッドが必要なたびに新しいスレッドを作っていると、リソースが枯渇してしまったり、効率が悪かったりします。そこで、スレッドをプールする仕組みがあると良いのですが、これを自分でコードを書くのはめんどくさいでしょう。C#ではこの辺をよしなにやってくれるTaskというクラスがありますが、これはまた次の機会に。
レースコンディション
マルチスレッドで問題になるのが、レースコンディションという問題です。プログラムの処理の結果が予期しない状態になるのdせうが、その原因が、プログラミングのパーツが依存関係をもっていて、イベントや処理実行の順番の違いなどで発生します。あまり深く考えずにマルチスレッドで、ステートのあるコードを書くと簡単します。
レースコンディションが発生する例
下記の例では Parallel.Forは、マルチスレッドで指定した数で平行にActionの処理を実行してくれます。次の例では、0 -> 19 まで、並列で、アクションを実行してくれます。ちなみに、並列数を制御したいとか、CancellationToken を使いたいとかいう場合は、ParallelOptionsというオプションを使えるようになるオーバーロードがあります。また戻り値のオブジェクトでは、実行が正しく実行されたかのフラグを保持しています。
さて、この場合は i => { }
のアクションの部分が並列で実行されますが、平行で、numを更新にいっています。このnumが同時に更新されるために、本来実行結果では、20 * 20 の 400 の数だけカウントされるはずが、平行実行でそれぞれが更新する結果、実行結果が毎回変わります。
static void MultiThread()
{
const int ThreadNum = 20;
const int LoopNum = 20;
int num = 0;
Parallel.For(0, ThreadNum, i =>
{
for (int j = 0; j < LoopNum; j++)
{
int tmp = num;
Thread.Sleep(1);
num = tmp + 1;
}
});
Console.Write($"{num} ({ThreadNum * LoopNum})");
}
結果
80 (400)
ロックを使用する
解決の方法としては、ここは、平行で実行すると困るという箇所を、クリティカルセクションと呼ぶのですが、そのクリティカルセクションに対して、ロックをかけます。基本的な書き方は、ロックオブジェクトというオブジェクトをつくります。ここではsyncObj
がそれに該当します。
そして、クリティカルセクションをlock(syncObj)
で囲います。すると、syncObjにロックがかかって、1つのスレッドしか同時にロックができなくなります。つまり同時に1つのスレッドしかこのセクションを実行できません。1つのスレッドがセクションの実行を終えると、ロックが解放されて、他のスレッドが使用可能になります。
static void MultiThreadWithLock()
{
const int ThreadNum = 20;
const int LoopNum = 20;
int num = 0;
var syncObj = new object();
Parallel.For(0, ThreadNum, i =>
{
for (int j = 0; j < LoopNum; j++)
{
lock (syncObj)
{
int tmp = num;
Thread.Sleep(1);
num = tmp + 1;
}
}
});
Write($"{num} ({ThreadNum * LoopNum})");
}
実行結果は正しくなります。
400 (400)
実行時間
ロックをかけると、当然実行時間にインパクトが出ます。ベンチマークをとってみました。あたりまえですが、ロックが無いのが最速です(答え間違っていますが)ロックをすると極端に実行時間が増えます。ちなみに2個目のMontorLockは、Monitor.Enter()/Minotor.Exec()
を使ったロックで、元のブログに載っていたサンプルを実行した結果です。lockは、それのシンタックスシュガーですので、似たような実行結果になります。
80 (400) True The first(no lock): Elapsed Time: 1442 ms
400 (400)The second(Monitor lock): Elapsed Time: 6189 ms
400 (400)The third(lock): Elapsed Time: 6231 ms