Предисловие
В статье встречаются фразы наподобие: "бассейн потоков", "тростниковый сахар" и другие. Это не машинный перевод - это мое чувство юмора.
Пожалуйста, не оценивайте статью только на основе этих фраз. Всем кто прочитал статью и понял, что это шутки или не стал ставить негативную реакцию только на основе этих фраз - огромное спасибо
0. Кто такая асинхронность?
Асинхронность — это способ не блокировать поток, пока ты ждешь завершения операции. Она позволяет программе продолжать работать, даже если одна из операций (например, запрос к серверу) занимает время.
Какие преимущества дает?

Здесь поток не будет заблокирован на время получения данных. Пока ждем ответа, программа может вернуться к выполнению других задач, которые также асинхронно ждали получения результата.
Звучит прекрасно, но стоит понимать, что переключение потока на другие задачи несет накладные расходы - время выполнения одной задачи потенциально будет немного больше, но при этом мы сможем обработать гораздо больше задач за фиксированный промежуток времени.
Когда стоит использовать?
Для всех IO-bound
(Input Output Bound
) операций - это операции, на которые влияет не процессор, а какие-то внешние ресурсы. К примеру:
запись файла в файловой системе;
обращение к базе данных.
В противовес IO-bound
операциям выступают CPU-bound
операции, которые представляют собой "числодробилки", то есть операции выполнение которых зависит только от CPU
.
1. Ключевые слова async await
async
- это модификатор метода, указывающий компилятору, что метод будет асинхронным. Это "тростниковый сахар", который вырождается в конечный автомат для обеспечения асинхронности.await
- это унарный оператор, который указывает на необходимость дождаться завершения асинхронной операции.
2. Асинхронные методы
Кто такие асинхронный методы?
Методы, которые используют ключевые слова
async/await
. Одно без другого использовать не выйдет...
public async Task MethodAsync() {
await Task.Run(() => Console.WriteLine("Hello Async World"));
}
Это простой пример асинхронного метода, отмечу, что наличие ключевого слова async не означает, что метод будет выполняться в фоновом потоке.
3. Как работает асинхронность
Для начала хорошо будет разобраться с тем, как работает асинхронность на верхнем уровне, после уже залезем под "капот". Может быть мы и не хотели, но каждый раз добавляя модификатор async
, мы создавали машину состояний...
Машина состояний состоит из состояний (начальное, конечные и промежуточные) и функций перехода. Для нас это будет служит формализацией алгоритма, то есть, что описывает шаги алгоритма и как можно перейти от одного шага к другому.
Настал тот день, настал тот час, когда машина состояний будет радовать нас:

