私は何とか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
、サブスクライブされていないハンドラーの同等の可能性のある呼び出しを防ぐのになぜですか? これは分かりませんでした。 しかし、答えを探す過程で、私は他の多くのことに気づきました。ここに小さな要約があります。
最後に何がありますか
- 最も一般的な方法は、不等式をチェックした後にデリゲートを
null
にする可能性があるため、 スレッドセーフではありません 。NullReferenceException
危険があります
public event Action MyLittleEvent; ... protected virtual void OnMyLittleEvent() { if (MyLittleEvent != null) // NullReferenceException MyLittleEvent(); }
- SkeetおよびRichterのメソッドは
NullReferenceException
を回避するのに役立ちますが、まだ未登録のハンドラーを呼び出す可能性があるため 、 スレッドセーフではありません 。
スキート法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); } }
リヒター法protected virtual void OnMyLittleEvent() { // Action tempAction = Volatile.Read(ref MyLittleEvent); if (tempAction != null) tempAction (); }
- 空の
delegate {};
メソッドdelegate {};
イベントがnull
に変わることはないため、NullReferenceException
を取り除くことができますが、すでにサブスクライブされていないハンドラーを呼び出す可能性があるため、 スレッドセーフではありません 。 さらに、volatile
なしで、イベントが呼び出されたときにデリゲートの最新バージョンを取得する機会があります。
-
lock
でイベントの追加、削除、呼び出しをラップすることはできません。これはデッドロックの危険を引き起こすからです。 技術的には、これにより、サブスクライブされていないハンドラーの呼び出しを省くことができますが、イベントからサブスクライブする前にサブスクライブするオブジェクトがどのアクションを実行したかを確認できないため、「破損」オブジェクトにObjectDisposedException
) このメソッドはスレッドセーフでもありません 。
- ローカルの「フリーズ」イベント後にサブスクライブしていないデリゲートをキャッチする試みは無意味です-多数のサブスクライバーでは、(イベントコールの開始後に)サブスクライブしていないハンドラーを呼び出す確率は、ローカルの「フリーズ」よりも高くなります。
技術的には、提示されたオプションはいずれもスレッドではありません-イベントをトリガーする 安全な方法です。 さらに、デリゲートのローカルコピーを使用してデリゲート検証メソッドを追加すると、誤ったセキュリティ感が生じます 。 自分自身を完全に保護する唯一の方法は、特定のイベントのサブスクリプションが既に解除されているかどうかをイベントハンドラーに強制的に確認させることです。 残念ながら、イベントを発生さ
NullReferenceException
ときに
NullReferenceException
を防止する一般的な方法とは異なり、ハンドラーの規定はありません。 別個のライブラリを作成する場合、ほとんどの場合、ユーザーに何らかの方法で影響を与えることはできません。イベントからサブスクライブを解除した後、ハンドラーが呼び出されないとクライアントに強制させることはできません。
これらすべての問題を認識した後、私はまだC#でのデリゲートの内部実装について複雑な気持ちを抱いていました。 一方で、これらは不変であるため、
foreach
て変更コレクションを列挙する場合のように
InvalidOperationException
の可能性はありませんが、一方で、呼び出し中に誰かがイベントからサブスクライブを解除したかどうかを確認する方法はありません。 イベントホルダーが実行できる唯一のことは、
NullReferenceException
に対して自身を保護し、サブスクライバーが何も台無しにしないようにすることです。 その結果、提起された質問は次のように回答できます。
サブスクライブされていないサブスクライバーのハンドラーを呼び出す可能性が常にあるため、マルチスレッド環境でスレッドセーフイベント呼び出しを提供することは不可能です。 この不確実性は、「スレッドセーフティ」という用語の定義、特に条項と矛盾しています実装は、複数のスレッドから同時にアクセスされたときに競合状態がないことが保証されています。
追加の読書
もちろん、見つけたものをすべてコピー/翻訳することはできませんでした。 したがって、直接または間接的に使用されたソースのリストを残します。
- C#経由のCLR(Jeffrey Richter)
- ジョンスキートが競合状態の不可避性について話すSOに関する質問。 イベントコールの時間の比較テストの結果もあります。
- イベントを呼び出すさまざまな方法の欠点を明確かつ慎重に説明する小さな記事(古い)
- C#の詳細-デリゲートとイベント(Jon Skeet)
- スレッドセーフはハンドラーの仕事であると述べている有用な記事
- 別のアプローチを使用したイベント呼び出し時間の別の比較分析
- 空のデリゲートの詳細
- メモリーバリアと揮発性について少し(Joe Albahari)
- 揮発性についてもう少し
- 参照型を使用した操作の原子性について
- 擬似スレッドセーフイベントトリガーの拡張メソッド
- 一言で言えばC#5.0(まだC#のお気に入りの本、強くお勧めします)