Skip to content

Instantly share code, notes, and snippets.

@navferty
Last active December 23, 2023 17:34
Show Gist options
  • Save navferty/828f22889f562b35e997b6bba5f8be6c to your computer and use it in GitHub Desktop.
Save navferty/828f22889f562b35e997b6bba5f8be6c to your computer and use it in GitHub Desktop.

Async-await

План

Сценарий видео

Вступление

Рад приветствовать слушателей после долгого перерыва. Надеюсь, за это время многие из вас решали учебные задачи, занимались пет-проектами, проходили собеседования, а кто-то, наверно, даже устроился на работу программистом. И в учебных, и в рабочих проектах вы наверняка сталкивались с ключевыми словами async-await. Сегодня мы попробуем разобраться, чем синхронный вызов метода отличается от асинхронного, почему метод, внутри которого есть await, обязательно должен быть помечен словом async и что происходит "под капотом" при компиляции такого метода. Пример асинхронного метода Вы видите на экране. Эти два метода выглядят очень похоже: мы читаем файл, только используем во втором случае метод ReadAllTextAsync, который возвращает задачу (Task). Работают они тоже, кажется, одинаково. Но вскоре мы с вами убедимся, что в реальности при вызове асинхронного метода происходит существенно больше работы, только она скрыта, и за нас её выполняет компилятор.

// обычный синхронный метод
void PrintFileContents(string filePath)
{
    var fileContents = File.ReadAllText(filePath);
    Console.WriteLine($"File contents: {fileContents}");
}

// асинхронный метод помечен словом async, а также возвращает Task, а не void
async Task PrintFileContentsAsync(string filePath)
{
    var fileContents = await File.ReadAllTextAsync(filePath);
    Console.WriteLine($"File contents: {fileContents}");
}

// определение метода File.ReadAllTextAsync
public static class File
{
   public static Task<string> ReadAllTextAsync(string path, CancellationToken cancellationToken = default(CancellationToken))
   {
      // ...
   }
}

Пример-метафора

Прежде чем приступать непосредственно к теории и практике, давайте на примере рассмотрим, чем может являться асинхронная операция, и в чём ее преимущества. Скоро Новый год, поэтому возьмём немного абсурдный пример производства новогодних мандаринов на заводе =)

Давайте представим, что у нас есть небольшой завод по производству мандаринов. Есть несколько рабочих — они представляют потоки нашего процессора и могут выполнять работу, которую мы им поручим. Для изготовления мандарина нужно выполнить некоторые шаги: достать комплектующие со склада, вручную изготовить дольки, собрать дольки в шарик и обернуть их в кожуру на специальном станке. На готовое изделие нужно наклеить бумажку и положить его в ящик. Эти шаги мы описали в виде инструкций, которые мы передаём рабочим.

Итак, рабочий получает материалы и садится за ручное изготовление долек и формирование из них шарика. Пока он это делает, давайте взглянем на листок с инструкциями нашего трудяги.

фруктассемблер

Это наша программа, аналог ассемблерного кода — подробных инструкций для процессора, шаг за шагом описывающих каждую операцию. Если программа в настоящий момент выполняется, мы видим instruction pointer, который указывает на тот шаг, который нужно будет делать следующим.

Итак, мандаринка из долек готова, следующим шагом нужно завернуть ее в кожуру. Для этого на нашей фабрике есть несколько специальных станков, которые делают это автоматически, нужно лишь положить туда заготовку и материал для кожуры и нажать на кнопку. Процесс будет выполняться 5 минут. Этот шаг олицетворяет длительную операцию ввода-вывода (I/O) — например, запись большого файла на диск или сетевой запрос к серверу.

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

рабочие на производстве мандаринов

