Асинхронность в C#. Разрушение легенд

Всем привет! Меня зовут Влад, я — старший разработчик в компании DataArt. Статья будет посвящена асинхронному программированию на C#, а именно — нюансам работы с TAP (Task-based Asynchronous Pattern) — паттерном асинхронного программирования, основанным на задачах. Статья довольно обширная и разбита на пять разделов:

I. Асинхронность: как и зачем это использовать.

II. Взгляд вовнутрь через популярные заблуждения.

III. Проблемный код и лучшие практики.

IV. Сторонние библиотеки и тулинг.

V. Что еще почитать/посмотреть.

I. Асинхронность: как и зачем это использовать

Что такое асинхронность и зачем она нужна?

Все внешние устройства, не работающие на одной шине с микропроцессором, — сетевые адаптеры, видеокарты, хранилища данных — возвращают результат своей работы не сразу. Следовательно, нам выбирать: либо наш поток выполнения будет останавливаться и ожидать результат операции, либо выполнять какой-то другой код. Таким образом, код, написанный с неблокирующим (асинхронным) ожиданием результата, потребляет меньше ресурсов и является более производительным на дистанции.

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

Однако многопоточность и асинхронность можно также классифицировать по типам многозадачности, как ее формы:

  • Вытесняющая (Preemptive) многозадачность — вид многозадачности, когда система выделяет каждой задаче некоторый квант времени — реализуется через механизм потоков и Task, выполняющий свой код в многопоточных контекстах.
  • Кооперативная (Cooperative) многозадачность — вид многозадачности, когда система выделяет задаче время до тех пор, пока задача не завершится. Похоже на асинхронные вызовы в однопоточном контексте синхронизации, например UI-поток WinForms или работу движка V8 для выполнения JavaScript.

Изучая асинхронные подходы в .NET, я плохо понимал, как все устроено изнутри. Это не позволяло решать ряд проблем, связанных с асинхронностью.Также слышал разные истории коллег, которые сталкивались с аналогичными проблемами и не всегда знали, как их решить: например, дедлоки или «нелогичное» поведение асинхронного кода. В статье рассмотрим механику работы и лучшие практики TAP: как это устроено изнутри и какие ошибки лучше не совершать.

В .NET-фреймворке исторически сложилось несколько более старых паттернов организации асинхронного кода:

  • APM (IAsyncResult, они же коллбеки) (.NET 1.0).
  • EAP — события, этот паттерн все видели в WinForms (.NET 2.0).
  • TAP (Task Asynchronous Pattern) — класс Task и его экосистема (.NET 4.0).

Сейчас рекомендованный и самый мощный паттерн — TAP. В C# 5 он был дополнен механизмом async/await, помогающим избежать блокирующего исполнения кода, в более новых версиях языка появилось еще несколько нововведений.

Вообще, говоря про асинхронность и проблемы, которые она решает, нужно упомянуть те самые блокировки, от которых мы хотим избавиться. Существует два типа возможности занять поток:

  • CPU Bound — блокировка, когда поток занят непосредственно вычислениями. Здесь необходимо позаботиться о том, чтобы длинная операция не блокировала потоки пула потоков .NET (ThreadPool), а работала отдельно и синхронизировала возврат результата.
  • IO Bound — блокировка, ожидание результата от устройств ввода-вывода — тут асинхронный подход имеет максимальный эффект, так как, по сути, мы занимаемся ожиданием, и наши потоки могут выполнять пустую работу.

Async/Await идеально решает проблему IO Bound, с CPU Bound можно использовать средства Parallel или неявного создания отдельных потоков, но об этом позже.

Какая бывает асинхронность?

Лично я для себя условно разбил асинхронные подходы на три группы, включив реализации из JavaScript и Golang для примеров.


Классификация подходовПаттерныИмплементация в JSИмплементация в GolangИмплементация в C#
Императивные Saga и ее вариации, коллбеки Redux-saga
ES7 async/await,
передача колбеков
Передача коллбеков,

Select + Channels
async/await, передача коллбеков
ОбъектныйDTO
Объектно-ориентированное представление статуса о выполненной задаче
Promise-Task
РеактивныеObserver/Observable
(pub/sub), Builder
RxJS
Observable
EventEmitter
MobX
ChannelsEvents
Rx.NET

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

В C# бэкенд разработке нативно меньше реактивных подходов. Основными методами являются либо запуск и менеджмент объектов Task и их неблокирующего ожидания с помощью await, либо коллбеки. Реактивность же чаще используется в UI-разработке.

Однако можно использовать и имплементацию библиотеки Rx под C# для работы с источником событий как с потоком (стримом) и реакций на них.

В этой же статье мы поговорим о нативных способах работы с асинхронностью в C#.

TAP (Task Asynchronous Pattern)

Сам паттерн состоит из двух частей: набора классов из пространства имен System.Threading.Tasks и конвенций написания своих асинхронных классов и методов.

Что нам дает асинхронный подход в контексте TAP:

  1. Реализации фасадов по согласованной работе с задачами, а именно:
    • Запуск задач.
    • Отмена задач.
    • Отчет о прогрессе.
    • Комбинация цепочек задач, комбинаторы.
    • Неблокирующие ожидания (механизм async/await).
  2. Конвенции по именованию и использованию асинхронных методов:
    • В конец добавляем постфикс Async.
    • В аргументы метода можем передавать или не передавать CancellationToken & IProgress имплементацию.
    • Возвращаем только запущенные задачи.

Если хотите подойти к изучению более фундаментально, посмотрите whitepaper на 40 страниц от Microsoft, как это работает. Скачать документ можно тут.

Как создать и запустить задачу

Условно я разделил возможные пути создания задач на четыре группы:

1. Фабрики запущенных задач

Task.Run(Action/Func)
Task.Factory.StartNew(Action/Func)
3. Конструктор

var t = new Task(Action/Func);
t.Start();
2. Фабрики завершенных задач

Task.FromResult(Result)
Task.FromCanceled(CancellationToken)
Task.FromException(Exception)
Task.CompletedTask
4. Фабрики-таскофикаторы

