Pull to refresh

Async/Await в C#. Часть 3. Чем Tasks(Задачи) лучше чем IAsyncResult. О чем не написал Stephen Toub

Level of difficultyMedium
Reading time9 min
Views7.4K

В этот раз я достаточно внимательно прочитал перевод главы про задачи(Tasks) из этого Поста, чтобы выяснить, что он не очень точно передает смысл исходного текста. Я попробую пересказать содержание так, как я его понял, в том числе полагаясь на свой практический опыт программирования многопоточных приложений и embedded приложений с множеством прерываний.

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

Пару ссылок на мои предыдущие работы по этой теме вы тоже найдете под катом.


Вот пара ссылок на предыдущие материалы по теме:

1. Уроки по асинхронному программированию из первой половины работы

2. Async/Await из C#. Головоломка для разработчиков компилятора и для нас

То что относится к пересказу содержания выделено через форматирование аббревиатуры. ВНИМАНИЕ! Не пытайтесь далее открывать пояснения к таким выделениям, это выглядит страшно, но я не нашел лучшего способа отделить текст пересказа содержания от текста комментариев. Мне бы хотелось чтобы разработчики Хабра придумали что-то, чтобы можно было использовать такой стиль текста без подсказок, а может быть, добавили бы еще пару стилей для таких возможностей разделения-выделения текста.

<Еще, слово "Задача" везде где используется, заменяет термин Task, я старался везде писать с большой буквы.>

.NET Framework 4.0 предоставил к использованию тип: System.Threading.Tasks.Task. По своей сути Task — это просто структура данных, представляющая конечное завершение некоторой асинхронной операции (другие фреймворки называют подобный тип “promise” или “future”). Задача создается для представления некоторой операции, а затем, когда операция, которую она логически представляет, завершается, результаты сохраняются в этой Задаче. Все достаточно просто.

<Замечание: на самом деле в этих двух предложениях автор как минимум не последователен в своих определениях, когда сначала говорит, что Task это какое-то «конечное завершение», а потом говорит что Task это «представление некоторой операции», то есть всей целой операции с результатом или без. Наверно так получилось, потому что автор старается выдержать линию на преемственность исторических решений по асинхронным операциям.

Насколько я понимаю, правильным и полным определением является это:

Task это «представление некоторой операции»

>

 Но ключевая особенность, которую предоставляет Task и которая делает его значительно более полезным, чем IAsyncResult, заключается в том, что он встраивает в себя понятие продолжения.

<Пояснение: в предудущих работах посвященных этому Посту .NET блога я уже несколько раз обращал внимание что содержание этого Поста крутится вокруг термина-понятия «продолжение» это callback-функция которая должна быть вызвана при завершении асинхронной операции

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

 Эта единственная функция означает, что вы можете подойти к любой Задаче и попросить асинхронно получать уведомления о ее завершении, при этом сама Задача занимается синхронизацией <ред: насколько я это понимаю: синхронизацией асинхронной операции с инициировавшим эту операцию кодом/объектом/потоком/…>, чтобы гарантировать, что callback-продолжение будет вызвано независимо от того, была ли Задача уже выполнена, еще не завершена или завершается одновременно с запросом о таком уведомлении.<ред: дальше есть иллюстрация в коде обозначим ее (А) там я смогу пояснить что имеется ввиду > Почему это так важно? Что ж, если вы помните наше обсуждение старого шаблона APM, там было две основные проблемы.

  1. Вы должны были создать пользовательскую реализацию IAsyncResult для каждой операции: не было предопределенной реализации IAsyncResult, которую кто угодно мог бы просто использовать для своих целей.

  2. Вы должны были знать до вызова метода Begin, что вы хотите сделать, когда он будет завершен. Это создает значительную проблему для реализации комбинаторов <ред: комбинаторы – это операции над набором асинхронных операций, собранных, например в List-списке> и других обобщенных(generic) процедур для использования и реализации некоторой произвольной асинхронности между ними.

 С задачами (Task) таких проблем нет, Задачи позволяют нам обращаться к асинхронной операции после того, как вы уже инициировали операцию, и предоставить продолжение после того, как вы уже инициировали операцию… вам не нужно предоставлять это продолжение методу, который инициирует операцию. Каждый, кто создает асинхронные операции, может создать Задачу, и каждый, кто потребляет асинхронные операции, может использовать Задачу, и для их соединения не нужно ничего настраивать: Задача становится универсальным языком общения, позволяющим производителям и потребителям асинхронных операций понимать друг друга. И это изменило облик .NET. Подробнее об этом чуть позже…

 А пока давайте лучше разберемся, что это на самом деле означает. Вместо того, чтобы углубляться в сложный код для Task, мы реализуем простую версию как на школьном уроке. Это не должно быть идеальной реализацией, но эта реализация должна быть достаточно полной функционально, чтобы помочь понять сущность типа Task, который, в конце концов, на самом деле, является просто структурой данных, которая предоставляет скоординированные настройки и данные о приеме сигнала завершения. Мы начнем всего с нескольких полей:

class MyTask
{
    private bool _completed;
    private Exception? _error;
    private Action<MyTask>? _continuation;
    private ExecutionContext? _ec;
    ...
}

 Нам нужно поле, чтобы знать, завершена ли Задача (_completed), и нам нужно поле для хранения любой ошибки, которая привела к сбою Задачи (_error); если бы нам понадобился обобщенный MyTask<TResult>, мы бы добавили private поле TResult _result для хранения успешного результата операции. Пока что это очень похоже на нашу пользовательскую реализацию IAsyncResult ранее (не совпадение, конечно). Но теперь самое главное, поле _continuation. В этой простой реализации мы поддерживаем только одно продолжение, но этого достаточно для нашей учебной Задачи (в реальной Задаче используется поле object, которое может быть либо отдельным объектом продолжения, либо списком объектов продолжения). Это делегат, который будет вызван, когда Задача завершится.

Теперь немного о том, что видно снаружи. Как уже отмечалось, одним из фундаментальных улучшений в Task по сравнению с предыдущими моделями была возможность предоставлять работу продолжения (обратный вызов) после того, как операция была инициирована. Нам нужен метод, позволяющий это делать, поэтому давайте добавим ContinueWith:

class MyTask
{
  ...
public void ContinueWith(Action<MyTask> action)
{
    lock (this)
    {
        if (_completed)
        {
            ThreadPool.QueueUserWorkItem(_ => action(this));
        }
        else if (_continuation is not null)
        {
            throw new InvalidOperationException("Unlike Task, this implementation only supports a single continuation.");
        }
        else
        {
            _continuation = action;
            _ec = ExecutionContext.Capture();
        }
    }
}
}

<Это та самая иллюстрация в коде обозначенная (А). Помните что это реализация для метода из класса MyTask, который мы пишем и разбираем в целях обучения. В следующем абзаце с выделением код этой функции, по сути, просто переписан в словах. Единственное что там пропущено это пояснения для использования

lock (this)

Этот lock нужен как раз чтобы разрешить ситуацию когда callback-продолжение будет передано в Задачу, когда Задача завершается одновременно с запросом о таком уведомлении. То есть когда вызов функции, которая передает продолжение в Задачу накладывается на обработку завершения операции из Задачи. Таким образом исключается race conditions в этом случае.>

Если Задача уже была помечена как завершенная к моменту вызова ContinueWith, ContinueWith просто ставит выполнение делегата в очередь ThreadPool на исполнение. В противном случае метод сохраняет делегат, так что продолжение может быть поставлено в очередь на исполнение после завершения Задачи (метод также сохраняет что-то, называемое ExecutionContext, и затем использует это при последующем вызове делегата, но пока не беспокойтесь об этой части… мы вернемся к этому позже). Все достаточно просто.

Затем нам нужно иметь возможность пометить MyTask как завершенную, что означает, что любая асинхронная операция, которую она представляет, завершена. Для этого мы предоставим два метода: один, чтобы отметить успешное завершение операции которую представляет Задача (“SetResult”), и один, чтобы отметить завершение этой операции с ошибкой (“SetException”).: 

class MyTask
{
  ...
public void SetResult() => Complete(null);

public void SetException(Exception error) => Complete(error);

private void Complete(Exception? error)
{
    lock (this)
    {
        if (_completed)
        {
            throw new InvalidOperationException("Already completed");
        }

        _error = error;
        _completed = true;

        if (_continuation is not null)
        {
            ThreadPool.QueueUserWorkItem(_ =>
            {
                if (_ec is not null)
                {
                    ExecutionContext.Run(_ec, _ => _continuation(this), null);
                }
                else
                {
                    _continuation(this);
                }
            });
        }
    }
}
}

Мы сохраняем ошибку если она произошла, помечаем Задачу как выполненную, а затем, если ранее было зарегистрировано продолжение, ставим его в очередь для вызова.

Наконец, нам нужен способ продвижения вероятного исключения, которое могло возникнуть в Задаче (а, если бы это была обобщенная MyTask<T>, вернуть ее _result); для облегчения определенных сценариев мы добавим метод который разрешает блокировать ожидание завершения Задачи, ожидание которое мы можем реализовать через обращение к ContinueWith (продолжение просто активизирует ManualResetEventSlim, на котором вызывающий объект блокируется в ожидании завершения-продолжения ).