Но теперь пришёл новый начальник производства, который знает про асинхронную модель. Он просит рабочих не бездельничать, пока станок работает, а делать в этот момент другую работу. Он убрал табуретки, но вместо этого сделал небольшую полку. Давайте проследим, как действует наш рабочий. Он подходит к станку, как обычно запускает оборачивание в кожуру, но теперь он делает какие-то пометки на своём листе с инструкциями и кладёт его на полку, после чего, взяв новый листок с инструкциями, идёт на склад за материалами для следующего мандарина. Но вот прошло 5 минут, и раздался звонок — мандарин готов. Мы видим, что другой рабочий, который только что как раз заступил на смену, хватает с полки у станка листок с инструкциями и продолжает как ни в чём ни бывало работу с мандаринкой: наклеивает маркировку и упаковывает её в ящик. Ведь на листке есть вся нужная информация для продолжения работы (например, комплектация этого конкретного мандарина и цвет наклейки).

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

Теория: процессы и потоки

Теперь, давайте немного формализуем понятия "процесс" и "поток".

Прежде всего, рассмотрим понятие "поток" применительно к операционным системам. Процессор, как робот, последовательно выполняет инструкции одна за другой — это и есть поток исполнения. Но помимо самих инструкций, для выполнения требуются и другие ресурсы: выделенная область в оперативной памяти, где лежит стек вызовов, состояние регистров процессора (небольших ячеек памяти внутри самого процессора). Всё это в совокупности называют "потоком", по-английски — "thread".

image
Пример набора инструкций, которые выполняются в потоке одна за другой

Операционная система управляет тем, какой поток отдаётся на исполнение процессору (а точнее — ядрам процессора), а при переключении разных потоков ей приходится сохранять контекст старого потока (например, значения регистров), и восстанавливать контекст нового. То есть создание новых потоков и их переключение можно считать относительно дорогой операцией.

Когда мы запускаем программу, стартует процесс, и он содержит как минимум один поток. В ходе работы программы могут создаваться и дополнительные потоки, которые называют рабочими. Надо отметить, что для .NET приложений есть понятие "пул потоков" — то есть обычно программист не создает потоки сам, они создаются средой исполнения. При необходимости программист может запланировать выполнение своей задачи на потоке из этого пула, а после её выполнения этот поток будет возвращён в пул и доступен для повторного использования.

Также процесс обладает некоторыми другими ресурсами, например, общая память, которая используется его потоками совместно. Более подробно о процессах и потоках вы можете прочитать в материалах по ссылкам в описании.

Асинхронная операция

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

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

Надо заметить, что хотя случаются ситуации, когда после завершения такой операции не нужно ничего выполнять (принцип fire-and-forget, то есть "запустил и забыл"), чаще всего такие операции запускаются для того, чтобы дождаться результата, и что-то с ним сделать. Например, выполняем сетевой запрос, чтобы сохранить информацию из ответа в базу данных, или читаем большой файл с диска, чтобы проанализировать его содержимое.

Знакомство с асинхронными методами, возвращающими Task и Task<T>

Теперь, когда мы познакомились с понятием "асинхронной операции", давайте посмотрим, как такие операции реализуются на C#.

public async Task<int> M()
{
   Console.WriteLine($"Please type file path to read...");
   var filePath = Console.ReadLine();

   var content = await File.ReadAllTextAsync(filePath);
   Console.WriteLine($"Read file finished: {content}");

   // подождать 500 миллисекунд
   await Task.Delay(500);
   Console.WriteLine("Second delay finished");
   return 42;
}

Мы видим метод, который сначала просит у пользователя ввести путь к файлу, потом читает его содержимое, а также выполняет простое ожидание в полсекунды. Всё выглядит довольно просто, как привычный синхронный код: последовательно идут инструкции одна за другой. Но две из них помечены словом await, что вынуждает нас пометить сам метод словом async, а также заставляет компилятор выполнить дополнительную работу, превратив наш метод в более сложный код.

Машина состояний (state machine) и логика её работы

Давайте познакомимся с этим кодом, который в реальности будет в результате. Я воспользовался сайтом sharplab.io, где можно посмотреть результат компиляции C# кода, причем выберем для просмотра также C#, только более упрощённый, в котором уже не будет "синтаксического сахара" в виде async-await. Мы видим сгенерированный код, он выглядит довольно громоздко и нечитабельно, в том числе благодаря тому, что имена полей намеренно сделаны невалидными для C#, чтобы избежать коллизий (в частности, используются угловые скобки).

