Inside Out

自分自身の備忘録。アウトプット用のブログです。

.NET のスレッド同期に利用する基本的な処理(同期プリミティブ)について調べてみた

これは Sansan Advent Calendar 2019 の23日目の記事です。 adventar.org

最近 Qiita で書いてたけどはるか昔に自前でブログ作ってたの思い出したのでこちらに記載してみます。

先日業務でマルチスレッドで共有リソースを触るような処理を検討する必要があったのですが、スレッド同期とかやった事なかったので知識が中途半端だったので一回ちゃんと理解したいと感じたので調べてみました。 調べただけなのでちょっと内容的に薄いかもしれません。(チームの同僚から濃いの希望とか言われてたが勘弁...)

スレッドの同期はすべきではない

まず前提としてスレッドの同期は複数のスレッドが共有データに同時にアクセスする時にデータの破壊を防ぐために使われます。 しかしスレッドの同期は以下の理由から、そもそも可能ならやるべきではありません。

  1. 複雑になりミスを誘発しやすい 複数のスレッドから扱われるデータを全部把握しておく必要があります。 それらのデータを扱う処理をロック獲得と開放の処理で囲む必要がありますが、囲み方を間違えるだけでデータの破壊に繋がります。 またロック処理を正しく実装できている保証をする事は難しいです。タイミングの問題になるので、普通にテストしているだけだと再現しないケースも多く存在します。

  2. パフォーマンスの劣化に繋がる ロックの獲得と開放は、どのスレッドが最初にロックを取得するのか決定するために協調するためにCPU間の通信を行う必要があります。 この通信がパフォーマンスに悪影響を及ぼします。スレッドプールが獲得できないロックを獲得しようとすると新しいスレッドを作成しようとします。 スレッドの作成自体がメモリとパフォーマンス上高コストな処理です。またブロックされたスレッドが実行を再開する時にスレッドプールスレッドも一緒に動作します。 そのためCPUよりも多くのスレッドをスケジュールするようになりコンテキストスイッチが多発してパフォーマンスの劣化に繋がる可能性があります。

同期プリミティブ(Synchronization Primitives)

これらの前提がありながらもケースによってはスレッド同期の実装が必要になってくるケースがあるかと思います。 その場合に認識しておかなければならないのが同期に関する機能がどう動くのかという事です。これらの機能は同期プリミティブによって提供されます。 同期プリミティブとは並列コンピューティングの世界に置いて、クリティカルセクションで競合状態を起こす危険があるコンピューティングリソースを直列にするため利用できる、最も基本的なメカニズムの事を表します。(これは言語に関わらないコンピュータサイエンス用語) .NET の同期プリミティブにはユーザモードとカーネルモードの2種類が存在します。

  • ユーザモード
    • ユーザモードの方がスレッド協調のための特別なCPU命令を使用するのでカーネルモードよりもはるかに高速に動作します。
      • これは協調がハードウェアの内部で発生する事をさします。
      • ただしこれはOSがユーザモードの同期プリミティブでスレッドがブロックされた事を検出できない事も意味します。
        • ユーザモードの同期プリミティブでブロックされたスレッドプールスレッドはブロックされたとOSからはみなされないので、スレッドプールはブロックされているスレッドを置き換えるための新しいスレッドを生成しません。
        • スレッドの生成はメモリやパフォーマンス上高コストなのでこの方が望ましいです。
    • 一方でユーザモードで動作中のスレッドはシステムによって横取りされる可能性があります。(可能な限り高速に再スケジュールされるが)
      • これは何らかのリソースを獲得したいけど、できないスレッドはユーザーモードでスピン(可能になるまでループ)してCPUを無駄に大量浪費してしまう可能性があります。
  • カーネルモード
    • カーネルモードの同期プリミティブはOSによって提供されます。なので利用にはアプリケーションからOSのカーネルに実装されてる関数を呼ぶ必要があります。
    • スレッドが別のスレッドが保持するリソースを獲得するためにカーネルモードの同期プリミティブを使用するとOSはCPUを浪費しないようにスレッドをブロックし、スレッドがリソースにアクセス可能になったらOSはスレッドを再開させます。

