Skip to content

Commit 3ede27a

Browse files
committed
Оптимальное количество потоков
1 parent d0bf4d3 commit 3ede27a

File tree

1 file changed

+15
-1
lines changed

1 file changed

+15
-1
lines changed

book/ru/Execution/01-Threads/01-03-Threads-Basics.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141

4242
Единственное, что команда Microsoft сделала неправильно -- что сделала ThreadPool статическим классом без возможности повлиять на реализацию. Это ограничение создало целый пласт недопонимания между разработчиком runtime и конечным разработчиком потому как у последнего нет никакого ощущения, что можно разработать свои пулы потоков и что ими можно пользоваться в рамках текущей реализации. Что есть абстракции над пулами потоков, отдавая которые во внешние алгоритмы вы заставляете их ими пользоваться вместо того чтобы пользоваться стандартным, общим пулом.
4343

44-
Но если закрыть глаза на эти недочёты (к которым мы вернёмся позже), то с точки упрощения менеджмента параллелизма, конечно, пул потоков -- идеальное решение. Ведь в конечном счёте параллельно исполняющийся код -- просто набор множества задач, которым совершенно не важен поток, на котором они работают.
44+
Но если закрыть глаза на эти недочёты (к которым мы вернёмся позже), то с точки упрощения менеджмента параллелизма, конечно, пул потоков -- идеальное решение. Ведь в конечном счёте параллельно исполняющийся код -- просто набор множества задач, которым совершенно не важен поток, на котором они работают. Им важно исполняться параллельно друг другу. Вам даже не так важно количество. Вам важно, чтобы CPU расходовался оптимально.
4545

4646
>{.big-quote} Пока мы будем рассматривать ThreadPool как *идеальный* пул потоков, который без всяких проблем сопровождает задачу из одного потока в другой
4747
@@ -118,6 +118,20 @@ ThreadPool.QueueuserWorkItem(
118118

119119
В этом случае второй делегат уйдёт на второй пул IO-bound операций, который не влияет на исполнение CPU-bound пула потоков.
120120

121+
### Оптимальное количество потоков
122+
123+
Каким может стать оптимальное количество потоков? Ведь работать можно как на двух, так и на 1000 потоках. От чего это зависит?
124+
125+
Пусть у вас есть ряд CPU-bound делегатов. Ну, для весомости их пусть будет миллион. Ну и давайте попробуем ответить на вопрос: на каком количестве потоков их выработка будет максимально быстрой?
126+
127+
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`), либо с очень низкой долей вероятности -- на разных).
128+
2. На малой длительности работы делегатов результат мало того, что не становится быстрее, он становится медленнее, чем на 2 потоках, т.к. на малом времени работы увеличивается вероятность одновременной работы методов очереди ConcurrentQueue, что вводит очередь в состояние Contention и как результат -- ещё большее снижение производительности. Однако если сравнивать работу на 1 ядре и на нескольких, на нескольких ядрах работа будет быстрее;
129+
3. И последний вариант, который относится к сравнению по времени исполнения делегатов -- когда сами делегаты ну очень короткие. Тогда получится, что при увеличении уровня параллелизма вы наращиваете вероятность состояния contention в очереди ConcurrentQueue. В итоге код, который борется внутри очереди за установку Head и Tail настолько неудачно часто срабатывает, что время contention становится намного больше времени исполнения самих делегатов. А самый лучший вариант их исполнения -- выполнить их последовательно, в одном потоке. И это подтверждается тестами: на моём компьютере с 8 ядрами (16 в HyperThreading) код исполняется в 8 раз медленнее, чем на одном ядре.
130+
131+
Другими словами, при исполнении только CPU-bound кода выше количества ядер количество потоков в пуле потоков иметь не стоит, а в некоторых случаях это даже замедлит приложение, и лучше бы вообще работать на одном. Однако, очень часто работа происходит не только CPU-bound кода, но и IO-bound, но на COU-bound группе потоков стандартного `ThreadPool`. Я говорю о делегатах, которые запланированы в ThreadPool, например, при помощи `QueueUserWorkItem`. Это значит, что делегаты, выполнив часть процессорных инструкций, сообщают ОС, что этот поток чем-то заблокирован. Например, ожиданием от дисковой подсистемы чтения файла.
132+
133+
В этом случае помимо потока вы блокируете возможности ThreadPool по быстрой отработке делегатов. ThreadPool уже не может работать на все 100%. И по факту таких случаев достаточно много. Мы то отправляем Request, то ждём Response, то ещё что. И именно поэтому стандартный ThreadPool имеет настройку "по два потока на ядро". В худшем варианте, когда у нас только CPU-bound код ThreadPool просто будет псевдопаралллельно разбирать задачи потоками, но с такой же производительностью. Но когда какая-то из задач встанет в блокировку, ThreadPool имеет второй поток на том же ядре, который подхватит работу и сможет работать уже не 50% от времени, а все 100%. Другими словами, имея количество потоков x2 от количества ядер при условии наличия IO-bound операций ThreadPool их отработает быстрее. Однако, если выставить уже большее количество потоков, например x3, то это уже создаст проблемы, т.к. вероятность того, что два потока из трёх на ядре уйдут в IO-bound операции крайне мала и потому в этом нет смысла.
134+
121135
## SynchronizationContext
122136

123137
ThreadPool -- вещь удобная и во многом всем понятная, т.к. очень простая. Однако, было бы ещё удобнее если бы можно было ввести ещё более абстрактное понятие, которое бы означало, что я хочу синхронно или же асинхронно выполнить некий код относительно своего в некотором потоке или же на некоторой группе потоков.

0 commit comments

Comments
 (0)