Поэтому давайте рассмотрим несколько упрощенную версию этого же кода: я опустил некоторые несущественные детали, а также переименовал сгенерированные имена, но общая логика осталась той же.

private sealed class M_StateMachine : IAsyncStateMachine
{
    public int _state;
    public AsyncTaskMethodBuilder<int> _builder;

    // локальные переменные теперь представлены полями класса
    private string _filePath;
    private string _content;

    // отдельные поля предусмотрены для ожидания каждой асинхронной операции
    private TaskAwaiter<string> _awaiter1;
    private TaskAwaiter _awaiter2;

    // логика стейт-машины: выполнение соответствующего кода
    // в зависимости от текущего состояния _state
    public void MoveNext()
    {
        // ...
    }
}

// вместо исходного содержимого, метод теперь содержит лишь
// создание, инициализацию и запуск стейт-машины
public Task<int> M()
{
    var stateMachine = new M_StateMachine
    {
        _builder = AsyncTaskMethodBuilder<int>.Create(),
        _state = -1,
    };
    
    // метод Start запускает стейт-машину, в том числе вызывая метод MoveNext
    stateMachine._builder.Start(ref stateMachine);
    return stateMachine._builder.Task;
}

Мы видим класс, описывающий так называемую "машину состояний", или "стейт-машину". Локальные переменные нашего метода становятся полями такого класса. Под каждый асинхронный вызов мы видим отдельные поля с некими "эвейтерами" — их мы рассмотрим чуть позже. Также есть отдельное числовое поле, которое задаёт само состояние машины в текущий момент, и вспомогательное поле билдера.

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

А наш метод теперь содержит лишь вспомогательный код инициализации и запуска стейт-машины. Исходная логика самого метода переехала внутрь MoveNext(). Опять же, я немного реорганизовал код для лучшего понимания, но суть осталась той же.

public void MoveNext()
{
    int result = default;
    try
    {
        // часть 1. начало метода, запуск чтения из файла
        if (_state == -1)
        {
            Console.WriteLine("Please type file path to read...");
            _filePath = Console.ReadLine();
            _awaiter1 = File.ReadAllTextAsync(_filePath).GetAwaiter();
            _state = 0;
            if (!_awaiter1.IsCompleted)
            {
                // если асинхронная операция не завершена сразу же после её запуска,
                // запланируем очередной вызов MoveNext сразу после её завершения
                var stateMachine = this;
                _builder.AwaitUnsafeOnCompleted(ref _awaiter1, ref stateMachine);
                return;
            }
        }

        // часть 2. результат чтения из файла, запуск Task.Delay
        if (_state == 0)
        {
            _content = _awaiter1.GetResult();
            Console.WriteLine(string.Concat("Read file finished: ", _content));
            _awaiter2 = Task.Delay(100).GetAwaiter();
            _state = 1;
            if (!_awaiter2.IsCompleted)
            {
                var stateMachine = this;
                _builder.AwaitUnsafeOnCompleted(ref _awaiter2, ref stateMachine);
                return;
            }
        }

        // часть 3. завершение метода, определение возвращаемого значения 42
        if (_state == 1)
        {
            _awaiter2.GetResult();
            Console.WriteLine("Second delay finished");
            result = 42;
        }
    }
    catch (Exception exception)
    {
        _state = -2;
        _builder.SetException(exception);
        return;
    }
    _state = -2;
    _builder.SetResult(result);
}

Так как исходный метод имел два await вызова, они делят логику исходного метода на три части:

  • самое начало (до первого await)
  • середина (после чтения файла, но до ожидания Task.Delay)
  • конец (вывод в консоль и возврат 42 после завершения ожидания)

И в методе MoveNext мы также можем заметить те же три части, которые будут исполнены при соответствующем состоянии нашей стейт-машины.

Разобрать по шагам логику метода MoveNext, в зависимости от состояния в _state

Обратите внимание, что async не является частью видимой для внешнего пользователя сигнатуры, это лишь деталь реализации. Сам метод при вызове сразу возвращает объект Task, который может быть как в завершенном виде, так и в незавершенном.

