三流プログラマの戯言

プログラミング初心者が気になったことを書き綴るだけ。主にc#

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.0ldfld:先ほどと同じです。_noneMethodを呼んできています。ここがスレッドセーフじゃない理由ですね。
callvirt:指定したメソッドをスタックの一番上を元に実行します。
ret:呼び出し元に制御を返します。
以上です。
上にも書きましたが、フィールドを2回呼んでるのでスレッドセーフじゃないわけです。

ではDo2を見てみましょう。
ldarg.0ldfld:先ほどと同じです。_noneMethodを呼んできています。
stloc.0:0番目のローカル変数にスタックの一番上を代入します。
ldloc.0:0番目のローカル変数をスタックの一番上におきます。
brfalse.s:スタックをポップし、その値が0なら指定ラベルに飛びます。
ldloc.0:0番目のローカル変数をスタックの一番上におきます。
callvirt:指定したメソッドをスタックの一番上を元に実行します。
ret:呼び出し元に制御を返します。
以上です。
スタックは利用してしまうと値がpopされ再利用できませんが、ローカルにコピーすることで、再利用しています。
フィールドは一回しか呼んでいないので、スレッドセーフになります。

最後にDo3()
ldarg.0ldfld:先ほどと同じです。_noneMethodを呼んできています。
dup:スタックの一番上の値を取り出し、その値を2回プッシュします。すなわち、一番上の値をコピーします。
brtrue.s:スタックをポップし、その値が非0なら指定ラベルに飛びます。
pop:スタックの一番上の値を取り出します。
ret:呼び出し元に制御を返します。
callvirt:指定したメソッドをスタックの一番上を元に実行します。
ret:呼び出し元に制御を返します。
以上です。
Do2()と同じくフィールドを一回しか呼んでないので、スレッドセーフになります。
また、Do2()と比べて、コード長は等しいものの、実行が速いであろうことが予測されます。まぁ誤差でしょうけど。

長々と書きましたが、デリゲードのnullチェックはDelegate?.Invoke();が良いということですね。伊達に推奨されているわけではないということです。

ではさようなら。

(IL覗いてると、難解プログラミング言語勉強している気になりますね。)