Task.Factory.FromAsync (APM)
TaskCompletionSource (EAP, APM, etc)
  1. Фабрики запущенных задач. Run — более легкая версия метода StartNew с установленными дополнительными параметрами по умолчанию. Возвращает созданную и запущенную задачу. Самый популярный способ запуска задач. Оба метода вызывают скрытый от нас Task.InternalStartNew. Возвращают объект Task.
  2. Фабрики завершенных задач. Иногда нужно вернуть результат задачи без необходимости создавать асинхронную операцию. Это может пригодиться в случае подмены результата операции на заглушку при юнит-тестировании или при возврате заранее известного/рассчитанного результата.
  3. Конструктор. Создает незапущенную задачу, которую вы можете далее запустить. Я не рекомендую использовать этот способ. Старайтесь использовать фабрики, если это возможно, чтобы не писать дополнительную логику по запуску.
  4. Фабрики-таскофикаторы. Помогают либо произвести миграцию с других асинхронных моделей в TAP, либо обернуть логику ожидания результата в вашем классе в TAP. Например, FromAsync принимает методы паттерна APM в качестве аргументов и возвращает Task, который оборачивает более ранний паттерн в новый.

Кстати, библиотеки в .NET, в том числе и механизм async/await, организуют работу по установке результата либо исключения для таск с помощью TaskCompletionSource.

Будьте внимательны, если создаете задачу через конструктор класса: по умолчанию она не будет запущена.

Как отменить задачу

За отмену задач отвечает класс CancellationTokenSource и порождаемый им CancellationToken.
Работает это приблизительно так:

  1. Создается экземпляр CancellationTokenSource (cts).
  2. cts.Token отправляется параметром в задачу (ассоциируется с ней).
  3. При необходимости отмены задачи для экземпляра CancellationTokenSource вызывается метод Cancel().
  4. Внутри кода задачи на токене вызывается метод ThrowIfCancellationRequested(), который выбрасывает исключение в случае, если в CancellationTokenSource произошла отмена. Если токен был ассоциирован с задачей при создании, исключение будет перехвачено, выполнение задачи остановлено (так как исключение), ей будет выставлен статус Cancelled. В противном случае задача перейдет в статус Faulted.

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

Кстати, конструктор CancellationTokenSource может принимать значение таймаута, после которого метод Cancel будет вызван автоматически.

Асинхронные контроллеры в ASP.NET могут инжектить экземпляр CancellationToken прямо в метод контроллера, вызываться же отмена токена будет по разрыву соединения с сервером. Это позволит значительно упростить инфраструктуру поддержки обрыва ненужных запросов. Если этот токен будет вовремя обрывать операции, результата которых уже не ждут, производительность может заметно повыситься. Далее два примера согласованной отмены.

Пример #1 кода согласованной отмены:

//Подготовка
var cts = new CancellationTokenSource();
var token = cts.Token;
token.Register(() => Console.WriteLine("Token works"));

//Получаем задачу
var t = Task.Run(async () =>
{
         //производим отмену на CancellationTokenSource
         cts.Cancel();
         //в Delay попадет уже отмененный токен, что выбросит исключение
         await Task.Delay(10000, token);
}, token);

try
{
    //неблокирующее ожидание задачи
await t;
}
//В данном случае выбросится TaskCanceledException
catch (TaskCanceledException e)
{
      Console.WriteLine(e.Message + " TaskCanceledException");
}

В этом случае мы получаем в консоль:

Token works
A task was canceled. TaskCanceledException

Пример #2

В случае же работы с опросом токена исключение будет иное

(такой же код инициализации, как и выше)

//Получаем задачу
var t = Task.Run(async () =>
{
         //производим отмену на CancellationTokenSource
         cts.Cancel();
         //выбрасываем исключение на отмененном токене
         token.ThrowIfCancellationRequested();

}, token);

try
{
     await t;
}
//В данном случае выбросится OperationCanceledException
catch (OperationCanceledException e)
{
     Console.WriteLine(e.Message + " OperationCanceledException");
}

В этом случае мы получаем в консоль:

Token works
The operation was canceled.OperationCanceledException

Обратите внимание, что Task.Delay выбросит TaskCanceledException, а не OperationCanceledException.

Более детально о согласованной отмене можно почитать тут.

Как следить за прогрессом выполнения

TAP содержит специальный интерфейс для использования в своих асинхронных классах — IProgress<T>, где T — тип, содержащий информацию о прогрессе, например int. Согласно конвенциям, IProgress может передаваться как последние аргументы в метод вместе с CancellationToken. В случае если вы хотите передать только что-то из них, в паттерне существуют значения по умолчанию: для IProgress принято передавать null, а для CancellationToken — CancellationToken.None, так как это структура.

//Не используйте такой код в продакшене :) написано с целью демонстрации
//Код считает до 100 с определенной задержкой репортуя прогресс
public async Task RunAsync(int delay, CancellationToken cancellationToken, IProgress<int> progress)
{
      int completePercent = 0;

      while (completePercent < 100)
      {
        await Task.Run(() =>
        {
            completePercent++;

             new Task(() =>
             {
                 progress?.Report(completePercent);
             }, cancellationToken, 
                TaskCreationOptions.PreferFairness).Start();

        }, cancellationToken);

        await Task.Delay(delay, cancellationToken);
      }
}

Как синхронизировать задачи

Существуют такие способы объединять задачи в логические цепочки друг за другом или же ожидать группы задач по определенному принципу:

Комбинаторы задач

Task.WaitAll (list of tasks) -> Task
Task.WaitAny (list of tasks) -> Task
Task.WhenAll (list of tasks) -> Task
Task.WhenAny (list of tasks) -> Task
Метод расширения ContinueWith для экземпляров задач с опциями реакции на исключения, отмену или удачное завершение предыдущей задачи

t.ContinueWith( res=>{ код продолжения }, TaskContinuationOptions )
Метод расширения ContinueWith для экземпляров задач с опциями продолжения синхронно или асинхронно, установкой другого планировщика задач