基本的にはユーザモードの方が高速で動作するので、こちらのものを利用すべきです。 ただし保持している同期を開放しないとスレッドが永久にブロックされるようなケースでは以下の通りになります。 ユーザモードはスレッドをCPU上で永遠に動作させ続けることにならいライブロック状態にになります。この場合CPUとメモリの両方を浪費します。 カーネルモードの場合はスレッドが永久にブロックされるとデッドロック状態になり、無駄なスレッドの割り当てだけになるのでメモリのみ浪費になり、こちらの方がまだマシになります。

ユーザーモードの同期プリミティブ

.NET では以下のデータ型への読み書きが atomic であることを保証しています。 - Boolean - Char - Byte - Sbyte - Int16 - Int32 - IntPtr - UInt16 - UInt32 - UintPtr - Single - 参照型

一方 Int64 などの読み書きが atomic である事が保証されてない型では複数のスレッドで同時に行うと値が変わる可能性があります。

long x = 0x0123456789abcdef;

この値は別スレッドから読み取ると 0x0123456700000000 または 0x0000000089abcdef で取得される場合があります。 これは分裂読み取り(torn read)と呼ばれます。コンパイラやCPUの最適化のために読み取り、書き込みがいつ行われるかの保証はありません。 これらの Int64, UInt64 や Double に atomic な読み書きを強制するのが以下二つの同期コンストラクトになります。

  • Volatile
    • 単純なデータ型への atomic な読み取り または 書き込みを実行する
  • Interlocked
    • 単純なデータ型への atomic な読み取り 及び 書き込みを実行する

Volatile

.NETはコンパイラがILに変換し、JITコンパイラが実行時にそれをネイティブのCPU命令にしてCPUがそれを実行されます。 この変換処理毎に最適化が働くようになっています。そのため以下のコードが想定通りに動かない可能性があります。 例えば以下のケースではコンパイラ_stopWorker が true/false のいずれかと認識し Worker メソッド内で変更されないと認識してしまいます。 そのためコンパイラは最初に _stopWorker をメソッドの最初にチェックするコードを生成する可能性がります。 (特にビルド時にx86でoptimizeスイッチをonにした場合はx64 よりも成熟した最適化が走るので積極的に最適化を行おうとする傾向にあるそうです。)

class static StrangeBehavior 
{
    private static bool _stopWorker = false;

    public static void Main()
    {
        var thread = new Thread(Worker);

        thread.Start();

        Thread.Sleep(500);

        _stopWorker = true;

        thread.Join();
    }

    private static void Worker()
    {
        var x = 0;
        while(!_stopWorker)
        {
            x++;
        }
    }
}

このコードを修正するには volatile を使います。

private static volatile bool _stopWorker = false;

volatile をfieldの宣言に付けることで最適化の対象とされなくなります。 しかし volatile で宣言された field の値を参照渡しすることをサポートしてない上に、最適化が外れるのでパフォーマンスが悪化する可能性があります。 volatile は System.Threading.Volatile の Read/Write を共有リソースの読み書き時に利用する事で同様の事ができます。

Interlocked

Volatile は Read と Write によって atomic な読み取りか書き込みを提供してました。 一方で System.Threading.Interlocked は読み書きを atomic な操作として提供します。 通常コンピュータ上で int の increment/decrement は以下の3つの操作となります。 1. インスタンス変数からレジスタに値をロードする 2. 値を increment/decrement する 3. 値をインスタンスに保存する これを Interlocked.Increment/Decrement を利用しない場合は以下のことが発生する可能性があります。 最初のスレッドが 1. 2. を実行 => 別スレッドが1.2.3.の全部を実行 => 最初のスレッドが操作を再開し値を上書き => 別スレッドの操作は無かった事に。 Interlocked のメソッドはフルメモリフェンスです。つまり Interlockedのメソッドの呼び出しより前の変数の書き込みは Interlocked のメソッドよりも前に行われ、 Interlocked のメソッドより後の変数の読み取りは Interlocked のメソッドよりも後に実行される事は保証されます。

