.NET: Инструменти за работа с многопоточност и асинхронност. Част 1

Публикувам в Хабр оригинала на статията, преводът на която е публикуван в корпоративния блога си..

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

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

Процес (Process) — обект на ОС, изолирано адресно пространство, съдържа потоци.
Поток (Thread) — обект на ОС, най-малката единица на изпълнение, част от процес, потоковете споделят памет и други ресурси помежду си в рамките на процеса.
Многозадачност — свойство на ОС, способността за изпълнение на няколко процеса едновременно
Многоядреност — свойство на процесора, способността да използва няколко ядра за обработка на данни
Многопроцесорност — свойство на компютъра, способността да работи едновременно с няколко физически процесора
Многопоточност — свойство на процеса, възможността за разпределяне на обработката на данни между няколко потока.
Паралелизъм — изпълнение на няколко действия физически едновременно в единица време
Асинхронност — изпълнение на операция без изчакване за завършването на това обработване, резултатът от изпълнението може да бъде обработен по-късно.

Метафора

Не всички определения са добри и някои се нуждаят от допълнително обяснение, затова към формално въведената терминология добавям метафора за приготвянето на закуска. Приготвянето на закуска в тази метафора — процес.

Приготвяйки закуска сутрин аз (CPU) влизам в кухнята (Компютър). Имам 2 ръце (Ядра). В кухнята има редица устройства (IO): печка, чайник, тостер, хладилник. Включвам газа, слагам тигана на него и наливам масло, без да чакам да се загрее (асинхронно, Non-Blocking-IO-Wait), изваждам яйца от хладилника и ги счупвам в чиния, след което ги разбивам с една ръка (Thread#1), а с втората (Thread#2) държа чинията (Shared Resource). В момента, в който трябва да включа чайника, но ръцете не стигат (Thread Starvation) През това време тигана се загрява (Обработка на резултата), в който изсипвам това, което разбих. Протягам се до чайника и го включвам и просто гледам как водата в него завира (Blocking-IO-Wait), макар че през това време можех да измия чинията, в която разбивах омлета.

Приготвях омлет, използвайки само 2 ръце, но нямам повече, но в момента на разбиването на омлета се случваха веднага 3 операции: разбиване на омлета, държане на чинията, загряване на тигана. CPU — е най-бързата част на компютъра, IO е това, което най-често забавя, затова често ефективно решение е да заетите CPU, докато се получават данни от IO.

Продължавайки метафората:

  • Ако по време на готвенето на омлета и се опитвах да се преоблека, това щеше да бъде пример за многозадачност. Важен нюанс: компютрите с това се справят много по-добре от хората.
  • Кухня с няколко готвачи, например в ресторант — многоядрен компютър.
  • Множество ресторанти на фуудкорт в търговски център — датацентър.

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

В работата с потоци, както и в много други неща, .NET е добър. С всяка нова версия той представя все повече нови инструменти за работа с тях, нови слоеве абстракция над ОС потоките. В работата с изграждането на абстракции разработчиците на фреймворка използват подход, който оставя възможността, при използване на високо ниво на абстракция, да слезе на един или повече нива по-ниско. Най-често в това няма нужда, още повече това отваря възможността да си стреляш в крака с пушка, но понякога, в редките случаи, това може да е единственият начин да решите проблем, който не се решава на текущото ниво на абстракция.

Под инструментите имам предвид както софтуерните интерфейси (API), предоставяни от фреймворка и странични пакети, така и цялостни софтуерни решения, опростяващи търсенето на проблеми, свързани с многопоточния код.

Стартиране на поток

Класът Thread е най-основният в .NET за работа с потоци. В конструктора приема един от двата делегата:

  • ThreadStart — Без параметри
  • ParametrizedThreadStart — с един параметър от тип object.

Делегатът ще бъде изпълнен в новосъздадения поток след извикване на метода Start; ако в конструктора е предаден делегат от тип ParametrizedThreadStart, трябва да се предаде обект в метода Start. Този механизъм е необходим за предаване на всякаква локална информация в потока. Струва си да се отбележи, че създаването на поток е скъпа операция, а самият поток е тежък обект, най-малкото защото заема 1MB памет за стека и изисква взаимодействие с 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. Има начин, благодарение на механизма на външните методи, да се извика тази функция. Видял съм такова извикване само веднъж в изключително тревожен пример на остарял код, а мотивацията на автора, направил го по този начин, все още остава за мен загадка.

Kernel32.dll CreateThread

Преглед и отстраняване на грешки в потоците

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

.NET: Инструменти за работа с многопоточност и асинхронност. Част 1

Библиотека за паралелни задачи

Библиотеката за паралелни задачи (TPL) се появи в .NET 4.0. В момента това е стандарт и основен инструмент за работа с асинхронност. Всеки код, използващ по-стари подходи, се счита за остарял. Основната единица на TPL е класът Task от пространството от имената System.Threading.Tasks. Task представлява абстракция над потока. С новата версия на езика C# получихме елегантен начин за работа с Tasks — операторите async/await. Тези концепции позволиха да се пише асинхронен код така, сякаш е прост и синхронен, което даде възможност дори на хора, слабо разбиращи вътрешната кухня на потоките, да пишат приложения, които използват тях, приложения, които не блокират при извършване на дълги операции. Използването на async/await е тема за една или дори няколко статии, но ще опитам в няколко изречения да обясня същността:

  • async е модификатор на метод, който връща Task или void
  • а await е оператор за неблокиращо чакане на Task.

Отново: оператор await, в общи линии (има изключения), освобождава текущия поток на изпълнение, а когато Task завърши своето изпълнение, потокът (всъщност е по-правилно да се нарече контекст, но за това по-късно) ще бъде свободен да продължи с извършването на метода. В .NET този механизъм е реализиран така, както и yield return, където написаният метод се превръща в цял клас, който е машина на състояния и може да бъде изпълняван на отделни части в зависимост от тези състояния. Който проявява интерес, може да напише какъвто и да е прост код с използване на async/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(
        // Кодът на действието ще бъде изпълнен в друг контекст
        () => Thread.Sleep(10000),
        cancellationSource.Token,
        TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
        scheduler
    );

    // Код след await ще бъде изпълнен в запазения контекст
}