t.ContinueWith( res=>{ код продолжения }, TaskContinuationOptions )
Метод расширения ContinueWith для экземпляров задач с опциями присоединения к времени выполнения родительской задачи (дочерняя задача не сможет завершиться до завершения родительской)

t.ContinueWith( res=>{ код продолжения }, TaskContinuationOptions )

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

Всего у TaskContinuationOptions 15 значений, и они могут комбинироваться.ContinueWith оборачивает задачу еще в одну задачу, создавая Task<Task<... >>. Но не стоит злоупотреблять или имплементировать сложную логику, основанную на этом методе.

Более подробно об особенностях такого поведения и подводных камнях можно почитать в блоге Stephen Cleary.

Как извлечь результат из задачи

До появления await извлекать результат из задач можно было такими блокирующими способами:

  • t.Result(); — возврат результата / выброс исключения AggregateException.
  • t.Wait(); — ожидание выполнения задачи, выброс исключения AggregateException.
  • t.GetAwaiter().GetResult(); — возврат результата / выброс оригинального исключения — служебный метод компилятора, поэтому использовать его не рекомендуется. Используется механизмом async/await.

После появления async/await рекомендованной техникой стал оператор await, производящий неблокирующее ожидание. То есть если await добрался до незавершенной задачи, выполнение кода в потоке будет прервано и продолжится только с завершением задачи.

await t; — возврат результата / выброс оригинального исключения.

Следует заметить, что для t.GetAwaiter().GetResult(); и await будет выброшено только первое исключение, аналогично манере поведения обычного синхронного кода.

Выброс исключения в вызывающий поток тоже результат.

Почему исключения задач завернуты в AggregateException? Допустим, задача стала результатом работы комбинатора задач (например, Task.WhenAll). Он вернет задачу, которая станет завершенной только после завершения всех переданных ей задач. Значит, исключений может быть много, поэтому они будут завернуты в AggregateException.

Философия async/await

Основная идея async/await в том, чтобы писать асинхронный код в синхронной манере и не задумываться, как это работает. Но в этом и основной подводный камень — незнание начинки может породить неожиданные сайд-эффекты, о которых мы не задумывались.

Следующий код:

async Task<ResultType> funcAsync()
            {
                var result1 = await LongOperation1(null);
                var result2 = await LongOperation2(result1);
                var result3 = await LongOperation3(result2);
                return result3;
            }

ResultType funcResult = await funcAsync();

Логически представляет собой следующий код:

public static void CallbackFunc(Action<ResultType> resultCallback)
{
   LongOperation1(arg: null, onCompleted: (result1) =>
   {
      LongOperation2(arg: result1,  onCompleted: (result2) =>
      {
          LongOperation3(arg: result2,onCompleted: (result3) =>
           {           
               resultCallback(result3); 
           });
      });
    });
 }

CallbackFunc(result =>
{
   ResultType funcResult = result;
 });

где LongOperation1, LongOperation2, LongOperation3 — принимают аргумент и коллбек-функцию, выполняющуюся по завершении и принимающую результат операции.

Добавив немного инфраструктуры, мы бы изобрели самый старый асинхронный паттерн, APM.

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

Как использование async/await дополняет работу с TAP

Все, что могло ожидаться, блокируя поток, теперь может ожидаться, не блокируя поток, например:


БылоСталоЗачем нужно
task.Waitawait taskОжидать завершения Task’а
task.Resultawait taskПолучить результат завершенного Task’а
Task.WaitAnyawait Task.WhenAnyОжидать завершения одного (любого) Task’a из коллекции
Task.WaitAllawait Task.WhenAllОжидать завершения всех (последнего) Task’a из коллекции
Thread.Sleepawait Task.DelayЖдать заданный период времени

Что нового появилось в TAP начиная с C# 5

C# 5.0 / .NET 4.5

  • async/await;
  • Progress<T>.

C# 6.0

  • await в Catch/Finally блоках, в C# 5 так делать было нельзя;
  • Упрощенный синтаксис Task.Run(DoThings) вместо Task.Run(() => DoThings()).

C# 7.0 — 7.3

  • ValueTask<T> — структура-таск, повышающая производительность в некоторых случаях;
  • async Main method для консольного приложения.

C# 8 / .NET Standard 2.1 — (.NET Core 3, Mono 6.4)

  • AsyncStreams — асинхронные стримы, пока недоступны в .NET Framework, только для платформ, входящих в .NET Standard 2.1 +. Если вкратце — дают возможность на уровне языка реализовывать неблокирующие ожидания между чтениями из потока.

II. Взгляд вовнутрь через популярные заблуждения

Людям свойственно выбирать для сложных вещей самое простое объяснение, часто в реальной жизни это статистически оправдано. Однако технологии не всегда построены очевидным для нас способом, и «простое» объяснение может ввести нас в заблуждение.

Task — это облегченный Thread

Самое распространенное заблуждение среди начинающих разработчиков. Класс Task не имеет прямого отношения к потокам операционной системы. Условно, в полномочия Task входит:

  • Обслуживание статуса (выполнена, выполняется, отменена, ошибка) логической задачи — списка инструкций, объединенного в метод либо анонимный метод.
  • Фабричные статические методы по запуску логических задач с установкой параметров исполнения, также конструктор, позволяющий вручную создать задачу и затем ее запустить.
  • Создание инфраструктуры по согласованной отмене логической задачи, поддержки цепочек вызова задач.
  • Извлечение результата либо исключения в вызывающий поток.

Если вы программировали на JavaScript, то аналогом Task является объект Promise.

Лично я вижу класс Task как реализацию таких паттернов.

  • Фасад: Task не управляет выполнением задач и не имеет стратегии их планирования в потоки, это скорее интерфейс-абстракция, имеющая билдеры (ContinueWith), статические методы-фабрики создания задач и вариант создания задачи с помощью конструктора.
  • DTO (Data transfer object): Task отвечает за перенос состояния выполнения и результата связанного с ним кода. Причем установкой результата или исключения Task на низком уровне занимается TaskCompletionSource.