カーネルモードの同期プリミティブ

基本的にカーネルモードの同期プリミティブはOS自身の協調が必要になるためユーザモードのものより低速で動作します。 またカーネルのオブジェクトに対する関数呼び出しがマネージコードからネイティブのユーザモードコード、カーネルモードコードの遷移、そして終了時には逆の遷移を引き起こします。これはCPUを大量に消費する高コストな操作なのでパフォーマンスの悪化を招く場合があります。 ただしカーネルモードの同期プリミティブには以下のメリットがあります。 - リソースの競合が発生した場合にOSが敗者のスレッドがスピンしてCPUを浪費しないようにブロックする - ネイティブスレッドとマネージスレッドを互いに同期できる - 同じマシン上の異なるプロセスで実行中のスレッドを同期できる - アクセス権のないアカウントからのアクセスを防ぐセキュリティ設定が適用できる - スレッドを全ての、またはいずれかのカーネルモード同期プリミティブが利用可能に設定されるまでブロックできる - タイムアウト値が設定でき、スレッドをブロックできる。スレッドが一定時間リソースにアクセスできない場合ブロックを解除して別タスクを実行できる

カーネルモードの同期プリミティブには大きく分けて二つあり以下の二種類が存在します (Mutexはこれら二つの上で実装なので分類からは除外) - Event - Semaphore

これらは WaitHandle と呼ばれるOSのカーネルオブジェクトをラップしただけのシンプルな抽象クラスを継承しています。 この継承階層と概要は以下の通りとなります。 - WaitHadle - Mutex - プロセス間の同期に利用できる。アクセスを許可できるスレッドは1つのみ。 - Semaphore - リソースやリソースプールへアクセスできるスレッド数を制限できる。 - EventWaitHandle - スレッドの同期イベント。シグナルによって別スレッドに lock 可能になった状態を伝える。ローカルなイベントだけでなくプロセスを跨がる名前付きシステムイベントを扱える。 - AutoResetEvent - シグナルを送られた場合に自動でスレッドをブロック解除する同期イベント - ManualResetEvent - シグナルを送られてもスレッドを自動でブロック解除せず、手動でする必要がある同期イベント

Event

Event は簡単に言えばカーネルで維持される bool 変数で、 Event を待機するスレッドは Event が false になるとブロックされ、 true になるとブロックが解除されます。 Event には二種類あり、AutoResetEvent は true になる時カーネルは待機中の最初のスレッドをブロックを解除した後自動で false になり、ブロックされたスレッドは1つだけ起動されます。 ManualResetEvent は true になる時、自動的にリセットされないので待機中の全てのスレッドがブロック解除されます。この時コードで手動で Event を false に解除する必要があります。

Semaphore

Semaphore は簡単に言えばカーネルで維持される int32 の変数で、Semaphore を待機するスレッドはSemaphore が 0 の時ブロックされ、Semaphore が 0 よりも大きい場合に、ブロックが解除されます。 Semaphore を待機するスレッドのブロックが解除される時、Semaphore は自動的にカウントを 1 減らします。 AutoResetEvent と 最大値が 1 の Semaphore はほぼ同じ動きをしますが、違いとして AutoResetEvent は複数回 Set を読んでも 1 スレッドしかブロックを解除しませんが Semaphore は Release を複数回呼ぶと内部カウントを増加させ続けてしまい複数のスレッドのブロックを解除する事になります。

Mutex

Mutex は相互排他ロックを表現します。これは AutoResetEvent と似た動きをし、一度に待機中のスレッドの一つを開放します。

所感

実際に同期処理にこれらの同期プリミティブを利用する機会は実はそれほど多くはないかと思います。(Interlockedとかはよく見る気がするけど。) しかし実務においてはこれらの組みあわせによってできた同期処理群(Monitorや SemaphoreSlim, ReaderWriterLockSlim など)の利用はあるかと思います。 それらの挙動を理解するためにも一度は学習しておいた方がいいのでは無いでしょうか。

参考情報