C#またはJohn Skeet対Jeffrey Richterのスレッドセーフイベント





私は何とかC#に関するインタビューの準備をしていましたが、とりわけ次の内容について質問を見つけました。

「多数のスレッドが常にイベントをサブスクライブおよびサブスクライブ解除している場合、C#でスレッドセーフなイベントコールを編成する方法は?」




質問は非常に具体的で明確に提起されているので、私はそれに対する答えも明確かつ明確に与えることができることを疑いさえしませんでした。 しかし、私は非常に間違っていました。 これは非常に人気があり、ボロボロですが、まだ開かれたトピックであることが判明しました。 ロシア語の資料ではこの問題にほとんど注意が払われていないため(Habrも例外ではありません)、私はこの問題に関して見つけて消化したすべての情報を収集することにしました。

また、ジョンスキートとジェフリーリヒターについても説明します。実際、マルチスレッド環境でイベントがどのように機能するかという問題をよく理解する上で、彼らは重要な役割を果たしました。



特に注意深い読者は、記事の中に2つのxkcdスタイルのコミックを見つけるでしょう。

(注意、2つの写真の中は約300-400 kbです)





回答が必要な質問を複製します。



「多数のスレッドが常にイベントをサブスクライブおよびサブスクライブ解除している場合、C#でスレッドセーフなイベントコールを編成する方法は?」




いくつかの質問はC#を介しCLRブックに基づいているという仮定がありましたが、Nutshellでお気に入りのC#5.0はそのような質問にまったく対応していなかったので、ジェフリーリヒター(C#を介したCLR)から始めましょう。



ジェフリー・リヒターの道



書面からの抜粋:



長い間、イベントを呼び出すための推奨される方法は、次の構成についてでした。



オプション1:

public event Action MyLittleEvent; ... protected virtual void OnMyLittleEvent() { if (MyLittleEvent != null) MyLittleEvent(); }
      
      







このアプローチの問題は、 OnMyLittleEvent



メソッドで1つのスレッドがMyLittleEvent



イベントMyLittleEvent



null



MyLittleEvent



ないことを確認でき、このチェックの直後、ただしイベントがMyLittleEvent



前に、他のスレッドがサブスクライバーのリストからデリゲートを削除して、 MyLittleEvent null



イベントMyLittleEvent null



であり、イベントコールポイントでNullReferenceException



NullReferenceException



ます。



この状況を明確に示す小さなxkcdスタイルのコミックを次に示します(2つのスレッドが並行して動作し、上から下に時間が経過します)。

展開する






一般に、すべてが論理的であり、通常の競合状態 (以下、競合状態)があります。 そして、Richterがこの問題をどのように解決するかを以下に示します(そして、このオプションが最もよく遭遇します)



イベントを呼び出すメソッドにローカル変数を追加し、「エントリ」の時点でイベントをメソッドにコピーします。 デリゲートは不変オブジェクト(以降-不変)であるため、イベントの「凍結」コピーを取得します。このコピーからは誰も購読を解除できません。 イベントのサブスクMyLittleEvent



解除すると、新しいデリゲートオブジェクトが作成され、 MyLittleEvent



フィールドのオブジェクトが置き換えられますが、 古いデリゲートオブジェクトへのローカル参照が残っています。



