Delegate?.Invoke(); がスレッドセーフだという話。
皆さん、デリゲード使ってますか?
デリゲードを使ってNullReferenceException
を出したことがあるのは私だけではないと思います。
null
チェックはしっかりしましょう。
ここでnull
チェックをする上で、重要となるのがスレッドセーフかどうか。という話になります。
スレッドセーフじゃないコードを書いてしまうと、「低確率で例外が発生するけど、再現性が無い」といった事態になりかねません。
とりあえず適当なコードを書きます。
namespace Test { class Test { public static void MainFunc() { new Test().Do1(); new Test().Do2(); new Test().Do3(); } delegate void NoneMethod(); NoneMethod _noneMethod = null; private void Do1() { if (_noneMethod != null) _noneMethod(); } private void Do2() { var tempMethod = _noneMethod; if (tempMethod != null) tempMethod(); } private void Do3() { _noneMethod?.Invoke(); } } }
Do1()
が安直な書き方、
Do2()
がスレッドセーフな書き方、
Do3()
が私が使用している環境(VS2017/C#7.0)で推奨される書き方です。
ちなみにVS2017を使ってますが、Do1()
,Do2()
はDo3()
で書く事をVSに推奨されます。
Do1()
はif (_noneMethod != null)
と_noneMethod()
の間で別スレッドから変更が加わった際にNullReferenceException
を吐きうるというわけです。
一方でDo2()
は一度ローカルにコピーをとっているため、フィールドである_noneMethod
に変更が加わろうが、問題ないわけです。
で、問題はDo3()
。推奨されているぐらいだからスレッドセーフでしょうけど。
まぁ、結論はタイトルにある通りスレッドセーフ(っぽい)ですね。
なぜ「っぽい」にしてるかというと、ILを読んだわけですが、ILを読むのが初めてなのでなにか勘違いがあるかも。という話です。
以降の話は、にわか知識なので、なにか間違い・変な点があったらコメントください。
ちなみに参考にしたのはこのPDF
https://www.ecma-international.org/publications/files/ECMA-ST/ECMA-335.pdf
それぞれのをReleseビルドでコンパイルした物のILのコードを載せます。
ちなみにILでの表示はildasm.exe
を使用してます。検索したら出てくるかと。
まずはDo1()
.method private hidebysig instance void Do1() cil managed { // Code size 20 (0x14) .maxstack 8 IL_0000: ldarg.0 IL_0001: ldfld class Test.Test/NoneMethod Test.Test::_noneMethod IL_0006: brfalse.s IL_0013 IL_0008: ldarg.0 IL_0009: ldfld class Test.Test/NoneMethod Test.Test::_noneMethod IL_000e: callvirt instance void Test.Test/NoneMethod::Invoke() IL_0013: ret } // end of method Test::Do1
次にDo2()
.method private hidebysig instance void Do2() cil managed { // Code size 17 (0x11) .maxstack 1 .locals init ([0] class Test.Test/NoneMethod tempMethod) IL_0000: ldarg.0 IL_0001: ldfld class Test.Test/NoneMethod Test.Test::_noneMethod IL_0006: stloc.0 IL_0007: ldloc.0 IL_0008: brfalse.s IL_0010 IL_000a: ldloc.0 IL_000b: callvirt instance void Test.Test/NoneMethod::Invoke() IL_0010: ret } // end of method Test::Do2
最後にDo3()
.method private hidebysig instance void Do3() cil managed { // Code size 17 (0x11) .maxstack 8 IL_0000: ldarg.0 IL_0001: ldfld class Test.Test/NoneMethod Test.Test::_noneMethod IL_0006: dup IL_0007: brtrue.s IL_000b IL_0009: pop IL_000a: ret IL_000b: callvirt instance void Test.Test/NoneMethod::Invoke() IL_0010: ret } // end of method Test::Do3
この時点で、Do3()
はスレッドセーフな気がしますね。(だって一回しか呼んでないですし)
1行目はメソッドの説明、3行目はコードのサイズ、4行目はスタックの最大数ですかね。
この辺は重要でないですし省略しましょう。
つぎにDo2()
の5行目.locals init ([0] class Test.Test/NoneMethod tempMethod)
これはローカル変数の初期化ですかね。これも重要じゃないので飛ばします。
ちなみにIL_0000
とかIL_000a
とかはコードラベルです。フロー制御に使われていますね。
重要なのは、その次から。
まずDo1()
に関して。
ldarg.0
:0番目の引数をスタックに積みます。関数に引数はないですが、呼び出し元のインスタンス情報が入っています。
ldfld
:スタックの一番上のインスタンスのフィールドの値をスタックに積みます。
brfalse.s
:スタックをポップし、その値が0なら指定ラベルに飛びます。
ldarg.0
,ldfld
:先ほどと同じです。_noneMethod
を呼んできています。ここがスレッドセーフじゃない理由ですね。
callvirt
:指定したメソッドをスタックの一番上を元に実行します。
ret
:呼び出し元に制御を返します。
以上です。
上にも書きましたが、フィールドを2回呼んでるのでスレッドセーフじゃないわけです。
ではDo2
を見てみましょう。
ldarg.0
,ldfld
:先ほどと同じです。_noneMethod
を呼んできています。
stloc.0
:0番目のローカル変数にスタックの一番上を代入します。
ldloc.0
:0番目のローカル変数をスタックの一番上におきます。
brfalse.s
:スタックをポップし、その値が0なら指定ラベルに飛びます。
ldloc.0
:0番目のローカル変数をスタックの一番上におきます。
callvirt
:指定したメソッドをスタックの一番上を元に実行します。
ret
:呼び出し元に制御を返します。
以上です。
スタックは利用してしまうと値がpop
され再利用できませんが、ローカルにコピーすることで、再利用しています。
フィールドは一回しか呼んでいないので、スレッドセーフになります。
最後にDo3()
ldarg.0
,ldfld
:先ほどと同じです。_noneMethod
を呼んできています。
dup
:スタックの一番上の値を取り出し、その値を2回プッシュします。すなわち、一番上の値をコピーします。
brtrue.s
:スタックをポップし、その値が非0なら指定ラベルに飛びます。
pop
:スタックの一番上の値を取り出します。
ret
:呼び出し元に制御を返します。
callvirt
:指定したメソッドをスタックの一番上を元に実行します。
ret
:呼び出し元に制御を返します。
以上です。
Do2()
と同じくフィールドを一回しか呼んでないので、スレッドセーフになります。
また、Do2()
と比べて、コード長は等しいものの、実行が速いであろうことが予測されます。まぁ誤差でしょうけど。
長々と書きましたが、デリゲードのnull
チェックはDelegate?.Invoke();
が良いということですね。伊達に推奨されているわけではないということです。
ではさようなら。
(IL覗いてると、難解プログラミング言語勉強している気になりますね。)