C # 5 асинхронная CTP: почему внутреннее «состояние» установлено в 0 в сгенерированном коде перед вызовом EndAwait?

195

Вчера я говорил о новой функции асинхронности в C #, в частности, о том, как выглядит сгенерированный код, и the GetAwaiter()/ BeginAwait()/ EndAwait()вызовах.

Мы подробно рассмотрели конечный автомат, сгенерированный компилятором C #, и мы не могли понять два аспекта:

  • Почему сгенерированный класс содержит Dispose()метод и $__disposingпеременную, которые никогда не используются (а класс не реализуется IDisposable).
  • Почему внутренняя stateпеременная установлена ​​в 0 перед любым вызовом EndAwait(), когда 0 обычно означает «это начальная точка входа».

Я подозреваю, что на первый вопрос можно ответить, сделав что-то более интересное в асинхронном методе, хотя, если у кого-то будет какая-либо дополнительная информация, я буду рад ее услышать. Этот вопрос больше касается второго пункта, однако.

Вот очень простой пример кода:

using System.Threading.Tasks;

class Test
{
    static async Task<int> Sum(Task<int> t1, Task<int> t2)
    {
        return await t1 + await t2;
    }
}

... и вот код, который генерируется для MoveNext()метода, который реализует конечный автомат. Это скопировано непосредственно из Reflector - я не исправил невыразимые имена переменных:

public void MoveNext()
{
    try
    {
        this.$__doFinallyBodies = true;
        switch (this.<>1__state)
        {
            case 1:
                break;

            case 2:
                goto Label_00DA;

            case -1:
                return;

            default:
                this.<a1>t__$await2 = this.t1.GetAwaiter<int>();
                this.<>1__state = 1;
                this.$__doFinallyBodies = false;
                if (this.<a1>t__$await2.BeginAwait(this.MoveNextDelegate))
                {
                    return;
                }
                this.$__doFinallyBodies = true;
                break;
        }
        this.<>1__state = 0;
        this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();
        this.<a2>t__$await4 = this.t2.GetAwaiter<int>();
        this.<>1__state = 2;
        this.$__doFinallyBodies = false;
        if (this.<a2>t__$await4.BeginAwait(this.MoveNextDelegate))
        {
            return;
        }
        this.$__doFinallyBodies = true;
    Label_00DA:
        this.<>1__state = 0;
        this.<2>t__$await3 = this.<a2>t__$await4.EndAwait();
        this.<>1__state = -1;
        this.$builder.SetResult(this.<1>t__$await1 + this.<2>t__$await3);
    }
    catch (Exception exception)
    {
        this.<>1__state = -1;
        this.$builder.SetException(exception);
    }
}

Это долго, но важные строки для этого вопроса таковы:

// End of awaiting t1
this.<>1__state = 0;
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

// End of awaiting t2
this.<>1__state = 0;
this.<2>t__$await3 = this.<a2>t__$await4.EndAwait();

В обоих случаях состояние снова изменяется, прежде чем оно, очевидно, будет наблюдаться ... так зачем вообще устанавливать его в 0? Если бы MoveNext()в этот момент вызывали снова (напрямую или через Dispose), он снова эффективно запускал бы асинхронный метод, что, насколько я могу сказать, было бы совершенно неуместным ... если и MoveNext() не вызывается , изменение состояния не имеет значения.

Является ли это просто побочным эффектом повторного использования компилятором кода генерации блока итератора для асинхронного преобразования, где это может иметь более очевидное объяснение?

Важный отказ от ответственности

Очевидно, это всего лишь компилятор CTP. Я полностью ожидаю, что все изменится до окончательного выпуска - и, возможно, даже до следующего выпуска CTP. Этот вопрос никоим образом не пытается утверждать, что это недостаток компилятора C # или чего-то в этом роде. Я просто пытаюсь понять, есть ли тонкая причина для этого, которую я пропустил :)

Джон Скит
источник
7
Компилятор VB создает аналогичный конечный автомат (не знаю, ожидается ли это или нет, но у VB раньше не было блоков итераторов)
Damien_The_Unbeliever
1
@Rune: MoveNextDelegate - это просто поле делегата, которое ссылается на MoveNext. Я считаю, что он кешируется, чтобы не создавать новое действие, которое каждый раз попадает в официанта.
Джон Скит
5
Я думаю, что ответ: это ОСАГО. Команда высокого уровня получила все это, и дизайн языка был подтвержден. И они сделали это удивительно быстро. Вы должны ожидать, что поставленная реализация (компиляторов, а не MoveNext) будет существенно отличаться. Я думаю, что Эрик или Люциан ответят так, что здесь нет ничего глубокого, только поведение / ошибка, которая в большинстве случаев не имеет значения, и никто не заметил. Потому что это ОСАГО.
Крис Барроуз
2
@ Стилгар: Я только что проверил с ildasm, и он действительно делает это.
Джон Скит
3
@JonSkeet: обратите внимание, как никто не голосует против ответов. 99% из нас не могут действительно сказать, звучит ли ответ даже правильно.
the_drow