За планирование выполнения кода в потоках отвечает класс TaskScheduler, который имеет две реализации:

  • ThreadPoolTaskScheduler (неявно установлен для всех задач);
  • SynchronizationContextTaskScheduler.

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

  • ThreadPoolTaskScheduler выполняет код в потоках из ThreadPool. В виде исключения существует использование опции при создании задачи LongRunningTask — для таких задач ThreadPoolTaskScheduler создает отдельный поток.
  • SynchronizationContextTaskScheduler использует поведение текущего контекста синхронизации (установленного для потока либо по умолчанию). Контекст синхронизации является наследником класса SynchronizationContext. Получить этот TaskScheduler можно с помощью вызова TaskScheduler.FromSynchronizationContext(); в текущем потоке.

Async await — синтаксический сахар

Это утверждение отчасти верно, но только отчасти. Механизм async/await действительно не имеет реализации в CLR и разворачивается компилятором в довольно сложную конструкцию, указывающую, какую именно часть метода вызывать (стейт машина). Но вы не сможете реализовать async/await через механизм, например, тасок. Async/await — не синтаксический сахар вокруг тасок, это отдельный механизм, использующий класс Task для переноса состояния выполняющегося куска кода.

Await запускает операцию асинхронно

Оператор Await не запускает операцию асинхронно, он либо:

  • вызывает метод синхронно, если возвращенная им задача уже была завершена;
  • производит неблокирующее ожидание (отпускает поток) результата задачи, возвращая управление из метода вверх по иерархии await’ов, когда мы дошли до вложенного await, который возвращает незавершенную Task.

Результатом операции await может быть либо возврат результата из связанной с ним задачи, либо выброс исключения. Кстати, в случае с задачами, порожденными комбинаторами задач, будет выброшено только первое исключение, даже если результирующая задача накопила их несколько. Это обусловлено природой оператора await — сделать асинхронный код таким же простым, как синхронный. Если вы хотите получить все исключения — обратитесь к переменной типа Task, которую вы эвейтили.

Кстати, Task не единственный класс, который может работать с оператором await. С ним может работать любой класс, реализующий метод GetAwaiter(), в случае с Task — TaskAwaiter.

Продолжение метода после await будет выполнено в пуле потоков

Это утверждение верно, но не всегда. Я выше упомянул класс SynchronizationContext, так вот, он необходим для механизма работы async/await. Наследники класса SynchronizationContext устанавливаются той средой, где выполняется код, в свойствах потока.

Для ASP.NET Core, Console Application, созданных вручную потоков — SynchronizationContext не будет выставлен явно. Это означает, что async/await будет использовать ThreadPool SynchronizationContext (контекст по умолчанию), запуская продолжение методов в случае, если возвращаемая ими задача не завершена, в ThreadPool.

В ASP.NET (старом) установлен однопоточный AspNetSynchronizationContext, присоединяющий продолжение методов в тот же поток, из которого выполнялась их первая часть.

То же самое и для WinForms-приложений: UI-поток имеет установленный WindowsFormsSynchronizationContext, планирующий продолжение только в единственный UI-поток.

Можете провести простой тест. Если вы запустите Task из метода-обработчика события UI-контрола в WinForms-приложении, он выполнится в пуле потоков. Однако если вы сделаете это с помощью Task.Factory.StartNew и передадите ему в параметр TaskScheduler — TaskScheduler.FromCurrentSynchronizationContext, то задача выполнится в UI-потоке.

Кстати, метод configureAwait, вызываемый на классе Task, возвращает пропатченный TaskAwait’er, в котором сбрасывается текущий контекст синхронизации и заново устанавливается по умолчанию. В этом случае продолжение отработает в пуле потоков.

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

Будет очень неожиданно, если кто-нибудь додумается синхронно (t.Result / t.Wait() ) получить результат из асинхронного метода вашей библиотеки в однопоточном контексте синхронизации (WinForms, ASP.NET). Единственный поток будет заблокирован незаконченной задачей, а закинуть в него продолжение задачи и завершить эту же самую задачу вы не сможете. И получите классический дедлок.

Все вышеописанное можно подытожить в таблице:


ПотокКонтекст синхронизации по умолчаниюГде выполнится продолжение метода после await в случае возврата незавершенного Task
Собственный потокSynchronizationContextThreadPool
Console ApplicationSynchronizationContextThreadPool
ASP.NET Core SynchronizationContextThreadPool
Original ASP.NETAspNetSynchronizationContextТот же поток
WinFormsWindowsFormsSynchronizationContextЕдинственный UI-поток
WPFDispatcherSynchronizationContext Единственный UI-поток

Флаг async без вызовов await внутри никак не поменяет метод

Это не так. Async — флаг компиляции, он — не часть сигнатуры метода и может не быть объявлен в интерфейсах. Видя метод как async, компилятор уже все равно создаст из него state-машину, пускай даже с одним состоянием. Исходя из этого оставлять методы с async без await внутри — плохая практика.

Async await и ContinueWith у Task — одно и то же

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

await Task.Run(() => { })
                .ContinueWith(async prev =>
                {
                    Console.WriteLine("Continue with 1 start");
                    await Task.Delay(1000);
                    Console.WriteLine("Continue with 1 end");
                })
                .ContinueWith(prev =>
                {
                    Console.WriteLine("Continue with 2 start");
                });

В консоли мы получим:

Continue with 1 start
Continue with 2 start
Continue with 1 end

Такое поведение обусловлено особенностью механизма async/await — после прерывания метода из него возвращается незавершенная задача, что интерпретируется механизмом ContinueWith как завершение метода.

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

Если хотите другие объяснения, то я поднимал этот вопрос на Stack Overflow.

TaskScheduler — то же самое, что SynchronizationContext, только новее

На самом деле, SynchronizationContext был представлен в .NET гораздо раньше, чем TaskScheduler.

  • Наследники обоих классов отвечают за планирование асинхронных операций.
  • Оба наследника работают с пулом потоков (класс ThreadPool) в реализациях по умолчанию.