オプション2:

 protected virtual void OnMyLittleEvent() { Action tempAction = MyLittleEvent; // ""      //   tempAction     ,      null     if (tempAction != null) tempAction (); }
      
      







Richterによれば、JITコンパイラーは最適化のためにローカル変数の作成を単に省略し、2番目のオプションから最初の変数を作成する、つまり「フリーズ」イベントをスキップすることができると説明されています。 その結果、 Volatile.Read(ref MyLittleEvent)



介してコピーすることをお勧めします。



オプション3:

 protected virtual void OnMyLittleEvent() { //      Action tempAction = Volatile.Read(ref MyLittleEvent); if (tempAction != null) tempAction (); }
      
      







Volatile



については長い間個別に語ることができますが、一般的な場合は「不要なJITコンパイラーの最適化を取り除くことができます」。 この主題については、さらに明確化と詳細がありますが、ここでは、ジェフリーリヒターによる現在の決定の一般的な考え方に焦点を当てます。



スレッドセーフなイベント呼び出しを確実にするには、イベントをローカル変数にコピーして現在のサブスクライバーのリストを「フリーズ」し、受信したリストが空でない場合、「フリーズ」リストからすべてのハンドラーを呼び出します。 したがって、可能性のあるNullReferenceException.



を取り除きNullReferenceException.









私はすぐに、サブスクライブされていないオブジェクト/スレッドでイベントをトリガーているという事実に混乱しました。 誰かがそのようにサブスクライブを解除することはほとんどありません-トレースの一般的な「クリーニング」中に誰かがこれを行った可能性があります-書き込み/読み取りストリーム(たとえば、イベントでファイルにデータを書き込むロガー)を閉じて、接続を閉じますなど、つまり、ハンドラーを呼び出した時点でのサブスクライブするオブジェクトの内部状態は、以降の作業に適さない可能性があります。

たとえば、サブスクライバがIDisposable



メソッドを実装し、解放された(以降、破棄される)オブジェクトでメソッドを呼び出そうとするとObjectDisposedException



をスローするという規則に従っているとします。 また、 Dispose



メソッドのすべてのイベントのDispose



を解除することにも同意します。

次に、このようなシナリオを想像してください。別のスレッドがそのサブスクライバーのリストを「凍結」した直後に、このオブジェクトでDispose



メソッドを呼び出します。 スレッドは、サブスクライブされていないオブジェクトのハンドラーを正常に呼び出します。そのハンドラーは、イベントを処理しようとしたときに、遅かれ早かれオブジェクトがすでに解放されたことを認識し、 ObjectDisposedException



をスローします。 ほとんどの場合、この例外はハンドラー自体にはキャッチされません。「サブスクライバーがサブスクライブ解除されて解放された場合、ハンドラーが呼び出されることはない」と仮定することは非常に論理的だからです。 アプリケーションのクラッシュ、アンマネージリソースのリーク、またはObjectDisposedException



最初にObjectDisposedException



(呼び出されたときに例外をキャッチした場合)イベント呼び出しは中断されますが、イベントは通常の「ライブ」ハンドラーに到達しません。



漫画本に戻る。 物語は同じです-2つのストリーム、時間がダウンします。 実際に起こることは次のとおりです。

展開する






私の意見では、この状況は、イベントがNullReferenceException



ときに発生する可能性のあるNullReferenceException



よりもはるかに深刻です。

興味深いことに、監視対象オブジェクトの両側でスレッドセーフイベントトリガーを実装するためのヒントがありますが、スレッドセーフハンドラーを実装するためのヒントはありません。



StackOverflowは何について話しているのですか?



SOでは、この問題に特化した詳細な「記事」を見つけることができます(はい、この質問は小さな記事全体を描いています)。



一般に、私の見解はそこで共有されていますが、この同志が追加するものは次のとおりです。



ローカル変数に関するこの誇大広告はすべて、 Cargo Cult Programmingに他ならないように思えます。 多数の人々がこの方法でスレッドセーフイベントの問題を解決しますが、 完全なスレッドセーフを実現するにはさらに多くの作業が必要です。 このようなチェックをコードに追加しない人は、コードなしでも実行できると自信を持って言えます。 この問題は、シングルスレッド環境には存在しません。オンラインコード例のvolatile



キーワードを満たすことはめったにないことを考えると、この追加チェックは無意味かもしれません。 NullReferenceException



を追跡することが目標の場合、クラスオブジェクトの初期化中に空のdelegate { }



イベントに割り当てることで、 null



をまったくチェックせずに実行できますか?





これにより、問題の別の解決策が得られます。



 public event Action MyLittleEvent = delegate {};
      
      







MyLittleEvent



null



になることはありません。追加のチェックをMyLittleEvent



できます。 マルチスレッド環境では、イベントサブスクライバーの追加と削除を同期するだけで済みますが、 NullReferenceException



を受け取ることを恐れずに呼び出すことができます。



オプション4:

 public event Action MyLittleEvent = delegate {}; protected virtual void OnMyLittleEvent() { // ,   MyLittleEvent(); }
      
      







前のアプローチと比較したこのアプローチの唯一の欠点は、空のイベントを呼び出すための小さなオーバーヘッドです(オーバーヘッドは呼び出しごとに約5ナノ秒であることが判明しました)。 また、異なるイベントを持つ多数の異なるクラスの場合、イベントのこれらの空の「ギャグ」は多くのRAMを占有しますが、C#3.0 からSO回答で John Skeetを信じると、コンパイラは同じものを使用します同じオブジェクトは、すべての「ギャグ」の空のデリゲートです。 結果のILコードをチェックするとき、このステートメントは確認されず、イベントごとに空のデリゲートが作成されることを自分で追加します(LINQPadとILSpyを使用してチェックします)。 極端な場合には、プログラムのすべての部分からアクセスできる空のデリゲートを使用して、プロジェクトに共通の静的フィールドを作成できます。



ジョン・スキートの道





ジョンスキートに着いたので、スレッドセーフイベントの実装に注目する価値があります。これは、 デリゲートおよびイベントセクションのC#の詳細オンライン記事および同志Klotos による翻訳 )で説明されています。



一番下の行は、 lock



add



remove



、およびlocal "freeze"を閉じることです。これにより、複数のスレッドのイベントを同時にサブスクライブしながら、起こりうる不確実性を取り除くことができます。



いくつかのコード
 SomeEventHandler someEvent; readonly object someEventLock = new object(); public event SomeEventHandler SomeEvent { add { lock (someEventLock) { someEvent += value; } } remove { lock (someEventLock) { someEvent -= value; } } } protected virtual void OnSomeEvent(EventArgs e) { SomeEventHandler handler; lock (someEventLock) { handler = someEvent; } if (handler != null) { handler (this, e); } }
      
      









このメソッドは非推奨であるという事実にもかかわらず(C#4.0以降のイベントの内部実装はまったく異なるようです。記事の最後にあるソースのリストを参照してください)、 lock



でイベントコール、サブスクリプション、およびサブスクライブをラップできないことを明確に示しています。これは、デッドロック(以下、デッドロック)につながる可能性が非常に高いです。 ローカル変数へのコピーのみがlock



あり、イベント自体はこの構造の外部で呼び出されます。



しかし、これは未登録イベントのハンドラーを呼び出す問題を完全に解決するわけではありません。



SOの質問に戻ります。 ダニエルは、 NullReferenceException



を防ぐためのすべて方法に対応して、非常に興味深い考えを持っています。



はい、すべてのコストでNullReferenceException



を防止しようとすることについて、このNullReferenceException



を本当に理解しました。 私たちの特定のケースでは、別のスレッドがイベントのサブスクライブを解除している場合にのみNullReferenceException



が発生する可能性があることを話しています。 そして彼は、 イベント二度と受け取らないようにするためだけにこれを行います。実際、ローカル変数のチェックを使用する場合、これは達成されません 。 レース状態を非表示にする場合、それを開いて結果を修正できますNullReferenceException



使用NullReferenceException



と、イベントの不適切な処理の瞬間を判断できNullReferenceException



。 一般に、このコピーとチェックの手法は、コードに混乱とノイズを追加する単純なカーゴカルトプログラミングである主張しますが、マルチスレッドイベントの問題はまったく解決しません。





とりわけ、ジョン・スキートは質問に答えました、そして、これは彼が書いているものです。



ジョン・スキートvs.ジェフリー・リヒター





JITコンパイラーには、条件があるため、デリゲートへのローカル参照を最適化する権利がありません。 この情報はしばらく前に「投げられた」が、これは真実ではない(Joe DuffyまたはVance Morrisonのいずれかでこの質問を明確にした)。 volatile



ないと、デリゲートへのローカル参照が少し古くなる可能volatile



だけです。 これにより、 NullReferenceException



は発生しません。



そして、はい、間違いなく競合状態にあります、あなたは正しいです。 しかし、常に存在します。 null



チェックを削除して、次のように書くとしましょう。

 MyLittleEvent();
      
      





サブスクライバーのリストが1000人のデリゲートで構成されていると想像してください。 サブスクライバーの1人がイベントのサブスクリプションを解除する前に、イベントのトリガーを開始する可能性があります。 この場合、古いリストに残るため、呼び出されます(デリゲートが不変であることを忘れないでください)。 私の知る限り、これは完全に避けられません。

空のdelegate {};



を使用するdelegate {};



null



のイベントをチェックする必要がnull



が、これはレースの次の状態から私たちを救いません。 さらに、このメソッドは、イベントの最新バージョンを使用することを保証しません。





さて、この答えは2009年に書かれ、CLRはC#4th editionを介して-2012年に書かれたことに注意する必要があります。

実際、 Volatile.Read



を介してローカル変数にコピーする場合を説明する理由を理解していませんでした。彼はSkeetの言葉をさらに確認しているからです。

JITコンパイラーはローカルのtempAction



変数を最適化することで誤って何ができるかを知っているため、 Volatile.Read



を使用したバージョンを使用することをお勧めしますが、 オプション2は省略できます。 理論的には 、これは将来変更される可能性があるため、 オプション3を使用することをお勧めします。 しかし、実際には、 Microsoftがこのような変更を行うことはほとんどありません。これは、既製の膨大な数のプログラムを破壊する可能性があるからです。





すべてが完全に混乱します-両方のオプションは同等ですが、 Volatile.Read



オプションVolatile.Read



より同等です。 また、サブスクライブされていないハンドラーを呼び出すときに、競合状態からあなたを救うオプションはありません。



イベントを呼び出すスレッドセーフな方法はまったく存在しないのでしょうか? なぜNullReferenceException



そうもないNullReferenceException



を防ぐのにそれほど多くの時間と労力がNullReferenceException



、サブスクライブされていないハンドラーの同等の可能性のある呼び出しを防ぐのになぜですか? これは分かりませんでした。 しかし、答えを探す過程で、私は他の多くのことに気づきました。ここに小さな要約があります。



最後に何がありますか









技術的には、提示されたオプションはいずれもスレッドではありません-イベントをトリガーする 安全な方法です。 さらに、デリゲートのローカルコピーを使用してデリゲート検証メソッドを追加すると、誤ったセキュリティ感が生じます 。 自分自身を完全に保護する唯一の方法は、特定のイベントのサブスクリプションが既に解除されているかどうかをイベントハンドラーに強制的に確認させることです。 残念ながら、イベントを発生さNullReferenceException



ときにNullReferenceException



を防止する一般的な方法とは異なり、ハンドラーの規定はありません。 別個のライブラリを作成する場合、ほとんどの場合、ユーザーに何らかの方法で影響を与えることはできません。イベントからサブスクライブを解除した後、ハンドラーが呼び出されないとクライアントに強制させることはできません。



これらすべての問題を認識した後、私はまだC#で​​のデリゲートの内部実装について複雑な気持ちを抱いていました。 一方で、これらは不変であるため、 foreach



て変更コレクションを列挙する場合のようにInvalidOperationException



の可能性はありませんが、一方で、呼び出し中に誰かがイベントからサブスクライブを解除したかどうかを確認する方法はありません。 イベントホルダーが実行できる唯一のことは、 NullReferenceException



に対して自身を保護し、サブスクライバーが何も台無しにしないようにすることです。 その結果、提起された質問は次のように回答できます。



サブスクライブされていないサブスクライバーのハンドラーを呼び出す可能性が常にあるため、マルチスレッド環境でスレッドセーフイベント呼び出しを提供することは不可能です。 この不確実性は、「スレッドセーフティ」という用語の定義、特に条項と矛盾しています
実装は、複数のスレッドから同時にアクセスされたときに競合状態がないことが保証されています。






追加の読書



もちろん、見つけたものをすべてコピー/翻訳することはできませんでした。 したがって、直接または間接的に使用されたソースのリストを残します。






All Articles