Надо заметить, что вспомогательный метод _builder.AwaitUnsafeOnCompleted(ref awaiter, ref this), который помогает нам запланировать следующий вызов MoveNext по завершении асинхронной операции, также предусматривает дополнительную задачу сохранения и восстановления контекста (ExecutionContext и SynchronizationContext). Например, для приложений WinForms и WPF контекст обеспечивает вызов продолжения на UI-потоке, в случае если и исходный вызов был с этого потока. Впрочем, эти темы мы планируем разобрать более подробно в следующих видео по этой теме.

Вот вольное и очень упрощённое изложение того, что делает этот метод, с комментариями.

void AwaitUnsafeOnCompleted<TAwaiter>(ref TAwaiter awaiter, IAsyncStateMachine stateMachine) where TAwaiter : INotifyCompletion
{
    var box = new AsyncStateMachineBox();

    // сохраняем текущий контекст на момент вызова асинхронной операции
    box._context = ExecutionContext.Capture();
    box._stateMachine = stateMachine;

    // не вызывая сам метод, мы создаём делегат - то есть указатель на него
    var action = new Action(box.MoveNextAction);

    // этот делегат, на который указывает action, будет вызван awaiter'ом по завершении операции
    awaiter.AwaitUnsafeOnCompleted(action);
}
class AsyncStateMachineBox
{
    internal ExecutionContext _context;
    internal IAsyncStateMachine _stateMachine;

    private void MoveNextAction()
    {
        if (_context == null)
        {
            _stateMachine.MoveNext();
        }
        else
        {
            // в зависимости от ранее захваченного контекста,
            // action может быть исполнен, например, на определённом потоке
            ExecutionContext.Run(_context, _ => _stateMachine.MoveNext(), null);
        }
    }
}

Обратите внимание на вызов ExecutionContext.Capture(): происходит сохранение того контекста, который был при запуске асинхронной операции. Вспомните рабочих на мандариновом заводе, которые при запуске станка сохраняли свой "контекст" на листочке и клали его на полку.

Сохранив контекст и ссылку на нашу стейт-машину в отдельный контейнер, в AwaitUnsafeOnCompleted мы создаём делегат Action, то есть просто указатель на метод. Передавая этот указатель эвейтеру через awaiter.AwaitUnsafeOnCompleted(action), мы поручаем ему вызвать MoveNextAction() по завершении операции. При этом MoveNextAction оборачивает вызов MoveNext нашей стейт-машины в некоторую дополнительную логику, обеспечивающую нужный контекст выполнения при необходимости.

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

Ссылка на пример кода в Shraplab.io

Стандарт языка C# про выражение await и про асинхронные функции, исходный код AsyncTaskMethodBuilder<T>

На сегодня мы закончили ковыряться во внутренностях фреймворка, дальше будет уже прикладной код. Если какие-то детали того, что мы сейчас смотрели, Вам непонятны — ничего страшного, главное — понимать общую идею того, как происходит вызов асинхронного метода и ожидание его результата. Вы можете еще раз посмотреть видео позже или почитать его текстовую версию, а также другие материалы по ссылкам в описании.

Sync-over-async

Рассмотрев то, во что раскрывается вызов асинхронного метода, если он сопровождается словом await, мы понимаем, почему вызывающий метод обязан сам быть асинхронным: ведь для компиляции такого вызова нужно собрать стейт-машину, которая как бы заменит исходный метод, от которого останется лишь вспомогательный код с AsyncTaskMethodBuilder'ом. Иными словами, если Вы пишете слово await внутри метода, то и сам метод нужно пометить словом async.

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

// по каким-то веским причинам, мы не хотим делать метод асинхронным
int GetNumberOfCodingResources()
{
    var result = _httpClient
        .GetFromJsonAsync<CodingResource[]>("https://api.sampleapis.com/codingresources/codingResources")
        .GetAwaiter()
        .GetResult();
    return result.Length;
}

class CodingResource
{
    public int Id { get; init; }
    public string? Description { get; init; }
}