TaskScheduler

  • Появился в .NET 4.0.
  • Высокоуровневая абстракция для работы с Task.
  • Позволяет планировать выполнение Task и продолжений.
  • Имеет две реализации по умолчанию:
    ThreadPoolTaskScheduler
    и SynchronizationContextTaskScheduler.
Где используется:

  • Любые операции с Task API, явно или неявно.
SynchronizationContext

  • Появился в .NET 2.0.
  • Низкоуровневый класс, позволяет запускать делегаты в нужных потоках.
  • Используется для работы await.
  • Имеет множество реализаций в зависимости от типа окружения.
Где используется:

  • Продолжение метода после await.
  • TaskScheduler.FromCurrentSynchronizationContext().
  • Запуск обработчиков в WinForms.

III. Проблемный код и лучшие практики

Проблемный код

Async void problem

Не используйте void вместе с async, если только это не написание обработчиков WinForms/WPF. Метод, отмеченный как async, будет запущен в пуле потоков, но у него нет механизма отлова исключений. Также вы не сможете отследить прогресс его выполнения, так как объекта Task, отвечающего за статус, здесь нет. Опасность отсутствия механизмов отлова исключений в том, что в случае падения такой метод завершит работу домена приложения, а если он единственный — то и работу всего приложения.

Кстати, анонимный лямбда-метод — async Action, а Action имеет результат void. Поэтому, вернув в async лямбде результат Task, компилятор автоматически выберет нужную перегрузку метода Task.Run, возвращающий async Task — и проблем не будет.

Deadlock problem

В однопоточных контекстах синхронизации (Original asp.net, WinForms, WPF) возможны дедлоки из-за необходимости добавлять продолжение метода в уже занятый поток. При этом освободить поток нельзя из-за незаконченности задачи. Чтобы было проще понять, о чем я, давайте посмотрим на такой код:

public static async Task<JObject> GetJsonAsync(Uri uri)
{
  using (var client = new HttpClient())
  {
	//await ожидает освобождения потока, чтобы запланировать запуск продолжения метода
    var jsonString = await client.GetStringAsync(uri); 
    return JObject.Parse(jsonString);
  }
}

public string Get(){
    var jsonTask = GetJsonAsync(...);
//поток заблокирован с помощью Result, ожидается завершение Task
    return jsonTask.Result.ToString();
}

Если он будет вызван на старом ASP.NET или на WinForms/WPF-приложении, результатом будет дедлок.

По порядку:

  1. Выполнение заходит в метод GetJsonAsync.
  2. Выполнение доходит до оператора await, возвращая вверх по вызову незаконченную Task.
  3. На незаконченной Task запускается блокирующее ожидание результата свойством Result.
  4. После прихода await однопоточный контекст синхронизации планирует продолжение в единственно возможный поток, который ждет окончания Task. Но Task не закончится, пока не отработает продолжение.

Еще один пример:

Блокирующие операции

Надеюсь, я уже привел достаточно аргументов в пользу того, что блокировки в асинхронном коде — это плохо. Смесь блокирующего и асинхронного кодов может приводить к дедлокам и нивелировать все преимущества написания кода в асинхронной манере.

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

Потерянные исключения

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

Запустите этот код в ASP.NET Core консольном приложении:

#if DEBUG
            Console.WriteLine("Please, switch to Release mode");
#endif
#if RELEASE
            TaskScheduler.UnobservedTaskException += (s, e) =>
            {
                Console.WriteLine("Unobserved exception");
            };
#endif

  Task.Factory.StartNew(() => throw new ArgumentNullException());
  Task.Factory.StartNew(() => throw new ArgumentOutOfRangeException());

  Thread.Sleep(100);
  GC.Collect();
  GC.WaitForPendingFinalizers();
      
  await Task.Delay(10000);

Вы увидите, что после сборки мусора два раза сработает событие UnobservedTaskException, при этом никакой проблемы в работе приложения не будет.

В .NET 4.0 поведение по умолчанию было иным: в случае необработанного исключения (оно считается необработанным, если Task, в котором оно произошло, попадает под сборку мусора, при этом мы не обратились к свойству Exception явно или неявно) будет выброшено исключение в пул потоков, что приведет к краху приложения.

Ambient objects

  • Никогда не используйте ThreadLocal-хранилище в асинхронном коде, вместо этого был создан класс AsyncLocal.
  • При использовании TransactionScope помните об опции AsyncFlow, без нее работа транзакций не будет корректной. Я не сторонник использования TransactionScope в своих приложениях, однако при рефакторинге строго кода — вы вполне можете все сломать.

Работа асинхронных методов и IDisposable

В следующем коде:

public async Task<Result> GetResult()
{
      return await service.get();
}

public Task<Result> GetResultWrapper()
{
      using(var serviceContext = new ServiceContext())
      {
            return serviceContext.GetResult();
      }
}

Если вызывать async метод конструкцией await внутри в синхронном using, то Dispose для serviceContext отработает перед тем, как завершится метод GetResult.

Причина такого поведения в том, что после первого же await внутри метода GetResult нам вернется Task, исполнение кода продолжится, и по выходу из using будет вызван Dispose.

Затем придет продолжение после await внутри метода GetResult, но будет поздно.

Производительность

await в цикле

Если у вас есть код, где каждому элементу необходимо независимо от других сделать await, выполнение в цикле будет очень долгим. С логической точки зрения, если в основе вызываемых методов лежит IO Bound блокировка ожидания, то нет смысла вызывать их последовательно. С точки зрения конечного автомата внутри механизма async/await, это будет некоторый оверхед.

Гораздо лучше собрать все таски — и вызвать Task.WhenAll для всех сразу. ThreadPool сам поймет, как лучше оптимизировать их работу.

Dynamic & try/catch в async-методах

Если в вашем приложении каждая миллисекунда имеет значение, помните, что использование try/catch внутри async-метода значительно его усложнит. То же самое — с await dynamics-результата. Стейт-машина станет в разы сложнее, что замедлит выполнение кода.

ValueTask

