Что именно происходит, когда поток ожидает задачу внутри цикла while?

10

Поработав некоторое время с асинхронным / ожидающим паттерном в C #, я внезапно осознал, что не знаю, как объяснить, что происходит в следующем коде:

async void MyThread()
{
    while (!_quit)
    {
        await GetWorkAsync();
    }
}

GetWorkAsync()предполагается, что он возвращает ожидаемое, Taskчто может вызвать или не вызвать переключение потока при выполнении продолжения.

Я не был бы смущен, если бы ожидание не было в петле. Естественно, я ожидаю, что остальная часть метода (то есть продолжение) будет потенциально выполняться в другом потоке, что нормально.

Однако внутри цикла концепция «остальной части метода» становится для меня немного туманной.

Что происходит с «остальной частью цикла», если поток включен в продолжение, если он не переключен? В каком потоке выполняется следующая итерация цикла?

Мои наблюдения показывают (не окончательно проверено), что каждая итерация начинается в том же потоке (исходном), а продолжение выполняется в другом. Это действительно может быть? Если да, то является ли это степенью неожиданного параллелизма, который необходимо учитывать для обеспечения безопасности потоков метода GetWorkAsync?

ОБНОВЛЕНИЕ: Мой вопрос не является дубликатом, как предлагают некоторые. while (!_quit) { ... }Кодовый просто упрощение моего фактического кода. В действительности мой поток представляет собой долгоживущий цикл, который обрабатывает входную очередь рабочих элементов через регулярные промежутки времени (по умолчанию каждые 5 секунд). Фактическая проверка состояния выхода также является не простой проверкой поля, как предлагается в примере кода, а проверкой дескриптора события.

aoven
источник
1
См. Также Как выход и ожидание реализуют поток управления в .NET? для некоторой отличной информации о том, как все это связано вместе.
Джон Ву
@John Wu: я еще не видел эту тему. Там много интересных информационных самородков. Спасибо!
aoven

Ответы:

6

Вы можете проверить это в Try Roslyn . Ваш метод await переписывается в void IAsyncStateMachine.MoveNext()сгенерированный асинхронный класс.

То, что вы увидите, выглядит примерно так:

            if (this.state != 0)
                goto label_2;
            //set up the state machine here
            label_1:
            taskAwaiter.GetResult();
            taskAwaiter = default(TaskAwaiter);
            label_2:
            if (!OuterClass._quit)
            {
               taskAwaiter = GetWorkAsync().GetAwaiter();
               //state machine stuff here
            }
            goto label_1;

По сути, не имеет значения, на какой нити вы находитесь; конечный автомат может возобновиться должным образом, заменив ваш цикл эквивалентной структурой if / goto.

При этом асинхронные методы не обязательно выполняются в другом потоке. Посмотрите объяснение Эрика Липперта «Это не волшебство», чтобы объяснить, как вы можете работать async/awaitтолько над одним потоком.

Мейсон Уилер
источник
2
Кажется, я недооцениваю степень перезаписи, которую компилятор выполняет в моем асинхронном коде. По сути, после переписывания "петли" нет! Это была недостающая часть для меня. Здорово и спасибо за ссылку "Попробуйте Roslyn"!
aoven
GOTO - это оригинальная конструкция цикла. Давайте не будем забывать это.
2

Во-первых, Servy написал некоторый код в ответе на аналогичный вопрос, на котором основан этот ответ:

/programming/22049339/how-to-create-a-cancellable-task-loop

