C#6で不必要な割り当てを行わずにスレッドセーフでイベントハンドラーを呼び出す

翻訳者から



多くの場合、初心者の開発者は、ハンドラーを呼び出すときに、ハンドラーをローカル変数にコピーする必要がある理由を尋ねます。 C#6では、言語開発者は、ヌル条件演算子(ヌル条件演算子またはエルビス演算子- ?。 )を含む多くの構文糖を追加しましたこれにより、不要な(一見)割り当てを取り除くことができます。 カットの下で、ジョン・スキートからの説明-最も有名なピルボックスの1つはグルではありません。



問題



サブスクライバーを持たないイベントはnull参照として表されるため、C#でのハンドラーの呼び出しには常に、最も明白なコードは含まれていません。 このため、通常次のように書きました。

public event EventHandler Foo; public void OnFoo() { EventHandler handler = Foo; if (handler != null) { handler(this, EventArgs.Empty); } }
      
      





ローカル変数ハンドラーを使用する必要があります。ローカル変数ハンドラーがない場合、Fooイベントハンドラーへのアクセスは2回アクセスされます(nullのチェック時および呼び出し中)。 この場合、これらのFooへのアクセスの間に最後のサブスクライバーが削除される可能性があります。

 //  ,   ! if (Foo != null) { // Foo   null,   //      . Foo(this, EventArgs.Empty); }
      
      





このメソッドは、拡張メソッドを作成することにより簡素化できます。

 public static void Raise(this EventHandler handler, object sender, EventArgs args) { if (handler != null) { handler(sender, args); } }
      
      





次に、この拡張メソッドを使用して、最初の呼び出しが書き換えられます。

 public void OnFoo() { Foo.Raise(this, EventArgs.Empty); }
      
      





このアプローチの欠点は、ハンドラーのタイプごとに拡張メソッドを作成する必要があることです。



C#6は私たちを救います!



C#6で導入されたnull条件演算子( ?。 )は、プロパティへのアクセスだけでなく、メソッドの呼び出しにも使用できます。 コンパイラは式を一度だけ評価するため、拡張メソッドを使用せずにコードを記述できます。

 public void OnFoo() { Foo?.Invoke(this, EventArgs.Empty); }
      
      





やった! このコードはNullReferenceExceptionをスローすることはなく、ヘルパークラスは必要ありません。



もちろん、Foo?(これ、EventArgs.Empty)を記述できれば良いのですが、そうではありません。 演算子は、言語を少し複雑にします。 したがって、追加のInvoke呼び出しはあまり気にしません。



このスレッドセーフティとは何ですか?



私たちが書いたコードは、他のスレッドが何をするかを気にしないという意味で「スレッドセーフ」です。NullReferenceExceptionを受け取ることはありません。 ただし、他のストリームがイベントをサブスクライブまたはサブスクライブ解除する場合、イベントサブスクライバーのリストに最新の変更が表示されない場合があります。 これは、共通メモリモデルの実装が困難なためです。



C#4では、イベントはInterlocked.CompareExchangeメソッドを使用して実装されているため、正しいInterlocked.CompareExchangeメソッドを使用するだけで、最新の値を取得できます。 これで、これら2つのアプローチを組み合わせて記述できます。

 public void OnFoo() { Interlocked.CompareExchange(ref Foo, null, null)?.Invoke(this, EventArgs.Empty); }
      
      





これで、 追加のコードを記述することなく、NullReferenceExceptionに陥るリスクなしに、最新のサブスクライバーセットに通知できます。 この機会を思い出させてくれたDavid Fowlerに感謝します。