Использование ValueTask может дать незначительный прирост производительности в коде, массово использующем класс Task. ValueTask — структура, где на создание экземпляра не выделяется дополнительная память в управляемой куче.

Лучшие практики

По ссылке вы можете найти собранные в одном месте лучшие практики написания асинхронного кода.

Если упростить:

  • Не используйте async void, за исключением обработчиков WinForms/WPF.
  • Если начали, делайте все приложение асинхронным.
  • Не используйте блокирующие конструкции, используйте await.
  • Выбирайте неблокирующее ожидание await > ContinueWith.
  • Используйте ConfigureAwait(false) в коде вашей библиотеки.
  • Возвращайте только запущенные задачи.
  • Используйте конвенции именований.
  • Используйте флаги задач, если это необходимо.
  • Используйте асинхронную версию SemaphoreSlim для синхронизации доступа к ресурсу.

IV. Библиотеки и тулинг

Неблокирующие коллекции

Non-blocking dictionary — усовершенствованный по перфомансу словарь.

Immutable collections — стандартный набор неизменяемых коллекций. Примеры использования и кейс можно найти в этой статье.

Анализаторы кода

AsyncFixer — анализатор-расширение для Visual Studio для проблемных мест, связанных с асинхронностью. Ненужные await, async void методы, использование async & using, места, где можно использовать async-версии методов, обнаружение явных кастов Task<T> к Task.

Ben.BlockingDetector — библиотека-помощник обнаружения блокировок в вашем коде.

Демистифаеры стек-трейса

Ben.Demystifier позволяет получить более приятный стек-трейс в вашем приложении.

V. Что почитать/посмотреть

Можете глянуть мой доклад «Асинхронность в .NET — от простого к сложному» по этой теме. По структуре материала он похож на эту статью.

Довольно интересный доклад Игоря Фесенко про работу асинхронности и многопоточности, скрытые проблемы и методы их решения.

Блог Stephen Cleary, автора Concurrency in C# Cookbook (2nd ed).

Блог непосредственно разработчиков асинхронных средств Pfx team: async/await, Tasks in-depth.

TAP Pattern whitepaper.

ILSpy/DotPeek, чтобы посмотреть все самому :) Если хотите посмотреть код, генерируемый для async-методов — в настройках вашей reverse-engineering tool необходимо включить соответствующую настройку.

Еще пара книг по этой теме, которые мне показались вполне понятными: Алекс Дэвис «Асинхронное программирование в C# 5.0», Richard Blewett, Andrew Clymer Pro Asynchronous Programming with .NET.


Если у вас есть вопросы, замечания или пожелания, можете писать мне на Facebook.

Также если вы начинающий или опытный разработчик в поиске работы/в процессе изучения технологий, можете вступить в мое комьюнити в Telegram. Участвуйте в обсуждениях, задавайте вопросы — или просто поговорим с вами за жизнь!

LinkedIn

60 комментариев

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

Владислав, нашел небольшую ошибку у вас в статье. В случае с

Original ASP.NET AspNetSynchronizationContext

выполнение может продолжиться на любом потоке из ThreadPool. Блокировка при синхронном вызове в этом случае происходит из-за внутреннего механизма самого AspNetSynchronizationContext, который позвляет захваченному контексту выполняться только на одном потоке, который заблокирован. Я не возьмусь сейчас описать механизм в деталях, думаю, вот эти ссылки могут помочь:
blog.stephencleary.com/...​-block-on-async-code.html
docs.microsoft.com/...​he-synchronizationcontext

Спасибо за дельное замечание! Смотрел в дотпик, в старом asp.net возможно все действительно работает не так просто, как будет время изучу детальней и добавлю правки в статью.

Шёл 2020, а в C# ещё не сделали хорошее и красивое API для конкаренси и асинхрощины, почему? Есть же рабочие примеры такового, почему бы не позаимствовать?

Он спрашивает почему C# не Scala, забей.

Ну Иван же имел ввиду конкретные рабочие примеры красивых API, вот мне стало интересно.

typelevel.org/...​datatypes/io.html(которое кроме jvm умеет в js), monix.io (высокоуровневое),fs2.io/index.html(для стриминга), хасклеллевое IO, zio.dev/(которое вместе с джавой работает: github.com/zio/interop-java), дальше по степени неудобности идут всякие RX и акторы типа doc.akka.io/...​current/typed/actors.html или стримы той же акки, которые гораздо более колючие чем fs2, но имеют чуть больше интеграций doc.akka.io/...​am/stream-quickstart.html, или же более неудобная но зато под С# getakka.net. И это — не просто пачка отдельных библиотек, под всё это есть экосистема и возможность дописать что-то своё.

Если конечно, не понятно что в них хорошего, это другой вопрос, можно очень много написать по этому поводу.

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

А как акторы или RX решают проблему асинхронного воода/вывода? Иван несет какую-то дич, как он часто любит. Его посыл — IO монады и почему их нет в C#. Ответ: есть, и монады можно использовать и в C#, просто с ними рабтать неудобно, язык императивный.

А как акторы или RX решают проблему асинхронного ввода/вывода?

вообще RX и та самая IO то и создавались для удобных асинхронных походов в бд, на хард и иные сервисы. Акторы вполне себе хорошая штука, если хочется императивочки — доставка сообщений между акторами асинхронная.

RX создавались для удобной разработки push-based (реактивной) логики, например, обработки событий интерфейса. А акторы-то тут каким боком? Один актор выполняет логику, а второй сохраняет данные? А что если второй отвалится? Первому придется постоятнно попинывать второго «ну сохрани данные, ну пожалуйста»? А если при этом первый отвалится? Акторы это немного про другое. Обработко сообщение включает в собя сохранение состояние, если это необходимо. Это обязанность одного актора. Асинхронность там в том, что разные транзанкции в выполняются разными акторами. Обработка одного сообщения — одна транзакция.

Один актор выполняет логику, а второй сохраняет данные? А что если второй отвалится? Первому придется постоятнно попинывать второго «ну сохрани данные, ну пожалуйста»? А если при этом первый отвалится? Акторы это немного про другое.

