C#のSystem.Delegateの挙動について気になることがあったので、Delegateを利用したプログラムのILを調べてみました。
ラムダ式で変数をキャプチャしたときの挙動など面白いところがあったのでポストとして残しておきます。

環境は.NET 8です。

※ ILの確認には有償のLinqpad 8を利用しています。
※ 手軽にILを参照したい方はildasm、ILSpy、SharpLab等の利用をお勧めします。

Delegateとは

Delegateとは関数を表す型です。
以下の様に記述ができる。

public partial class Program {
    static void Main() {
        // fnにラムダ式を代入
        var fn = (int value) => value * 2;
        // 200を出力
        Console.WriteLine(fn(100));
    }
}

上記のラムダ式はRoslynによってFunc<int, int>デリゲートとして解釈されます。
FuncやAction等のデリゲートは抽象クラスSystem.Delegateを継承しており実行時はクラスとして実行されます。
つまり、ILではnewobj命令を利用してFuncクラスのインスタンスを作成する命令が出力されるということですね。
1フレームで大量の処理を実行する必要があるゲームや、大量のデータをループで処理するジョブ等ではパフォーマンスに影響を与えそうです。
※ 後述しますがインスタンスをキャッシュをすることでアロケーション・GCのコストを削減しています。
※ 逆にインスタンスが大量に生成されるパターンもあります。これも後述します。

実際にILを見てみます。

Program.MainIL_0014を見ると、Func<int, int>のインスタンスを作成しているのが分かります。
そして、IL_0021の位置でcallvirt命令でInvokeメソッドを呼び出し、その結果がスタックされConsole.WriteLineに渡されています。

ここまでは予想の範疇ですが、<>cというクラスがコンパイラによって生成されています。
これは何かというと、ラムダ式で記述した処理と、生成したデリゲートのインスタンスをキャッシュする静的フィールドを持つクラスです。
再度ILを見てみましょう。

IL_000Eldftn命令で、<>cインスタンスの<Main>b__0_0メソッドをスタックに積み、IL_0014newobj命令でFunc<int, int>..ctorに渡されています。
こうすることでラムダ式で記述した部分の処理がFuncに渡されます。
ここからが面白いところで、IL0019~IL_001Aを見ると分かるが、<>c.<>9__0_0にFuncのインスタンスをキャッシュしていることが分かる。
これによってアロケーション・GCのコストを削減しているのですね。
IL_0000 ~ IL_0006でキャッシュの有無を確認し、キャッシュが存在する場合はbrtrue.s命令でインスタンス生成の手順をスキップしています。

変数をキャプチャしてみる

先ほどのプログラムを変更し、以下の様にしてみる。

public partial class Program {
    static void Main() {
        var mul = 100;
        // 変数mulをキャプチャ
        var fn = (int value) => value * mul;
        Console.WriteLine(fn(100));
    }
}

ILは以下の様になる。

コ_ンパイラによって生成されているのが、<>c__DisplayClass0_0となっているのが分かる。
IL_0006 ~ IL_0008を見ると、変数mulの値が、<>c__DisplayClass0_0.mulに代入されているのが分かる。
要するにクラスにキャプチャ対象を代入するためのフィールドを用意することで、変数のキャプチャを行っているのですね。
また、キャプチャを行った場合、デリゲートのインスタンスをキャッシュする処理が消えています。
当然と言えば当然ですが、注意が必要ですね。

メソッドを代入してみる

今度はデリゲートに静的メソッドを代入してみます。
以下の様にプログラムを変更します。  

public partial class Program {
    static void Main() {
        var fn = Multiply;
        Console.WriteLine(fn(100));
    }

    static int Multiply(int value) {
      return value * 2;
    }
}

ILは以下の様になる。

キャッシュロジックが生成されていることが分かります。
パフォーマンスを気にせず使えそうです。

次はSampleクラスを作成し、インスタンスメソッドを代入してみました。

public class Sample {
    public void Main() {
        var a = Method;
        Console.WriteLine(a(100));
    }	

    public int Method(int value) {
        return value * 2;
    }
}

ILを見てみると...

今度はキャッシュロジックが生成されていませんね。
インスタンスメソッドをキャッシュするわけにはいかないので当然ですね。
アロケーション・GCのコストを削減したい場合は静的メソッドを利用するのがいいかもしれません。

マルチキャストデリゲート

FuncAction、ユーザー定義のデリゲートはSystem.MulticastDelegateを継承しています。
マルチキャストデリゲートは以下のソースの様に+=演算子を利用することで複数のメソッドを登録することができます。

public partial class Program {
  static void Main() {
    // System.Actionとして解釈される。
    var a = () => Console.WriteLine(1);
    // +=演算子で複数の関数を代入できる。
    a += () => Console.WriteLine(2);
  }
}

System.MulticastDelegateのソースを見るとわかるのですが+=演算子をオーバーロードしていません。
では代わりにどういった処理になっているかILを確認してみます。

IL_003E ~ IL_0043を見ると、+=はDelegate.Combineメソッドに変換されています。
このメソッドを呼び出し新しいデリゲートをスタックし、それをActionにキャストして呼び出していることが分かります。

まとめ

ILを見るとコンパイラがかなり頑張ってくれていることが分かります。
通常デリゲートを利用するうえでパフォーマンスをそこまで気にする必要は無いと思いますが、アロケーション・GCのコストは馬鹿にならない場合もあります。
自分の周りではILを確認しているエンジニアは見かけませんが、パフォーマンスを突き詰める場合、やはりこの辺の理解が必要になると感じました。