Важно отметить, что помимо блокировки потока на долгое время, что само по себе может быть проблемой, Вы можете столкнуться с гораздо более серьезной проблемой. Дело в том, что при завершении асинхронной операции для вызова продолжения (того самого MoveNext) будет нужен другой поток — ведь исходный поток заблокирован на вызове GetResult() тем, что сам ждёт получения результата. И тут возможны два варианта:

  • В лучшем случае, если старт асинхронной операции происходил на рабочем потоке, у которого не было определённого контекста, и для выполнения продолжения может использоваться любой поток - Вы получите использование двух потоков для такой операции. То есть дополнительный поток потребуется для того, чтобы выполнить метод MoveNext, где будет установлен SetResult, которого ожидает первый поток. Такой вариант может ударить по производительности, но всё может быть намного хуже в другом случае, если...
  • Запуск асинхронной операции происходил с UI-потока. Это отдельный поток в приложениях типа WinForms или WPF, который крутится в цикле MessageLoop и отвечает за взаимодействие с пользователем через графический интерфейс. Его особенность в том, что он имеет определенный контекст синхронизации, и этот контекст заставляет выполняться продолжение (метод MoveNext) именно на том же потоке. Постойте, как раз этот поток уже занят тем, что ждёт, пока кто-то установит SetResult для этой асинхронной задачи, завершение которой требует этого же потока... Замкнутый круг! Эта ситуация называется дедлок и сопровождается полным зависанием интерфейса программы: её можно только принудительно завершить через диспетчер задач.
// метод в библиотеке, которая может использоваться в WPF-приложении
// этот метод может быть вызван UI-потоком (имеющим контекст синхронизации)
public async Task<MyData> DoQuery()
{
    var result = await RunQuery().ConfigureAwait(false);
   
    // для выполнения продолжения метода нет необходимости восстанавливать контекст,
    // то есть выполнять его, так же как и начало метода, на UI-потоке

    _logger.LogInformation("Получено {DataCount} записей", result.Data.Count);
    return result;
}

Если Вы пишете код, который, вероятно, будет запускаться в таких приложениях, имеющих контекст синхронизации (например WinForms и WPF), Вы можете уменьшить риск такого дедлока, добавляя .ConfigureAwait(false) при асинхронном вызове. Это предотвратит захват контекста для выполнения продолжения Вашего асинхронного метода.

Таким образом, за исключением очень редких ситуаций, всегда рекомендуется избегать синхронного ожидания асинхронных операций. И пусть, чтобы написать await, Вам придётся поменять тип возвращаемого значения в методе на Task и даже, может быть, придётся изменить интерфейс, который Вы наследуете в своём классе — это намного лучше, чем заворачивать ожидание асинхронного метода в GetAwaiter().GetResult().

Запуск асинхронной операции через Task.Run

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

Например, у вас есть какой-то метод, выполняющий тяжёлые математические расчёты, и вы хотите запустить эти расчёты по нажатию на кнопку в приложении WinForms. Но, чтобы не блокировать UI поток, можно запустить их в рабочем потоке, и асинхронно дождаться их завершения.

private async void Button_Click(object sender, EventArgs e)
{
    // запускаем операцию в фоне, чтобы графический интерфейс оставался отзывчивым
    var calculationResults = await Task.Run(() => DoCalculations());
    
    // продолжение выполняется в UI потоке (благодаря логике захвата контекста)
    // поэтому можем напрямую управлять элементами интерфейса
    label1.Text = "Расчёты завершены";
}

string DoCalculations()
{
    // тяжёлые операции, которые нужно выполнить в фоне
}

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

Выводы

Таким образом, если говорить упрощенно, когда мы пишем await DoSmth(), мы как бы просим: "начни операцию DoSmth и позови, когда она будет выполнена, чтобы я мог обработать её результат своим кодом, который идёт после этого вызова". И если эта операция не была уже завершена в момент вызова, мы возвращаем поток в общий пул, и он может быть использован другими задачами.