Task се създава с набор от опции:

  • LongRunning — подсказка, че задачата няма да бъде изпълнена бързо, следователно, може би, е добре да се обмисли създаването на отделен поток за тази Task, за да не се навреди на другите.
  • AttachedToParent — Task-овете могат да се организират в йерархии. Ако е използвана тази опция, Task може да бъде в състояние, когато той е завършен и чака изпълнението на дъщерните.
  • PreferFairness — означава, че е добре да се изпълняват Task-ове, изпратени по-рано, преди тези, изпратени по-късно. Но това е само препоръка и резултатът не е гарантиран.

Вторият параметър в метода предава CancellationToken. За правилна обработка на отмяната на операцията след нейното стартиране, кода, който се изпълнява, трябва да бъде изпълнен с проверки на състоянието на CancellationToken. Ако проверките липсват, методът Cancel, извикан на обекта CancellationTokenSource, ще може да спре изпълнението на Task-а само до неговото стартиране.

Последният параметър предава обект scheduler от тип TaskScheduler. Този клас и наследниците му са предназначени за управление на стратегиите за разпределение на задачите по нишки; по подразбиране задачата ще бъде изпълнена на произволна нишка от пул.

Към създадената задача е приложен оператор await, което означава, че кодът, написан след него, ако такъв има, ще бъде изпълнен в същия контекст (често това означава, че на същата нишка), както и кодът преди await.

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

На задачата, която методът StartNew връща, както и на всяка друга задача, може да се извика метод ConfigureAwait с параметър false; тогава изпълнението след await ще продължи не в захванатия контекст, а в произволен. Това трябва да се прави винаги, когато контекстът на изпълнение след await не е съществен за кода. Също така, това е препоръка от MS при написването на код, който ще бъде доставен в опакован вид в библиотека.

Нека да се спрем още малко на това как можем да изчакаме завършването на изпълнението на задачите. По-долу е примерен код с коментари, кога изчакването е направено условно добре и кога условно лошо.

public static async void AnotherMethod() {

    int result = await AsyncMethod(); // добро

    result = AsyncMethod().Result; // лошо

    AsyncMethod().Wait(); // лошо

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

    await Task.WhenAll(tasks); // добро
    await Task.WhenAny(tasks); // добро

    Task.WaitAll(tasks.ToArray()); // лошо
}

В първия пример изчакваме изпълнението на задачата, без да блокираме извикващата нишка, и към обработката на резултата ще се върнем само когато той вече е налице, до тогава извикващата нишка е предоставена на самата себе си.

