LoginSignup
3
4

More than 3 years have passed since last update.

再入門C#:非同期処理・ロック

Last updated at Posted at 2020-03-21

普段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
3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4