class MyTask
{
  ...
public void Wait()
{
    ManualResetEventSlim? mres = null;
    lock (this)
    {
        if (!_completed)
        {
            mres = new ManualResetEventSlim();
            ContinueWith(_ => mres.Set());
        }
    }

    mres?.Wait();
    if (_error is not null)
    {
        ExceptionDispatchInfo.Throw(_error);
    }
}
  ...

И это, по сути, все. Но не надо забывать, реальная реализация Задач(Task) намного сложнее, с гораздо более эффективной реализацией, с поддержкой любого количества продолжений, с множеством настроек того, как она должна себя вести (например, должны ли продолжения ставиться в очередь, как это делается здесь, или они должны вызываться синхронно таким образом что завершение становится частью операции из Задачи), с возможностью хранить несколько исключений, а не только одно, со специальным пониманием о процедуре отмены, с множеством вспомогательных методов для выполнения общих операций (например Task.Run, который создает Задачу для делегата, поставленного в очередь для вызова в пуле потоков) и так далее. Но во всем этом нет ничего волшебного; по сути, это продолжение того, что мы здесь увидели.

Вы также можете заметить, что в этом учебном MyTask мы реализовали public методы SetResult/SetException, в то время как в настоящем Task их нет. На самом деле, у Task есть такие методы, только они объявлены как internal<Коментарий: то есть скрыты от внешних проектов>, с типом System.Threading.Tasks.TaskCompletionSource, который выступает в качестве отдельного “производителя” для Задачи и ее завершения; это было сделано не из технической необходимости, а как способ исключить методы продолжения там, где Задача предназначена только для потребления. Затем вы можете раздать ссылку на объект Task, не беспокоясь о том, что Задача будет выполнена без вашего ведома; сигнал завершения является деталью реализации для того, кто создал Задачу, и также оставляет за собой право завершить ее, сохранив TaskCompletionSource при себе. (CancellationToken и CancellationTokenSource следуют аналогичной схеме: CancellationToken — это просто интерфейс в виде структуры для CancellationTokenSource, общедоступный интерфейс, связанный с использованием сигнала отмены, но без возможности его создания, что являетсяограницением возможностей для всех, у кого есть доступ к CancellationTokenSource.)

<Коментарий: на мой взгляд в этом абзаце мы увидели хороший пример того, к чему приводит попытка выразить некоторые детали реализации, на три страницы описания (если не больше), в десяти строчках. В общем-то получилось просто перечисление того, о чем желательно почитать и с чем не плохо было бы разобраться из всего того, в чем, по уверению автора, нет ничего волшебного. И в чем вряд ли можно разобраться без практических примеров.>

Конечно, мы можем реализовать комбинаторы и методы-помощники для этой MyTask аналогично тому, что предоставляет Task. Хотите простую MyTask.WhenAll? Вот, пожалуйста:

public static MyTask WhenAll(MyTask t1, MyTask t2)
{
    var t = new MyTask();

    int remaining = 2;
    Exception? e = null;

    Action<MyTask> continuation = completed =>
    {
        e ??= completed._error; // just store a single exception for simplicity
        if (Interlocked.Decrement(ref remaining) == 0)
        {
            if (e is not null) t.SetException(e);
            else t.SetResult();
        }
    };

    t1.ContinueWith(continuation);
    t2.ContinueWith(continuation);

    return t;
}

ХотитеMyTask.Run? Пожалуйста:

public static MyTask Run(Action action)
{
    var t = new MyTask();

    ThreadPool.QueueUserWorkItem(_ =>
    {
        try
        {
            action();
            t.SetResult();
        }
        catch (Exception e)
        {
            t.SetException(e);
        }
    });

    return t;
}

А как насчет MyTask.Delay? Их есть у меня:

public static MyTask Delay(TimeSpan delay)
{
    var t = new MyTask();

    var timer = new Timer(_ => t.SetResult());
    timer.Change(delay, Timeout.InfiniteTimeSpan);

    return t;
}

Надеюсь вы все поняли.<Это не от меня, там, действительно, было что-то в этом роде по смыслу. Но я присоединяюсь, только в виде просьбы. Тут вроде все элементарно и разложено по полочкам.>

С появлением Task все предыдущие асинхронные шаблоны в .NET ушли в прошлое. Везде, где ранее для реализации асинхронных операций использовался шаблон APM или шаблон EAP, теперь доступны новые методы, возвращающие Задачу.

Конец части 3.

До меня тут довели неоднозначную информацию по поводу переводов, и я теперь совсем не уверен стоит ли продолжать эту работу по этому Посту .Net блога.

Может кто-нибудь поможет советом.

PS:

не устаю повторять: не связывайтесь с асинхронными операциями без крайней необходимости.

Tags:
Hubs:
Total votes 3: ↑2 and ↓1+3
Comments2

Articles