Ответы:

71

Хорошо, у меня наконец есть реальный ответ. Я как-то решил это сам, но только после того, как Лучиан Висчик из команды VB подтвердил, что для этого есть все основания. Большое ему спасибо - и, пожалуйста, посетите его блог , который качается.

Значение 0 здесь только специальное, потому что это недопустимое состояние, в котором вы можете находиться перед awaitобычным случаем. В частности, это не состояние, которое конечный автомат может в конечном итоге проверить в другом месте. Я считаю, что использование любого неположительного значения будет работать так же хорошо: -1 не используется для этого, поскольку это логически неверно, так как -1 обычно означает «закончено». Я мог бы поспорить, что в данный момент мы придаем дополнительный смысл состоянию 0, но в конечном итоге это не имеет значения. Смысл этого вопроса состоял в том, чтобы выяснить, почему государство устанавливается вообще.

Значение имеет значение, если ожидание заканчивается исключением, которое перехватывается. Мы можем в конечном итоге вернуться к тому же самому заявлению await снова, но мы не должны находиться в состоянии, означающем «Я только что вернусь из этого await», так как иначе все виды кода будут пропущены. Проще всего показать это на примере. Обратите внимание, что сейчас я использую второй CTP, поэтому сгенерированный код немного отличается от кода, приведенного в вопросе.

Вот асинхронный метод:

static async Task<int> FooAsync()
{
    var t = new SimpleAwaitable();

    for (int i = 0; i < 3; i++)
    {
        try
        {
            Console.WriteLine("In Try");
            return await t;
        }                
        catch (Exception)
        {
            Console.WriteLine("Trying again...");
        }
    }
    return 0;
}

Концептуально, это SimpleAwaitableможет быть любое ожидание - может быть, задача, может быть, что-то еще. В целях моих тестов он всегда возвращает false для IsCompletedи выдает исключение в GetResult.

Вот сгенерированный код для MoveNext:

public void MoveNext()
{
    int returnValue;
    try
    {
        int num3 = state;
        if (num3 == 1)
        {
            goto Label_ContinuationPoint;
        }
        if (state == -1)
        {
            return;
        }
        t = new SimpleAwaitable();
        i = 0;
      Label_ContinuationPoint:
        while (i < 3)
        {
            // Label_ContinuationPoint: should be here
            try
            {
                num3 = state;
                if (num3 != 1)
                {
                    Console.WriteLine("In Try");
                    awaiter = t.GetAwaiter();
                    if (!awaiter.IsCompleted)
                    {
                        state = 1;
                        awaiter.OnCompleted(MoveNextDelegate);
                        return;
                    }
                }
                else
                {
                    state = 0;
                }
                int result = awaiter.GetResult();
                awaiter = null;
                returnValue = result;
                goto Label_ReturnStatement;
            }
            catch (Exception)
            {
                Console.WriteLine("Trying again...");
            }
            i++;
        }
        returnValue = 0;
    }
    catch (Exception exception)
    {
        state = -1;
        Builder.SetException(exception);
        return;
    }
  Label_ReturnStatement:
    state = -1;
    Builder.SetResult(returnValue);
}

Мне пришлось перейти Label_ContinuationPointк тому, чтобы сделать его допустимым кодом - иначе это не входит в сферу gotoутверждения - но это не влияет на ответ.

Подумайте о том, что происходит, когда GetResultвыкидывает свое исключение. Мы пройдемся по блоку catch, инкременту i, а затем снова зациклимся (при условии, iчто все еще меньше 3). Мы все еще находимся в том состоянии, в котором мы были до GetResultвызова ... но когда мы попадаем внутрь tryблока, мы должны вывести "In Try" и GetAwaiterснова вызвать ... и мы сделаем это, только если состояние не равно 1. Без state = 0задание, он будет использовать существующую awaiter и пропустить Console.WriteLineзвонок.

Это довольно трудоемкий код, который нужно проработать, но он показывает, что нужно думать команде. Я рад, что не несу ответственности за это :)

Джон Скит
источник
8
@Shekhar_Pro: Да, это goto. Вы должны ожидать увидеть множество операторов goto в автоматически генерируемых
Jon Skeet,
12
@Shekhar_Pro: В написанном вручную коде это - потому что это делает код трудным для чтения и последующего исполнения. Никто не читает автоматически сгенерированный код, кроме таких дураков, как я, которые его декомпилируют :)
Джон Скит,
Так что же происходит, когда мы снова ждем после исключения? Мы начнем все сначала?
конфигуратор
1
@configurator: он вызывает GetAwaiter для ожидаемого, что я и ожидал.
Джон Скит
gotos не всегда усложняет чтение кода. На самом деле, иногда они даже имеют смысл использовать (скажу кощунство, я знаю). Например, иногда вам может понадобиться разорвать несколько вложенных циклов. Менее используемая особенность goto (и более уродливое использование IMO) - вызывать каскадные операторы switch. В отдельном примечании я помню день и возраст, когда gotos были основной основой некоторых языков программирования, и поэтому я полностью понимаю, почему простое упоминание goto заставляет разработчиков содрогаться. Они могут сделать вещи ужасными, если их использовать плохо.
Бен Леш
5

