Это скомпилированное дерево выражений...
var param = Expression.Parameter(typeof(int));
var innerParam = Expression.Parameter(typeof(Action<int>));
var inner = Expression.Lambda(innerParam.Type, Expression.Block(), "test", new[] { param });
var outer = Expression.Lambda<Action<int>>(Expression.Block(new[] { innerParam }, Expression.Assign(innerParam, inner), Expression.Invoke(innerParam, param)), param).Compile();
По-видимому, это приводит к созданию нового делегата при каждом вызове outer
— это заметно, если, например, установить точку останова в Delegate.CreateDelegateNoSecurityCheck
.
Напротив, эквивалент этой функции, не основанный на выражениях
Action<int> outer = x =>
{
Action<int> innerParam = _ => { };
innerParam(x);
};
похоже, не делает этого; повторные вызовы outer
не требуют выделения новых делегатов.
Я изо всех сил пытаюсь понять, почему. Это предназначено? Есть ли какой-нибудь изящный трюк для кэширования делегатов версии на основе выражений?
Для контекста: это возникло при использовании внешней библиотеки десериализации, которая, казалось, выделяла необоснованный объем памяти посредством создания делегата в нашем процессе. По сути, он делает нечто очень похожее — создает десериализаторы через деревья выражений и назначает делегатов локальным переменным для поддержки рекурсивной и циклической десериализации.
@shingo Спасибо, это соответствует моему пониманию захвата замыканий - хотя версии, основанные на выражениях, также не захватывают, поэтому мне до сих пор не совсем понятно, почему они не кэшируются
Один из вариантов здесь — скомпилировать внутреннюю лямбду и вызвать ее:
var compile = inner.Compile();
var method = compile.GetType().GetMethod(nameof(Action<int>.Invoke));
var outerExpression = Expression.Lambda<Action<int>>(
Expression.Block(
new[] { innerParam },
Expression.Assign(innerParam, Expression.Constant(compile)),
Expression.Call(innerParam, method, param))
, param);
var outer = outerExpression.Compile();
var totalAllocatedBytes = GC.GetTotalAllocatedBytes();
for (int i = 0; i < 1000_000; i++)
{
outer(1);
}
Console.WriteLine(GC.GetTotalAllocatedBytes() - totalAllocatedBytes);
Что должно быть полным аналогом второго фрагмента.
Спасибо! Я тоже об этом думал, и хотя я думаю, что это может быть отличным решением для некоторых случаев, оно, вероятно, не сработает для основного варианта использования (десериализации), который должен поддерживать ссылки на себя и циклические ссылки. Исходный код присваивает каждому десериализатору локальную переменную, чтобы таким образом он мог имитировать рекурсивные вызовы - может быть сложно, если вообще возможно, найти подход, при котором каждая лямбда-десериализация могла бы быть скомпилирована изолированно от всех остальных.
@Bogey Возможно, в общем случае это было бы не так уж невозможно - вынесите эту логику за пределы компиляции деревьев выражений. В основном поддерживайте словарь типа -> десериализатор и ищите/используйте его оттуда. Вы можете использовать этот подход для каждого типа или десериализовать «простые» типы на месте и выполнять поиск только для сложных.
Я изо всех сил пытаюсь понять, почему. Это предназначено? Есть ли что-нибудь изящное трюк с кэшированием делегатов версии на основе выражений?
Это потому, что вы явно не кэшируете результат компиляции выражения внутреннего делегата.
В случае с решением, не основанным на выражениях, outerLambda
фактически превращается в класс с полем, которое может удерживать innerLambda
при создании экземпляра класса. Тогда вызовы внешнего будут вызовами метода экземпляра, которые используют уже созданный делегат действия в поле:
private sealed class <>c__DisplayClass0_0
{
public Action<int> inner;
internal void <Main>b__1(int x)
{
Action<int> innerParam = inner;
innerParam(x);
}
}
К счастью, если мы воспользуемся Expression.Constant
, мы сможем иметь ту же функциональность с выражениями.
Глядя на то, что Compile
делает в исходном коде — он создает Delegate
в конце, вызывая этот метод:
private Delegate CreateDelegate() {
Debug.Assert(_method is DynamicMethod);
return _method.CreateDelegate(_lambda.Type, new Closure(_boundConstants.ToArray(), null));
}
Этот экземпляр Closure
будет служить object Target
внешнего Action<int>
. Таким образом, делегат сможет передать его в качестве аргумента при вызове фактического скомпилированного метода Delegate
:
Void lambda_method205(System.Runtime.CompilerServices.Closure, Int32)
Если мы посмотрим на исходный код outer
, мы увидим, что он имеет:
/// <summary>
/// Represents the non-trivial constants and locally executable expressions that are referenced by a dynamically generated method.
/// </summary>
public readonly object[] Constants;
который заполняется VariableBinder.VisitConstant на этапе обхода перед фактической компиляцией делегата:
...
// Constants that can be emitted into IL don't need to be stored on
// the delegate
if (ILGen.CanEmitConstant(node.Value, node.Type)) {
return node;
}
_constants.Peek().AddReference(node.Value!, node.Type);
...
Итак, чтобы изменить исходный пример (с указанием кода тестирования на другой ответ Гуру Строна), нам нужно будет:
static void Main() {
var outerOriginal = CreateOuter(useConstantExpression: false);
//outerOriginal.Target.Dump();
MeasureAllocationsForInvocations(outerOriginal); // 8168 on my pc
var outerWithConstantExpression = CreateOuter(useConstantExpression: true);
// the Target of the delegate has the cached value of the inner Action<int>
////outerWithConstantExpression.Dump();
//outerWithConstantExpression.Dump();
MeasureAllocationsForInvocations(outerWithConstantExpression); // 0
dynamic del = outerWithConstantExpression;
// del.Target is Closure (using dynamic because I cannot reference the type even though it's public.. would edit if somebody assists)
Console.WriteLine(del.Target.Constants[0].Method.ToString());
// Void lambda_method204(System.Runtime.CompilerServices.Closure, Int32)
Console.WriteLine(del.Method.ToString());
//Void lambda_method205(System.Runtime.CompilerServices.Closure, Int32)
}
static void MeasureAllocationsForInvocations(Action<int> action) {
var totalAllocatedBytes = GC.GetTotalAllocatedBytes();
for (int i = 0; i < 100; i++) {
action(1);
}
Console.WriteLine();
Console.WriteLine(GC.GetTotalAllocatedBytes() - totalAllocatedBytes);
}
static Action<int> CreateOuter(bool useConstantExpression, bool useConsoleWriteLineInnerBody = false) {
var intParamInnerAndOuter = Expression.Parameter(typeof(int));
var innerBlock = Expression.Block();
// for debugging
if (useConsoleWriteLineInnerBody) {
MethodInfo writeLineMethod = typeof(Console).GetMethod("Write",
new Type[] { typeof(int) });
var writeLineCall = Expression.Call(writeLineMethod, intParamInnerAndOuter);
innerBlock = Expression.Block(writeLineCall);
}
var innerParam = Expression.Parameter(typeof(Action<int>));
var inner = Expression.Lambda(innerParam.Type,
innerBlock, intParamInnerAndOuter);
Expression expressionAssignment = useConstantExpression ?
Expression.Constant(inner.Compile()) : inner;
var outerBlock = Expression.Block(
new[] { innerParam }, // Action<int> a;
Expression.Assign(innerParam, expressionAssignment),
// a = closureParam.Constants[0]
// or
// a = new Action<int>(innerExpressionCompiledMethod)
Expression.Invoke(innerParam, intParamInnerAndOuter)); // a(intParam);
var outerExpression = Expression.Lambda<Action<int>>(outerBlock,
intParamInnerAndOuter);
var outer = outerExpression.Compile();
// if constant it will inside do -> new Closure(new object[]{inner.Compile});
return outer;
}
Единственная разница заключается в:
Expression expressionAssignment = useConstantExpression ?
Expression.Constant(inner.Compile()) : inner;
Это присвоение локальной переменной во внешнем выражении либо использует «кэшированный» скомпилированный делегат из Closure
, либо каждый раз создает новый делегат на основе внутреннего выражения.
В тестовом коде (для моего компьютера) выделенная память составляет closureParam.Constants[0]
байт для исходного случая и 8168
для второго, который зависит от 0
Хм, насколько я понимаю, Expression.Compile() эффективно компилируется в DynamicAssembly -> DynamicModule -> DynamicType -> DynamicMethod? Разве здесь нельзя было бы просто добавить кэшированное состояние к динамическому типу?
Я хотел устранить возможное недоразумение, заключающееся в том, что в случае невыражения не происходит никакого «захвата». Есть. Возможно, так и было бы, но почему это будет значением по умолчанию для Expression.Assign по сравнению с менее предполагаемым поведением без кэширования.
О, да, вы правы - я изменил версию без выражения. Теперь это должно быть эквивалентно версии выражения, и я думаю, что обе они не захватывают
@Bogey, так что тестирование версии без выражения теперь будет таким же, верно? Декомпилированный код измененного невыражения: Action<int> innerParam = <>9__4_1 ?? (<>9__4_1 = new Action<int>(<>9.<Main>b__4_1)); innerParam(x);
Итак, новое выделение при каждом вызове.
который, я думаю, отвечает на исходный вопрос. Что касается «хитрости», другой ответ представляет собой хорошее решение.
Может быть, я сейчас упускаю что-то глупое, но - ваш самый вставленный фрагмент из Sharplab предполагает, что делегат каждый раз кэшируется, а не воссоздается заново? <>9__4_1 — это статический член, который содержит функцию и назначается только один раз, если значение равно нулю, в противном случае он используется повторно. Это связано с кэшированием этой версии, тогда как версия на основе выражений, к сожалению, не кэшируется.
в теле метода new
ничего не кэшируется с outer
. Это не csharplab, а ILSpy, хотя то же самое можно наблюдать и с первой ссылкой - ссылка -> сравните ее с вашей исходной версией, где поле используется повторно.
и, кстати, редактирование вашего вопроса таким образом полностью искажает заголовок. Скомпилированное лямбда-выражение, приводящее к новым распределениям делегатов, тогда как версия без выражения не делает этого, поскольку больше не приводит к какой-либо разнице...
Спасибо @Ivan, я, наверное, совсем тупой, но до сих пор не уверен, что понимаю. Текущая версия имеет два статических поля, каждое из которых назначается только один раз, если не равно нулю, — все кэшируется, тогда как...
... моя версия с предварительным редактированием выделяет новый <>c__DisplayClass0_0() при каждом вызове, т. е. не кэшируется. Итак, на мой взгляд, вопрос правильный в его нынешнем виде: текущий код без выражений после редактирования использует все кэшированные лямбда-выражения? Где вы видите там какие-либо некешированные вызовы?
@Богги, мой плохой Богги, должно быть, я опоздал, когда оставил комментарий. Я сосредоточился на new()
... Вы правы насчет статических полей. Нет необходимости модифицировать вопрос. И мне не нужно изменять свой ответ, потому что на этот раз компилятор использует классы со статическими полями вместо полей экземпляра в первой версии для кэширования делегата. Когда я смотрю на код Lambda.Combile, мы просто компилируем его в методы, а не в типы, поэтому кэширование действительно невозможно. Возможно, если бы вы объединили какой-нибудь Reflection.Emit для имитации поведения компилятора C#, но я считаю, что это становится слишком сложным.
@Bogey Я покопался немного глубже в исходном коде - кажется, я не знал об удобном Closure
классе, который делает то, что вы хотели. Нет необходимости в Expression.Call или получении метода в качестве другого ответа. Мы просто делаем Expression.Invoke
Если лямбда-выражение не ссылается ни на один член экземпляра, объект делегата будет кэширован: Sharplab.io/…