Във втория вариант блокираме извикващия поток, докато резултатът от метода не бъде изчислен. Това е лошо не само защото заетостта на потока е ценен ресурс за програмата, но и защото, ако в кода на метода, който извикваме, има await и контекстът на синхронизация изисква връщане в извикващия поток след await, ще получим deadlock: извикващият поток чака резултата от асинхронния метод, докато асинхронният метод безуспешно се опитва да продължи изпълнението си в извикващия поток.

Още един недостатък на този подход е усложнената обработка на грешки. Факт е, че грешките в асинхронния код при използване на async/await са много лесни за обработка — те се държат така, както ако кодът беше синхронен. Докато при прилагане на синхронно изчакване на Task оригиналното изключение се опаковна в AggregateException, така че за обработка на изключението ще трябва да проучим типа на InnerException и сами да напишем верига if в рамките на един catch блок или да използваме конструкцията catch when, вместо по-привичната в C# верига catch блокове.

Трети и последен пример също е маркиран с лоши резултати по същата причина и съдържа всички същите проблеми.

Методите WhenAny и WhenAll са изключително удобни за очакване на група Task’ове, те опаковат групата Task’ове в един, който ще сработи или при първото сработване на Task от групата, или когато всички завършат изпълнението си.

Спиране на потоците

По различни причини може да се наложи спиране на потока след старта му. За тази цел съществуват редица методи. Класът Thread има два метода с подходящи наименования — това са Прекрати и Interrupt. Първият не се препоръчва за използване, тъй като след неговото извикване, в произволен момент, по време на обработката на всяка инструкция, ще бъде хвърлено изключение ThreadAbortedException. Вие не очаквате такова изключение да изскочи при инкрементирането на някоя целочислена променлива, нали? А при използването на този метод, това е напълно реална ситуация. Ако е необходимо да се забрани на CLR да генерира такова изключение в определен участък от кода, може да го обвием в извиквания Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Все призовавания се опаковат в която и да е код написан в блока finally. По тази причина в дълбочината на кода на фреймуърка могат да се намерят блокове с празен try, но не и празен finally. Microsoft толкова не препоръчва да се използва този метод, че не го включи в .net core.

Методът Interrupt работи по-предсказуемо. Той може да прекрати потока с изключение. ThreadInterruptedException само в моментите, когато потокът е в състояние на чакане. В такова състояние той преминава, когато полага в очакване WaitHandle, lock или след извикването на Thread.Sleep.

И двата описани по-горе варианта са лоши поради своята непредсказуемост. Изходът е да се използва структурата CancellationToken и класът CancellationTokenSource. Същността е следната: създава се пример на класа CancellationTokenSource и само този, който го притежава, може да спре операцията, извиквайки метода Cancel. В самата операция се предава само CancellationToken. Притежателите на CancellationToken не могат сами да отменят операцията, а могат само да проверят дали операцията не е била отменена. За това има булево свойство IsCancellationRequested и метод ThrowIfCancelRequested. Последното генерира изключение TaskCancelledException ако на подадения CancellationToken пример на CancellationTokenSource е извикан методът Cancel. И точно този метод препоръчвам да се използва. Това е по-добре от предишните варианти, получаващи пълен контрол над моментите, в които операцията може да бъде прекратена с изключение.

Най-агресивният вариант за спиране на потока е извикването на функцията Win32 API TerminateThread. Поведението на CLR след извикването на тази функция може да бъде непредсказуемо. На MSDN относно тази функция е написано следното: “TerminateThread is a dangerous function that should only be used in the most extreme cases.”

Преобразуване на 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,
    wr.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), който обхващаше методите BeginDoSomething, връщащи IAsyncResult и методите EndDoSomething, които ги приемат. За legacy от тези години методът FromAsync подхожда отлично, но с времето, на негово място дойде Event Based Asynchronous Pattern (EAP), който предвижда, че при приключване на асинхронната операция ще бъде извикано събитие.

