🌊

C#:コールバック登録時のメモリ確保を抑えるパターン

2025/02/10に公開

C#で何らかのコールバックを登録する際、謎の引数がいるのを見たことはありませんか?

void RegisterSomeCallback(Action<Args, object> callback, object? state = null);

👆これでいう state という引数です。

メジャーどころだと SynchronizationContext.Post にも state 引数がありますね。
https://learn.microsoft.com/ja-jp/dotnet/api/system.threading.synchronizationcontext.post?view=net-9.0

今回はこの state の使い方についてです。


この state に渡したオブジェクトは、コールバックが呼び出される際に引数として戻ってくるようになっていることが多いです。

var message = "I'm a state";

RegisterSomeCallback((args, state) => {
    Console.WriteLine(state as string); // "I'm a state"
}, message);

しかし、わざわざこのようなことをしなくても、ラムダ式は外部の変数を参照することができるので state を使わずに書くことも可能に見えます。

var message = "I'm a state";

RegisterSomeCallback((args, _) => {
    Console.WriteLine(message); // "I'm a state"
}, null);

実はここに罠があります。コンパイラが展開するコードを見てみましょう。

    [CompilerGenerated]
    private sealed class <>c__DisplayClass0_0
    {
        public string message;

        [NullableContext(1)]
        internal void <<Main>$>b__0(object args, object _)
        {
            Console.WriteLine(message);
        }
    }

    private static void <Main>$(string[] args)
    {
        <>c__DisplayClass0_0 <>c__DisplayClass0_ = new <>c__DisplayClass0_0();
        <>c__DisplayClass0_.message = "I'm a state";
        <<Main>$>g__RegisterSomeCallback|0_1(new Action<object, object>(<>c__DisplayClass0_.<<Main>$>b__0));
    }

https://sharplab.io/#v2:D4AQTAjAsAULBuBDATgAgLYFMDO3EHNNUBeVAIgEkBydVRVbAF0UczIG5ZYAlTfASyaZkAZQD2WAMKIANjIBGiAMYBrABRqU+bABpUAfQCUJAHyoA3rFTXUICAE41WXAUyH2qAPSfy1WvSYWNlgAXz0AOwBXOXcuGBAAFlReASFRCUxpOUVVNTswAB4xeQArTCVGPWKyirMlWQVlFSrS8sYAfgZmVhJUKJjzEKA=

ちょっと見にくいですが、new <>c__DisplayClass0_0() でコンパイラが生成したクラス <>c__DisplayClass0_0 のインスタンスを作成しているので、ここでヒープメモリ確保が発生します。

ラムダ式やローカル関数は外部の変数をキャプチャするとコンパイラ展開でクロージャが作成され、特に頻繁に呼び出されるコードにおいてはパフォーマンスに悪影響があります。できるだけキャプチャを避けることが重要です。

https://qiita.com/ruccho_vector/items/f6abd88ae8c3724fd2e6

実は、state 引数を使うことでキャプチャを避けることができます。

var message = "I'm a state";

RegisterSomeCallback(static (args, state) => {
    Console.WriteLine(state as string); // "I'm a state"
}, message);

最初に書いたのとほぼ同じコードですが、ラムダ式の先頭に static をつけています。static をつけるとラムダ式がローカル変数をキャプチャしている場合にエラーを出してくれるので、確実にキャプチャを回避したい場合に有用です。このコードならローカル変数のキャプチャは起こらずコンパイルが通ります。

パフォーマンスが重要な場合は多少面倒でも state を使った呼び出しに書き直すのがおすすめです。

コールバックを受け取る側の実装

state を使うパターンですが、コールバックを受け取る側の実装はどうなるでしょうか?
おおむね次のような感じになると思います。

using System;
using System.Collections.Generic;

public class C<TArgs>
{
    private readonly List<(Action<TArgs, object?>, object?)> _callbacks = new();

    public void RegisterCallback(Action<TArgs, object?> callback, object? state = null)
    {
        _callbacks.Add((callback, state));
    }

    public void RemoveCallback(Action<TArgs, object?> callback)
    {
        var callbacks = _callbacks;
        for(var i = 0; i < callbacks.Count; i++)
        {
            if (callbacks[i].Item1 == callback)
            {
                _callbacks.RemoveAt(i);
                return;
            }
        }

        throw new ArgumentException(nameof(callback));
    }

    private void Invoke(TArgs args)
    {
        List<Exception>? exceptions = null;
        foreach(var (callback, state) in _callbacks)
        {
            try
            {
                callback(args, state);
            }
            catch (Exception ex)
            {
                (exceptions ??= new()).Add(ex);
            }
        }

        if (exceptions != null) throw new AggregateException(exceptions);
    }
}

