C#:コールバック登録時のメモリ確保を抑えるパターン
C#で何らかのコールバックを登録する際、謎の引数がいるのを見たことはありませんか?
void RegisterSomeCallback(Action<Args, object> callback, object? state = null);
👆これでいう state
という引数です。
メジャーどころだと SynchronizationContext.Post
にも state
引数がありますね。
今回はこの 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));
}
ちょっと見にくいですが、new <>c__DisplayClass0_0()
でコンパイラが生成したクラス <>c__DisplayClass0_0
のインスタンスを作成しているので、ここでヒープメモリ確保が発生します。
ラムダ式やローカル関数は外部の変数をキャプチャするとコンパイラ展開でクロージャが作成され、特に頻繁に呼び出されるコードにおいてはパフォーマンスに悪影響があります。できるだけキャプチャを避けることが重要です。
実は、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);
}
}
さらに、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
{
/* ... */
}
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