Ответ Servy включает аналогичный ContinueWith()цикл с использованием конструкций TPL без явного использования ключевых слов asyncand await; поэтому, чтобы ответить на ваш вопрос, подумайте, как может выглядеть ваш код, когда ваш цикл развернут, используяContinueWith()

    private static Task GetWorkWhileNotQuit()
    {
        var tcs = new TaskCompletionSource<bool>();

        Task previous = Task.FromResult(_quit);
        Action<Task> continuation = null;
        continuation = t =>
        {
            if (!_quit)
            {
                previous = previous.ContinueWith(_ => GetWorkAsync())
                    .Unwrap()
                    .ContinueWith(_ => previous.ContinueWith(continuation));
            }
            else
            {
                tcs.SetResult(_quit);
            }
        };
        previous.ContinueWith(continuation);
        return tcs.Task;
    }

Это займет некоторое время, чтобы обернуть голову, но в итоге:

  • continuationпредставляет собой закрытие для «текущей итерации»
  • previousпредставляет Taskсостояние, в котором находится «предыдущая итерация» (т.е. она знает, когда «итерация» закончена и используется для запуска следующей…)
  • Предполагая , что GetWorkAsync()возвращает a Task, это означает, что ContinueWith(_ => GetWorkAsync())будет возвращать, Task<Task>следовательно, вызов, чтобы Unwrap()получить «внутреннюю задачу» (то есть фактический результат GetWorkAsync()).

Так:

  1. Изначально предыдущей итерации нет, поэтому ей просто присваивается значение Task.FromResult(_quit) - ее состояние начинается как Task.Completed == true.
  2. Первый continuationзапуск выполняется с использованиемprevious.ContinueWith(continuation)
  3. то continuationобновления закрытия , previousчтобы отразить состояние завершения процесса_ => GetWorkAsync()
  4. Когда _ => GetWorkAsync()завершено, оно «продолжает» _previous.ContinueWith(continuation)- т.е. continuationснова вызывает лямбду
    • Очевидно, в этот момент previousбыло обновлено состояние, _ => GetWorkAsync()поэтому continuationлямбда вызывается при GetWorkAsync()возврате.

continuationЛямбда всегда проверяет состояние _quitтак, если _quit == falseто нет больше продолжений, и TaskCompletionSourceполучает значение стоимости _quit, и все завершается.

Что касается вашего наблюдения относительно продолжения, выполняемого в другом потоке, это не то, что ключевое слово async/ awaitсделало бы для вас, согласно этому блогу «Задачи (все еще) не являются потоками, и асинхронность не параллельна» . - https://blogs.msdn.microsoft.com/benwilli/2015/09/10/tasks-are-still-not-threads-and-async-is-not-parallel/

Я бы предположил, что действительно стоит поближе взглянуть на ваш GetWorkAsync()метод в отношении потоков и безопасности потоков. Если ваша диагностика обнаруживает, что она выполнялась в другом потоке в результате вашего повторного асинхронного / ожидающего кода, то что-то в этом методе или связано с ним должно вызывать создание нового потока в другом месте. (Если это неожиданно, возможно, .ConfigureAwaitгде-нибудь есть?)

Бен Коттрелл
источник
2
Код, который я показал, (очень) упрощен. Внутри GetWorkAsync () есть еще несколько ожидающих. Некоторые из них имеют доступ к базе данных и сети, что означает истинный ввод-вывод. Как я понимаю, переключение потоков является естественным следствием (хотя и не обязательным) таких ожиданий, поскольку исходный поток не устанавливает никакого контекста синхронизации, который бы определял, где должны выполняться продолжения. Таким образом, они выполняются в потоке пула потоков. Мои рассуждения неверны?
aoven
@aoven Хороший вопрос - я не рассматривал различные виды SynchronizationContext- это, безусловно, важно, так как .ContinueWith()использует SynchronizationContext для отправки продолжения; это действительно объясняет поведение, которое вы видите, если awaitвызывается в потоке ThreadPool или потоке ASP.NET. В этих случаях продолжение, безусловно, может быть отправлено в другой поток. С другой стороны, вызова awaitоднопоточного контекста, такого как WPF Dispatcher или Winforms, должно быть достаточно, чтобы обеспечить продолжение в оригинале. нить
Бен Коттрелл