https://sharplab.io/#v2:D4AQTAjAsAUCAMACEEAsBuWsQGZlkQGEAeAFQEEAnAcwGcA+WAb1kTcQAdKBLANwEMALgFNElYfwAmAewB2AGwCeiADLdag4gAoUYMlToAaRNIBGAK2EBjQQH56xs5Zu2AlPUQB9K/3nzT/FYA1rSIALyIssIA7lqumDCs7LjIqIgASsLU6iKUhL7+gUE6EHoUNLSOFtZ2Hj5+AcFVznaIGkKiEbIArn6uSWwsMOwjXvWFwbQAdOSSklpa441Bxu0irvEDiAC+WMPJeCBpmQC20rzC+Q1FJWUGlSbVLnUFy/37g1sjApSIS0WhCLeV4AhKjdgAM2klC0P0Q3HCiHg6HhiGIfxBkymhGk3VkghR3AA1ET3uDPh9yWxuBDEItMSEANrcAC6UwAkiIThBwhF/sEyVT2EMheTgdcsadzsJyIItNxNpTRWwQAB2MHK3ZK9har7sQQAC0o0mikRiiAM3ROwnxAFEAB5WYQcQTcORaWT8a3SCH0iVBDYati6ylcPgdVKIdmyXjSILCLTlOiIfgVQUUqlqDTEB1Ol1u2T0WyIYSO52uuSAyK9eRB0ZQ8SBA2w1N0/krNqCDqueGyMYM2jp0Yi5WCSiKPXgkfK9jtrSpoyd7t1qlamc+QRWA103Plgsl+1DqnTmd00t5iuyUK2WxdGJxVwzOZaUuKmdr8khqk0s9l/OVxAAEIuhrHtDWNU0olNchqGocRqA6Xd/1kF8/0vQc6y1bYgA==

さらに、state の型を object 固定ではなくジェネリクスで任意の参照型にできるようにしてあげると便利です。System.Runtime.CompilerServices.Unsafe パッケージが必要になりますが、次にように改良できます。

    public void RegisterCallback<TState>(Action<TArgs, TState?> callback, TState? state = null) where TState : class
    {
        _callbacks.Add((Unsafe.As<Action<TArgs, object?>>(callback), state));
    }

    public void RemoveCallback<TState>(Action<TArgs, TState?> callback) where TState : class
    {
        /* ... */
    }

https://sharplab.io/#v2:D4AQTAjAsAUCAMACEEAsBuWDkQHQCUBXAOwBcBLAWwFNcBhAe0oAdyAbagJwGUuA3cgGNqAZ0wwsAZmRhEdADwAVAIKcA5iIB8sAN6xEBxM07k+AQ1LVEnamYAmDYmwCeiADLkRpeQAoUYJVUNABpEBgAjACtqQVIAfk1QiOjYuIBKTUQAfUEzNjZws0EAaxFEAF5EYmoAdx808X1DEGkQVER8ajVPS046PIKi4qVuUgtqTT8IAJV1EVDFUfGExFz8wpKFpcs4xC9xiqrCfLTEGoALLitFsctEAC4ZJoM9GEN37LXBkpFcZTs7D4fABVYgiMwAM1oyhE8n8gTmSSiMXimkmXw2xTSoX2ljSDWeiAAvrBCS1kO1OpQGHxqP11kMRrcJlMZkF5ogbstMhihqcLldOdsrI9wITXh9DOZOKsBpiypUcnKhmJCe8IQxOD5pYhyId4OhdYh5LKGT96AwSKRDeQANS2tJqwwSyUfcgQxA+Xk/ADa5AAurgAJKWSgQCqVb1Yp2Sl2u11Ks2lAjUam05SkHzkAlveOukAAdnEecMJNzkrLMcQpHOnAYNSqtUQQUINDIAFEAB7CZgURw+YhmGgMCFe5UlfHF0uk8vIVrtIPEPgMYrUHyzDSIMxzR2zuMfDxeeRdnt94iaXbUbvUXvkRwKo75KeSjU2IrnbXbz1RnHM07kYhPnHUpd3jfd41IThnCrd5wJLU1vmKHxtxCPY/2fPMy3g2VSEEc5PRPG8z0QK9QPguCSx8K9TzvMFEDiOJKmqOp8T+AEqM7HNsKw11K1nd53U9aiiNosoAEImOONhThrOsG2Y5s1DUGw1HGQjb37YSNLBLjpxgIkgA===

Action<TArgs, TState?>Action<TArgs, object?> に無理やり変換しています。通常、このようなキャストを行おうとすると InvalidCastException が発生しますが、Unsafe.As を使うことで回避できます。
もちろん Action<TArgs, object?> に変換することによって本来の TState 以外の引数を渡すことができるようになってしまうので、そうした場合の動作はおそらく未定義となりますが、今回は確実に TState 型の state が渡される実装になっています。また TState には class 制約をつけているため確実に参照型となり、Action<TArgs, object?>.Invoke()Action<TArgs, TState?>.Invoke() のシグネチャには互換性があり安全です(値型を許可するとシグネチャの互換性はメモリレイアウトに依存するため安全にできません)。こうした条件下では安全にデリゲート型の読み替えができます。

こうすれば、コールバックを渡す側のコードで object からのダウンキャストを省略できますね。

// 改善前
RegisterCallback((_, state) => Console.WriteLine(state as string), "Hello");

// 改善後
RegisterCallback((_, state) => Console.WriteLine(state), "Hello");

以上です!

Discussion