Рассмотрим несколько примеров для закрепления результатов. Отмечу, что подобные вопросы нередко встречаются на собеседовании.
static async Task Main()
{
Console.WriteLine($"Main thread: {Thread.CurrentThread.ManagedThreadId}"); // печатаем `id`, который сейчас работает
await DoWorkAsync();
Console.WriteLine($"Back in main thread: {Thread.CurrentThread.ManagedThreadId}"); // печатаем `id`, который сейчас работает
}
static async Task DoWorkAsync()
{
Console.WriteLine($"[Before await] Thread: {Thread.CurrentThread.ManagedThreadId}"); // печатаем `id`, который сейчас работает
await Task.FromResult(5); // создаем и ожидаем задачу, которая возвращает переданный результат
Console.WriteLine($"[After await] Thread: {Thread.CurrentThread.ManagedThreadId}"); // печатаем `id`, который сейчас работает
}
Вопрос, где в этом примере работа будет происходить в одних и тех же потоках?
Ответ
Все
Console.WriteLine
выведут один и тот жеThread ID
.Причина —
Task.FromResult(5)
завершена синхронно.await
не вызывает переключения потока, так как задача уже завершена.
На экране терминала мы увидим что-то наподобие:
Main thread: 1
[Before await] Thread: 1
[After await] Thread: 1
Back in main thread: 1
Теперь немного поменяем ситуацию:
static async Task Main()
{
Console.WriteLine($"Main thread: {Thread.CurrentThread.ManagedThreadId}"); // печатаем `id`, который сейчас работает
await DoWorkAsync();
Console.WriteLine($"Back in main thread: {Thread.CurrentThread.ManagedThreadId}"); // печатаем `id`, который сейчас работает
}
static async Task DoWorkAsync()
{
Console.WriteLine($"[Before await] Thread: {Thread.CurrentThread.ManagedThreadId}"); // печатаем `id`, который сейчас работает
await Task.Delay(5000); // задача, которая завершится через 5 секунд
Console.WriteLine($"[After await] Thread: {Thread.CurrentThread.ManagedThreadId}"); // печатаем `id`, который сейчас работает
}
Ответ
Потоки могут отличаться до и после
await Task.Delay(5000)
.Причина —
Task.Delay
не завершена синхронно, аawait
отпускает текущий поток. То есть на момент проверки задача еще не будет завершена.
Main thread: 1
[Before await] Thread: 1
[After await] Thread: 5
Back in main thread: 5
Не будем рассматривать, как полностью будет выглядеть код после того, как компилятор провернет все свои делишки - сконцентрируемся только на главных вещах.
[AsyncStateMachine(typeof (Program.<DoWorkAsync>d__1))]
[DebuggerStepThrough]
private static Task DoWorkAsync()
{
Program.<DoWorkAsync>d__1 stateMachine = new Program.<DoWorkAsync>d__1();
stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
stateMachine.<>1__state = -1;
stateMachine.<>t__builder.Start<Program.<DoWorkAsync>d__1>(ref stateMachine);
return stateMachine.<>t__builder.Task;
}
Это чистый и не причесанный код в котором происходит инициализация и запуск машины состояний:
Ключевое слово async исчезает, а метод превращается в обычный метод, возвращающий Task.
Метод получает два атрибута:
AsyncStateMachine
— указывает на класс машины состояний, который будет управлять выполнением метода;DebuggerStepThrough
— просит отладчик не заходить внутрь.
Вместо привычного кода вызывается и инициализируется структура Program.<DoWorkAsync>d__1
, которая реализует интерфейс IAsyncStateMachine
. В ней и происходит вся магия, которую за нас делает невидимый герой.
Отметим работу метода MoveNext
, именно он содержит трансформированный код исходного метода и делит его на участки между await
. Когда выполнение доходит до await
, текущее состояние сохраняется, а оставшаяся часть кода превращается в continuation
, которое будет вызвано при завершении ожидаемой задачи.
Таким образом, MoveNext
вызывается каждый раз, когда нужно продолжить выполнение метода после очередного await
, и его реализация зависит от текущего значения поля <>1__state
, которое указывает, на каком шаге метода мы остановились.
Да, еще внутри машины состояний используется goto
... Стоит уточнить, что это технически безвредное использование, а не то goto, что считается антипаттерном.
4. Ожидаемые методы
Ожидаемые методы - это асинхронные методы, завершение которых можно подождать, если необходим результат их работы в данный момент.
Существует два вида ожидания - синхронный (блокирующий) и асинхронный (не блокирующий поток). Если ожидание синхронное (блокирующие), то данный поток не будет выполнять никакую полезную деятельность, пока ждет завершения задачи. Асинхронное (не блокирующие ожидание) - это то что описано в 0 параграфе, то есть когда во время ожидания может быть выполнена какая-либо полезная работа.
Асинхронный метод | Синхронный метод | Описание |
---|---|---|
Ожидание завершения одной задачи. | ||
Ожидание завершения всех задач, будет один раз возвращено управление. | ||
Ожидание завершения любой из задач, будет возвращает управление столько же раз сколько будет завершенных |
Также отмечу один важный момент с обработкой исключений - синхронные методы выбрасывают исключение через AggregateException, который нужно будет уже разбирать на возникшие ошибки. Асинхронные операторы ожидания выбрасывают исключение напрямую.
Рассмотрим один пример, который содержит наиболее интересный, на мой нескромный взгляд, метод ожидания:
async Task RunAsync()
{
List<Task> tasks = new List<Task>
{
Task.Delay(1000), // задача, которая асинхронно ожидает 1 секунды
Task.Delay(2000) // задача, которая асинхронно ожидает 2 секунды
};
while (tasks.Count > 0)
{
Task finished = await Task.WhenAny(tasks);
Console.WriteLine("Одна задача завершена асинхронно (не блокируя поток)");
tasks.Remove(finished);
}
Console.WriteLine("Все задачи завершены");
}
Ничего сверхъестественного, есть ключевое слово async
в сигнатуре метода, поэтому можем использовать await
в комбинации с WhenAny
. Пример с синхронным Task.WaitAny
аналогичен за исключением того, что мы получали бы не Task
, а её индекс в списке tasks
.
Поскольку мы с Вами сейчас постигаем асинхронное программирование, то и пользоваться в асинхронном коде будем асинхронными операторами ожидания. Да, соглашусь, звучит неожиданно.
Искушенный читатель мог заметить подвох... Наш асинхронный RunAsync
должен быть вызыван другим асинхронным кодом, который также дождется его выполнения. Если мы продолжим эту логическую цепочку, то дойдем до метода Main
. Кто виноват и что делать? Начиная с версии языка 7.1 есть поддержка асинхронного метода Main
- достаточно дописать async
и жить не зная печали.
Еще более искушенный читатель мог озадачиться: как тогда жили наши деды?
public static async Task MainAsync()
{
await Task.Delay(1000);
}
public static int Main()
{
MainAsync()
.GetAwaiter() // получаем объект ожидания TaskAwaiter
.GetResult(); // запрашиваем результать, тем самым вставая в ожидание
return 0;
}
Данный подход GetAwaiter().GetResult() в текущих реалиях не рекомендуется, поскольку использует компоненты предназначенные для внутреннего использования компилятора... Мы рассматривает это исключительно в ознакомительных целях.
Поскольку мы сейчас немного заглянули за ширму ожидаемого метода, то рассмотрим все ограничения для применения оператора await
:
Ожидаемый тип должен предоставлять через публичный метод
GetAwaiter()
объект ожидания;Объект ожидания должен реализовать интерфейс
INotifyCompletion
, который обязывает реализовать методvoid OnCompleted
. Также предоставлять доступ к свойствуbool IsCompleted
и методvoid GetResult()
.
5. Типы возвращаемых значений асинхронных методов
Кто такой Task
, который мы видели? Один из возможных возвращаемых типов для асинхронных методов. Да, можно также определить свой тип, но сегодня не об этом.
Стандартных возвращаемых типов немного:
void
Task/Task<T>
ValueTask/ValueTask<T>
3.1 Void
Void
используем только для обработчиков событий - это все. Дальше будет будет интереснее...
3.2 Task/Task
Task/Task<T> - служит нескольким целям, но ключевое свойство этого класса - это "обещания", который "обещает", что примерно ко времени завершения поставленной задачи будет назначен поток, который вернет результат Task
и продолжит выполнения метода.
// первая часть
var result = await SomeOperationAsync();
// вторая часть
PrintResult(result);
Первая часть выполняется потоком A
, который начал выполнение этой функции, далее на моменте ожидания поток может вернуться в thread poll
и, к примеру, выполнить этот же код или какую-либо другую задачу. Вторая часть может продолжить свое выполнение, как потоком A
, так и другим потоком B
. Не стоит пугаться такого количество слова может
, главная цель примера - это показать, что наши ресурсы не простаивают и код будет продолжать выполняться последовательно сверху вниз.
Отвечу сразу на потенциальные вопросы:
Можно ли несколько раз дождаться выполнения (
await
) одной задачи?
Да,
Task/Task<T>
достаточно гибкий.
Если мы можем продолжить выполнение в другом потоке, то пробросится ли исключение из нового потока в основной?
Так точно, Вы сможете отливать исключение или результат в месте вызова
await
.
При этом Task/Task<T>
остается обычным классом, что это значит? Что мы будем каждый раз выделять память на куче(heap
), после еще и заставлять отрабатывать сборщик мусора(GC
)... Полностью от этого не избавиться, но стоит помнить при разработке высоконагруженных систем, где будет создаваться много экземпляров Task
.
public async Task WriteAsync(int value)
{
if (_bufferedCount == _buffer.Length)
{
await FlushAsync();
}
_buffer[_bufferedCount++] = value;
}
Это простой пример буфера с замещением, также можем заметить, что как правило, метод будет завершаться синхронно. Будет ли каждый раз выделяться память для Task
, который завершается синхронно, то есть только сигнализирует об выполнении? Нет, здесь нам на помощь прийдет кэширование и Task.CompletedTask (свойство, которое возвращает один и тот же объект Task
).
public async Task<bool> MoveNextAsync()
{
if (_bufferedCount == 0)
{
await FillBuffer();
}
return _bufferedCount > 0;
}
Сколько все возможных возвращаемых значений? Все верно, здесь также не имеет смысла каждый раз создавать новый объект, поскольку диапазон значение небольшой, поэтому мы также можем кэшировать. То есть будет выделяться новый объект Task<bool>
только в случаях асинхронного выполнения.
Последний пример здесь для того, чтобы показать, что Task/Task<T>
достаточно продуманы, но не лишены недостатков... Крайний риторический вопрос на эту под главу - что насчет int
, будет кэшировать 4 миллиарда значений? И да, и нет - будет кэшироваться только небольшой диапазон.
Как видим, потенциальная проблема решена частично, поскольку не может охватить все типы данных, что и приводит нас к следующему типу...
3.3 ValueTask/ValueTask
ValueTask/ValueTask<T> служит той же ключевой цели, что и Task/Task<T>
, только представляет собой структуру, которая оборачивает T
или Task<T>
. То есть в случае синхронного завершения ValueTask<T>
будет проинициализирована только T
, и только в случае асинхронного завершения будет проинициализирована Task<T>
.
Получается мы нашли "святой грааль", который стоит всегда использовать? Для ответа на этот вопрос нам нужно будет спуститься немного глубже...
Для ValueTask/ValueTask<T>
используется низкоуровневый интерфейс IValueTaskSource, который также стремится уменьшить количество памяти, которую мы выделяем. Его поведение можно сравнить с многоразовой батарейкой, которую можно повторно зарядить только с помощью специальной станции. Мы получаем нашу батарейку ValueTask/ValueTask<T>, после дожидаемся её выполнения, далее она уходит на подзарядку, которую мы не контролируем - то есть эта память может быть периспользована для других задач (похоже на
memory poll`).
Также ValueTask/ValueTask<T>
не являются потокобезопасными, в том числе из-за переиспользования памяти. Это как если бы вы одолжили другу многоразовую бутылку для воды, а он начал её повторно заполнять, пока вы из неё ещё пьёте. То есть ничего хорошего из этого не выйдет...
Это естественным образом накладывает ограничения на `ValueTask/ValueTask:
Поведение становится неопределенным, когда мы множество раз вызываем
await
;Эти объекты не рассчитаны на параллельное ожидание (
await
), что может привести к состоянию гонки;Использование
GetAwaiter().GetResult
также может привести к состоянию гонки.
Все это ведет к усложнению кода, который будет сложнее поддерживать. Что если все таки есть жизненная необходимость сделать что-либо из этого? Тогда мы можем превратить ValueTask
в Task
c помощью функции .AsTask()
, только после мы никак не должны возвращаться к объекту ValueTask
.
Краткое резюме по ValueTask/ValueTask<T>
: основной сценарий использования это одноразовое ожидание через await
.
3.4 Что же мы будем использовать?
Стоит использовать ValueTask/ValueTask<T>
в тех случаях, когда есть жесткие требования к производительности, тем сам мы берем на себя ответственность по соблюдению всех ограничений. В большинстве случаев нам подойдет Task/Task<T>
, как более гибкая структура.
6. Бассейн потоков (да, это шутка)
Система будет под каждый наш запрос выделять поток? Это дорогая операция? Можно ли что-то с этим сделать?
Коротко - да, дорогая. Коротко, нет - не каждый раз.
Вместо этого используется ThreadPool
- это специальный механизм, который позволяет повторно использовать уже созданные потоки, чтобы не тратить ресурсы на постоянное создание и уничтожение.
У каждого потока есть своя очередь заданий, также глобальная очередь, куда попадают задачи от внешних потоков;
Если задача была добавлена из потока самого пула — она кладётся в его локальную очередь;
Поток сначала заглядывает в свою очередь, если там пусто — идёт в глобальную. Если и там пусто — пытается украсть задачи у других потоков;
Если заданий становится слишком много, а потоков не хватает, они ставятся в очередь, и
ThreadPool
старается подогнать под нужную нагрузку.
Отмечу, что все потоки - это фоновые потоки, то есть если все основное приложение завершится — они не будут держать процесс живым.
7. Полезные дополнения для работы
Если мы используем
ThreadPool
, а наша задачу планируется на долгое время работы или даже на протяжении всего жизненного цикла программы, то не возникнут ли от этого проблемы?
Да, в таких случаях случаях лучше воспользоваться запуском задачи с помощью:
Task.Factory.StartNew(action, TaskCreationOptions.LongRunning);
Так мы создаем задачу с указанием LongRunning
(длительная работа), что позволит оптимизировать работу ThreadPool
. Рекомендую изучить Task.Factory.StartNew, поскольку он дает более гибкие возможности для запуска задач.
Ладно, задачу мы запустили, но как быть если в процессе её нужно отменить?
Для этого лучшей практикой считается использование CancellationToken - это структура, которая определяется четырьмя свойствами:
CanBeCanceled
- значение, указывающее, может ли данный токен находиться в отмененном состоянии;IsCancellationRequested
- значение, указывающее, есть ли для данного токена запрос на отмену;WaitHandle
- дескриптор WaitHandle, получающий сигнал при отмене токена.
Простой пример:
Пример
static async Task Main()
{
using var cts = new CancellationTokenSource(); // создаем источник токена
var task = DoWorkAsync(cts.Token);
Console.WriteLine("Нажми любую клавишу, чтобы отменить...");
Console.ReadKey();
cts.Cancel(); // посылаем сигнал на отмену
try
{
await task;
}
catch (OperationCanceledException)
{
Console.WriteLine("Задача была отменена.");
}
}
static async Task DoWorkAsync(CancellationToken token)
{
Console.WriteLine("Начинаем работу...");
while (true)
{
token.ThrowIfCancellationRequested(); // проверяем, не отменена ли задача
Console.WriteLine($"Работаем... {i + 1}/10");
await Task.Delay(1000, token); // тоже реагирует на отмену
}
}
Я слышал, что
ConfigureAwait(false)
ускоряет работу. Это правда? Если да, то почему это не работает так по умолчанию?
ConfigureAwait(bool) - определяет будет ли продолжение выполняться в исходном захваченном контексте. Если значение будет true
, то будет гарантированно возвращаться в исходный захваченный контекст, иначе нет.
Исходный захваченный контекст - это контекст, в котором мы находились до await
. К примеру, это может быть UI
-поток, который обновлял интерфейс у пользователя (в WPF
это может делать только этот поток).
Возвращение в исходный захваченный поток ведет к дополнительным накладным расходам, поэтому если такое поведение не обязательно, то оптимальнее будет выставить false
. Но, к примеру, ASP.NET
имеет только один контекст, поэтому в этой ситуации это не приведет ни к чему кроме лишних буковок на экране.
Ответ на исходный вопрос: да, это может сказаться на производительность в лучшую сторону, но нужна знать где.
А если результат придёт от другого потока или события? Или я хочу подружить асинхронный и синхронный код, как это сделать?
Для всего этого и много другого нам поможет TaskCompletionSource<T> - это обёртка, с помощью которой можно полностью контролировать Task
(задачу):
SetResult(value)
— завершить успешно;SetException(ex)
— бахнуть ошибку;SetCanceled()
— отменить.
Пример
var tcs = new TaskCompletionSource();
// где-то снаружи придёт результат:
Task.Run(() =>
{
Thread.Sleep(1000);
tcs.SetResult("Готово!");
});
string result = await tcs.Task; // кто-то ждёт:
Console.WriteLine(result); // => Готово!
Особенности работы:
Если не уверен, используй
TrySet
— безопаснее.Если из нескольких потоков — синхронизация обязательна, иначе можно ловить баги.
Если не вызвал
SetResult
—await
зависнет навсегда.
Итоги
Мы разобрали основные моменты: от того, как асинхронные операции могут помочь нам сэкономить время и ресурсы, до того, как важно правильно понимать их поведение в реальном времени.
Важно помнить, что асинхронность — это не панацея и не универсальное решение. Она идеально подходит для задач, связанных с I/O
(IO-bound
), но если ваше приложение работает с вычислениями ("числодробилки"), то здесь лучше обратить внимание на параллелизм.
Также теперь мы знаем, что используется внутри для работы с асинхронностью, и разобрали ключевые моменты и подходы к её использованию.
Всем бассейна полного не потоков, а воды.