.NET: Інструменти для роботи з багатопоточністю та асинхронністю. Частина 1

Публікую на Хабр оригінал статті, переклад якої розміщено в корпоративному блозі.

Необхідність робити щось асинхронно, не чекаючи результату тут і зараз, або розділяти велику роботу між кількома одиницями, що її виконували, була і до появи комп'ютерів. З їхньою появою така необхідність стала дуже відчутною. Зараз, у 2019, набираючи цю статтю на ноутбуці з 8 ядерним процесором Intel Core, на якому паралельно цьому працює не одна сотня процесів, а потоків і більше. Поруч лежить вже трохи пошарпаний, куплений кілька років тому телефон, у нього на борту 8 ядерний процесор. На тематичних ресурсах повно статей та відео, де їх автори захоплюються флагманськими смартфонами цього року куди ставлять 16-ядерні процесори. MS Azure надає менш ніж за 20 $ / годину віртуальну машину зі 128 ядерним процесором та 2 TB RAM. На жаль неможливо витягти максимум і приборкати цю міць, не вміючи керувати взаємодією потоків.

Термінологія

Процес (Process) - Об'єкт ОС, ізольований адресний простір, містить потоки.
Потік (Thread) - Об'єкт ОС, найменша одиниця виконання, частина процесу, потоки ділять пам'ять та інші ресурси між собою в рамках процесу.
багатозадачність - Властивість ОС, можливість виконувати кілька процесів одночасно
Багатоядерність - Властивість процесора, можливість використовувати кілька ядер для обробки даних
Багатопроцесорність - Властивість комп'ютера, можливість одночасно працювати з кількома процесорами фізично
Багатопоточність - властивість процесу, можливість розподіляти обробку даних між кількома потоками.
Паралельність - Виконання декількох дій фізично одночасно в одиницю часу
Асинхронність - виконання операції без очікування закінчення завершення цієї обробки, результат виконання може бути оброблений пізніше.

метафора

Не всі визначення хороші і деякі потребують додаткового пояснення, тому до формально введеної термінології додам метафору про приготування сніданку. Приготування сніданку у цій метафорі – process.