Если вернуться к примеру с рабочими, которых мы считаем метафорой потоков, — когда Вы делаете асинхронный вызов, Вы даёте возможность рабочему выполнять другую работу и не заставляете его ждать, пока выполняется долгий процесс. Но когда он всё же выполнится — любой из рабочих сможет продолжить выполнять работу с нужного места.

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

Отдельные темы для других видео. Углубленное погружение в асинхронную модель в .NET

В этом разделе — наброски тем для дальнейших видео.

Task.WhenAny и Task.WhenAll

Обычно, где-то в 99% случаев, когда мы используем метод, возвращающий таску, мы просто пишем await, чтобы получить её результат. Но иногда возникает необходимость запустить сразу несколько асинхронных задач, и дождаться выполнения одной из них, или всех задач. Для таких ситуаций пригодятся методы Task.WhenAny и Task.WhenAll.

var client = new HttpClient();

// опустив слово await, мы просто сохраняем в переменную ссылку на Task<HttpResponseMessage>
var a = client.GetAsync("http://example.org");
var b = client.GetAsync("http://example.com");
var c = client.GetAsync("http://example");

// resultAny - это задача, которая завершилась раньше всех
var resultAny = await Task.WhenAny<HttpResponseMessage>(a, b, c);

// при этом она может быть в состянии Faulted, то есть
// при попытке ее await'ить выбросит исключение
var firstResult = await resultAny;

// resultAll - массив результатов всех задач
var resultAll = await Task.WhenAll<HttpResponseMessage>(a, b, c);

Отладка асинхронного кода с Task

Давайте поговорим об приёмах отладки асинхронных методов. Современные инструменты, такие как Rider или Visual Studio 2022, делают отладку асинхронного кода такой же удобной, как и работу с синхронными методами.

демонстрация стека вызовов асинхронного метода:

image

Обратите внимание на кадр стека ThreadPoolWorkQueue.Dispatch - это говорит нам о том, что таймер сработал именно на рабочем потоке из пула, да и сам поток, как мы видим, называется ThreadPool worker.

...и асинхронного стека (выбор Threads/Tasks в окне Parallel stacks)

image

Статья на devblogs.microsoft.com об отладке асинхронного кода в Visual Studio

Продемонстировать инструменты в стуции:

  • Просмотр стека вызовов (Ctrl-Alt-C)
  • Окно Parallel stacks, отображающее стеки вызовов множества потоков
    • Отображение стеков потоков в режиме "Threads"
    • Построение и отображение логических асинхронных стеков в режиме "Tasks"
  • Окно "Tasks"

Отмена асинхронных задач с помощью CancellationToken

При проектировании методов, которые содержат асинхронные вызовы, следует не забывать про сценарий, когда с вызывающей стороны приходит сигнал об отмене операции. Для этих целей используется структура CancellationToken.

async Task Process(CancellationToken token)
{
    var items = await GetAllItems(token);
    foreach (var item in items)
    {
        // явно прервать цикл, если вызывающая сторона запросила отмену операции
        token.ThrowIfCancellationRequested();
        
        // передаем токен отмены дальше при вызове других методов
        await ProcessItem(item, token);
        
        // многие методы имеют перегрузку, в которой CancellationToken является одним из аргументов
        await Task.Delay(500, token);
    }
}

Надо заметить, что CancellationToken не обязательно используется только в асинхронных методах. Например, если вы выполняете сложные математические расчёты, то может быть разумным также добавить проверку токена отмены в цикл, чтобы не пришлось ждать окончания длительных расчётов в случае, когда они уже не нужны.

Вы можете предусмотреть отдельную логику на случай, если операция будет отменена, обработав OperationCanceledException в отдельном блоке catch.

try
{
    await Process(token);
}
catch (OperationCanceledException)
{
    _logger.LogError("The operation was cancelled");
    throw;
}
catch (Exception ex)
{
    _logger.LogError("Unexpected error occured: {Error}", ex.Message);
    throw;
}

При необходимости, Вы можете сами управлять отменой с помощью CancellationTokenSource.

// CancellationTokenSource наследует интерфейс IDisposable, поэтому мы обязаны вызвать Dispose после его использования
using var cancellationSource = new CancellationTokenSource();
var token = cancellationSource.Token;

