26
32

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

[C#備忘録]MultiThreadデザインパターンのC#版について

Last updated at Posted at 2017-08-19

What's This ?

結城浩氏著の「Java言語で学ぶデザインパターン【マルチスレッド編】」をC#で使用するにはどうするか?
ということで、C#で実際に実装してみた。
また、デザインパターンの自分なりの理解を備忘録として残しておく。

こちらから移動

ここに独断と偏見でまとめた

注意

一応、ここに書いているのは個人の解釈が主なので、正解じゃない可能性が高いです。
現に、かなりの頻度で修正を入れています。(現在進行形)

デザインパターン一覧

1.Single Thread Execution パターン

概要

クラスメソッドを以下の2パターンに分類する

  • SafeMethod・・・スレッドセーフを考慮しないでよいメソッド
  • UnsafeMethod・・・スレッドセーフを考慮する必要があるメソッド

上記UnsafeMethodに対して、lockステートメントを使用してスレッドセーフにする

気を付けること

  • クラスを継承したり、メソッドを追加するとき、その人はlockステートメントを使うべきかどうかを判断する必要がある(「異常継承」というらしい)
  • パフォーマンスがちょっと落ちる

2.Immutable パターン

概要

クラスを以下の2パターンに分類する

  • Immutable・・・メンバやクラスが参照するインスタンスの状態が変わることのないクラス
    (例えば、stringはImmutableである)
  • Mutable・・・状態が変わるクラス

上記のImmutableクラスは、複数のスレッドから同時にアクセスしても問題ない
そのため、lockステートメントを使用しなくてもよい
クラスがImmutableであるかどうかを意識することが重要である
Immutableクラスは、外部フィールドはreadonlyまたは読み取り専用メソッドのみとし、メソッドもクラス内部を変更する処理は行わない

気を付けること

  • Immutableであることは非常に脆い。ちょっとの変更ですぐに崩れる。
    →Single Thread Execution パターンと同様に、異常継承になる場合がある
  • クラスに sealed オプションを付けることで、継承異常を防ぐことが可能

個人的な解釈

値オブジェクトはImmutableなクラスであるといえるっぽい 
 →値オブジェクトは状態を持たないから
つまり、値オブジェクトはスレッドセーフであるといえる(はず)

3.Guarded Suspension パターン

概要

  • Monitor.WaitMonitor.Pulseメソッドを使う1
  • スレッドAは実行条件に合致するまで動作をWait状態にする。実行条件に合致するときは、待たずに処理を即実行する。
    (上記の実行条件のことを、「ガード条件」という)
  • スレッドBはガード条件を満たした際に、Pulseを通知する
  • Wait状態やPulseを通知するのは、ガードオブジェクトとして定義されたクラスが行う。つまり、スレッドAとスレッドBはガードオブジェクトを操作するのみで、ガード条件などには関与しない。ガード条件とそれに伴うWait/Pulseの責務はガードオブジェクトのみが負うことが重要。

つまり、「条件が満たされるまでは待ちが発生するスレッド」を実現する
パターンの意図としては、条件付きのSingle Thread Execution パターンとして使用する

気を付けること

  • スレッドAで「ガード条件の評価」と「条件に合致しなければWaitすること」を行う。
    スレッドBで「ガード条件を満たす処理」と「Pulseすること」を行う。
    つまり、ちょっとのミスでスレッド処理が進まなくなることになる。
  • これを回避するために、Waitメソッドにタイムアウトを設ける方法がある
  • 恐らくC#4.0以降では、Monitor.*の代わりにManualResetEventSlimを使うべきっぽい
    (詳しくはこちらを参照)
  • が、現段階ではこれは今後の課題としとく

その他(ConcurrentQueue誤りを使うべきとしていたのを修正)

  • ConcurrentQueueを使用することで、lockやWait/Pulseを使用することなく実装できる
  • TryDequeueメソッドとEnqueueメソッドを使用する
  • たぶん、実現するならConcurrentQueueを使用すべき
  • BlockingCollectionを使用すべきでした。ConcurrentQueueはWait/Pulseを内部的に持っていないようで、スレッドを待たせることはできないです。(たぶん)
    • Add/Takeメソッドで、Take時にキューが空なら内部で待ちが発生する
    • ということで、実現するならBlockingCollectionを使用すべき

個人的な解釈

  • 応用することでメッセージキューを実現できるような気がする(サンプル
  • 素朴なメッセージキューだと、メッセージを受け取れない間は、短いSleepをはさみつつ無限ループで待つ方式になる
  • このパターンでWaitを使用することで、効率的な待ちを実現できる(気がする)

4.Balking パターン

概要

  • 「Balk」とは、野球のボークと同じ意味。「途中でやめる」こと。
  • 基本的には、Guarded Suspension パターンの派生と思ってよいかも
  • ガード条件に一致しない場合、処理を終了させる
  • つまり、1つのスレッドで処理が実行中または実行完了していたら、他のスレッドからは実行しなくてよいときとかに使える

使えるシチュエーション

  • 途中でやめることでパフォーマンスを上げる(または応答性を上げる)
  • ガード条件が満たされるのが最初の一回だけのとき
スレッドセーフで最初の一回だけ初期化する処理.cs
class Something
{
  bool _isInited = false;
  object _lock = new object();

  public void Init()
  {
    lock(_lock)
    {
      if(_isInited)
      {
        return:
      }

      // 何かしらの初期化処理を実行

      _isInited = true;
    }
  }
}

その他

  • タイムアウトを設定することで、Guarded Suspension パターンと Balking パターンの中間を表現することができる
  • タイムアウトまでにガード条件が成立すると、処理を実行する
  • タイムアウトしてもガード条件が成立しなければ、処理を放棄する(途中でやめる)

5.Producer-Consumer パターン

概要

  • Producer・・・生産者:データを作成するスレッド
  • Consumer・・・消費者:データを利用するスレッド
  • Channel・・・ProducerがConsumerへ安全にデータを渡す橋渡し役
  • Channelはキューで実装し、Producerはキューに仕事を追加し、Consumerはキューから仕事を取り出す

Channelは、Producer向けとConsumer向けそれぞれのガード条件付きメソッドを持つ。
ProducerおよびConsumerは、Channelのメソッドを呼び出すだけなので、ガード条件の存在を知る必要がない。もっというとスレッドセーフであるかどうかを気にする必要もない。
→スレッドセーフの責務を負うのはChannelのみ

個人的な解釈&使えるシチュエーション

  • Guarded Suspension パターンと似ているが、ガード条件がProducer側向けメソッド(サンプルでいうPutメソッド)とConsumer側向けメソッド(サンプルでいうTakeメソッド)の両方にそれぞれ存在するのが異なる。
  • Guarded Suspension パターンではputする側(=Producer)は条件なくputできていたが、Producer-Consumer パターンではputする際にも条件が必要になる。
  • 大きさが有限のキューなどに適用できる?と思う
  • 実際に実装する場合は、BlockingCollectionを使うべき
  • スレッドセーフなキュー
  • ブロッキング処理を内包(WaitとPulseをカプセル化)
  • キューイングできる範囲を指定可

6.Read-Write Lock パターン

概要

  • 「読む」処理と「書く」処理を分けて考える。
  • スレッドがインスタンスの状態を「読む」処理を行っても、インスタンスの状態は変化しない。
    しかし、「書く」処理を行うと、インスタンスの状態は変化する。
  • 「読み用ロック」と「書き用ロック」をそれぞれ用意する
  • 「読む」処理は、インスタンスの状態が変わらないから、複数スレッドから同時に読むのは問題ない。しかし、あるスレッドが「読む」処理実行中は、他のスレッドは「書く」処理は禁止する
  • 同様に、あるスレッドが「書く」処理中は、他のスレッドは「読む」処理および「書く」処理を禁止する

排他制御の詳細(衝突の種類)

読む 書く
読む 衝突なし 「読む」と「書く」の衝突
⇒read-write conflict
書く 「読む」と「書く」の衝突
⇒read-write conflict
「書く」と「書く」の衝突
⇒write-write conflict

以上より、排他制御には3種類存在する。

個人的な解釈

  • 「読み用ロック」および「書き用ロック」を自前で作る?
  • ロックの中身はSemaphoreと同様で、値をインクリメント/デクリメントして制御する
  • 自前Semaphoreってこと?なんか車輪の再開発な感じがするが・・・
  • スレッド間で「読む」処理が同時に起こった場合は、衝突としない。このように自前条件を拡張できるから自前Semaphoreってこと?

使えるシチュエーション

  • 読む処理と書き処理が同時発生する可能性がある場合で、読む処理同士を止める必要がない場合に使える?

その他

  • .NETでは、ReaderWriterLockSlimという既製クラスが存在する。これ使えば格段とコードしやすくなる。

7.Thread-Per-Message パターン

概要

  • 何らかの命令(要求)ごとに、新しくスレッドが処理を行う。
  • 「依頼する側」は、「処理する側」が実際に処理していることを関知しない
    ⇒非同期で処理が実行される。処理を待たない。

個人的な解釈

  • スレッドを立てる際、インスタンスメソッドを並列で呼び出す仕組みなのがミソ?
  • →並列処理するが、インスタンスは「依頼する側」で生成するため、インスタンスが複数生成されるわけではない。
  • こうすることで、以下の点を狙っている?(メリット?)にしても、スレッドごとにインスタンスを生成して処理してもいいような・・・
  • メモリの無駄な浪費を避ける
  • インスタンス内のフィールドを共有できる
  • そういうことまではこのパターンでは意図していないのかな?
  • なんかしっくりこないパターン・・・
  • ただの非同期処理のことなら、わざわざパターンとして定義するほどのことではないと思うが?

8.Worker Thread パターン

概要

  • 一般的にThread Poolと呼ばれるもの
  • あらかじめワーカスレッドを起動しておき、クライアントスレッドはキューに仕事を追加し、ワーカスレッドはキューから仕事を取り出す、を繰り返す
  • つまり、Producer-Consumerパターンを使用している。
  • Producer-Consumerパターンと同じく、クライアント側とワーカ側はスレッドセーフとかを意識しなくてよい
  • あらかじめワーカスレッドを起動するので、起動するスレッド数は固定となる

個人的な解釈

  • このパターンの実体は、あらかじめ先にスレッドを生成しておくProducer-Consumerパターンと言い切ってしまってよいかも?
  • できることは、 Producer-Consumerパターンと同じと思ってよい?
  • 以下の点が異なる。
  • 起動時にあらかじめワーカスレッドを起動することで、新しいスレッドを起動するコストを軽減する
  • スレッドの生成数をあらかじめ決める

使えるシチュエーション

  • 処理コストに比べて、スレッド生成コストがネックとなる場合
  • あらかじめ起動スレッドの数を決めておきたいとき
  • Thread-Per-MessageパターンとWorkerThreadパターンの両方に言えることだが、Commandパターンの実現に使用できる
  • 命令(仕事)をクラス化するのがCommandパターン
  • Thread-Per-MessageパターンとWorkerThreadパターンにおいて、仕事をクラス化してマルチスレッドで処理することを実現できる

補足

  • C#5.0以降はTaskがThreadPoolの役割を果たしているらしい
  • つまり、ここで提示したサンプルプログラムはTaskを使用しているので、冗長で意味がない
  • もっというと、Thread-Per-MessageパターンのサンプルもTaskを使用しているので、内部的にはThreadPoolになっているので、実際はサンプルになっていない

9.Future パターン

概要

  • 処理を別スレッドで実行し、依頼側に疑似的な答えをすぐに返す。実際の処理結果は後から取得する。
  • 本には「実行結果が得るまで待つ代わりに「引換券」をもらう」と記載されている。
  • いきなり難易度が上がった感じがする。というか、かなり複雑・・・
  • [追記]しかし、どうやら本質的にはC#でいうawait実装だけで実現できるみたいなので、解釈の仕方が合っているなら、目的も単純であるし、実装も簡単。
  • サンプルでは、Mainメソッドと実際に仕事を行うRealDataクラスは、マルチスレッドで処理していることを関知していない。マルチスレッドであることをカプセル化するならば、ちょっとややこしくなるかも。

個人的な解釈

  • 処理開始は非同期で実行し、結果受け取りは同期する処理を実現する。
  • C#の場合、同期処理は通常async/awaitを使用するが、awaitせずに同期処理を実行できる感じ?
  • ↑ かなり頓珍漢なことを言っていた。どうやらawaitキーワードそのものがFutureパターンを満たすものっぽい。
  • つまり、C#ではFutureパターンをゴリゴリ実装する必要はなく、awaitキーワード一発で実現できるっぽい
  • 疑似的な答えを返すクラスと、実処理の答えを返すクラスが、同じインターフェイスを実装しているあたり、Proxyパターンと同じような考え方のようだ。
  • というか、たぶんFutureパターン=Proxyパターンと考えて良さそう。
  • Future役=Proxy役。RealData役=RealSubject役。

以下、C#でのTask.Startawaitを使用することで、非同期で処理をスタートし、処理の終了は同期をとれるかを確認してみた。
(処理結果を受け取ることはしていないが、Futureパターンの本質は上記のように処理開始は非同期だが、処理の終わりは待合せたいということだと解釈しています)

async Task FutureSampleAsync()
{
    Console.WriteLine("Async BEGIN");
    var task = new Task(() =>
    {
        Console.WriteLine("Task BEGIN");
        Thread.Sleep(2000);
        Console.WriteLine("Task END");
    });
    task.Start();
    Console.WriteLine("Async END");

    Thread.Sleep(1000);

    Console.WriteLine("Await BEGIN");
    await task;
    Console.WriteLine("Await END");
}

上記FutureSampleAsyncを実行すると、以下のように出力された。
Await BEGINの前にTask BEGINが出力されたことで、task.Startですぐに制御が戻って、かつラムダ式内の処理が非同期に裏で実行されていることがわかる。
最後、Task ENDの後にAwait ENDと出力されていることから、処理完了を待合せていることがわかる。

Async BEGIN
Async END
Task BEGIN
Await BEGIN
Task END
Await END

10.Two-Phase Termination パターン

概要

  • 終了処理をちゃんと行ってからスレッドを終了するように、「2段階の終了」を行うパターン
  • スレッドが終了させる際に、まず「終了要求」を通知する。これで、スレッドは「作業中」状態から「終了処理中」状態へ移行する
  • スレッドは「終了処理中」に、正しく終了処理を完了すると、スレッドが終了する。
  • ポイントは以下の通りらしい(本からそのまま受け売り)
    • 安全に終了すること(安全性)
    • 必ず終了処理を行うこと(生存性)
    • 「終了要求」を出したら、できるだけ早く終了処理に入ること(応答性)

個人的な解釈

  • スレッドの終了方法のパターンであり、今までのパターンとは趣向が違う。
  • 常駐スレッドなどでは必須(というか当たり前)なこと。
  • 本のサンプルではキャンセル時の処理が含まれていたが、C#実装した自分のサンプルではあえてその処理は入れていない。
    • CancellationToken等を利用すればたぶん実現できるけど、ここの本質ではないと思ったので省略

その他

本の中では、volatile修飾子が出てくる。本はjavaだが、たぶんC#でも本質は変わらないはず。
volatileの効果についてはこちらを参照。

11.Thread-Specific Storage パターン

概要

  • 入り口は1つでも、内部ではスレッド固有の領域が用意されている
  • 後述するThraedLocalクラスを使用して実現するのがよさそうだが、スレッドプールを使用したマルチスレッド処理では恐らく使えない(=Taskでは使えない)ので、個人的には流し読み程度とする。
    • というか、そもそも個人的に以下の欠点があるのが好みではない。
  • 以下の点でトレードオフである
    • (利点)スレッド固有の領域であるため、他のオブジェクトからアクセスされる心配がない
    • (利点?)情報を取得するのにスレッドID等の引数が不要である
    • (欠点)処理の流れを追う際、グローバル変数のようにメソッド内で閉じていないのでわかりにくくなる

ThreadLocalクラスの紹介

本ではjavaの同名クラスを紹介していたが、たぶん同じ

  • これを使うと、1つのスレッド内からだけしか読み書きできない変数を作れる
    • つまり、同名だが領域は異なるスレッドごとの変数を用意できる
    • これはThreadLocal<T>クラスがstaticだとしても同様である
    • コレクションの一種と考えていいかも(本にはそう書いてた)
  • 注意しないといけないのが、C#ではデファクトスタンダードになりつつあるTaskを使用したマルチスレッド処理においては、恐らく期待した通りの動作をしない
    • Taskは内部ではスレッドプールを使用しているため、スレッドの使い回しである
    • そのため異なるTask同士で同じ領域を共有することになるかもしれない(基本的にTaskはタスクが終了してから次のTaskへ使い回すことになると思うが、前のTaskで使用した値が入っているかもしれない(未確認))
    • あと、awaitしたりすると、内部でスレッドが切り替わるので、変数格納領域が切り替わる
      • これは、AsyncLocal<T>スレッドというのを使用すればよいかも

12.Active Object パターン

概要

  • 「能動的なオブジェクト」として、オブジェクトが固有のスレッドを持つ
  • この「能動的なオブジェクト」は、外部から(他スレッドから)非同期にメッセージを受け取って処理し、必要に応じて処理の結果を返せる
    • 各オブジェクトが自律的な処理を可能とする
  • 本のサンプルは複雑すぎる。やばい・・・

個人的な解釈

サンプルが複雑でかなりげんなりしたが、たぶん言いたいことは以下だと勝手に意訳

  • 処理を依頼するクライアントと、処理を実行するワーカーをスレッドを分ける。
    • ワーカースレッドは常駐スレッドとして処理を待ち受けて、処理が来れば処理をするし、なければ何もしない。
    • つまりWokerThreadパターン
  • クライアント側はマルチスレッドを意識しなくてよい仕組みとする。そのために、クライアントの依頼はノンブロッキングで制御が返る。
    • つまりFutureパターン
  • ワーカースレッドは処理が終わると、クライアントスレッドのメモリ領域に、処理結果を格納する
    • WokerThreadパターンとFutureパターンの融合。たぶんここがミソ
  • クライアントスレッド複数に対して、ワーカースレッドは1つでシーケンシャルに処理をする。(たぶんこれは必須ではない?が以下のメリットがある)
    • ワーカーでの処理は排他制御を取る必要がなくなる。

つまり、勝手に解釈すると、FutureパターンとWokerThreadパターンの融合?

使えるシチュエーション

個人的な解釈としては以下。

  • 非同期で処理を行いたい(例えばUI処理とか通信関連の処理とか)
  • でも、クライアント側はマルチスレッドを意識しない作りとしたい(既存からの変更とか、並行処理の箇所と逐次処理の箇所を分離するためとか)
  • そのために、クライアント側は内部でメッセージキューみたいな感じでワーカースレッドに通知を行い、ワーカースレッドでは処理を行う。
    • 処理結果をクライアント側のメモリ領域に格納するので、クライアント側はあたかも逐次処理しているかのように振舞える
  1. WaitメソッドおよびPulseメソッドは、lockステートメント内で実行することが前提となる。**Waitメソッドを実行すると、内部ではlockによる排他制御を開放してスレッドがwait状態に入る。**これは、コード的には直感的でないように思えるんだけど、かなり重要なことだと思う。

26
32
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
26
32

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?