О Thread и ThreadPool в .NET подробно (часть 2)
В предыдущей публикации мы рассмотрели некоторые базовые вопросы относительно потоков и пулов потоков и готовы двигаться дальше. Давайте проведём эксперимент и найдём правильный объём работы для пула потоков. Чтобы его издержки не давлели над объёмом полезной работы
Проект SmartThreadPool, о котором идёт речь в статье:
⚠️ Материал средней сложности
С другой стороны, показанные примеры доказывают, что на производительность сильно влияет гранулярность элементов работы. Имеется ввиду, конечно же, длительность работы делегатов. Чтобы достичь хороших показателей, гранулярность работы не может быть абы какой: она должна быть правильной. И помимо планирования задач на ThreadPool, планировать их можно также как через TPL так и через какой-либо свой собственный пул потоков. Например, если взять обычный ThreadPool, то можно примерно измерить издержки алгоритмов ThreadPool в тактах Time Stamp Counter счётчика времени (можно, конечно и в чём-то более привычном типа микросекунд, но там на многих сценариях вполне могут быть нули)
ConcurrentQueue bag = new ConcurrentQueue();
unsafe delegate* rdtsc;
long rdtscCallCost;
long[] lastValues = new long[100];
unsafe void Main()
{
rdtsc = CreateRdtscMethod();
var rdtsc_costs = new List(10000);
for(int i = 0; i < 10000; i++)
{
rdtsc_costs.Add(Math.Abs(rdtsc()-rdtsc()));
}
rdtscCallCost = (long)rdtsc_costs.Average();
for(int i = 0; i < 1_000_000; i++)
{
ThreadPool.QueueUserWorkItem(TraceWork, bag);
}
Console.Read();
bag.Dump();
}
unsafe void TraceWork(object x)
{
var rd = rdtsc();
var tid = Environment.CurrentManagedThreadId;
var last = lastValues[tid];
bag.Enqueue(new Record {
Ticks = rd - last - rdtscCallCost,
TID = Environment.CurrentManagedThreadId
});
lastValues[tid] = rdtsc();
}
struct Record {
public long Ticks;
public int TID;
}
unsafe static delegate* CreateRdtscMethod()
{
var ptr = VirtualAlloc(IntPtr.Zero, (uint)rdtscAsm.Length, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
Marshal.Copy(rdtscAsm, 0, ptr, rdtscAsm.Length);
return (delegate*)ptr;
}
const uint PAGE_EXECUTE_READWRITE = 0x40;
const uint MEM_COMMIT = 0x1000;
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr VirtualAlloc(
IntPtr lpAddress, uint dwSize,
uint flAllocationType, uint flProtect);
static readonly byte[] rdtscAsm =
{
0x0F, 0x31, // rdtsc
0xC3 // ret
};
Здесь мы при помощи метода CreateRdtscMethod
выделяем память напрямую у Windows и помечаем её как разрешённую к исполнению кода. Метод этот помещает в выделенный участок памяти содержимое массива rdtscAsm
, который содержит в себе вызов процессорной инструкции rdtsc
и возврат в вызываемый код. После чего приводим указатель на буфер с инструкциями к указателю на метод и получаем таким образом .NET метод на основе asm кода вызова CPU инструкции rdtsc
, которая читает счётчик TSC (Time Stamp Counter) и возвращает в регистрах EDX: EAX 64-битное количество тактов с момента последнего сброса процессора. Старшая часть числа нам не нужна, т.к. мы считаем разницу, а потому разница между значениями 2-х вызовов метода rdtsc()
даст количество тактов между ними.
После инициализации мы закладываем в пул один миллион делегатов, которые закладывают в очередь ConcurrentQueue
разницу между текущим rdtsc
и прошлым на некотором потоке. Сохранять номер потока важно, т.к. на разных ядрах может быть разным значение этого счётчика.
Иногда могут быть огромные, идущие мимо статистики, пробелы. Это значит, что скорее всего поток был прерван и вместо него на том же ядре отработали другие потоки, может быть даже не вашего приложения.
После замера мы получим, что когда пул только начал отрабатывать задачу, он стартовал на 1 потоке и проводил через себя делегаты с издержками на свою работу, примерно равными 100-200
тактов. Далее, по мере роста параллелизма пула растёт и цена издержек: через отметку 200-300
она доходит до 500-900
тактов. Единичные 1500-2500
тактов приходятся, скорее всего на неудачные попытки взять делегат из очереди, когда все остальные потоки разбирают из очереди делегаты, а тот, что притормозил постоянно борется за смену «хвоста» очереди.
После этого мы получим ситуацию, в которой чередуются интервалы полезной работы (40
тактов) и выбора следующей единицы работы пулом (пусть, 800
тактов). Если учесть, что работа идёт в параллель, легко представить, к чему это приведёт. Если в параллели на 8 потоках будет полезная работа и только у одного — выбор следующего делегата, то тот произведет выбор очень быстро, т.к. ни с кем не соперничает. Однако если таких потоков уже два, то возникает спор за общий ресурс: очередь делегатов. Если три, спор сильнее и от того — дольше (как раз тут он, наверняка, достигает дистанции в 500
тактов). Если увеличивается дистанция выбора следующего делегата, что происходит? Увеличивается вероятность, что несколько потоков попадают в состояние спора. Дистанция спора растёт. Пул при этом считает, что производительность резко падает, а значит необходимо увеличить количество потоков. Это в свою очередь увеличивает спор за очередь, увеличивая дистанцию спора. В конечном счёте дистанция становится такой, что в один момент времени полезной работой занимается 1 поток. Остальные — спорят, кто заберёт с неё значение.
Поэтому если наша задача очень короткая и занимает, например, 40 тактов (как содержимое метода TraceWork
), то издержки для относительно удачного примера выше будут стоить 1250% - 2000%
от полезного кода. Другими словами, эту задачу дешевле запустить последовательно, чем на ThreadPool. Отталкиваясь от этих цифр можно смело подсчитать, что если брать издержки за 200-300
тактов, а этого можно достичь умеренно-большими задачами, и если взять их за 5%, то полезная нагрузка должна быть не меньше (250 / 5 * 95) = 4750
тактов. Т.е. кода в делегате должно исполняться достаточно много. Но и длительными они не должны быть, в особенности со случаем ThreadPool, т.к. он является общим ресурсом.
В реальности же ситуация может быть очень плохой. Ниже приведён пример для 1 млн делегатов в ThreadPool на 6 (12 HyperThreading) ядрах. Т.е. 24 потоках ThreadPool:
Первая 1,000 значений: на старте пул ещё не принял решения в резкому расширению количества потоков и работает более-менее приемлемоВесь диапазон значений: далее пул принял неверное решение и его состояние резко ухудшилось
И давайте построим «вероятностное» распределение значений. Для этого посчитаем, сколько значений из графика выше попадает на какие 50-миллисекундные интервалы издержек пула. Замеры приведём для указанных в легенде длин внутреннего цикла «работы» метода, отправляемого в пул потоков:
Каждая кривая — это график для определенной длинны внутреннего цикла. Более блеклые линии — более короткие циклы и соответственно — более короткая работа метода в пуле потоков. На рисунке видно, что очень много значений «уходят вправо» по оси абсцисс. Т.е. есть достаточно много (если всё сложить) значений в зоне очень больших накладных расходов на работу ThreadPool: есть достаточно много ситуаций, когда между окончанием работы некоторого метода на потоке пула и взятием в работу следующего проходит много времени. Увеличим более значимую часть:
На рисунке становится заметно, что на малых размерах работы накладные расходы ложатся ближе к пиковым значениям. Затем пики приходятся на середину, затем на начало и достаточно быстро перемещаются снова на вариант худшей задержки. Это значит, что вероятность получить крайне плохое значение задержки между отработкой делегатов крайне высока. Плюс ко всему из-за того, что большой объем задержек уходит «хвостом» вправо (см первый график), средняя задержка будет смещена вправо и будет колебаться в районе 600 тактов на операцию.
Второе, что стоит пояснить: задержки обусловлены борьбой за ресурс. В данном случае — за голову и хвост очереди задач. А потому они обусловлены уровнем параллелизма. Три пика — это срабатывание борьбы за общий ресурс. А то что их чётко три означает, что пул дискретно увеличил количество потоков три раза: при запуске и на некоторых объёмах работ. Причём делел это несколько раз через те же числа уровня параллелизма.
Но обо всём по порядку.
Ищем идеал
☠️ Материал высокой сложности
Давайте добавим операций в действия ThreadPool’а так чтобы это ложилось в наши изначальные представления о хорошем отношении кода задачи к коду издержек пула 5% к 95%:
unsafe void TraceWork(object x)
{
var rd = rdtsc();
ulong sum = (ulong)rd;
for (int i = 0; i < 600; i++)
{
sum = sum + (ulong)i;
}
var end = rdtsc();
bag.Enqueue(new Record {
Ticks = end - rd - rdtscCallCost,
TID = Environment.CurrentManagedThreadId
});
}
Ticks
будет варьироваться в пределах 4700 тактов (процессор использует кэширование, поэтому варьируется). После чего вернём код для замера издержек пула:
unsafe void TraceWork(object x)
{
var rd = rdtsc();
var tid = Environment.CurrentManagedThreadId;
var last = lastValues[tid];
ulong sum = (ulong)rd;
for (int i = 0; i < 600; i++)
{
sum = sum + (ulong)i;
}
bag.Enqueue(new Record {
Ticks = rd - last - rdtscCallCost,
TID = Environment.CurrentManagedThreadId
});
lastValues[tid] = rdtsc();
}
Что мы получим:
Отношение 5% издержек к 95% работы на полной загрузке. Не отсортировано
Выглядит страшно. Давайте отсортируем эти значения для того чтобы понять их распределение: так ли много «пиковых» значений.
Отношение 5% издержек к 95% работы на полной загрузке. Отсортировано по длительности издержки
И для сравнений попробуем драматически увеличить длительность внутреннего цикла до 100_000
итераций. Стоимость такого цикла стала примерно равна 87,000 тактам, что составит более 99%
времени работы (исли считать с издержками). Тогда мы получим, что большинство операций издержек стало занимать около 550 тактов, что практически идеал:
А теперь чтобы понять, какими алгоритмами и пулами пользоваться в каких сценариях, давайте помотрим все возможные статистики, которые можно выудить из этого теста (ThreadPool, .NET 5/.NET 6 (одинаково))
Легенда к таблицам:
Итераций цикла: влияет на количество операций в теле TraceWork: на длительность его работы
Издержки, тактов: среднее количество тактов, которое расходуются ThreadPool’ом на выбор следующего метода
Работа, тактов: среднее количество тактов, которое расходуется телом метода (с циклом)
Цена единицы работы: (издержки+работа)/кол-во итераций цикла.
Время, мс: линейное время в миллисекундах, с точки зрения наблюдателя
∑ издержек, мс: общее время издержек по всем потокам
∑ работы, мс: общее время работы по всем потокам
Итераций цикла | Издержки, тактов | Работа, тактов | Цена единицы работы | Издержки, % | Работа, % | Время, мс | Потоков | Изд, сум, мс | Раб, сум, мс |
---|---|---|---|---|---|---|---|---|---|
0 | 600 | 134 | ? | 81,73 | 18,27 | 97 | 12 | 951,29 | 212,71 |
5 | 679 | 225 | 180,91 | 75,12 | 24,88 | 85 | 13 | 830,08 | 274,92 |
11 | 640 | 209 | 77,30 | 75,34 | 24,66 | 84 | 12 | 759,44 | 248,56 |
51 | 731 | 850 | 31,02 | 46,22 | 53,78 | 76 | 13 | 456,63 | 531,37 |
101 | 672 | 1360 | 20,13 | 33,06 | 66,94 | 92 | 13 | 395,40 | 800,60 |
501 | 486 | 5692 | 12,33 | 7,87 | 92,13 | 141 | 12 | 133,19 | 1558,81 |
1001 | 530 | 11634 | 12,15 | 4,36 | 95,64 | 237 | 27 | 278,80 | 6120,20 |
5001 | 408 | 37793 | 7,64 | 1,07 | 98,93 | 617 | 12 | 79,15 | 7324,85 |
10001 | 396 | 70788 | 7,12 | 0,56 | 99,44 | 1118 | 12 | 74,72 | 13341,28 |
100001 | 492 | 725510 | 7,26 | 0,07 | 99,93 | 10409 | 12 | 84,72 | 124823,28 |
Или графически для издержек в зависимости от объема работы:
Из таблицы можно подчерпнуть, что зависимость издержек пула от количества работы особенно сильно влияет на малых значениях количества работы и чем работы больше, тем меньше влияния. Так, таблица подтверждает корректность правила 5% — 95%, где находится та невидимая линия, выше которой начинается сильное влияние издержек. Это соответствует, условно, 500 итерациям цикла сложения чисел. Много это или мало? Пожалуй, достаточно много с учётом того, как мы любим использовать async/await
плотной грядой вызовов и на сколько коротких делегатов разбиты как следствие эти вызовы.
Забегая вперёд хочется показать метрики пула потоков собственного производства, речь о котором пойдёт чуть позже (SmartThreadPool, .NET 5/.NET 6):
Итераций цикла | Издержки, тактов | Работа, тактов | Цена единицы работы | Издержки, % | Работа, % | Время, мс | Потоков | Изд, сум, мсек | Раб, сум, мсек |
---|---|---|---|---|---|---|---|---|---|
0 | 133 | 54 | ? | 71,08 | 28,92 | 126 | 1 | 89,56 | 36,44 |
5 | 143 | 170 | 62,80 | 45,79 | 54,21 | 123 | 1 | 56,32 | 66,68 |
11 | 146 | 82 | 20,85 | 64,04 | 35,96 | 131 | 1 | 83,89 | 47,11 |
51 | 130 | 398 | 10,38 | 24,70 | 75,30 | 151 | 1 | 37,30 | 113,70 |
101 | 130 | 719 | 8,42 | 15,39 | 84,61 | 213 | 1 | 32,78 | 180,22 |
501 | 118 | 3007 | 6,24 | 3,79 | 96,21 | 614 | 1 | 23,28 | 590,72 |
1001 | 408 | 6020 | 6,42 | 6,36 | 93,64 | 597 | 3 | 113,89 | 1677,11 |
5001 | 497 | 35364 | 7,17 | 1,39 | 98,61 | 931 | 11 | 141,93 | 10099,07 |
10001 | 526 | 84755 | 8,53 | 0,62 | 99,38 | 1368 | 12 | 101,31 | 16314,69 |
100001 | 722 | 860697 | 8,61 | 0,08 | 99,92 | 13288 | 12 | 133,80 | 159322,20 |
Из таблицы видно, что издержки упали значительно, упала цена единицы работы, упало суммарное время работы по потокам. А всё почему? А всё потому это этот пул создан в т.ч. для решения проблем сверхкороткой работы и пул догадывается провести всю её на одном потоке вместо параллелизации плюс в нём не реализованы редко-используемые механизмы стандартного пула потоков. Когда же он понимает, что работа пойдёт быстрее параллельно, он создаёт для этих целей дополнительные потоки.
И для интереса давайте те же замеры сделаем для прямого цикла, без параллелизации (издержки = цена организации цикла for(...)
):
Итераций цикла | Издержки, тактов | Работа, тактов | Цена единицы работы | Издержки, % | Работа, % | Время, мс | Потоков | Изд, сум, мсек | Раб, сум, мсек |
---|---|---|---|---|---|---|---|---|---|
0 | 23 | 43 | ? | 34,39 | 65,61 | 51 | 1 | 17,54 | 33,46 |
5 | 19 | 45 | 12,98 | 29,42 | 70,58 | 40 | 1 | 11,77 | 28,23 |
11 | 18 | 75 | 8,59 | 20,09 | 79,91 | 45 | 1 | 9,04 | 35,96 |
51 | 19 | 386 | 7,96 | 4,72 | 95,28 | 112 | 1 | 5,28 | 106,72 |
101 | 18 | 699 | 7,11 | 2,64 | 97,36 | 157 | 1 | 4,15 | 152,85 |
501 | 16 | 2946 | 5,91 | 0,57 | 99,43 | 566 | 1 | 3,24 | 562,76 |
1001 | 15 | 5169 | 5,18 | 0,29 | 99,71 | 977 | 1 | 2,83 | 974,17 |
5001 | 14 | 25602 | 5,12 | 0,06 | 99,94 | 4743 | 1 | 2,76 | 4740,24 |
10001 | 15 | 51282 | 5,13 | 0,03 | 99,97 | 9482 | 1 | 2,80 | 9479,20 |
100001 | 25 | 547279 | 5,47 | 0,00 | 100,00 | 100921 | 1 | 4,63 | 100916,37 |
И составим график зависимости линейного времени от объёма работы для всех трёх типов работы: просто цикл, ThreadPool и некий наш SmartThreadPool, речь о котором пойдёт чуть позже:
Сравнение общего линейного времени исполнения Сревнение общего времени исполнения с учётом параллелизма
Что мы увидим? Поскольку обычный цикл почти не имеет издержек (см табл), вплоть до 500 — 600 итераций он обгоняет параллелизацию через TheradPool и SmartThreadPool. Однако после — вперёд вырывается SmartThreadPool, удерживающий лидерство на всём остатке объемов рабочего цикла.
Давайте подсчитаем критичное время исполнения тела метода чтобы параллелизация имела смысл, а работа шла с минимальными издержками. Для этого вернёмся к таблицам и графикам. Как мы только что выяснили, параллелизация начинает обгонять прямой цикл на 600 итерациях. И это справедливо как для стандартного ThreadPool, так и для собственного. Но давайте подсчитаем отдельно.
Проверяем корректность метрик
На моём ноутбуке 2.7GHz = 2700000000
операций в сек. Т.е. 1 сек / (2.7*10^9) = 0,37 наносек
на такт. Смотрим посчитанное количество тактов для 51 итерации: 386 + 19 = 405 тактов на цикл. 0,37 наносек * 405 = 150 наносек
на тело метода. 150 наносек * 500_000 делегатов / 1_000_000 (наносек -> миллисек) = 75 мс
. Смотрим линейное время по таблице: 76 мс
. Что в точности попадает, т.к. в 75 мс не учтено сохранение статистики времени исполнения. Получается, метрики собраны корректно. А заодно и измерили минимальный размер метода для ThreadPool’а: 150 наносекунд. Результат значит то, что нормальность размера метода не измерить обычными средствами т.к. он слишком маленький. Минимальный порядок замера времени, который способен выдать .NET — это Stopwatch.GetTimestamp()
. Точность отсчёта указана в свойстве Stopwatch.Frequency
, который у меня равен 10_000_000 тактов
(тактов этого счётчика, не CPU) в секунду. Т.е. 100 наносекундам. Что не является приемлимым значением точности для нас. Ведь первое взятие GetTimestamp()
может быть в конце 100-наносекундного интервала, а второй — в начале и дельта, равная 2 не будет равна 199 наносекундам, а, например, 110, что для нас не подходит: нам надо 150 и выше. Т.е. ориентироваться стоит на число 3. Но тогда это может означать интервал в 299 наносекунд. А это уже не 51 итерация сложения чисел, а 100 итераций. Но на это число уже можно ориентироваться, чтобы не уходить в зону повышенных накладных расходов.
С другой стороны мой ноутбук не настолько быстр чтобы на него ориентироваться. Существуют варианты намного быстрее: например с частотой 4.9 GHz
. Что даёт 0.2 наносек на такт и 81 такт на тело метода. А это вполне ложится в диапозон разницы между вызовами Stopwatch.GetTimestamp()
1–2, а не 2–3. Поэтому если вы когда-то дойдёте до таких вопросов, ориентироваться лучше на диапазон более медленных процессоров, чтобы код работал одинаково хорошо на обоих типах систем.
Вместо заключения
Напомню, что мы тестируем именно ThreadPool`ы на различных объемах задач. Если бы нам было необходимо решить конкретно эту задачу, это не было бы 500_000 делегатов в ThreadPool, а скорее 12 потоков по циклу в 41666 итераций, но зато мы выяснили очень важную вещь: далеко не каждую задачу стоит решать через пул потоков. Очень часто это приведёт к замедлению приложения. Второе — если уж решать вопрос параллелизации через ThreadPool, то надо помнить о том, что много сворехкоротких делегатов туда лучше не планировать: это вовсе не ускорит приложение, а, скорее, замедит.
Как изменилась функция распределения?
ThreadPool SmartThreadPool
Пики задержек перераспределились и ушли в первый диапазон, диапазон «без дополнительных накладных расходов», когда нет борьбы за ресурс. Второй пик практически исчез, а третий стал сильно ниже. Т.е. новый пул собственного производства обгоняет стандартный.
Также стоит отметить, что очень часто работа происходит не только CPU-bound кода, но и IO-bound без использования IO-bound группы потоков ThreadPool (без ThreadPool.RegisterWaitForSingleObject
). Я говорю о делегатах, которые запланированы в ThreadPool, например, при помощи QueueUserWorkItem
. Это значит, что делегаты, выполнив часть процессорных инструкций, ожидают от ОС сигнала, снятия блокировки. Например, ожиданием от дисковой подсистемы окончания чтения файла. И тем самым пусть и не на долго, но снижают пропускную способность ThreadPool снизив при этом его результативность (возможность загрузить процессор максимально возможно).
В этом случае помимо потока вы блокируете возможности ThreadPool по быстрой отработке делегатов: ThreadPool уже не может работать на все 100%. И по факту таких ситуаций случается достаточно много. Мы то отправляем Request, то ждём Response, то ещё что. И именно поэтому стандартный ThreadPool имеет настройку «по два потока на ядро». В худшем варианте, но при длительных по времени исполнения делегатах: когда на ThreadPool работает только CPU-bound код ThreadPool просто будет псевдопараллельно разбирать задачи потоками, но с такой же производительностью. Но когда какая-то из задач встанет в блокировку, а ThreadPool имея второй поток на том же ядре, подхватит работу и сможет работать уже не 50% от времени, а все 100%. Другими словами, имея количество потоков x2 от количества ядер при условии наличия IO-bound операций ThreadPool их отработает быстрее. Однако, если выставить ещё большее количество потоков, например x3, то это уже создаст проблемы, т.к. вероятность того, что два потока из трёх на ядре уйдут в IO-bound операции крайне мала и потому в этом нет смысла.
Выводы по разделу
Потоки — абстракция операционной системы. Процессор о них ничего не знает;
Потоки могут исполняться как параллельно друг другу, когда они использяются на разных ядрах, так и псевдопараллельно, когда на одном ядре они черездуются достаточно быстро чтобы для стороннего наблюдателя это выглядело одновременной работой;
А потому поток может прекратить исполняться в любой момент, даже по середине операции
x = y
;Работать на нескольких вручную созданных потоках можно, но для этого у вас должно быть много работы, т.к. создание потоков сама по себе дорогостоящая операция;
Иначе вполне логично воспользоваться ThreadPool’ом: он для того и создан чтобы через себя пропускать ряд независимых друг от друга задач автоматически регулируя внутренний уровень параллелизма;
Существуют IO-bound и CPU-bound операции. Первые — на оборудовании, вторые — на прооцессоре. Пока идёт IO-bound операция, поток должен встать в блокировку;
Для CPU-bound операций у пула потоков свои методы, для IO-bound — свои;
Блокировка потока — это исключение его из планирования, а значит поток в блокировке не занимает процессорное время;
Во время своей работы пул потоков естественным образом тратит процессорный ресурс на свои алгоритмы;
А значит есть грань, какую работу выгодно исполнить линейно, а какую — параллельно, через пул потоков;
Цена эта — 500 итераций цикла суммирования двух чисел или 3000 тактов. Считать сложно, но можно :)