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

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

I. Асинхронність: як і навіщо це використовувати.

II. Погляд всередину через популярні омани.

III. Проблемний код та найкращі практики.

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

V. Що ще почитати/подивитися.

I. Асинхронність: як і навіщо це використовувати

Що таке асинхронність і навіщо вона потрібна?

Всі зовнішні пристрої, що не працюють на одній шині з мікропроцесором, — мережеві адаптери, відеокарти, сховища даних — повертають результат своєї роботи не відразу. Отже, нам вибирати: або наш потік виконання буде зупинятися і чекати результат операції, або виконувати якийсь інший код. Таким чином, код, написаний з неблокирующим (асинхронним) очікуванням результату, споживає менше ресурсів і є більш продуктивним на дистанції.

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

Однак багатопоточність і асинхронність можна також класифікувати за типами багатозадачності, як її форми:

Вивчаючи асинхронні підходи .NET, я погано розумів, як все влаштовано зсередини. Це не дозволяло вирішувати ряд проблем, пов'язаних з асинхронностью.Також чув різні історії колег, які стикалися з аналогічними проблемами і не завжди знали, як їх вирішити: наприклад, дедлоки або «нелогічне» поведінка асинхронного коду. У статті розглянемо механіку роботи та найкращі практики TAP: як це влаштовано зсередини і які помилки краще не робити.

В .NET-фреймворку історично склалося декілька старих патернів організації асинхронного коду:

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

Взагалі, говорячи про асинхронність та проблеми, які вона вирішує, потрібно згадати ті самі блокування, від яких ми хочемо позбутися. Існує два типи можливості зайняти потік:

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/Вами
(pub/sub), Builder
RxJS
Вами
EventEmitter
MobX
Channels Events
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 імплементацію.
    • Повертаємо тільки запущені завдання.

Якщо хочете підійти до вивчення більш фундаментально, подивіться офіційний документ на 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 отримувати результат з завдань можна було такими блокуючими способами:

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

await t; — повернення результату/викид оригінального виключення.

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

Викид виключення викликає потіктеж результат.

Чому винятку завдань загорнуті в AggregateException? Припустимо, завдання стала результатом роботи комбінатора завдань (наприклад, Завдань.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.Wait await task Чекати завершення Task'а
task.Result await task Отримати результат завершеного Task'а
Task.WaitAny await Task.WhenAny Чекати завершення одного (будь-якого) Task'a з колекції
Task.WaitAll await Task.WhenAll Чекати завершення всіх (останнього) Task'a з колекції
Thread.Sleep await Task.Delay Чекати заданий період часу

Що нового з'явилося в TAP починаючи з C# 5

C# 5.0/.NET 4.5

C# 6.0

C# 7.0 — 7.3

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

II. Погляд всередину через популярні омани

Людям властиво вибирати для складних речей найпростіше пояснення , часто в реальному житті це статистично виправдано. Проте технології не завжди побудовані очевидним для нас способом, і «просте» пояснення може ввести нас в оману.

Task — це полегшений Thread

Найпоширеніша помилка серед початківців розробників. Клас Task не має прямого відношення до потоків операційної системи. Умовно, в повноваження Task входить:

Якщо ви програмували на JavaScript, то аналогом Task є об'єкт Promise.

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

За планування виконання коду в потоках відповідає клас TaskScheduler, який має дві реалізації:

Ви маєте право написати власний TaskScheduler, реалізувавши стратегію використання потоків і планування в них коду, переданого в задачі.

Async await — синтаксичний цукор

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

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

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

Результатом операції 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
Власний потік SynchronizationContext ThreadPool
Console Application SynchronizationContext ThreadPool
ASP.NET Core SynchronizationContext ThreadPool
Original ASP.NET AspNetSynchronizationContext Той же потік
WinForms WindowsFormsSynchronizationContext Єдиний UI-потік
WPF DispatcherSynchronizationContext Єдиний 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.

TaskScheduler

  • З'явився .NET 4.0.
  • Високорівнева абстракція для роботи з 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

Робота асинхронних методів і 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 — структура, де на створення екземпляра не виділяється додаткова пам'ять в керованій купі.

Кращі практики

За посиланням ви можете знайти зібрані в одному місці кращі практики написання асинхронного коду.

Якщо спростити:

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 . Беріть участь в обговореннях, задавайте питання — або просто поговоримо з вами життя!

Опубліковано: 09/01/20 @ 11:00
Розділ Блоги

Рекомендуємо:

Чи є життя після macOS, або Як я переїхав на Linux десктоп і не шкодую
Коли в добі досить годин, або Чому варто навчитися грамотному плануванню
DOU Hobby: авіамоделювання - від розробки моделі літака до запуску в небо
iOS дайджест #35: курс Combine, Redux + SwiftUI, Vapor 4
CI/CD для фронтенда: огляд інструментів і практик для автоматизації розробки