Публикую на Хабр оригинал статьи, перевод которой размещен в блоге Codingsight.
Вторая часть доступна здесь
Необходимость делать что-то асинхронно, не дожидаясь результат здесь и сейчас, или разделять большую работу между несколькими выполняющими ее единицами была и до появления компьютеров. С их появлением такая необходимость стала очень ощутимой. Сейчас, в 2019, набирая эту статью на ноутбуке с 8 ядерным процессором Intel Core, на котором параллельно этому работает не одна сотня процессов, а потоков и того больше. Рядом, лежит уже немного потрепанный, купленный пару лет назад телефон, у него на борту 8 ядерный процессор. На тематических ресурсах полно статей и видео, где их авторы восхищаются флагманскими смартфонами этого года куда ставят 16ти-ядерные процессоры. MS Azure предоставляет менее чем за 20$/час виртуальную машину со 128 ядерным процессором и 2 TB RAM. К сожалению невозможно извлечь максимум и обуздать эту мощь не умея управлять взаимодействием потоков.
Процесс (Process) — объект ОС, изолированное адресное пространство, содержит потоки.
Поток (Thread) — объект ОС, наименьшая единица выполнения, часть процесса, потоки делят память и другие ресурсы между собой в рамках процесса.
Многозадачность — свойство ОС, возможность выполнять несколько процессов одновременно
Многоядерность — свойство процессора, возможность использовать несколько ядер для обработки данных
Многопроцессорность — свойство компьютера, возможность одновременно работать с несколькими процессорами физически
Многопоточность — свойство процесса, возможность распределять обработку данных между несколькими потоками.
Параллельность — выполнение нескольких действий физически одновременно в единицу времени
Асинхронность — выполнение операции без ожидания окончания завершения этой обработки, результат же выполнения может быть обработан позднее.
Не все определения хороши и некоторые нуждаются в дополнительном объяснении, потому к формально введенной терминологии добавлю метафору о приготовлении завтрака. Приготовление завтрака в этой метафоре — process.
Готовя завтрак с утра я (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 хорош. С каждой новой версией он представляет все больше новых инструментов для работы с ними, новые слои абстракции над потоками ОС. В работе с построением абстракций разработчики фреймворка используют подход оставляющий возможность при использовании высокоуровневой абстракции, спустится на один или несколько уровней ниже. Чаще всего в этом нет необходимости, более того это открывает возможность для выстрела себе в ногу из дробовика, но иногда, в редких случаях, это может оказаться единственным способом решить проблему, не решающую на текущем уровне абстракции.
Под инструментами я имею ввиду как программные интерфейсы (API) предоставляемые фреймворком и сторонними пакетами, так и целый программные решения упрощающий поиск каких-либо проблем связанных с многопоточным кодом.
Класс Thread, самый базовый в .NET для работы с потоками. В конструктор принимает один из двух делегатов:
Делегат будет выполнен во вновь созданном потоке после вызова метода Start, если в конструктор был передан делегат типа ParametrizedThreadStart, то в метод Start необходимо передать объект. Этот механизм нужен для передачи любой локальной информации в поток. Стоит отметить что создание потока это дорогостоящая операция, а сам поток это тяжелый объект, как минимум потому, что происходит выделение 1МБ памяти на стек, и требует взаимодействия с API ОС.
Класс ThreadPool представляет концепцию пула. В .NET пул потоков является произведением инженерного искусства и разработчики из Microsoft вложили множество усилий, чтобы он работал оптимально в самых разных сценариях.
Общая концепция:
С момента старта приложение в фоне создает несколько потоков про запас и предоставляет возможность брать их в пользование. Если потоки используются часто и в большом количестве, то пул расширяется, чтобы удовлетворить потребность вызывающего кода. Когда в пуле в нужный момент времени не оказывается свободных потоков он или дождется возврата одного из потоков, либо создаст новый. Из этого следует, что пул потоков отлично подходит для неких коротких действий и плохо подходит, для операций работающих как службы на протяжении всей работы приложений.
Для использования потока из пула, есть метод QueueUserWorkItem, который принимает делегат типа WaitCallback, что по сигнатуре совпадает с ParametrizedThreadStart, а передаваемый в него параметр выполняет туже функцию.
Менее известный метод пула потоков RegisterWaitForSingleObject служит для организации неблокирующих IO операций. Делегат переданный в этот метод будет вызван тогда, когда WaitHandle переданный в метод будет “отпущен”(Released).
В .NET есть потоковый таймер и отличается он от таймеров WinForms/WPF тем, что его обработчик будет вызван в потоке взятом из пула.
Так же есть довольно экзотический способ отправить делегат на выполнение в поток из пула — метод BeginInvoke.
Хочу еще вскользь остановится на функции к вызову которой сводится многие из вышеуказанных методов — CreateThread из Kernel32.dll Win32 API. Существует способ, благодаря механизму extern методов вызвать эту функцию. Я видел такой вызов лишь однажды в жутчайшем примере legacy кода, а мотивация автора сделавшего именно так все еще остается для меня загадкой.
Созданные вами лично, всеми третисторонними компонентами и пулом .NET потоки можно просмотреть в окне Threads Visual Studio. Это окно отобразит информацию о потоках лишь, когда приложение будет находится под отладкой и в режиме останова (Break mode). Здесь можно удобно просмотреть стек имена и приоритеты каждого потока, переключить отладку на конкретный поток. Свойством Priority класса Thread можно задать приоритет потока, который OC и CLR будут воспринимать как рекомендацию при разделении процессорного времени между потоками.
Task Parallel Library (TPL) появился в .NET 4.0. Сейчас это стандарт и основной инструмент для работы с асинхронностью. Любой код использующий более старые подход считается legacy. Основной единицей TPL является класс Task из пространства имен System.Threading.Tasks. Task представляет собой абстракцию над потоком. С новой версией языка C# мы получили изящный способ работы с Task`ами — операторы async/await. Эти концепции позволили писать асинхронный код так, как если бы он был простым и синхронным, это дало возможность даже людям слабо понимающим внутреннюю кухню потоков писать приложения их использующие, приложения не зависающие при выполнении долгих операций. Использование async/await тема для одной или даже нескольких статей, но я попробую в нескольких предложениях уложить суть:
Еще раз: оператор await, в общем случае (есть исключения), отпустит текущий поток выполнения дальше, а когда Task закончит свое выполнение, а поток (на самом деле правильнее сказать контекст, но об этом позднее) будет свободен продолжит выполнении метода дальше. Внутри .NET этот механизм реализован так же как и yield return, когда написанный метод превращается в целый класс, который является машиной состояний и может быть выполнен отдельными кусками в зависимости от этих состояний. Кому интересно может написать любой несложный код с использованием asynс/await, скомпилировать и просмотреть сборку с помощью JetBrains dotPeek с включенным Compiler Generated Code.
Рассмотрим варианты запуска и использования Task’а. На примере кода ниже, мы создаем новый таск, который не делает ничего полезного (Thread.Sleep(10000)), но в реальной жизни это должна быть некая сложная задействующая CPU работа.
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’и. Ниже пример кода, с комментариями, когда ожидание сделано условно хорошо и когда условно плохо.
В первом примере мы дожидаемся выполнения Task’и не блокируя вызывающий поток, к обработке результата вернемся лишь когда он уже будет, до тех пор вызывающий поток предоставлен себе.
Во втором варианте мы блокируем вызывающий поток до тех пор пока не будет подсчитан результат метода. Это плохо не только потому, что мы заняли поток, столь ценный ресурс программы, простым безделием, но еще и потому, что если в коде метода что мы вызываем есть await, а контекст синхронизации предполагает возвращение в вызывающий поток после await, то мы получим deadlock: вызывающий поток ждет пока будет вычислен результат асинхронного метода, асинхронный метод тщетно пытается продолжить свое выполнение в вызывающем потоке.
Еще одним недостатком такого подхода является усложненная обработка ошибок. Дело в том, что ошибки в асинхронном коде при использовании async/await обрабатывать очень легко — они ведут себя так же как если бы код был синхронным. В то время, как если мы применяемэкзорцизм синхронное ожидание к Task’e оригинальное исключение оборачивается в 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. “
Если вам посчастливилось работать на проекте, что был начат уже после того как Task’и были введены и перестали вызывать тихий ужас большинства разработчиков, то вам не придется иметь дело с большим количеством старых API, как третисторонних, так и вымученых вашей командой в прошлом. К счастью, команда разработки .NET Framework позаботилась о нас, хотя возможно целью была забота о себе. Как бы то ни было в .NET есть ряд инструментов для безболезненного преобразования кода написанного в старых подходах асинхронного программирования в новую. Один из них это метод FromAsync у TaskFactory. На примере кода ниже, я оборачиваю старые асинхронные методы класса WebRequest в Task с помощью этого метода.
Это лишь пример и делать такое со встроенными типами вам вряд ли придется, но любой старый проект просто кишит методами BeginDoSomething возвращающими IAsyncResult и методами EndDoSomething его принимающими.
Еще один важный для рассмотрения инструмент, это класс 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 как раз отлично подходит для обертки в Task’и legacy-API построенных вокруг событийной модели. Суть его работы в следующем: у объекта этого класса есть публичное свойство типа Task состоянием которого можно управлять через методы SetResult, SetException и пр. Класса TaskCompletionSource. В местах же где был применен оператор await к этому Task’у он будет выполнен или обрушен с исключением в зависимости от примененного к TaskCompletionSource метода. Если все еще не понятно, то давайте посмотрим на этот пример кода, где некое старое API времен EAP заворачивается в Task при помощи TaskCompletionSource: при срабатывании события Task будет переведен в состояние Completed, а метод применивший к этому Task’у оператор await возобновит свое выполнение получив объект result.
Обертка старых API это не все что можно провернуть с помощью TaskCompletionSource. Использование этого класса открывает интересную возможность проектирования различных API, на Task’ах, что не занимают потоки. А поток, как мы помним ресурс дорогой и количество их ограничено (в основном объемом RAM). Этого ограничения легко достичь разрабатывая, например, нагруженное web-приложение со сложной бизнес логикой. Рассмотрим те возможности о которых я говорю на реализации такого трюка как Long-Polling.
Если коротко суть трюка вот в чем: вам нужно получать от API информацию о некоторых событиях происходящих на его стороне, при этом API по каким-то причинам не может сообщить о событии, а может лишь вернуть состояние. Пример таких — все API построенные поверх HTTP до времен WebSocket или при невозможности по какой-то причине использовать эту технологию. Клиент может спросить у HTTP сервера. HTTP сервер не может сам спровоцировать общение с клиентом. Простым решением является опрос сервера по таймеру, но это создает дополнительную нагрузку на сервер и дополнительную задержку в среднем TimerInterval / 2. Для обхода этого был изобретен трюк получивший название Long Polling, которые предполагает задержку ответа от сервера до тех пор пока не истечет Timeout или не произойдет событие. Если событие произошло, то оно обрабатывается, если нет, то запрос посылается заново.
Но такое решение покажет себя ужасно, как только число клиентом ожидающих событие вырастет, т.к. Каждый такой клиент в ожидании события занимает целый поток. Да и получаем дополнительную задержку в 1мс на срабатывании события, чаще всего это не существенно, но зачем делать ПО хуже чем оно может быть? Если же убрать Thread.Sleep(1), то зазря загрузим одно ядро процессора на 100% в холостую вращаясь в бесполезном цикле. С помощью TaskCompletionSource можно легко переделать этот код и решить все обозначенные выше проблемы:
Этот код не является production-ready, а лишь демонстрационным. Для использования в реальных случаях нужно еще, как-минимум, обработать ситуацию когда сообщение пришло в момент, когда его никто не ожидает: в таком случае метод AsseptMessageAsync должен вернуть уже завершенный Task. Если же этот случай и является наиболее частым, то можно подумать и об использовании ValueTask.
При получении запроса на сообщение мы создаем и помещаем в словарь TaskCompletionSource, а далее ждем что произойдет первее: истечет заданный интервал времени или будет получено сообщение.
Операторы async/await как и оператор yield return генерирует из метода машину состояний, а это создание нового объекта, что почти всегда не важно, но в редких случаях может создать проблему. Этим случаем может быть метод вызываемый действительно часто, речь о десятках и сотнях тысяч вызовов в секунду. Если такой метод написан так, что в большинстве случаев он возвращает результат обходя все await методы, то .NET предоставляет инструмент что бы это оптимизировать — структура ValueTask. Чтобы стало понятно рассмотрим пример его использования: есть кеш в который мы ходим очень часто. Какие-то значения в нем есть и тогда мы их просто возвращаем, если нет, то идем в какой-нибудь медленный IO за ними. Последнее хочется делать асинхронно, а значит весь метод получается асинхронным. Таким образом очевидный вариант написания метода — следующий:
Из-за желания немного оптимизировать, и легкой боязни по-поводу того что сгенерирует Roslyn компилируя этот код, можно этот пример переписать следующим образом:
Действительно же оптимальным решением в этом случае будет оптимизировать hot-path, а именно получение значения из словаря вообще без лишних аллокаций и нагрузки на GC, в то время когда в тех редких случаях, когда нам все таки нужно идти в IO за данными все останется плюс/минус по старому:
Давайте подробнее разберем этот фрагмент кода: при наличиии значения в кеше мы создаем структуру, в противном случае реальный же таск будет завернут в значимый. Вызывающему коду все равно по какому пути выполнялся этот код: ValueTask с точки зрения синтаксиса C# будет вести себя так же как и обычный Task в этом случае.
Следующее API, что хотелось бы рассмотреть это класс TaskScheduler и его производные. Я уже упоминал выше, что в TPL есть возможность управлять стратегиями распределения Task’ов по потокам. Такие стратегии определяются в наследниках класса TaskScheduler. Практически любая стратегия, что может понадобится будет найдена в библиотеке ParallelExtensionsExtras, разработанной microsoft, но не являющейся частью .NET, а поставляемую в виде Nuget пакета. Коротко рассмотрим некоторые из них:
Есть хорошая подробная статья о TaskScheduler’ах в блоге microsoft.
Для удобной отладки всего связанного с Task’ами в Visual Studio есть окно Tasks. В этом окне можно увидеть текущее состояние задачи и перейти к выполняемой в данный момент строчке кода.
Кроме Task’ов и всего с ними сказанного в .NET есть еще два интересных инструмента это PLinq(Linq2Parallel) и класс Parallel. Первый обещает параллельное выполнение всех Linq операций на нескольких потоках. Число потоков можно сконфигурировать методом-расширением WithDegreeOfParallelism. К сожалению, чаще всего PLinq в режиме работаты по умолчанию не хватит информации о внутренностях вашего источника данных, чтобы обеспечить существенный выигрыш по скорости, с другой стороны цена попытки очень низкая: нужно всего лишь вызвать метод AsParallel перед цепочкой Linq методов и провести тесты производительности. Более того существует возможность передать в PLinq дополнительную информацию о природе вашего источника данных при помощи механизма Partitions. Подробнее можно почитать здесь и здесь.
Статический класс Parallel предоставляет методы для параллельного перебора коллекции Foreach, выполнения цикла For и выполнения нескольких делегатов в параллель Invoke. Выполнение текущего потока будет остановлено до окончания выполнения расчетов. Количество потоков можно сконфигурировать передав ParallelOptions последним аргументом. С помощью опций также можно указать TaskScheduler и CancellationToken.
Когда я начинал писать эту статью по материалам своего доклада и информации что собрал за время работы после него, я не ожидал, что ее получится так много. Сейчас, когда текстовый редактор в котором я набираю эту статью укоризненно говорит мне о том, что пошла 15я страница, я подведу промежуточные итоги. Другие трюки, API, визуальные инструменты и подводные камни будут рассмотрены в следующей статье.
Выводы:
Вторая часть доступна здесь
Необходимость делать что-то асинхронно, не дожидаясь результат здесь и сейчас, или разделять большую работу между несколькими выполняющими ее единицами была и до появления компьютеров. С их появлением такая необходимость стала очень ощутимой. Сейчас, в 2019, набирая эту статью на ноутбуке с 8 ядерным процессором Intel Core, на котором параллельно этому работает не одна сотня процессов, а потоков и того больше. Рядом, лежит уже немного потрепанный, купленный пару лет назад телефон, у него на борту 8 ядерный процессор. На тематических ресурсах полно статей и видео, где их авторы восхищаются флагманскими смартфонами этого года куда ставят 16ти-ядерные процессоры. MS Azure предоставляет менее чем за 20$/час виртуальную машину со 128 ядерным процессором и 2 TB RAM. К сожалению невозможно извлечь максимум и обуздать эту мощь не умея управлять взаимодействием потоков.
Терминология
Процесс (Process) — объект ОС, изолированное адресное пространство, содержит потоки.
Поток (Thread) — объект ОС, наименьшая единица выполнения, часть процесса, потоки делят память и другие ресурсы между собой в рамках процесса.
Многозадачность — свойство ОС, возможность выполнять несколько процессов одновременно
Многоядерность — свойство процессора, возможность использовать несколько ядер для обработки данных
Многопроцессорность — свойство компьютера, возможность одновременно работать с несколькими процессорами физически
Многопоточность — свойство процесса, возможность распределять обработку данных между несколькими потоками.
Параллельность — выполнение нескольких действий физически одновременно в единицу времени
Асинхронность — выполнение операции без ожидания окончания завершения этой обработки, результат же выполнения может быть обработан позднее.
Метафора
Не все определения хороши и некоторые нуждаются в дополнительном объяснении, потому к формально введенной терминологии добавлю метафору о приготовлении завтрака. Приготовление завтрака в этой метафоре — process.
Готовя завтрак с утра я (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 необходимо передать объект. Этот механизм нужен для передачи любой локальной информации в поток. Стоит отметить что создание потока это дорогостоящая операция, а сам поток это тяжелый объект, как минимум потому, что происходит выделение 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 будут воспринимать как рекомендацию при разделении процессорного времени между потоками.
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, когда написанный метод превращается в целый класс, который является машиной состояний и может быть выполнен отдельными кусками в зависимости от этих состояний. Кому интересно может написать любой несложный код с использованием 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 обрабатывать очень легко — они ведут себя так же как если бы код был синхронным. В то время, как если мы применяем
Третий и последний примеры так же отмечены плохими по той же причине и содержат все те же проблемы.
Методы 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,
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), который был о методах BeginDoSomething возвращающем IAsyncResult и методах EndDoSomething его принимающем и для legacy этих годов как раз отлично подходит метод FromAsync, но со временем, на смену ему пришел Event Based Asynchronous Pattern (EAP), который предполагал, что по завершению выполнения асинхронной операции будет вызвано событие.
TaskCompletionSource как раз отлично подходит для обертки в Task’и legacy-API построенных вокруг событийной модели. Суть его работы в следующем: у объекта этого класса есть публичное свойство типа Task состоянием которого можно управлять через методы SetResult, SetException и пр. Класса TaskCompletionSource. В местах же где был применен оператор await к этому Task’у он будет выполнен или обрушен с исключением в зависимости от примененного к TaskCompletionSource метода. Если все еще не понятно, то давайте посмотрим на этот пример кода, где некое старое API времен EAP заворачивается в Task при помощи TaskCompletionSource: при срабатывании события Task будет переведен в состояние Completed, а метод применивший к этому Task’у оператор await возобновит свое выполнение получив объект result.
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, что хотелось бы рассмотреть это класс TaskScheduler и его производные. Я уже упоминал выше, что в 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. В этом окне можно увидеть текущее состояние задачи и перейти к выполняемой в данный момент строчке кода.
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
- Продолжение следует...