Почему переменная scope класса захвачена при использовании метода async, но не при использовании Action <T> (примеры кода внутри)?
Во время прогулки по собаке я думал о Action<T>
, Func<T>
, Task<T>
, async/await
(да, тупо, я знаю...) и сконструировал небольшую пробную программу в своем уме и задался вопросом, что ответ был. Я заметил, что не уверен в результатах, поэтому создал два простых теста.
Здесь настройка:
- У меня есть переменная класса (строка) класса.
- Ему присваивается начальное значение.
- Переменная передается как параметр методу класса.
- Метод не будет выполняться напрямую, а вместо него назначен "Действие".
- Перед выполнением действия я изменяю значение переменной.
Каким будет выход? Начальное значение или измененное значение?
Немного удивительно, но понятно, выход - это измененное значение. Мое объяснение: переменная не помещается в стек до тех пор, пока действие не выполнится, поэтому оно будет изменено.
public class foo
{
string token;
public foo ()
{
this.token = "Initial Value";
}
void DoIt(string someString)
{
Console.WriteLine("SomeString is '{0}'", someString);
}
public void Run()
{
Action op = () => DoIt(this.token);
this.token = "Changed value";
// Will output "Changed value".
op();
}
}
Затем я создал вариант:
public class foo
{
string token;
public foo ()
{
this.token = "Initial Value";
}
Task DoIt(string someString)
{
// Delay(0) is just there to try if timing is the issue here - can also try Delay(1000) or whatever.
return Task.Delay(0).ContinueWith(t => Console.WriteLine("SomeString is '{0}'", someString));
}
async Task Execute(Func<Task> op)
{
await op();
}
public async void Run()
{
var op = DoIt(this.token);
this.token = "Changed value";
// The output will be "Initial Value"!
await Execute(() => op);
}
}
Здесь я сделал DoIt()
вернуть a Task
. op
теперь является Task
и уже не является Action
. Метод Execute()
ожидает выполнения задачи. К моему удивлению, выход теперь "Начальное значение".
Почему он ведет себя по-другому?
DoIt()
не будет выполняться до тех пор, пока Execute()
не будет вызван, поэтому почему он фиксирует начальное значение token
?
Полные тесты: https://gist.github.com/Krumelur/c20cb3d3b4c44134311f и https://gist.github.com/Krumelur/3f93afb50b02fba6a7c8
Ответы
Ответ 1
У вас есть несколько неправильных представлений. Во-первых, когда вы вызываете DoIt
, он возвращает задание, которое уже начало выполнение. Выполнение не запускается, только если вы await
Задача.
Вы также создаете замыкание над переменной someString
, значение которой не изменяется при переназначении поля уровня класса:
Task DoIt(string someString)
{
return Task.Delay(0).ContinueWith(t
=> Console.WriteLine("SomeString is '{0}'", someString));
}
Action
, переданный в ContinueWith
, закрывается в переменной someString
. Помните, что строки неизменяемы, поэтому, когда вы переназначаете значение token
, вы фактически назначаете новую ссылку на строку. Однако локальная переменная someString
внутри DoIt
сохраняет старую ссылку, поэтому ее значение остается неизменным даже после переназначения поля класса.
Вы можете решить эту проблему, вместо этого непосредственно связав это действие над полем класса:
Task DoIt()
{
return Task.Delay(0).ContinueWith(t
=> Console.WriteLine("SomeString is '{0}'", this.token));
}
Ответ 2
В обоих случаях вы делаете закрытие. Тем не менее, вы делаете замыкание на разные вещи в двух случаях.
В первом случае вы создаете анонимный метод с закрытием над this
- когда вы, наконец, выполните делегат, он примет текущее значение this
, получит текущее значение this.token
и использовать это. Таким образом, вы видите измененное значение.
Во втором случае нет замыкания над this
- или, если это так, это не имеет значения. Вы явно передаете this.token
, а метод DoIt
должен только закрывать свой собственный аргумент, someString
. Это происходит немедленно (синхронно), а не лениво - поэтому фиксируется начальное значение this.token
. await
фактически не выполняет делегат - он только ждет результатов асинхронного метода. Сам метод уже запущен, и только его асинхронная часть, ну, асинхронная - в этом случае только Console.WriteLine("SomeString is '{0}'", someString)
.
Если вы хотите увидеть это более четко, добавьте Thread.Sleep(1000)
после this.token = "Changed value";
- вы увидите распечатку SomeString is 'Initial Value'
, прежде чем вы даже дойдете до await
.
Чтобы второй пример выглядел как первый, все, что вам нужно сделать, это изменить op
как делегат снова, а не Task
- var op = () => DoIt(this.token);
. Это снова задерживает выполнение DoIt
и вызывает то же закрытие, что и в первом примере.
TL; ДР:
Поведение отличается от того, что в первом случае вы откладываете выполнение DoIt(this.token)
, а во втором примере вы запускаете DoIt(this.token)
немедленно. Остальные вопросы в моем ответе также важны, но в этом и заключается суть.
Ответ 3
Пусть разбивается каждый случай.
Начиная с Action<T>
:
Мое объяснение: переменная не помещается в стек до тех пор, пока действие выполняется, поэтому оно будет изменено.
Это не имеет никакого отношения к стеку. Компилятор генерирует следующее из вашего первого фрагмента кода:
public foo()
{
this.token = "Initial Value";
}
private void DoIt(string someString)
{
Console.WriteLine("SomeString is '{0}'", someString);
}
public void Run()
{
Action action = new Action(this.<Run>b__3_0);
this.token = "Changed value";
action();
}
[CompilerGenerated]
private void <Run>b__3_0()
{
this.DoIt(this.token);
}
Компилятор испускает именованный метод из вашего выражения лямбда. Когда вы вызываете действие, и поскольку мы находимся в одном классе, this.token
представляет собой обновленное "Измененное значение". Компилятор даже не поднимает это в класс отображения, так как это все создано и вызывается внутри метода экземпляра.
Теперь, для метода async
. Создаются две государственные машины, они плохо разбираются в раздувании государственной машины и попадают в соответствующие части. Государство-машина выполняет следующее:
this.<>8__1 = new foo.<>c__DisplayClass4_0();
this.<>8__1.op = this.<>4__this.DoIt(this.<>4__this.token);
this.<>4__this.token = "Changed value";
taskAwaiter = this.<>4__this.Execute(new Func<Task>(this.<>8__1.<Run>b__0)).GetAwaiter();
Что здесь происходит? token
передается на DoIt
, который вернет a Func<Task>
. Этот делегат содержит ссылку на старую строку токена "Начальное значение". Помните, хотя мы говорим о ссылочных типах, все они передаются по значению. Это фактически означает, что теперь есть новая ячейка хранения старой строки в методе DoIt
, которая указывает на "Начальное значение". Затем следующая строка изменяет token
на "Измененное значение". string
, хранящийся внутри Func
и тот, который был изменен, теперь указывает на две разные строки.
Когда вы вызываете делегата, он будет печатать начальное значение, так как задача op
хранит ваше старое устаревшее значение. Вот почему вы видите два разных поведения.