diff --git a/book/ru/Execution/01-Threads/01-03-Threads-Basics.md b/book/ru/Execution/01-Threads/01-03-Threads-Basics.md index fb1ae1c..6c7560c 100644 --- a/book/ru/Execution/01-Threads/01-03-Threads-Basics.md +++ b/book/ru/Execution/01-Threads/01-03-Threads-Basics.md @@ -26,10 +26,11 @@ ## ThreadPool Именно поэтому и возник пул потоков, ThreadPool. Он решает несколько задач: -- с одной стороны он абстрагирует создание потока: мы этим заниматься не должны; -- создав когда-то поток, он исполняет на нём совершенно разные задачи. Вам же не важно, на каком из них исполняться? Главное чтобы был; -- а потому мы более не тратим время на создание потока ОС: мы работаем на уже созданных; -- а потому нагружая ThreadPool своими делегатами мы можем равномерно загрузить ядра CPU работой. +- с одной стороны он абстрагирует создание потока: мы этим заниматься не должны +- создав когда-то поток, он исполняет на нём совершенно разные задачи. Вам же не важно, на каком из них исполняться? Главное чтобы был + - а потому мы более не тратим время на создание потока ОС: мы работаем на уже созданных + - а потому нагружая ThreadPool своими делегатами мы можем равномерно загрузить ядра CPU работой +- либо ограничивает пропускную способность либо наоборот: даёт возможность работать на все 100% от всех процессорных ядер. Однако, как мы убедимся позже у любой такой абстракции есть масса нюансов, в которых эта абстракция работает очень плохо и может стать причиной серъёзных проблем. Пул потоков можно использовать не во всех сценариях, а во многих он станет серьёзным "замедлителем" процессов. Однако давайте взглянем на ситуацию под другим углом. Зачастую при объяснении какой-либо темы автором перечисляются функционал и возможности, но совершенно не объясняется "зачем": как будто и так всё понятно. Мы же попробуем пойти другим путём. Давайте попробуем понять ThreadPool. @@ -59,12 +60,14 @@ По сути механизм блокировок и есть встроенный в операционную систему механизм разделения IO-/CPU-bound операций. Но решает ли он все задачи разделения? -Каждая постановка в блокировку снижает уровень параллелизма с точки зрения IO-bound кода. Т.е., конечно же операции IO-bound и CPU-bound идут в параллель, но с другой стороны когда некий поток уходит в ожидание, он исчезает из планирования и наше приложение меньше задействует процессор, т.к. с точки зрения только CPU-bound операций мы *теряем* поток исполнения. И уже с точки зрения приложения, а не операционной системы может возникнуть истественное желание занять процессор чем-то ещё. Чтобы это сделать, можно насоздавать потоков и работать на них в параллель, однако это будет достаточно тяжким занятием и потому был придуман следующий очень простой концепт. Создаётся контейнер, который хранит и управляет хранимыми потоками. Этот контейнер, пул потоков, при старте передаёт потоку метод исполнения, который внутри себя в бесконечном цикле разбирает очередь поставленных пулу потоков задач и исполняет их. Какую задачу это решает: программный код построен так, что он, понятно дело исполняется кусочками. Кусочек исполнился, ждём оборудования. Второй кусочек исполнился, ждём оборудования. И так бесконечно. Причем нет же никакой разницы, на каком потоке исполнять вот такие кусочки, верно? А значит их можно отдавать в очередь на исполнение пулу потоков, который будет их разбирать и исполнять. +Каждая постановка в блокировку снижает уровень параллелизма с точки зрения IO-bound кода. Т.е., конечно же? операции IO-bound и CPU-bound идут в параллель, но с другой стороны когда некий поток уходит в ожидание, он исчезает из планирования и наше приложение меньше задействует процессор, т.к. с точки зрения только CPU-bound операций мы *теряем* поток исполнения. И уже с точки зрения приложения, а не операционной системы может возникнуть естественное желание занять процессор чем-то ещё. Чтобы это сделать, можно насоздавать потоков и работать на них в параллель, однако это будет достаточно тяжким занятием и потому был придуман следующий очень простой концепт. Создаётся контейнер, который хранит и управляет хранимыми потоками. Этот контейнер, пул потоков, при старте передаёт потоку метод исполнения, который внутри себя в бесконечном цикле разбирает очередь поставленных пулу потоков задач и исполняет их. Какую задачу это решает: программный код построен так, что он, понятно дело исполняется кусочками. Кусочек исполнился, ждём оборудования. Второй кусочек исполнился, ждём оборудования. И так бесконечно. Причем нет же никакой разницы, на каком потоке исполнять вот такие кусочки, верно? А значит их можно отдавать в очередь на исполнение пулу потоков, который будет их разбирать и исполнять. -Но возникает проблема: ожидание от оборудования происходит в потоке, который инициировал это ожидание. Т.е. если мы будем ожидать в пуле потоков, мы заблокируем его. Как это решить? Инженеры из Microsoft создали для этих целей внутри ThreadPool второй пул: пул ожидающих потоков. И когда некий код, работающий в основном пуле решается встать в блокировку, сделать он это должен не тут же, в основном пуле, а специальным образом: перепланировавшись на второй пул (об этом позже). +Но возникает проблема: ожидание от оборудования происходит в потоке, который инициировал это ожидание. Т.е. если мы будем ожидать в пуле потоков, мы снизим его уровень параллелизма. Как это решить? Инженеры из Microsoft создали для этих целей внутри ThreadPool второй пул: пул ожидающих потоков. И когда некий код, работающий в основном пуле решается встать в блокировку, сделать он это должен не тут же, в основном пуле, а специальным образом: перепланировавшись на второй пул (об этом позже). Работа запланированных к исполнению делегатов (давайте называть вещи своими именами) на нескольких рабочих потоках, а ожидание - на других, предназначенных для "спячки" реализует второй уровень разделения IO-/CPU-bound операций, когда уже приложение, а не операционная система получает механизм максимального занятия работой процессорных ядер. При наличии постоянной работы (нескончаемого списка делегатов в основном пуле CPU-bound операций) приложение может загрузить процессор на все 100%. +### Прикладной уровень + На прикладном уровне объяснения пул потоков работает крайне просто. Существует очередь делегатов, которые переданы пулу потоков на исполнение. Очередь делегатов хранится в широко-известной коллекции `ConcurrentQueue`. Далее, когда вы вызываете `ThreadPool.QueueUserWorkItem(() => Console.WriteLine($"Hello from {Thread.CurrentThread.ManagedThreadId}"))`, то просто помещаете делегат в эту очередь. ThreadPool внутри себя в зависимости от некоторых метрик, о которых мы поговорим позже создаёт некоторое необходимое ему количество рабочих потоков. Каждый из которых содержит цикл выборки делегатов на исполнение: и запускает их. Если упрощать, внутренний код выглядит примерно так: @@ -86,7 +89,7 @@ void ThreadMethod() Конечно же он сложнее, но общая суть именно такая. -Однако помимо кода, который исполняет процессор (т.н. IO-bound код) существует также код, приводящий к блокировке исполнения потока: ожиданию ответа от оборудования. Клавиатура, мышь, сеть, диск, вертикальная синхронизация монитора и прочие сигналы от оборудования. Этот код называется IO-bound. Если мы будем ожидать оборудование на потоках пула, это приведёт к тому, что часть потоков в пуле перестенут быть рабочими на время блокировки. Это как минимум испортит пулу потоков статистики которые он считает и как следствие этого пул начнёт работать сильно медленнее. Пока он поймёт, что ему необходимо расширение, пройдёт много времени. У него появится некий "лаг" между наваливанием нагрузки на пул и срабатыванием кода, расширяющего его. А без блокировок-то он отрабатывал бы очень быстро. +Однако помимо кода, который исполняет процессор (т.н. IO-bound код) существует также код, приводящий к блокировке исполнения потока: ожиданию ответа от оборудования. Клавиатура, мышь, сеть, диск и прочие сигналы от оборудования. Этот код называется IO-bound. Если мы будем ожидать оборудование на потоках пула, это приведёт к тому, что часть потоков в пуле перестенут быть рабочими на время блокировки. Это как минимум испортит пулу потоков статистики которые он считает и как следствие этого пул начнёт работать сильно медленнее. Пока он поймёт, что ему необходимо расширение, пройдёт много времени. У него появится некий "лаг" между наваливанием нагрузки на пул и срабатыванием кода, расширяющего его. А без блокировок-то он отрабатывал бы очень быстро. Чтобы избежать таких ситуаций и сохранить возможность "аренды" потоков, ввели второй пул потоков. Для IO-bound операций. А потому в стандартном ThreadPool два пула: один -- для CPU-bound операций и второй -- для IO-bound. @@ -118,19 +121,125 @@ ThreadPool.QueueuserWorkItem( В этом случае второй делегат уйдёт на второй пул IO-bound операций, который не влияет на исполнение CPU-bound пула потоков. -### Оптимальное количество потоков +### Оптимальная длительность работы делегатов, количества потоков Каким может стать оптимальное количество потоков? Ведь работать можно как на двух, так и на 1000 потоках. От чего это зависит? Пусть у вас есть ряд CPU-bound делегатов. Ну, для весомости их пусть будет миллион. Ну и давайте попробуем ответить на вопрос: на каком количестве потоков их выработка будет максимально быстрой? -1. Пусть длительность работы каждого делегата заметна: например, 100 мс. Тогда результат будет зависеть от того количества процессорных ядер, на которых идёт исполнение. Мы возьмём некоторую идеализированную систему, где кроме нас -- никого нет. Ну и возьмём для примера 2-х ядерный процессор. Во сколько потоков имеет смысл работать CPU-bound коду на 2-х процессорной системе? Очевино, ответ = 2, т.к. если один поток на одном ядре, второй -- на втором, то оба они будут вырабатывать все 100% из каждого ядра ни разу не уходя в блокировку. Станет ли код быстрее, увеличь мы количество потоков в 2 раза? Нет. Если мы добиваим два потока, что произойдёт? У каждого ядра появится по второму потоку. Поскольку ядро не резиновое, а железное, частота та же самая, то на выходе мы будем иметь два потока, исполняющиеся последовательно друг за другом, по, например, 120 мс. А поскольку время исполнения одинаковое, то фактически первый поток стал работать на 50% от изначальной производительности, отдав 50% второму потоку. Добавь мы ещё по два потока, мы стова поделим между всеми это ядро и каждому достанется по 33,33%. С другой стороны если перестать воспринимать ThreadPool как идеальный и без алгоритмов, а вспомнить, что у него под капотом как минимум ConcurrentQueue, то возникает ещё одна проблема: contention, т.е. состояние спора между потоками за некий ресурс. В нашем случае спро будет идти за смену указателей на голову и хвост очереди внутри ConcurrentQueue. А это в свою очередь *снизит* общую производительность, хоть и практически незаметно: при дистанции в 100 мс очень низка вероятность на разрыв кода методов `Push` и `TryPop` системный таймером процессора с последующим переключением планировщиком потоков на поток, который также будет делать `Push` либо `TryPop` (contention у них будет происходить с высокой долей вероятности на одинаковых операциях (например, `Push` + `Push`), либо с очень низкой долей вероятности -- на разных). -2. На малой длительности работы делегатов результат мало того, что не становится быстрее, он становится медленнее, чем на 2 потоках, т.к. на малом времени работы увеличивается вероятность одновременной работы методов очереди ConcurrentQueue, что вводит очередь в состояние Contention и как результат -- ещё большее снижение производительности. Однако если сравнивать работу на 1 ядре и на нескольких, на нескольких ядрах работа будет быстрее; -3. И последний вариант, который относится к сравнению по времени исполнения делегатов -- когда сами делегаты ну очень короткие. Тогда получится, что при увеличении уровня параллелизма вы наращиваете вероятность состояния contention в очереди ConcurrentQueue. В итоге код, который борется внутри очереди за установку Head и Tail настолько неудачно часто срабатывает, что время contention становится намного больше времени исполнения самих делегатов. А самый лучший вариант их исполнения -- выполнить их последовательно, в одном потоке. И это подтверждается тестами: на моём компьютере с 8 ядрами (16 в HyperThreading) код исполняется в 8 раз медленнее, чем на одном ядре. +1. Пусть длительность работы каждого делегата заметна: например, 100 мс. Тогда результат будет зависеть от того количества процессорных ядер, на которых идёт исполнение. Давайте поступим как физики и возьмём некоторую идеализированную систему, где кроме нас -- никого нет: ни потоков ОС ни других процессов. Только мы и CPU. Ну и возьмём для примера 2-х ядерный процессор. Во сколько потоков имеет смысл работать CPU-bound коду на 2-х процессорной системе? Очевино, ответ = 2, т.к. если один поток на одном ядре, второй -- на втором, то оба они будут вырабатывать все 100% из каждого ядра ни разу не уходя в блокировку. Станет ли код быстрее, увеличь мы количество потоков в 2 раза? Нет. Если мы добавим два потока, что произойдёт? У каждого ядра появится по второму потоку. Поскольку ядро не резиновое, а железное, частота та же самая, то на выходе на каждом ядре мы будем иметь по два потока, исполняющиеся последовательно друг за другом, по, например, 120 мс. А поскольку время исполнения одинаковое, то фактически первый поток стал работать на 50% от изначальной производительности, отдав 50% второму потоку. Добавь мы ещё по два потока, мы снова поделим между всеми это ядро и каждому достанется по 33,33%. С другой стороны если перестать воспринимать ThreadPool как идеальный и без алгоритмов, а вспомнить, что у него под капотом как минимум ConcurrentQueue, то возникает ещё одна проблема: contention, т.е. состояние спора между потоками за некий ресурс. В нашем случае спор будет идти за смену указателей на голову и хвост очереди внутри ConcurrentQueue. А это в свою очередь *снизит* общую производительность, хоть и практически незаметно: при дистанции в 100 мс очень низка вероятность на разрыв кода методов `Enqueue` и `TryDequeue` системным таймером процессора с последующим переключением планировщиком потоков на поток, который также будет делать `Enqueue` либо `TryDequeue` (contention у них будет происходить с высокой долей вероятности на одинаковых операциях (например, `Enqueue` + `Enqueue`), либо с очень низкой долей вероятности -- на разных). +2. На малой длительности работы делегатов результат мало того, что не становится быстрее, он становится медленнее, чем на 2 потоках, т.к. на малом времени работы увеличивается вероятность одновременной работы методов очереди ConcurrentQueue, что вводит очередь в состояние contention и как результат -- ещё большее снижение производительности. Однако если сравнивать работу на 1 ядре и на нескольких, на нескольких ядрах работа будет быстрее; +3. И последний вариант, который относится к сравнению по времени исполнения делегатов -- когда сами делегаты ну очень короткие. Тогда получится, что при увеличении уровня параллелизма вы наращиваете вероятность состояния contention в очереди ConcurrentQueue. В итоге код, который борется внутри очереди за установку Head и Tail настолько неудачно часто срабатывает, что время contention становится намного больше времени исполнения самих делегатов. А самый лучший вариант их исполнения -- выполнить их последовательно, **в одном потоке**. И это подтверждается тестами: на моём компьютере с 8 ядрами (16 в HyperThreading) код исполняется в 8 раз медленнее, чем на одном ядре. + +Другими словами, исполнять CPU-bound код на количестве потоков выше количества ядер не стоит, а в некоторых случаях это даже замедлит приложение, а в совсем вырожденных сценариях лучше бы вообще работать на одном. + +С другой стороны, показанные примеры доказывают, что на производительность сильно влияет **гранулярность элементов работы**. Имеется ввиду, конечно же, длительность работы делегатов. Чтобы достичь хороших показателей, гранулярность работы не может быть абы какой: она должна быть *правильной*. И помимо планирования задач на ThreadPool, планировать их можно также как через TPL так и через какой-либо свой собственный пул потоков. Например, если взять обычный ThreadPool, то можно примерно измерить издержки алгоритмов ThreadPool в процессорных тактах (можно, конечно и в чём-то более привычном типа микросекунд, но там на многих сценариях вполне могут быть нули. А ниже тактов ничего нет): + +{.wide} +```csharp +// указатель на метод, вызывающий asm инструкцию drtsc +unsafe delegate* rdtsc; + +// цена вызова drtsc +long cost; + +// очередь, хранящая результаты замеров +ConcurrentQueue bag; + +// последнее замеренное количество тактов для конкретного потока +long[] lastValues = new long[100]; + +unsafe void Main() +{ + rdtsc = (delegate*)(Alloc(rdtscAsm)); + var rdtsc_costs = new List(10000); + for(int i = 0; i < 10000; i++) + { + rdtsc_costs.Add(Math.Abs(rdtsc()-rdtsc())); + } + cost = (long)rdtsc_costs.Average(); + cost.Dump(); + + (Stopwatch.GetTimestamp() - Stopwatch.GetTimestamp()).Dump(); + bag = new ConcurrentQueue(); + bag.Enqueue(new Record { Ticks = rdtsc() - cost }); + + 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 - cost, + TID = Environment.CurrentManagedThreadId + }); + + lastValues[tid] = rdtsc(); +} + +struct Record { + public long Ticks; + public int TID; +} + + +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 IntPtr Alloc(byte[] asm) +{ + var ptr = VirtualAlloc(IntPtr.Zero, (uint)asm.Length, + MEM_COMMIT, PAGE_EXECUTE_READWRITE); + Marshal.Copy(asm, 0, ptr, asm.Length); + return ptr; +} + +delegate long RdtscDelegate(); + +static readonly byte[] rdtscAsm = +{ + 0x0F, 0x31, // rdtsc + 0xC3 // ret +}; + +``` + +Здесь мы при помощи метода `Alloc` выделяем память напрямую у Windows и помечаем её как разрешённую к исполнению кода. Далее помещаем туда содержимое массива `rdtscAsm`, который содержит в себе вызов процессорной инструкции `rdtsc` и возврат в вызываемый код. После чего приводим указатель на буфер с инструкциями к указателю на метод и получаем таким образом .NET метод на основе asm кода вызова CPU инструкции `rdtsc`, которая читает счётчик TSC (Time Stamp Counter) и возвращает в регистрах EDX:EAX 64-битное количество тактов с момента последнего сброса процессора. Старшая часть нам не нужна, т.к. мы считаем разницу, а потому раззница между значениями 2-х вызовов метода `rdtsc()` даст количество тактов между ними. + +После инициализации мы закладываем в пул один миллион делегатов, которые закладывают в очередь `ConcurrentQueue` разницу между текущим `rdtsc` и прошлым на некотором потоке. Сохранять номер потока важно, т.к. на разных ядрах может быть разным значение этого счётчика. + +После размера мы получим, что когда пул только начал отрабатывать задачу, он стартовал на 1 потоке и проводил через себя делегаты с издержками на свою работу, примерно равными `200-300` тактов. Далее, по мере роста параллелизма пула растёт и цена издержек: она доходит до `500-900` тактов. Единичные `1500-2500` тактов приходятся, скорее всего на неудачные попытки взять делегат из очереди, когда все остальные потоки разбирают из очереди делегаты, а тот, что *притормозил* постоянно борется за смену "хвоста" очереди. + +Поэтому если наша задача очень короткая и занимает, например, 40 тактов (как содержимое метода `TraceWork`), то издержки будут стоить `1250% - 2000%` от полезного кода. Другими словами, эту задачу дешевле запустить последовательно, чем на ThreadPool. Отталкиваясь от этих цифр можно смело подсчитать, что если брать издержки за `200-300` тактов, а этого можно достичь умеренно-большими задачами, и если взять их за 5%, то полезная нагрузка должна быть не меньше `(250 / 5 * 95) = 4750` тактов. Т.е. кода в делегате должно исполняться достаточно много. Но и длительными они не должны быть, в особенности со случаем ThreadPool, т.к. он является общим ресурсом. + +![](./img/TasksSpreadInCpuTicks.png) + +На приведённом рисунке видно, что на приложенном примере когда ThreadPool отрабатывает достаточно большое время, ему "сносит крышу" и он начинает колебаться между 300 тактами на операцию до 50.000 тактов на операцию. Что говорит о том, что сверхкороткие делегаты туда лучше не планировать: 50,000 тактов простоя против 40 тактов полезной нагрузки -- так себе ситуация. + +Также стоит отметить, что очень часто работа происходит не только 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 операции крайне мала и потому в этом нет смысла. + +### Общее описание алгоритмов ThreadPool -Другими словами, при исполнении только CPU-bound кода выше количества ядер количество потоков в пуле потоков иметь не стоит, а в некоторых случаях это даже замедлит приложение, и лучше бы вообще работать на одном. Однако, очень часто работа происходит не только CPU-bound кода, но и IO-bound, но на COU-bound группе потоков стандартного `ThreadPool`. Я говорю о делегатах, которые запланированы в ThreadPool, например, при помощи `QueueUserWorkItem`. Это значит, что делегаты, выполнив часть процессорных инструкций, сообщают ОС, что этот поток чем-то заблокирован. Например, ожиданием от дисковой подсистемы чтения файла. +ThreadPool в своей работе основывается на трёх алгоритмах: +- CPU-bound операции, пришедшие снаружи, от пользователя. В этом случае любой освободившийся поток заберёт себе ваш делегат; +- CPU-bound операции, пришедшие от потока, являющегося частью пула потоков, но переданные с флагом работы на том же потоке. Любой поток -В этом случае помимо потока вы блокируете возможности ThreadPool по быстрой отработке делегатов. ThreadPool уже не может работать на все 100%. И по факту таких случаев достаточно много. Мы то отправляем Request, то ждём Response, то ещё что. И именно поэтому стандартный ThreadPool имеет настройку "по два потока на ядро". В худшем варианте, когда у нас только CPU-bound код ThreadPool просто будет псевдопаралллельно разбирать задачи потоками, но с такой же производительностью. Но когда какая-то из задач встанет в блокировку, ThreadPool имеет второй поток на том же ядре, который подхватит работу и сможет работать уже не 50% от времени, а все 100%. Другими словами, имея количество потоков x2 от количества ядер при условии наличия IO-bound операций ThreadPool их отработает быстрее. Однако, если выставить уже большее количество потоков, например x3, то это уже создаст проблемы, т.к. вероятность того, что два потока из трёх на ядре уйдут в IO-bound операции крайне мала и потому в этом нет смысла. +### А если охота по-другому? ## SynchronizationContext diff --git a/book/ru/Execution/01-Threads/img/TasksSpreadInCpuTicks.png b/book/ru/Execution/01-Threads/img/TasksSpreadInCpuTicks.png new file mode 100644 index 0000000..1eaa363 Binary files /dev/null and b/book/ru/Execution/01-Threads/img/TasksSpreadInCpuTicks.png differ