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

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

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

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

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

Метафора

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

Готвейки закуска сутрин, аз (CPU) влизам в кухнята (Компютър). Имам 2 ръце (Cores). В кухнята има редица устройства (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. Съществува начин, благодарение на механизма на extern методи, да се извика тази функция. Видях такова извикване само веднъж в ужасяващ пример за legacy код, а мотивацията на автора, направил това, все още остава за мен загадка.

Kernel32.dll CreateThread

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

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

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

Task Parallel Library

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, когато написаният метод се преобразува в цял клас, който е автоматична машина на състояния и може да бъде изпълняван на отделни части в зависимост от тези състояния. За интересуващите се, може да се напише всякакъв несложен код с използване на 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.

На 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 има два метода с подходящи наименования — това са Abort и 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, които ги приемат.

Преобразуване на наследствени 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, които ги приемат, и за наследствени API от тези години метода 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 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<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);
}

Наистина оптималното решение в този случай ще бъде оптимизация на горещия път, а именно получаването на стойността от речника без ненужни алокации и натоварвания на 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, което бихме искали да разгледаме, е класът TaskScheduler и производните му. Вече споменах по-горе, че в TPL има възможност за управление на стратегиите за разпределение на задачите по потоци. Такива стратегии се определят в наследниците на класа TaskScheduler. Практически всяка стратегия, която може да се наложи, ще бъде намерена в библиотеката ParallelExtensionsExtras, разработена от microsoft, но не е част от .NET и се предоставя под формата на Nuget пакет. Накратко ще разгледаме някои от тях:

  • CurrentThreadTaskScheduler — изпълнява задачите на текущия поток
  • LimitedConcurrencyLevelTaskScheduler — ограничава броя на изпълняваните едновременно задачи с параметър N, който се приема в конструктора
  • OrderedTaskScheduler — се определя като LimitedConcurrencyLevelTaskScheduler(1), затова задачите ще се изпълняват последователно.
  • WorkStealingTaskScheduler — реализира work-stealing подхода за разпределение на задачите. По същество представлява отделен ThreadPool. Решава проблема с това, че в .NET ThreadPool е статичен клас, един за всички приложения, което означава, че претоварването или неправилната употреба в една част от програмата може да доведе до странични ефекти в друга. Освен това, разбирането на причините за такива дефекти е изключително трудно. Тоест може да съществува необходимост от използване на отделни WorkStealingTaskScheduler-ы в онези части от програмата, където употребата на ThreadPool може да бъде агресивна и непредсказуема.
  • QueuedTaskScheduler — позволява изпълнението на задачи според правилата на опашка с приоритети
  • ThreadPerTaskScheduler — създава отделен поток за всяка задача, която се изпълнява на него. Може да бъде полезен за задачи с непредсказуемо дълго изпълнение.

Има добър подробен статия за TaskScheduler-ите в блога на microsoft.

За удобна отладка на всичко свързано с задачите в 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, визуални инструменти и подводни камъни ще бъдат разгледани в следващата статия.

Изводи:

  • Трябва да знаете инструментите за работа с потоци, асинхронност и паралелизъм, за да използвате ресурсите на съвременните PC.
  • .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

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