TaskCompletionSource е идеален за опаковане на старите API, изградени около модел на събития. Основната идея е следната: обектът от този клас има публично свойство от тип Task, състоянието на което може да се управлява чрез методите SetResult, SetException и др. Класът TaskCompletionSource. На местата, където е приложен оператор await към този Task, той ще се изпълни или ще предизвика изключение в зависимост от прилагания метод към TaskCompletionSource. Ако все още не е ясно, нека да разгледаме този кодов пример, където некое старо API от времето на EAP се опакова в Task с помощта на TaskCompletionSource: при задействане на събитието Task ще бъде прехвърлен в състояние Completed, а методът, който е приложил оператор await към този Task, ще възобнови изпълнението си, получавайки обект. резултат.

public static Task DoAsync(this SomeApiInstance someApiObj) {

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

    return completionSource.Task;
}

Съвети и трикове за TaskCompletionSource

Опаковането на стари API не е всичко, което може да се направи с помощта на TaskCompletionSource. Използването на този клас открива интересни възможности за проектиране на различни API на базата на Task’и, които не заемат потоци. А потокът, както знаем, е скъпоценен ресурс и броят им е ограничен (основно от обема на RAM). Това ограничение лесно може да се постигне, разработвайки, например, натоварено уеб приложение с сложна бизнес логика. Нека разгледаме тези възможности, за които говоря в контекста на реализирането на такъв трик, какъвто е 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> tasks;

    public async Task AcceptMessageAsync(int userId, int duration) {

        var cs = new TaskCompletionSource();
        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);
    }
}

Този код не е готов за продукция, а е само демонстрационен. За използване в реални случаи е нужно да се обработи ситуацията, когато съобщението е пристигнало в момент, когато никой не го очаква: в такъв случай методът AcceptMessageAsync трябва да върне вече завършен Task. Ако този случай е и най-често срещаният, може да се помисли и за използването на ValueTask.

При получаване на заявка за съобщение създаваме и поставяме в речника TaskCompletionSource, след което изчакваме да се случи едно от двете: да изтече зададения времеви интервал или да бъде получено съобщение.

ValueTask: защо и как

Операторите async/await, подобно на оператора yield return, генерират машина за състояния от метода, което означава създаването на нов обект, което почти винаги е незначително, но в редки случаи може да създаде проблем. Такъв случай може да бъде метод, който наистина се извиква често, говорим за десетки и стотици хиляди извиквания в секунда. Ако такъв метод е написан така, че в повечето случаи да връща резултат, заобикаляйки всички await методи, .NET предоставя инструмент за оптимизация — структурата ValueTask. За да стане ясно, нека разгледаме пример за нейното използване: имаме кеш, който проверяваме много често. Някои от стойностите в него са налични, и тогава просто ги връщаме; ако не са налични, отиваме за тях в бавен IO. Последното искаме да направим асинхронно, което прави целия метод асинхронен. Очевидният начин за написване на метода е следния:

public async Task GetById(int id) {

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

Поради желанието да оптимизираме малко и леката ни страх от това, какво ще генерира Roslyn при компилиране на този код, можем да пренапишем примера по следния начин:

public Task GetById(int id) {

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

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

public ValueTask GetById(int id) {

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

Нека разгледаме по-подробно този фрагмент от кода: при наличието на стойност в кеша създаваме структура, в противен случай реалният таск ще бъде опакован в значим. На извикващия код не му пука по кой път е изпълняван този код: ValueTask от гледна точка на синтаксиса C# ще се държи по същия начин като обикновен Task в този случай.

TaskScheduler-и: управление на стратегиите за изпълнение на Task-ове

Следващото API, което бихме искали да разгледаме, е класът TaskScheduler и неговите производни. Както споменах по-горе, в TPL има възможност за управление на стратегиите за разпределение на задачите по потоци. Тези стратегии се определят в наследниците на класа TaskScheduler. Практически всяка стратегия, която може да е необходима, ще бъде намерена в библиотеката ParallelExtensionsExtras, разработена от Microsoft, но не е част от .NET и се предоставя под формата на Nuget пакет. Нека накратко разгледаме някои от тях:

информация за TaskScheduler-ите в блога на Microsoft. статия За удобна отладка на всичко свързано с задачите в Visual Studio има прозорец Tasks. В този прозорец можете да видите текущото състояние на задачата и да преминете към изпълняваната в момента линия код.

PLinq и клас Parallel

.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 е страхотен инструмент, но може да не разполага с достатъчно информация за източника на данни, въпреки че това може да се коригира с помощта на механизма за разпределение.
  • Продължение следва…

Източник: habr.com

Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри 🔥 Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри | ProHoster