с этими вопросами в документацию акки, там всё это подробно описано.

Обработко сообщение включает в себя сохранение состояние, если это необходимо.

akka persistnece, видел в продакшене, прекрасно работает.

Асинхронность там в том, что разные транзанкции в выполняются разными акторами.

У не персистентных акторов(но и персистентных хватает проблем) нет даже на намёков на транзакционность. Единственное что можно просить у инфраструктуры — то что твоему актору это самое сообщение прилетит при удачном стечении обстоятельств.

RX создавались для удобной разработки push-based (реактивной) логики, например

для этих целей хорошо подходят стримы, а там где не подходят есть акторы и моникс. Голое дёргание этих самых пушей ещё то поедание кактуса.

вообще RX и та самая IO то и создавались для удобных асинхронных походов в бд, на хард и иные сервисы.

А вот это, если вкратце — то по хорошему, все эти вещи заворачиваются в эффекты, и дальше при помощи либо исполнителя IO, либо исполнителя RX, контроллируем когда где и что выполнится. В итоге у нас чистый код отдельно, effectful код отдельно, а их взаимодействие чётко определено. При использовании «нативных» средств такого хорошего разделения можно добиться только лишь при железной дисциплине, а Discipline Driven Development это штука ненадёжная и часто расходная.

Ты хоть немного осознаешь насколько нерелевантную инфу пишешь? Топик про async I/O в C#.

но тут именно обозначил нативные подходы

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

акторы это уже о другом

условную «таску» можно рассматривать как актора, а актора — как множество «тасок». Но тем не менее, можно вполне можно писать на акке так, что все асинхронные действия (за исключенем привязок к каким-нибуть библиотекам) будут обрабатываться акторами и обменом сообщений между ними.

И дело не в нативности/не нативности, а в том, что все решения, для которых не применялось систематическое и целенаправленное закапывание всех подводных камней и убирание граблей в хранилище для инвентаря, вышеприведенными вещами пестрят. В итоге, вместо того что задумывали авторы получается Quirk Oriented Programming. Несмотря на то, парадигма QOP на сегодняшний момент даже более распространена чем OOP(хотя, если задуматься, OOP это разновидность QOP и этому есть масса подтверждений), существуют возможности убрать часть «приколов» технологии вовсе, а те которые нельзя — сдвинуть куда-нибуть к краю скоупа пользовательского кода, и подчинить некоторой понятной логической теории/системе. Но, обычно, если и разработчики языков/либ снисходят до улучшения UX разработчиков, то только в очень малом объёме.

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

а в C# ещё не сделали хорошее и красивое API для конкаренси и асинхрощины

я собственно и имел в виду — почему ни авторы языка, ни разработчики библиотек не делают вот такой важной работы, имея для этого все средства. Неужели привыкание разработчиков к любимым «приколам» настолько сильно?

Для себя просто интересно. Был ли до C#5 нормальный вариант завернуть синхронные задачи в асинхронную оболочку (деск-топ с UI). С блокировкой UI или без нее. Потому что из примерно 4х найденных в хелпах способов, 2 вообще не работало. Backgroundworker (кажется так назывался) — работал для ограниченных простых случаев. Более-менее удавалось реализовать только через Task Factory. Но и там заводилось все только с бубном и не всегда.

С использованием Task ? Если нужны калькуляции в бекграунде — то можно TaskFactory.StartNew/Task.Run будет колбасить в тредпуле, далее нужно будет пробросить результат в UI Thread.

не важно как. Главное реализовать :-) Таски просто единственное что работало. Делал как описано. Но заводилось через раз.

Надо код смотреть. Так сложно.

Користуватися TaskContinuationOptions треба обережно, особливо це стосується опції ExecuteSynchronously, бо можна зловити дедлок. Взагалі локування в асинхронному коді — складна штука. Добре, якщо вдається встановити lock ordering, але коли в коді багато компонентів (тим більше чужих, бібліотечних і/або з закритим кодом), або коли пишеться бібліотека, це може бути важко. З власного гіркого досвіду можу порадити звертати увагу на виклики CancellationTokenSource.Cancel() та TaskCompletionSource.SetXxx/TrySetXxx, бо за замовчанням всі колбеки, які висять на цих об’єктах — а це дуже часто продовження скінченно-станових машин асинхронних методів — виконуються на потоці викликача. Якщо хтось вище по стеку триматиме потрібний колбеку лок, то вийде дедлок (а інколи «вдається» і переповнення стеку влаштувати). Безпечніше викидати всі підозрілі виклики Cancel/SetXxx/TrySetXxx на тредпул. Так, це деякі втрати латентності, але це дасть шанс стеку викликача розкрутитися і повідпускати локи.

Спасибо!
Да, я тоже написал что ContiueWith и опций лучше избегать.

TaskCompletionSource

лучше вообще просто так не использовать, это более служебная штука.

TaskCompletionSource лучше вообще просто так не использовать, это более служебная штука.

На самом деле очень полезная штука во многих сценариях. Довольно часто использую.

Ну если хочется таскофицировать свои кастомные вещи — то да, единственный способ :)

Просто так не треба, 100%. Але коли приєднуєш до C# якісь нестандартні зовнішні асинхронні механізми, робиш складну синхронізацію, асинхронні колекції чи щось таке, вона незамінна.

Async streams (тобто мовну конструкцію await foreach і іже з нею) можна використовувати і в фул фреймворку, потрібно лише дати компілятору необхідні допоміжні типи та інтерфейси (IAsyncEnumerable і т.д.) Для цього є два nuget пакети: AsyncEnumerator та ElasticBytes.AsyncEnumerable.

Спасибо за хорошее замечание!

Update: найкраще додавати Microsoft.Bcl.AsyncInterfaces, який підтягується з багатьох інших майкрософтівських пакетів. EB.AsyncEnumerable тепер містить лише адаптери для більш зручної роботи з IAsyncEnumerator. IAsyncEnumerable, який на відміну від IAsyncEnumerator можна await foreach’ити, часто має непідходящу семантику: він описує послідовності, які можна асинхронно перелічувати скільки завгодно разів, а скажімо для запитів які приходять з сокета це безглуздо.