もちろん、CompareExchangeの呼び出しは見苦しくなります。 .NET 4.5以降では、問題解決できる Volatile.Readメソッドがありますが、(ドキュメントを読んだ場合)このメソッドが必要なことを行うかどうかは完全にはわかりません。 (メソッドの説明では、このメソッドの前に後続の読み取り/書き込み操作を設定することは禁止されています。この場合この可変読み取りの後に 以前の書き込み操作を設定することを禁止する必要があります)。

 public void OnFoo() { // .NET 4.5+,   ,     ... Volatile.Read(ref Foo)?.Invoke(this, EventArgs.Empty); }
      
      





私はすべてを予見したかどうかわからないため、このアプローチは好きではありません。 上級読者は、このアプローチが真実ではなく、BCLに入らなかった理由を提案できるかもしれません。



代替アプローチ



以前は、この代替ソリューションを使用していました。ラムダ式よりも優れている匿名メソッドの利点の1つ、パラメーターのリストを指定しない機能を使用して、空のダミーイベントハンドラーを作成します。

 public event EventHandler Foo = delegate {} public void OnFoo() { // Foo will never be null Volatile.Read(ref Foo).Invoke(this, EventArgs.Empty); }
      
      





このアプローチでは、サブスクライバーの最新のリストを呼び出せない可能性があるという事実にはまだ問題がありますが、nullおよびNullReferenceExceptionのチェックについて心配する必要はありません。



MSILの探索



翻訳者から:この部分はジョンの記事にはありません。これはildasmでの私の個人的な研究です。

さまざまなケースでどのMSILコードが生成されるかを見てみましょう。

悪いコード
 public event EventHandler Foo; public void OnFoo() { if (Foo != null) { Foo(this, EventArgs.Empty); } } .method public hidebysig instance void OnFoo() cil managed { // Code size 35 (0x23) .maxstack 3 .locals init ([0] bool V_0) IL_0000: nop IL_0001: ldarg.0 //  this   IL_0002: ldfld class [mscorlib]System.EventHandler A::Foo //     Foo IL_0007: ldnull //    null IL_0008: cgt.un //  2     (Foo  null) -  ,     0 (false) IL_000a: stloc.0 //        bool IL_000b: ldloc.0 //     IL_000c: brfalse.s IL_0022 //     false,    IL_0022 (return) IL_000e: nop IL_000f: ldarg.0 //    this IL_0010: ldfld class [mscorlib]System.EventHandler A::Foo //     Foo - !!!     null IL_0015: ldarg.0 //    this IL_0016: ldsfld class [mscorlib]System.EventArgs [mscorlib]System.EventArgs::Empty //    System.EventArgs::Empty IL_001b: callvirt instance void [mscorlib]System.EventHandler::Invoke(object, class [mscorlib]System.EventArgs) //  Foo(this, EventArgs.Empty) IL_0020: nop IL_0021: nop IL_0022: ret } // end of method A::OnFoo
      
      







このコードでは、Fooフィールドを2回参照しています。NULL(IL_0002:ldfld)と実際の呼び出し(IL_0010:ldfld)との比較のためです。 一方、Fooでnullの等価性とそのアクセス方法を確認し、スタックに配置してメソッドを呼び出すと、最後のサブスクライバーがイベントからサブスクライブを解除でき、nullが再度読み込まれます(hello、NullReferenceException)。



追加のローカル変数を使用して問題を解決する方法を見てみましょう。

変数を使用する
 public event EventHandler Foo; public void OnFoo() { EventHandler handler = Foo; if (handler != null) { handler(this, EventArgs.Empty); } } .method public hidebysig instance void OnFoo() cil managed { // Code size 32 (0x20) .maxstack 3 .locals init ([0] class [mscorlib]System.EventHandler 'handler', [1] bool V_1) IL_0000: nop IL_0001: ldarg.0 //  this   IL_0002: ldfld class [mscorlib]System.EventHandler A::Foo //  Foo,     IL_0007: stloc.0 //  Foo   handler IL_0008: ldloc.0 //    handler IL_0009: ldnull //    null IL_000a: cgt.un //  2     (handler  null) -  ,     0 (false) IL_000c: stloc.1 //        bool IL_000d: ldloc.1 //     IL_000e: brfalse.s IL_001f //     false,    IL_001f (return) IL_0010: nop IL_0011: ldloc.0 //    handler IL_0012: ldarg.0 //    this IL_0013: ldsfld class [mscorlib]System.EventArgs [mscorlib]System.EventArgs::Empty //    System.EventArgs::Empty IL_0018: callvirt instance void [mscorlib]System.EventHandler::Invoke(object, class [mscorlib]System.EventArgs) //  handler(this, EventArgs.Empty) IL_001d: nop IL_001e: nop IL_001f: ret } // end of method A::OnFoo
      
      







この場合、すべてが単純です。Fooへのアクセスは1回発生し(IL_0002:ldfld)、すべての作業は変数ハンドラーで行われるため、NullReferenceExceptionが発生する危険はありません。



次に、 演算子を使用したソリューション

C#6
 public event EventHandler Foo; public void OnFoo() { Foo?.Invoke(this, EventArgs.Empty); } .method public hidebysig instance void OnFoo() cil managed { // Code size 26 (0x1a) .maxstack 8 IL_0000: nop IL_0001: ldarg.0 //    this IL_0002: ldfld class [mscorlib]System.EventHandler A::Foo //     Foo IL_0007: dup //    Foo IL_0008: brtrue.s IL_000d //     true   null   0,    IL_000d ( ) IL_000a: pop //   -    Foo (  ,  Foo == null) IL_000b: br.s IL_0019 //    IL_000d: ldarg.0 //    this (  ,  Foo != null) IL_000e: ldsfld class [mscorlib]System.EventArgs [mscorlib]System.EventArgs::Empty //    EventArgs::Empty IL_0013: callvirt instance void [mscorlib]System.EventHandler::Invoke(object, class [mscorlib]System.EventArgs) //  Invoke IL_0018: nop IL_0019: ret } // end of method A::OnFoo
      
      







C#6では演算子を使用します。 すべてがより面白くなります。 Fooフィールドをスタックに配置して複製し(IL_0007:dup-すべての魔法はここにあります)、それがnullでない場合は、IL_000dに移動してInvokeメソッドを呼び出します。 Foo == nullの場合、スタックをクリアして終了します(IL_000b:br.s IL_0019)。 実際にFooを読み取るのは1回だけなので、NullReferenceExceptionは発生しません。



演算子を使用します。 およびInterlocked.CompareExchange。

Interlocked.CompareExchange
 public event EventHandler Foo; public void OnFoo() { Interlocked.CompareExchange(ref Foo, null, null)?.Invoke(this, EventArgs.Empty); } .method public hidebysig instance void OnFoo() cil managed { // Code size 33 (0x21) .maxstack 8 IL_0000: nop IL_0001: ldarg.0 //    this IL_0002: ldflda class [mscorlib]System.EventHandler A::Foo //      Foo IL_0007: ldnull //    null IL_0008: ldnull //    null IL_0009: call !!0 [mscorlib]System.Threading.Interlocked::CompareExchange<class [mscorlib]System.EventHandler>(!!0&, !!0, !!0) //  Interlocked::CompareExchange IL_000e: dup //    Foo -  ,   Interlocked::CompareExchange IL_000f: brtrue.s IL_0014 //     true   null   0,    IL_0014 ( ) IL_0011: pop //   -    Foo (  ,  Foo == null) IL_0012: br.s IL_0020 //    IL_0014: ldarg.0 //    this IL_0015: ldsfld class [mscorlib]System.EventArgs [mscorlib]System.EventArgs::Empty //    EventArgs::Empty IL_001a: callvirt instance void [mscorlib]System.EventHandler::Invoke(object, class [mscorlib]System.EventArgs) //  Invoke IL_001f: nop IL_0020: ret } // end of method A::OnFoo
      
      







このコードは、Interlocked.CompareExchange(IL_0009:call !! 0 [mscorlib] System.Threading.Interlocked :: CompareExchange)を呼び出すことのみが前のコードと異なり、コードは前のメソッド(IL_000eで始まる)とまったく同じです。



演算子を使用します。 と揮発性。

揮発性
 public event EventHandler Foo; public void OnFoo() { Volatile.Read(ref Foo)?.Invoke(this, EventArgs.Empty); } .method public hidebysig instance void OnFoo() cil managed { // Code size 31 (0x1f) .maxstack 8 IL_0000: nop IL_0001: ldarg.0 //    this IL_0002: ldflda class [mscorlib]System.EventHandler A::Foo //      Foo IL_0007: call !!0 [mscorlib]System.Threading.Volatile::Read<class [mscorlib]System.EventHandler>(!!0&) //  Volatile::Read IL_000c: dup //    Foo -  ,   Volatile::Read IL_000d: brtrue.s IL_0012 //     true   null   0,    IL_0012 ( ) IL_000f: pop //   -    Foo (  ,  Foo == null) IL_0010: br.s IL_001e //    IL_0012: ldarg.0 //    this IL_0013: ldsfld class [mscorlib]System.EventArgs [mscorlib]System.EventArgs::Empty //    EventArgs::Empty IL_0018: callvirt instance void [mscorlib]System.EventHandler::Invoke(object, class [mscorlib]System.EventArgs) //  Invoke IL_001d: nop IL_001e: ret } // end of method A::OnFoo
      
      







この場合、Interlocked.CompareExchange呼び出しはVolatile.Read呼び出しに変更され、その後(IL_000c:dupで始まる)すべてが変更されません。



を使用するすべてのソリューション フィールドに1回アクセスするだけで異なり、コピーを使用してハンドラーを呼び出します(MSIL dupコマンド)。したがって、Invokeを呼び出してオブジェクトの正確なコピーを作成し、nullと比較してNullReferenceExceptionを発生させることはできません。 それ以外の場合、メソッドは、マルチスレッド環境で変更をキャッチする速さのみが異なります。



おわりに



はい、C#6ドライブ-初めてではありません。 そして、私たちはすでに安定したバージョンが利用可能です!



All Articles