var longDelayTask = Task.Run(async () => await Task.Delay(10_000, token));

await Task.Delay(1_000);
cancellationSource.Cancel();

await longDelayTask;

Использование TaskCompletionSource

Вы можете использовать класс TaskCompletionSource для объявления асинхронной операции, которая будет завершена при ручном вызове метода SetResult (или SetException/SetCanceled).

TODO

Немного истории. Старая модель APM

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

Давайте рассмотри эволюцию подходов к асинхронным задачам в языке C#, до того как были добавлены ключевые слова async/await. Раньше использовался подход, называемый asynchronous programming model (APM), выглядит примерно так:

// old APM approach
handler.BeginDoStuff("something", iar =>
{
    // этот метод обратного вызова (callback) будет выполняться только после выполнения длительной операции
    var handler = (ISampleHandler)iar.AsyncState!;
    var i = handler.EndDoStuff(iar);
    Console.WriteLine($"Async operation got result {i}");
}, handler);

// эта строка выполнится сразу после старта длительной операции, не дожидаясь её окончания
Console.WriteLine("Async operation started");

interface ISampleHandler
{
    int DoStuff(string arg);

    // этот метод запускает некоторую продолжительную операцию, например сетеовой запрос
    IAsyncResult BeginDoStuff(string arg, AsyncCallback? callback, object? state);
    int EndDoStuff(IAsyncResult asyncResult);
}

Этот подход старый, и я привожу его только для более наглядной иллюстрации подхода. Скорее всего, вы не будете сталкиваться с таким при программировании современных приложений на C#.

Метод BeginDoStuff вторым аргументом принимает некий AsyncCallback, который является всего лишь методом, который должен быть вызван при завершении некоторой продолжительной операции. Это может произойти моментально, или через 5 минут, неважно - главное, что наш поток не блокировался в ожидании результата, а давал нам возможность продолжить работу (в данном случае - вывод в консоль сообщения о старте операции). А когда результат получен - будет вызван коллбек, представленный в данном случае анонимной лямбда-функцией.

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

Await anything

Надо заметить, что асинхронный метод, результат которого мы await'им, необязательно должен возвращать именно объект конкретного типа Task. Например, несколько лет назад в стандартной библиотеке наряду с классом Task была также добавлена структура ValueTask, и использование struct вместо class позволяет уменьшить количество аллокаций для нагруженного кода (ведь память под структуру выделяется на стеке, и её высвобождение не требует работы сборщика мусора).

Но использование await вообще не ограничено конкретными типами, есть лишь критерий, по которому возвращаемый тип должен реализовывать метод GetAwaiter. Для возвращаемого значения из этого метода есть также несколько критериев (реализация интерфейса INotifyCompletion, свойство bool IsCompleted и метод GetResult()). Скорее всего, Вам не понадобится реализовывать собственные реализации асинхронных задач, но стоит иметь в виду, что в принципе такое тоже возможно. Если же Вам интересно, рекомендую статью Стивена Тауба "Await anything", ссылка в описании к видео.

"await anything;" by Stephen Toub

// Represents an operation that schedules continuations when it completes.
public interface INotifyCompletion
{
    // Schedules the continuation action that's invoked when the instance completes.
    void OnCompleted(Action continuation);
}

Литература и полезные ссылки

@navferty
Copy link
Author

navferty commented Jul 10, 2023

добавить про:

  • CancellationToken
  • ExecutionContext
  • SynchronizationContext

@navferty
Copy link
Author

navferty commented Dec 7, 2023

  • sync-over-async
  • WhenAll

@navferty
Copy link
Author

navferty commented Dec 15, 2023

Тайминг

01:04 - Вступление
03:38 - Пример-метафора
01:38 - Теория: процессы и потоки
01:26 - Асинхронная операция
00:50 - Знакомство с асинхронными методами, возвращающими `Task` и `Task<T>`
11:33 - Машина состояний (state machine) и логика её работы
05:02 - Sync-over-async
01:34 - Запуск асинхронной операции через `Task.Run`
00:56 - Выводы

Итого 27:44

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment