В предыдущих сериях
Инструменты анализа эффективности работы приложения. PerfView #performance_analysis #trace #perfview
Сказка про Method as Parameter #dotnet #methods #gc
Сказка про Guid.NewGuid() #os_specific #dotnet #microoptimization
Тема тредпула, скажем так, complex and hard. У меня в жизни было два «осознания», когда я гордо говорил себе — вот теперь-то я точно понял, как устроен и как работает тредпул в дотнете! Впрочем, после второго раза я неоднократно осознавал, как же я ошибался.
Пользоваться тредпулом просто. И нужно. И современный C# очень хорош, позволяет достаточно эффективно и просто им пользоваться, иногда даже не осознавая почти ничего о его устройстве. А современный .Net очень хорошо реализует тредпул (особенно, в сравнении с .NetFramework).
Художественное отступление, шутка на текущую тему. Для того, чтобы узнать самое интересное, читать текст под катом совершенно не обязательно.
Капитан D младшего ранга Имперского Флота превозмогал уже целое десятилетие. В хаосе, поглотившем его мир, он не переставал выполнять все повседневные обязанности. И параллельно решал все внезапно возникающие задачи на корабле.
Находясь среди этого хаоса, нужно сохранять холодный рассудок. Немножко здравого и контролируемого эскапизма не помешает никогда. И D позволил себе забыться. Но лишь совсем ненадолго. Он всегда любил изучать устройство своего корабля — махины, строившейся десятилетиями. И не было ни одного человека, который целиком и полностью разбирался в каждой его частичке. В этот раз капитан размышлял о том, как эффективно устраивать асинхронную коммуникацию с остальным миром.
Важной составляющей каждого корабля является комплекс связи, в котором трудится целый хор Астропатов. Связь — очень важная штука, обмен сообщениями с другими кораблями почти всегда осуществляется асинхронно. Астропатам можно давать задачи, чтобы они их исполняли. А также у них всегда есть работа слушать астральные песни своих далёких братьев и передавать полученную информацию капитану. И от умелого управления этим хором зависит эффективность связи.
Ведь Астропат — очень дорогой ресурс, они дороги в создании и подготовке к службе. А ещё, если на корабле их будет много, они начнут мешаться друг другу, будет сложнее контролировать и управлять их работой. И потому задания, которые капитан даёт Астропатам, должны быть четкими и лаконичными, не должны содержать в себе ничего лишнего.
Так, капитан уже заметил, что Астропатам лучше доверять только ту работу, которая связана с асинхронной передачей и получением информации и первичной её обработкой. А если выдавать им длительную и рутинную работу, то внезапно может случиться так, что все они заняты и некому получить или отправить важное и срочное сообщение.
А поэкспериментировав с количеством задач, капитан понял, что лучше не выдавать Астропатам очень много очень коротких задач. Ведь при переключении между ними теряется настрой на волны имматериума, большая часть времени и ресурса уходит не на полезную работу, а на необходимые и неизбежные ритуалы.
Чтобы пользоваться тредпулом искусно, недостаточно «понимать» его устройство. Тредпул надо чувствовать, ощущать. И, безусловно, читать его код. Кстати, мало кто знает, но в Контуре когда-то очень давно даже была своя реализация тредпула! Я же постараюсь подходить к этой теме издалека, разбирая разные забавные ситуации. И, надеюсь, в рамках этих статей удастся когда-нибудь погрузиться и в самые недра.
Let's play
А сейчас, давайте поиграемся с Task'ами. Думаю, в нашем обществе смело можно опустить вводную, что Task'и непосредственно связаны с тредпулом. А также то, что в тредпуле есть набор WorkerThread'ов и CompletionPortThread'ов, которые и будут исполнять наши задачи.
Давайте напишем код, который будет запускать таску с весьма простой работой внутри — сложением чисел. Дожидаться её исполнения. И затем запускать следующую точно такую же таску.
public static int X;
public static void RunOneTaskSequentially()
{
while (true)
{
Task.Run(() => X += 1).GetAwaiter().GetResult();
}
}
Запустим?
Ой! Этот код на 4х ядерной машине потребляет почти все 4 ядра (91%). Хотя код выглядит абсолютно точно «однопоточным». Сколько же потоков съел этот процесс?
Потоков тут 17. Немножко неожиданно от программы, которая «должна» работать в один поток. Ну как должна. Это мы такого хотели, интуитивно пользуясь тредпулом. А активно и примерно равномерно потребляют CPU аж целых 10 потоков.
Чем же заняты треды? Заглянем в один из них.
Чем-то непонятным. Все треды (где потрачено много циклов CPU) заняты работой в практически идентичных стеках. Давайте трейс снимем.
Те две строчки по 15% — это методы из Main-треда. Которые стартуют, и дожидаются завершения работы таски. Они нам не интересны. А чем занята та непонятная функция из ntdll, которая потребила 62% от времени работы программы (от всех тредов)?
А это просто CPU работа где-то в тредах. В разных тредах. CPU-работа в недрах дотнета.
Давайте заглянем чуть глубже. Благо, у нас Windows. Снимем трейс с нашего горе-приложения с помощью WPR, откроем в WPA. Изучим, что за функции самые популярные.
Что мы тут видим? Огромное количество работы, связанной с тредпулом. Это свопы контекста, разные функции с подстрокой «Thread». А также мы видим CLRLifoSemaphore::Wait
— это «спящие» треды тредпула. А вот ThreadNative::SpinWait
— откровенная CPU-работа. И её доля очень высока. Если что, всякие Rtlp*
функции — это функции сбора той телеметрии, что мы сейчас смотрим. Их надо игнорировать.
«Скроем» откровенные бесполезные ожидания, потому что они не тратят на самом деле CPU (что не совсем правда, но всё равно). Скроем Rtlp* методы, потому что они не про полезную работу. Убедимся, что большую часть времени занимают всё ещё всякие «бесполезные» функции:
Ещё можно посмотреть статистику не в разрезе функций, а в разрезе стеков. Но там ничего интересного, мы увидим все те же функции, но сгруппированные по стекам. И там всё также преобладает всякая тредпульная обёртка.
Что мы увидели?
Итого, какой вывод мы можем сделать. В нашем коде работа самого тредпула существенно преобладает над полезной работой. Работа по постановке новых тасок, работа с очередью задач, выбор и активация WorkerThread'а для исполнения нашего сложения, контекст-свичи, ожидания (а особенно SpinWait'ы). И эта работа (где-то не вся, а лишь частично), безусловно, выполняется во всех тредах тредпула. А значит параллельно.
Мы «разворошили» тредпул своей агрессивной постановкой и завершением тасок. Все воркер-треды переполошились и старательно пытались быть полезными в обработке нашей малюсенькой задачи. И в итоге, они все (WorkerThread'ы) поделали кучу работы, чтобы один из них сложил чиселко. И так while true.
Успокоим бедняжку-тредпул
Что будет, если удлинить операцию? В одной «таске» делать побольше «полезной» работы подряд? Чтобы было меньше лишних телодвижений, больше полезной работы.
static int OperationsCount;
static int ActionsInOperation;
public static void RunOneTaskSequentially()
{
for (var i = 0; i < OperationsCount; i++)
{
Task.Run(() => ChangeFunction()).GetAwaiter().GetResult();
}
}
private static Action ChangeFunction = () =>
{
for (int i = 0; i < ActionsInOperation; i++)
Interlocked.Increment(ref X);
};
Замерим время выполнения нашей RunOneTaskSequentially с разными параметрами.
Запустим наш код с разными аргументами: OperationsCount и ActionsInOperation:
Elapsed 00:00:09.5915062. OperationsCount = 10000000, ActionsInOperation = 1.
Elapsed 00:00:01.1307198. OperationsCount = 1000000, ActionsInOperation = 10.
Elapsed 00:00:00.1640366. OperationsCount = 100000, ActionsInOperation = 100.
Elapsed 00:00:00.0795597. OperationsCount = 10000, ActionsInOperation = 1000.
Зависимость тривиально прослеживается. Легко заметить, что чем длиннее работа внутри таски, и чем меньше телодвижений в тредпуле, тем эффективнее наша программа. А результат одинаковый (X в конце везде равен 10000000). На одинаковое количество «полезной» работы мы делаем меньше «бесполезной» работы тредпула.
Мораль
Тредпул и его работа совершенно не бесплатна. Тредпул — очень (относительно) дорогая и сложная штука. И его ресурсами нужно пользоваться аккуратно. Пока что я хотел продемонстрировать именно это.
Прямо сейчас не получится сформулировать какие-то best-practices, или же наоборот. Но какое-то наблюдение зафиксировать можно:
Не выполняйте на тредпуле очень короткие и небольшие CPU-задачи, если они не связаны ни с какой IO-работой.
Кстати, выполнять на тредпуле очень длинные, долгие CPU-задачи тоже ни в коем случае нельзя. Если что, тредпул вообще не предназначен для выполнения CPU-bound работ. Тредпул нужен только для эффективного связывания различной «вспомогательной» и небольшой работы с асинхронной, IO-работой. Если вам нужно жечь CPU полезной работой, для этого есть инструменты намного лучше. Почему — это мы обязательно рассмотрим позднее.
Комментарии (4)
qw1
10.04.2023 08:05Кстати, выполнять на тредпуле очень длинные, долгие CPU-задачи тоже ни в коем случае нельзя
Пишем как удобнее, просто не забываем о наличии
TaskCreationOptions.LongRunning
Redvirg
10.04.2023 08:05"... Cуществуют более эффективные решения, нежели указание TaskCreationOptions.LongRunning:
если задачи являются интенсивными в плане ввода-вывода, то вместо потоков следует применять класс TaskCompletionSource.
-
если задачи являются интенсивными в плане вычислений, то отрегулировать параллелизм для таких задач позволит очередь производителей/потребителей, избегая ограничения других потоков и процессов".
Албахари Джозеф. C# Справочник. Полное описание языка.
AgentFire
Ну, с одной стороны, вывод казалось бы очевидный, а с другой - без конкретных данных по "просадке" производительности это мало что полезного показывает. Да, много строчек в табличек, но что если они все особо ни на что не влияют?
deniaa Автор
Конкретные данные нельзя брать в отрыве от реальных задач. В какой-то задаче оверхед от менеджмента тасок в тредпуле будет существенным для качества решения, в какой-то нет. Ведь задач, которые можно решать с помощью тредпула, много.
Также, конкретные данные нельзя брать в отрыве от какой-то конкретной реализации (и даже в отрыве от определённой конфигурации сервера, где это исполняется). Ведь способов реализации работы с тасками много.
Данная статья не преследует цель показать "вот это решение вот этой задачи в N раз лучше, чем вот то решение". В теме тредпула достаточно тяжело делать такие однозначные и прямолинейные выводы. Ведь она достаточно обширна, до выведения условной "ThreadPool throughput" идти очень далеко и глубоко, и по пути нужно не споткнуться об ThreadPool starvation, не утонуть в высоком потреблении CPU от менеджмента тасок и потоков, и так далее. И я надеюсь, что в этом блоге удастся потрогать все эти нюансы и особенности.
Эта статья, как и весь блог, преследует цель показывать интересные особенности и детали работы .NET'а (и не только). И я надеюсь, что инженеры, которые не так хорошо знакомы с обозреваемыми темами, узнают что-то новое и научатся применять полученные знания на практике. Весь блог посвящен скорее этому, чем рекомендациям "вот это лучше вот того в столько раз".