Согласен!
Но это скорей про параллелизм, а не про асинхронность

Это и про то, и про другое

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

любые возвращают результат не сразу, отличие — в степени задержек
software.intel.com/...​99/76/latency-pyramid.jpg

Абсолютно малыми велечинами можем пренебречь, но спасибо за инфу :)

В конец добавляем постфикс Async.

Сейчас по-моему тот же Microsoft уже не рекомендует добавлять этот постфикс к именам асинхронных методов.

Там может зависить от контекста. Если в класе и Sync и Async методы, тогда можно добавлять постфикс Async. Если всё приложение async и все методы так же, тогда да, нет смысла добавлять

Формально должны, но не во всех случаях
docs.microsoft.com/...​en-us/dotnet/csharp/async
Но здравый смысл подсказывает, что можно это делать только в случае наличия синхронного аналога.

Сейчас по-моему тот же Microsoft уже не рекомендует добавлять этот постфикс к именам асинхронных методов.

Наканецта. А где об этом можно почитать? А то адептов Async все еще много.

На стековерфлоу можно, на docs.microsoft.com только адепты Async :)

А-а, так и раньше было. Я думал MS поменяли свое мнение на этот счет.

3-4 года назад я был согласен с Ником, но сейчас мы дропнули Async уже несколько лет как и это очень приятно. В том примере var result = GetResult(); что происходит с результатом? Его нужно или передать или как-то использовать. Даже если его просто сериализуют и передают в response stream, какой-то тест должен упасть еще до того, как код попал на review. Если нет — то проблемы в процессе разработки, CI/CD, а не в суффиксах. С такимим же аргументами можно топить и за венгерскую нотацию. В JS же как-то справляются без префикса Promise/Async.

IO Bound — блокировка, ожидание результата от устройств ввода-вывода — тут асинхронный подход имеет максимальный эффект

Я бы даже сказал «только тут и имеет эффект асинхронный подход».

в случае CPU bound тем не менее тоже можно с помощью тасок планировать работу правильно.

Но мне не приходит в голову никакой пример, где есть смысл await_ить таску, код которой есть вычисление в памяти, то есть код этой Task не делает никаких запросов во вне (IO, Database, Network, etc).

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

доступ к памяти тоже имеет разную реализацию — UMA/NUMA

мы в своих дотнетах даже не думаем о таком)

Примером может быть случай, когда работаешь на WPF или WinForms, в которых выполнение происходит в UI потоке. Тогда есть смысл создавать фоновый поток и await_ить его. И тут не имеет значения будет это вычисление на микропроцессоре или же это IO bound задача. В остальных же случаях и вправду, как мне видится, лучше не await_ить CPU bound задачи, а выполнять их синхронно. Исключением может быть разве что случай, когда выполняешь эти задачи параллельно, а затем пишешь await Task.WhenAll().

Спасибо за статью!

Еще можно объяснять многопоточность через связь с параллелизмом. Параллелизм (конкурентность) — свойство системы выполнять в один момент времени несколько процессов вычислений. Многопоточность — способ реализации параллелизма

Параллелизм и конкурентность — не синонимы. Это разные вещи

Статья вообще не об этом была ) Я пытался донести мысль, что асинхронность — это логическая вещь, она может работать как в однопоточной среде так и в многопоточной.

Я не про статью, меня зацепила эта фраза, подразумевающая, что параллелизм и конкурентность — одно и то же, что не верно.

Параллелизм (конкурентность)

А статья — гуд

Спасибо, что обратил внимание. В нескольких источниках увидел сравнение параллелизма и конкурентности в том, что параллелизм — это про непосредственное выполнение в один момент времени.

А конкурентность — более абстрактная вещь, подразумевающая существующие одновременные активности, возможно не параллельные на одной системе фактически:

«Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.»

Большинство англоязычных ресурсов описывают конкурентность как просто факт выполнения нескольких задач в один момент времени. Честно говоря, в таком контексте определение конкурентности скорее соответствует «многозадачности».

«Concurrency is the act of running and managing multiple tasks at the same time. On the other hand, parallelism is the act of running various tasks simultaneously». Честно говоря, для меня — слишком абстрактно для двух разных терминов.

У вас здесь t это Task в Task’е

var t = Task.Run(async () =>
{
      //производим отмену на CancellationTokenSource
      cts.Cancel();
      //в Delay попадет уже отмененный токен, что выбросит исключение
      await Task.Delay(10000, token);

      //Возвратом задачи делаем из async void метода -> async Task метод
      return Task.CompletedTask;
}, token);

Да, вы правы. Можно и не возвращать еще одну таску, суть примера от этого не поменяется.

«Многопоточность — параллельное выполнение»

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

К примеру, если мы создали два потока и так повезло, что у процессора оказалось свободно два ядра

а когда мы пишем код, то мы должны его писать именно для этого случая — будет выполнен параллельно, в одно и то же время

даже если это однопоточный PHP — работу с БД надо писать с учетом — параллельности изменения данных в ней.

другими словами — многопоточность подразумевает паралльность обязатально.
и даже однопоточность нередко должна учитывать — параллельность выполнения этого однопоточного кода.

смысл тогда отличать многопоточность от параллельности?
разве что когда разрабатывать сам шедулер потоков.

Оно параллельное логически — если у нас 2-4-8 ядер, а потоков ОС около сотни — логически они выполняются параллельно, физически это реализуется за счет переключений контекста.

Все-таки в английском «параллельное выполнение» (parallel) означает именно физический параллелизм, а не логический (concurrent). Поэтому фраза

Многопоточность — параллельное выполнение

никогда не будет истинной в случае одного ядра.

Параллельное выполнение — это когда в одно и тоже время выполняется более одной задачи. Многопоточность это (редкий) частный случай параллельности.

Ну тогда наверное наоборот: параллельность — это частный случай многопоточности.

Спасибо!

Подписаться на комментарии