если он будет равен 1 (первый случай), вы получите вызов EndAwaitбез вызова BeginAwait. Если его оставить равным 2 (второй случай), вы получите тот же результат только для другого ожидающего.

Я предполагаю, что вызов BeginAwait возвращает false, если он уже был запущен (предположение с моей стороны), и сохраняет исходное значение для возврата в EndAwait. Если это так, он будет работать правильно, тогда как если вы установите его на -1, то this.<1>t__$await1для первого случая вы можете иметь неинициализированный код .

Это, однако, предполагает, что BeginAwaiter фактически не будет запускать действие для каких-либо вызовов после первого и что в этих случаях он вернет false. Начало, конечно, было бы неприемлемо, поскольку оно могло бы иметь побочный эффект или просто дать другой результат. Также предполагается, что EndAwaiter всегда будет возвращать одно и то же значение независимо от того, сколько раз оно вызывается, и это можно вызвать, когда BeginAwait возвращает false (согласно приведенному выше предположению).

Это может показаться защитой от условий гонки. Если мы добавим операторы, в которых movenext вызывается другим потоком после состояния = 0, то в вопросах это будет выглядеть примерно так:

this.<a1>t__$await2 = this.t1.GetAwaiter<int>();
this.<>1__state = 1;
this.$__doFinallyBodies = false;
this.<a1>t__$await2.BeginAwait(this.MoveNextDelegate)
this.<>1__state = 0;

//second thread
this.<a1>t__$await2 = this.t1.GetAwaiter<int>();
this.<>1__state = 1;
this.$__doFinallyBodies = false;
this.<a1>t__$await2.BeginAwait(this.MoveNextDelegate)
this.$__doFinallyBodies = true;
this.<>1__state = 0;
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

//other thread
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

Если приведенные выше предположения верны, выполняется некоторая ненужная работа, такая как get sawiater и переназначение того же значения на <1> t __ $ await1. Если бы состояние сохранялось на 1, то последняя часть была бы:

//second thread
//I suppose this un matched call to EndAwait will fail
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

далее, если он был установлен в 2, конечный автомат предположил бы, что он уже получил значение первого действия, которое было бы неверным, и (потенциально) неназначенная переменная использовалась бы для вычисления результата

Руна ФС
источник
Имейте в виду, что состояние фактически не используется между присваиванием 0 и присвоением более значимому значению. Если это предназначено для защиты от условий гонки, я ожидаю, что какое-то другое значение будет указывать это, например, -2, с проверкой на это в начале MoveNext, чтобы обнаружить несоответствующее использование. Имейте в виду, что один и тот же экземпляр никогда не должен использоваться двумя потоками одновременно - это должно создать иллюзию единого синхронного вызова метода, который часто «останавливает» паузу.
Джон Скит
@Jon Я согласен, что это не должно быть проблемой с состоянием гонки в асинхронном случае, но может быть в итерационном блоке и может быть оставленным
Rune FS
@ Тони: Думаю, я подожду, пока выйдет следующая ОС или бета-версия, и проверим это поведение.
Джон Скит
1

Может быть это как-то связано со сложенными / вложенными асинхронными вызовами? ..

то есть:

async Task m1()
{
    await m2;
}

async Task m2()
{
    await m3();
}

async Task m3()
{
Thread.Sleep(10000);
}

В такой ситуации вызывается ли делегат movenext несколько раз?

Просто пунт на самом деле?

GaryMcAllister
источник
В этом случае будет три разных сгенерированных класса. MoveNext()будет вызван один раз на каждого из них.
Джон Скит
0

Объяснение актуальных состояний:

возможные состояния:

  • 0 инициализирован (я так думаю) или жду окончания операции
  • > 0 только что вызвал MoveNext, выбирая следующее состояние
  • -1 закончился

Возможно ли, что эта реализация просто хочет гарантировать, что, если произойдет еще один вызов MoveNext от того, где бы он ни находился (во время ожидания), он заново пересмотрит всю цепочку состояний с самого начала, чтобы переоценить результаты, которые могут быть тем временем уже устаревшими?

fixagon
источник
Но почему он хочет начать с самого начала? Это почти наверняка не то, что вы на самом деле хотели бы случиться - вы бы хотели выбросить исключение, потому что ничто другое не должно вызывать MoveNext.
Джон Скит