Асинхронная рекурсия. Куда же на самом деле уходит моя память?


Этот вопрос задается скорее из любопытства, чем в связи с какой-либо реальной проблемой.

Рассмотрим следующий код:

void Main()
{
    FAsync().Wait();
}

async Task FAsync()
{
    await Task.Yield();
    await FAsync();
}

В синхронном мире это, в конечном счете, вызовет stackoverflow.

В асинхронном мире это просто потребляет много памяти (что, как я предполагаю, связано с тем, что я мог бы свободно назвать "асинхронным стеком"?)

Что это за данные, и как они хранятся?
2 6

2 ответа:

Хороший вопрос.

Стек является воплощениемпродолжения . Продолжение-это просто информация о том, что программа собирается делать дальше. В традиционной несинхронной среде это представлено как обратный адрес в стеке; когда метод возвращает, он смотрит на стек и ветвится на обратный адрес. Стек также содержит информацию о том, какие значения локальных переменных находятся в точке, где происходит продолжение.

В асинхронная ситуация вся эта информация хранится в куче. Задача содержит делегат, который вызывается по завершении задачи. Делегат привязан к экземпляру класса" closure", который содержит поля для любых локальных переменных или другого состояния. И, конечно же, задачи сами по себе являются объектами кучи.

Вы можете задаться вопросом: если это так, что продолжение является делегатом, который вызывается, когда задача завершается, как тогда код, который завершает задачу, не на вызове стек в точке, где выполняется завершение? Задача может выбрать вызов делегата продолжения путем отправки сообщения windows, и когда цикл сообщений обрабатывает сообщение, он выполняет вызов. Таким образом, вызов находится в "верхней" части стека, где обычно находится цикл сообщений. (Точные детали стратегии вызова, используемой для продолжения, зависят от контекста, в котором создается задача; см. Более подробное руководство по параллельной задаче библиотека для деталей.)

Хорошую вступительную статью о том, как все это работает, можно найти здесь:

Https://msdn.microsoft.com/en-us/magazine/hh456403.aspx

Некоторые детали изменились с тех пор, как Мэдс написал эту статью, но идеи здравые. (ответ i3arnon иллюстрирует, как это развивалось; в статье Мэдса все идет на кучу, но в некоторых сценариях это приводит к избыточному мусору. Более сложный кодеген позволяет нам сохранить некоторые из информация на стеке. Понимание этого различия не обязательно для того, чтобы увидеть, как логически представлены продолжения.)

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

Компилятор превращает ваш асинхронный метод в структуру машины состояний. Структура создается в первую очередь в стеке. Когда вы ждете незавершенную задачу (в противном случае она продолжает выполняться синхронно и вызовет переполнение стека), эта машина состояния упаковывается и перемещается в кучу.

Например этот метод:

public async Task M()
{
}

Превращается в эту государственную машину:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    void IAsyncStateMachine.MoveNext()
    {
        try
        {
        }
        catch (Exception exception)
        {
            this.<>1__state = -2;
            this.<>t__builder.SetException(exception);
            return;
        }
        this.<>1__state = -2;
        this.<>t__builder.SetResult();
    }
    [DebuggerHidden]
    void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
    {
        this.<>t__builder.SetStateMachine(stateMachine);
    }
}

Итак, в" традиционной " рекурсии состояние для каждой итерации хранится в стеке, поэтому слишком много итерации могут переполнить эту память. В асинхронном методе состояние хранится в куче, и оно также может переполняться (хотя обычно оно намного больше).