Готуючи сніданок з ранку я (центральний процесор) приходжу на кухню (Комп'ютер). У мене 2 руки (Ядра). На кухні є ряд пристроїв.IO): піч, чайник, тостер, холодильник. Я вмикаю газ, ставлю на нього сковорідку і наливаю туди олію, не чекаючи поки вона розігріється.асинхронно, Non-Blocking-IO-Wait), я дістаю з холодильника яйця і розбиваю їх у тарілку, після чого збиваю однією рукою (Thread#1), а другий (Thread#2) притримую тарілку (Shared Resource). Зараз би ще включити чайник, але рук не вистачає (Thread Starvation) За цей час розігрівається сковорідка (обробка результату) куди я виливаю те, що збив. Я дотягуюсь до чайника і вмикаю його та тупо дивлюся як вода в ньому закипає (Blocking-IO-Wait), хоча міг би за цей час вимити тарілку, де збивав омлет.

Я готував омлет використовуючи всього 2 руки, та більше у мене і ні, але при цьому в момент збивання омлета відбувалося відразу 3 операції: збивання омлета, притримування тарілки, розігрівання сковорідки. всього гальмує, тому часто ефективним рішенням є зайняти чимось CPU, поки йде отримання даних від IO.

Продовжуючи метафору:

  • Якби в процесі готування омлету, я б ще й намагався переодягнутися, це був би приклад багатозадачності. Важливий нюанс: у комп'ютерів із цим набагато краще, ніж у людей.
  • Кухня з кількома кухарями, наприклад, у ресторані — багатоядерний комп'ютер.
  • Безліч ресторанів на фудкорті в торговому центрі - датацентр

Інструменти .NET

У роботі з потоками, як і багато в чому іншому, .NET хороший. З кожною новою версією він представляє все більше нових інструментів для роботи з ними, нові прошарки абстракції над потоками ОС. У роботі з побудовою абстракцій розробники фреймворку використовують підхід, що залишає можливість при використанні високорівневої абстракції, спуститься на один або кілька рівнів нижче. Найчастіше в цьому немає необхідності, навіть це відкриває можливість для пострілу собі в ногу з дробовика, але іноді, в окремих випадках, це може виявитися єдиним способом вирішити проблему, яка не вирішує на поточному рівні абстракції.

Під інструментами я маю на увазі як програмні інтерфейси (API), що надаються фреймворком і сторонніми пакетами, так і цілий програмні рішення, що спрощує пошук будь-яких проблем пов'язаних з багатопоточним кодом.

Запуск потоку

Клас Thread, найбільш базовий у .NET для роботи з потоками. У конструктор приймає один із двох делегатів:

  • ThreadStart — Без параметрів
  • ParametrizedThreadStart — один параметр типу object.

Делегат буде виконаний у новоствореному потоці після виклику методу Start, якщо конструктор був переданий делегат типу ParametrizedThreadStart, то метод Start необхідно передати об'єкт. Цей механізм потрібен передачі будь-якої локальної інформації в потік. Варто відзначити, що створення потоку це дорога операція, а сам потік це важкий об'єкт, як мінімум тому, що відбувається виділення 1МБ пам'яті на стек, і вимагає взаємодії з API ОС.

new Thread(...).Start(...);

Клас ThreadPool представляє концепцію пулу. У .NET пул потоків є твором інженерного мистецтва і розробники з Microsoft вклали безліч зусиль, щоб він працював оптимально в різних сценаріях.

Загальна концепція:

З моменту старту додаток на тлі створює кілька потоків про запас і надає можливість брати їх у користування. Якщо потоки використовуються часто і у великій кількості, то пул розширюється, щоб задовольнити потребу коду, що викликає. Коли в пулі в потрібний момент часу не виявляється вільних потоків, він або дочекається повернення одного з потоків, або створить новий. З цього випливає, що пул потоків відмінно підходить для деяких коротких дій і погано підходить, для операцій, що працюють як служби протягом усієї роботи додатків.

Для використання потоку з пулу, є метод QueueUserWorkItem, який приймає делегат типу WaitCallback, що за сигнатурою збігається з ParametrizedThreadStart, а параметр, що передається в нього, виконує ту ж функцію.

ThreadPool.QueueUserWorkItem(...);

Менш відомий метод пулу потоків RegisterWaitForSingleObject служить для організації неблокуючих IO операцій. Делегат передано у цей метод буде викликаний тоді, коли WaitHandle передано у метод буде “відпущений”(Released).

ThreadPool.RegisterWaitForSingleObject(...)

У .NET є потоковий таймер і відрізняється від таймерів WinForms/WPF тим, що його обробник буде викликаний в потоці взятому з пула.

System.Threading.Timer

Також є досить екзотичний спосіб відправити делегат на виконання в потік з пулу - метод BeginInvoke.

DelegateInstance.BeginInvoke

Хочу ще побіжно зупиниться на функції до виклику якої зводиться багато з вищевказаних методів - CreateThread з Kernel32.dll Win32 API. Існує спосіб завдяки механізму extern методів викликати цю функцію. Я бачив такий виклик лише одного разу в найжахливішому прикладі legacy коду, а мотивація автора, що зробив саме так, все ще залишається для мене загадкою.

Kernel32.dll CreateThread

Перегляд та налагодження потоків

Створені вами особисто, усіма третинними компонентами і пулом .NET потоки можна переглянути у вікні Threads Visual Studio. Це вікно відобразить інформацію про потоки лише, коли програма буде під налагодженням і в режимі зупинки (Break mode). Тут можна зручно переглянути стек імена та пріоритети кожного потоку, переключити налагодження на конкретний потік. Властивістю Priority класу Thread можна задати пріоритет потоку, який OC і CLR сприйматимуть як рекомендацію при розділенні процесорного часу між потоками.

.NET: Інструменти для роботи з багатопоточністю та асинхронністю. Частина 1

Паралельна бібліотека завдань

Task Parallel Library (TPL) з'явився у .NET 4.0. Зараз це стандарт та основний інструмент для роботи з асинхронністю. Будь-який код використовуючий старіший підхід вважається legacy. Основною одиницею TPL є клас Task із простору імен System.Threading.Tasks. Task є абстракцією над потоком. З новою версією мови C# ми одержали витончений спосіб роботи з Task`ами - оператори async/await. Ці концепції дозволили писати асинхронний код так, ніби він був простим і синхронним, це дало можливість навіть людям, які слабо розуміють внутрішню кухню потоків, писати додатки, що їх використовують, додатки, що не зависають при виконанні довгих операцій. Використання async/await тема для однієї або навіть кількох статей, але я спробую в кількох реченнях укласти суть:

  • async це модифікатор методу, що повертає Task або void
  • а await оператор неблокуючого очікування Task`а.

Ще раз: оператор await, в загальному випадку (є винятки), відпустить поточний потік виконання далі, а коли Task закінчить своє виконання, а потік (насправді правильніше сказати контекст, але про це пізніше) буде вільний продовжить виконання методу далі. Усередині .NET цей механізм реалізований так само як і yield return, коли написаний метод перетворюється на цілий клас, який є машиною станів і може бути виконаний окремими шматками залежно від цих станів. Кому цікаво може написати будь-який нескладний код з використанням asynс/await, скомпілювати та переглянути збірку за допомогою JetBrains dotPeek із включеним Compiler Generated Code.

Розглянемо варіанти запуску та використання Task'а. На прикладі нижче, ми створюємо новий таск, який не робить нічого корисного (Thread.Sleep (10000)), але в реальному житті це має бути якась складна діюча CPU робота.

using TCO = System.Threading.Tasks.TaskCreationOptions;

public static async void VoidAsyncMethod() {
    var cancellationSource = new CancellationTokenSource();

    await Task.Factory.StartNew(
        // Code of action will be executed on other context
        () => Thread.Sleep(10000),
        cancellationSource.Token,
        TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
        scheduler
    );

    //  Code after await will be executed on captured context
}

Task створюється з низкою опцій:

  • LongRunning - підказка про те, що завдання не буде виконано швидко, а значить, можливо, варто подумати над тим, щоб не брати потік з пулу, а створити окремий під цю Task'у, щоб не нашкодити іншим.
  • AttachedToParent - Task'і можуть вишиковуватися в ієрархії. Якщо була використана ця опція, то Task може перебувати у стані, коли сам він виконався і чекає виконання дочірніх.
  • PreferFairness означає, що добре б виконувати Task'і відправлені на виконання раніше перед тими, що були відправлені пізніше. Але це лише рекомендація і результат не гарантований.

Другим параметром метод передано CancellationToken. Для коректної обробки скасування операції після її запуску виконуваний код має бути наповнений перевірками стану CancellationToken. Якщо перевірок немає, то метод Cancel, викликаний на об'єкті CancellationTokenSource, зможе зупинити виконання Task'а лише до його запуску.

Останнім параметром передано об'єкт scheduler типу TaskScheduler. Цей клас та його спадкоємці призначені для управління стратегіями розподілу Task'ів за потоками, за умовчанням Task буде виконано на випадковому потоці з пулу.

До створеного Task'у застосований оператор await, а значить код написаний після нього, якщо такий буде виконаний в тому ж контексті (часто це означає, що на тому ж потоці), що і код до await.

Метод позначений як async void, це означає, що в ньому допустимо використання оператора await, але код, що викликає, не зможе дочекатися виконання. Якщо така можливість потрібна, то метод повинен повертати Task. Методи позначені async void зустрічаються досить часто: зазвичай це обробники подій чи інші методи, які працюють за принципом виконати і забути (fire and forget). Якщо необхідно не тільки дати можливість дочекатися закінчення виконання, а й повернути результат, необхідно використовувати Task.

На Task'і що повернув метод StartNew, втім як і будь-якому іншому, можна викликати метод ConfigureAwait з параметром false, тоді виконання після await продовжиться не на захопленому контексті, а на довільному. Це потрібно робити завжди, коли для коду після await не важливий контекст виконання. Також це є рекомендацією від MS при написанні коду, що поставлятиметься упакованому в бібліотеку вигляді.

Давайте ще трохи зупинимося на тому, як можна дочекатися закінчення виконання Task'і. Нижче приклад коду, з коментарями, коли очікування зроблено умовно добре і умовно погано.

public static async void AnotherMethod() {

    int result = await AsyncMethod(); // good

    result = AsyncMethod().Result; // bad

    AsyncMethod().Wait(); // bad

    IEnumerable<Task> tasks = new Task[] {
        AsyncMethod(), OtherAsyncMethod()
    };

    await Task.WhenAll(tasks); // good
    await Task.WhenAny(tasks); // good

    Task.WaitAll(tasks.ToArray()); // bad
}

У першому прикладі ми чекаємо виконання Task'і не блокуючи викликаючий потік, до обробки результату повернемося лише коли він вже буде, доти викликаючий потік наданий собі.

У другому варіанті ми блокуємо викликаючий потік до тих пір, поки не буде підрахований результат методу. Це погано не тільки тому, що ми зайняли потік, настільки цінний ресурс програми, простим неробством, але ще й тому, що якщо в коді методу, що ми викликаємо є await, а контекст синхронізації передбачає повернення в викликаючий потік після await, то ми отримаємо deadlock : викликаючий потік чекає, поки буде обчислений результат асинхронного методу, асинхронний метод марно намагається продовжити своє виконання в потоці, що викликає.

Ще одним недоліком такого підходу є ускладнена обробка помилок. Справа в тому, що помилки в асинхронному коді при використанні async/await обробляти дуже легко - вони поводяться так само, якби код був синхронним. У той час, як якщо ми застосовуємо екзорцизм синхронне очікування до Task'e оригінальний виняток обертається в AggregateException, т.ч. Для обробки виключення доведеться досліджувати тип InnerException і самому писати ланцюжок if всередині одного catch блоку або використовувати конструкцію catch when, замість більш звичної в C# світі ланцюжка catch блоків.

Третій і останній приклади так само відзначені поганими з тієї ж причини і містять ті самі проблеми.

Методи WhenAny та WhenAll вкрай зручні в очікуванні групи Task'ів, вони обертають групу Task'ів в один, який спрацює або по першому спрацюванню Task'а з групи, або коли виконання закінчать все.

Зупинка потоків

З різних причин може виникнути необхідність зупинити потік після його старту. І тому існує низка способів. У класу Thread є два методи з відповідними назвами - це викидень и переривання. Перший не рекомендується до використання, т.к. після його виклику у будь-який випадковий момент, у процесі обробки будь-якої інструкції, буде викинуто виняток ThreadAbortedException. Адже ви не очікуєте що такий виняток вилетить при інкременті будь-якої цілісної змінної, вірно? А за використання цього методу це цілком реальна ситуація. У разі потреби заборонити CLR генерувати такий виняток у певній ділянці коду можна обернути його на виклики Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Такими викликами обертається будь-який код, написаний в finally блоці. З цієї причини в надрах коду фреймворку можна знайти блоки з порожнім try, але не порожнім finally. Microsoft настільки не рекомендує використовувати цей метод, що не включили його до .net core.

Метод Interrupt працює більш передбачувано. Він може перервати потік винятком ThreadInterruptedException тільки в ті моменти, коли потік перебуває у стані очікування. У такий стан він переходить підвисаючи в очікуванні WaitHandle, lock або після виклику Thread.Sleep.

Обидва описані вище варіанти, погані своєю непередбачуваністю. Виходом є використання структури СкасуванняToken та класу CancelationTokenSource. Суть у наступному: створюється екземпляр класу CancellationTokenSource і тільки той, хто ним володіє, може зупинити операцію викликавши метод Скасувати. У саму ж операцію передається лише CancellationToken. Власники CancellationToken не можуть самі скасувати операцію, а можуть лише перевірити, чи не було скасовано операцію. Для цього є бульова властивість IsCancellationRequested та метод ThrowIfCancelRequested. Останній згенерує виняток TaskCancelledException якщо на примірнику CancellationToken примірнику CancellationTokenSource був викликаний метод Cancel. І саме цей метод я рекомендую використати. Це краще за попередні варіанти отримання повного контролю над тим у які моменти виняток операція може бути перервана.

Найбільш жорстоким варіантом зупинки потоку є виклик функції Win32 API TerminateThread. Поведінка CLR після виклику цієї функції може бути непередбачуваною. На MSDN про цю функцію написано наступне: “ТермінатТребет є незначною функцією, яка повинна тільки бути використана в найбільших випадках. “

Перетворення legacy-API в Task Based за допомогою методу FromAsync

Якщо вам пощастило працювати на проекті, який було розпочато вже після того, як Task'і були введені і перестали викликати тихий жах більшості розробників, то вам не доведеться мати справу з великою кількістю старих API, як третинних, так і вимучених вашою командою в минулому. На щастя, команда розробки .NET Framework подбала про нас, хоча можливо метою була турбота про себе. Як би там не було в .NET, є ряд інструментів для безболісного перетворення коду написаного в старих підходах асинхронного програмування в нову. Один з них це метод FromAsync у TaskFactory. На прикладі коду нижче, я обертаю старі асинхронні методи класу WebRequest Task за допомогою цього методу.

object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
    wr.BeginGetResponse,
    we.EndGetResponse
);

Це лише приклад і робити таке з вбудованими типами вам навряд чи доведеться, але будь-який старий проект просто кишить методами BeginDoSomething IAsyncResult, що повертають, і методами EndDoSomething його приймаючими.

Перетворення legacy-API в Task Based за допомогою класу TaskCompletionSource

Ще один важливий для розгляду інструмент, це клас TaskCompletionSource. За функціями, призначенням та принципом роботи він чимось може нагадати метод RegisterWaitForSingleObject класу ThreadPool про який я писав вище. За допомогою цього класу можна легко та зручно обертати старі асинхронні API у Task'і.

Ви скажете, що я вже говорив про метод FromAsync класу TaskFactory призначений для цих цілей. Тут доведеться згадати всю історію розвитку асинхронних моделей в .net що пропонував Microsoft за останні 15 років: до Task-Based Asynchronous Pattern (TAP) існували Asynchronous Programming Pattern (APP), який був про методи ПочинатиDoSomething повертаючому IAsyncResult та методах кінецьDoSomething його приймаючому і для legacy цих років якраз відмінно підходить метод FromAsync, але згодом, на зміну йому прийшов Event Based Asynchronous Pattern (EAP), який припускав, що після завершення виконання асинхронної операції буде викликано подію.

TaskCompletionSource якраз чудово підходить для обгортки в Task'і legacy-API побудованих навколо подієвої моделі. Суть його роботи в наступному: об'єкт цього класу має суспільну властивість типу Task станом якого можна керувати через методи SetResult, SetException та ін. Класу TaskCompletionSource. У місцях де був застосований оператор await до цього Task'у він буде виконаний або обрушений з винятком залежно від застосованого до TaskCompletionSource методу. Якщо все ще не зрозуміло, то давайте подивимося на цей приклад коду, де якесь старе API часів EAP загортається в Task за допомогою TaskCompletionSource: при спрацюванні події Task буде переведений у стан Completed, а метод оператор await, що застосував до цього Task'у, відновить своє виконання отримавши об'єкт результат.

public static Task<Result> DoAsync(this SomeApiInstance someApiObj) {

    var completionSource = new TaskCompletionSource<Result>();
    someApiObj.Done += 
        result => completionSource.SetResult(result);
    someApiObj.Do();

    result completionSource.Task;
}

TaskCompletionSource Tips & Tricks

Обгортка старих API це не все, що можна провернути за допомогою TaskCompletionSource. Використання цього класу відкриває цікаву можливість проектування різних API на Task'ах, що не займають потоки. А потік, як ми пам'ятаємо ресурс дорогий і їх кількість обмежена (в основному обсягом RAM). Цього обмеження легко досягти розробляючи, наприклад, навантажений web-додаток зі складною бізнес-логікою. Розглянемо ті можливості, про які я говорю на реалізації такого трюку як Long-Polling.

Якщо коротко суть трюку ось у чому: вам потрібно отримувати від API інформацію про деякі події, що відбуваються на його боці, при цьому API з якихось причин не може повідомити про подію, а може лише повернути стан. Приклад таких - всі API побудовані поверх HTTP до часів WebSocket або за неможливості з якоїсь причини використовувати цю технологію. Клієнт може запитати у сервера HTTP. HTTP сервер не може спровокувати спілкування з клієнтом. Простим рішенням є опитування сервера по таймеру, але це створює додаткове навантаження на сервер і додаткову затримку в середньому TimerInterval / 2. Для обходу цього був винайдений трюк, який отримав назву Long Polling, які передбачає затримку відповіді від сервера до тих пір, поки не закінчиться Timeout або не відбудеться подія. Якщо подія сталася, вона обробляється, якщо ні, то запит надсилається заново.

while(!eventOccures && !timeoutExceeded)  {

  CheckTimout();
  CheckEvent();
  Thread.Sleep(1);
}

Але таке рішення покаже себе жахливо, щойно число клієнтом, які чекають на подію, зросте, т.к. Кожен такий клієнт, чекаючи події, займає цілий потік. Та й отримуємо додаткову затримку в 1мс на спрацьовуванні події, найчастіше це не суттєво, але навіщо робити ПО гірше, ніж воно може бути? Якщо ж прибрати Thread.Sleep(1), то заздримо завантажимо одне ядро ​​процесора на 100% в холосту обертаючись в марному циклі. За допомогою TaskCompletionSource можна легко переробити цей код і вирішити всі зазначені вище проблеми:

class LongPollingApi {

    private Dictionary<int, TaskCompletionSource<Msg>> tasks;

    public async Task<Msg> AcceptMessageAsync(int userId, int duration) {

        var cs = new TaskCompletionSource<Msg>();
        tasks[userId] = cs;
        await Task.WhenAny(Task.Delay(duration), cs.Task);
        return cs.Task.IsCompleted ? cs.Task.Result : null;
    }

    public void SendMessage(int userId, Msg m) {

        if (tasks.TryGetValue(userId, out var completionSource))
            completionSource.SetResult(m);
    }
}

Цей код не є production-ready, а лише демонстраційним. Для використання в реальних випадках потрібно ще як мінімум обробити ситуацію коли повідомлення прийшло в момент, коли його ніхто не очікує: в такому випадку метод AsseptMessageAsync повинен повернути вже завершений Task. Якщо ж цей випадок і є найчастішим, можна подумати і про використання ValueTask.

При отриманні запиту на повідомлення ми створюємо та поміщаємо у словник TaskCompletionSource, а далі чекаємо на те, що станеться першим: закінчиться заданий інтервал часу або буде отримано повідомлення.

ValueTask: навіщо і як

Оператори async/await як і оператор yield return генерує з методу машину станів, а це створення нового об'єкта, що майже завжди не важливо, але в окремих випадках може створити проблему. Цим випадком може бути метод, що викликається дійсно часто, мова про десятки і сотні тисяч викликів за секунду. Якщо такий метод написаний так, що в більшості випадків він повертає результат обходячи всі await методи, то .NET надає інструмент, щоб це оптимізувати — структура ValueTask. Щоб стало зрозуміло розглянемо приклад його використання: є кеш, у який ми ходимо дуже часто. Якісь значення в ньому є і тоді ми їх просто повертаємо, якщо ні, то йдемо в якийсь повільний IO за ними. Останнє хочеться робити асинхронно, а отже, весь метод виходить асинхронним. Таким чином, очевидний варіант написання методу — наступний:

public async Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return val;
    return await RequestById(id);
}

Через бажання трохи оптимізувати, і легкої остраху з приводу того, що згенерує Roslyn компілюючи цей код, можна цей приклад переписати наступним чином:

public Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return Task.FromResult(val);
    return RequestById(id);
}

Дійсно ж оптимальним рішенням у цьому випадку буде оптимізувати hot-path, а саме отримання значення зі словника взагалі без зайвих алокацій та навантаження на GC, в той час коли в тих поодиноких випадках, коли нам все ж таки потрібно йти в IO за даними все залишиться плюс /мінус по старому:

public ValueTask<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return new ValueTask<string>(val);
    return new ValueTask<string>(RequestById(id));
}

Давайте докладніше розберемо цей фрагмент коду: за наявності значення в кеші ми створюємо структуру, інакше реальний же таск буде загорнутий у значний. Викликаючому коду однаково яким шляхом виконувався цей код: ValueTask з погляду синтаксису C# поводитиметься як і звичайний Task у разі.

TaskScheduler'и: управління стратегіями запуску Task'ів

Наступне API, що хотілося б розглянути цей клас задача планувальника та його похідні. Я вже згадував вище, що у TPL є можливість керувати стратегіями розподілу Task'ів за потоками. Такі стратегії визначаються спадкоємцями класу TaskScheduler. Практично будь-яка стратегія, що може знадобиться, буде знайдена в бібліотеці ParallelExtensionsExtras, розробленої microsoft, але не є частиною .NET, а поставляється у вигляді пакета Nuget. Коротко розглянемо деякі з них:

  • CurrentThreadTaskScheduler - виконує Task'і на поточному потоці
  • LimitedConcurrencyLevelTaskScheduler — обмежує кількість Task'ів, що виконуються одночасно параметром N, що приймає в конструкторі
  • OrderedTaskScheduler — визначається як LimitedConcurrencyLevelTaskScheduler(1), тому завдання виконуватимуться послідовно.
  • WorkStealingTaskScheduler - реалізує work-stealing підхід до розподілу завдань. Насправді є окремим ThreadPool. Вирішує проблему того, що в .NET ThreadPool це статичний клас, один на всі програми, а значить, його перевантаження або неправильне використання в одній частині програми може призвести до побічних ефектів в іншій. Більше того, зрозуміти причину таких дефектів вкрай складно. Т.о. може існувати необхідність використовувати окремі WorkStealingTaskScheduler'и у тих частинах програми, де використання ThreadPool може бути агресивним та непередбачуваним.
  • QueuedTaskScheduler — дозволяє виконувати завдання щодо правил черги з пріоритетами
  • ThreadPerTaskScheduler - Створює окремий потік на кожен Task що на ньому виконується. Може бути корисно для завдань, що виконуються непередбачено довго.

Є хороша докладна стаття про TaskScheduler'ах у блозі microsoft.

Для зручного налагодження всього пов'язаного з Task'ами Visual Studio є вікно Tasks. У цьому вікні можна побачити поточний стан завдання і перейти до рядка коду, що виконується в даний момент.

.NET: Інструменти для роботи з багатопоточністю та асинхронністю. Частина 1

PLinq та клас Parallel

Крім Task'ів і всього з ними сказаного в .NET є ще два цікаві інструменти - це PLinq (Linq2Parallel) і клас Parallel. Перший обіцяє паралельне виконання всіх Linq операцій на кількох потоках. Число потоків можна налаштувати методом-розширенням WithDegreeOfParallelism. На жаль, найчастіше PLinq в режимі роботи за умовчанням не вистачить інформації про нутрощі вашого джерела даних, щоб забезпечити суттєвий виграш за швидкістю, з іншого боку ціна спроби дуже низька: потрібно лише викликати метод AsParallel перед ланцюжком Linq методів і провести тести продуктивності. Більше того, існує можливість передати в PLinq додаткову інформацію про природу вашого джерела даних за допомогою механізму Partitions. Детальніше можна почитати тут и тут.

Статичний клас Parallel надає методи для паралельного перебору колекції Foreach, виконання циклу For та виконання кількох делегатів у паралель Invoke. Виконання поточного потоку буде зупинено до закінчення розрахунків. Кількість потоків можна налаштувати передавши ParallelOptions останнім аргументом. За допомогою опцій також можна вказати TaskScheduler та CancellationToken.

Висновки

Коли я починав писати цю статтю за матеріалами своєї доповіді та інформації, що зібрав за час роботи після неї, я не очікував, що її вийде так багато. Зараз, коли текстовий редактор, у якому я набираю цю статтю, докірливо говорить мені про те, що пішла 15-та сторінка, я підведу проміжні підсумки. Інші трюки, API, візуальні інструменти та підводні камені будуть розглянуті в наступній статті.

Висновки:

  • Потрібно знати інструменти роботи з потоками, асинхронністю та паралелізмом, щоб використовувати ресурси сучасних ПК.
  • У .NET багато різних інструментів для цих цілей
  • Не всі вони з'явилися відразу, тому часто можна зустріти legacy, проте є способи перетворення старих API без особливих зусиль.
  • Робота з потоками в .NET представлена ​​класами Thread та ThreadPool
  • Методи Thread.Abort, Thread.Interrupt, функція Win32 API TerminateThread небезпечні та не рекомендуються до використання. Замість них краще використовувати механізм CancellationToken'ів
  • Потік – цінний ресурс, їх кількість обмежена. Потрібно уникати ситуацій, коли потоки займаються очікуванням подій. Для цього зручно використовувати клас TaskCompletionSource
  • Найбільш потужним та просунутим інструментом .NET для роботи з паралелізмом та асинхронністю є Task'и.
  • Оператори c# async/await реалізують концепцію очікування, що не блокує.
  • Керувати розподілом Task'ів по потоках можна за допомогою похідних TaskScheduler'у класів
  • Структура ValueTask може бути корисною в оптимізації hot-paths і memory-traffic
  • Вікна Tasks та Threads Visual Studio надають багато корисної для налагодження багатопоточного або асинхронного коду інформації
  • PLinq крутий інструмент, але у нього може не бути достатньо інформації про ваше джерело даних, втім це можна виправити за допомогою механізму partitioning
  • Далі буде ...

Джерело: habr.com

Додати коментар або відгук