1
0
mirror of https://github.com/loginov-dmitry/multithread.git synced 2024-11-24 16:53:48 +02:00

переработан раздел "планирование потоков"

This commit is contained in:
loginov-dmitry 2020-08-15 14:48:20 +03:00
parent 903d6f39dd
commit 5f8cb1a61b

View File

@ -87,9 +87,10 @@ markdown-редакторы не подошли, т.к. они либо не у
6.1 Роль системного таймера [...](#os_timer)
6.2 Роль кэшей процессора [...](#cpu_cache)
<!--6.2 Роль кэшей процессора [...](#cpu_cache)-->
6.2 Стоимость переключения контекста потоков [...](#context_cost)</a>
6.3 Коротко о приоритетах потоков в Windows [...](#about_priority_in_windows)
6.3 Приоритеты потоков в Windows [...](#about_priority_in_windows)
7. Где можно и где нельзя создавать и уничтожать потоки [...](#bad_places_for_create_free_threads)
@ -1988,28 +1989,45 @@ end;
# 6. Планирование потоков <a name="os_scheduler"></a>
В разделе 1.3 были коротко сказано о том, что в операционной системе имеется присутствует системный планировщик задач, который отвечает за распределение процессорного времени между потоками. Я считаю, что читателю важно усвоить этот материал перед тем, как начать изучение такого сложного материала, как синхронизация между потоками.
В разделе 1.3 были коротко сказано о том, что в операционной системе имеется планировщик (scheduler), который отвечает за распределение процессорного времени между потоками. Я считаю, что читатель должен иметь хотя бы минимальное представление о работе планировщика. Благодаря этому усвоение такого сложного материала, как синхронизация между потоками, должно пройти легче.
# 6.1 Роль системного таймера <a name="os_timer"></a>
# 6.1 Роль системного таймера (system clock) <a name="os_timer"></a>
Как уже было сказано в разделе 1.3, системный планировщик автоматически выполняет перевод потока в спящий режим, как только поток истратит выделенный ему квант времен. Также поток может самостоятельно уйти в спящий режим, в тех случаях, если он выполнил свою работу, либо перешёл в режим ожидания завершения ввода/вывода. Каждый раз, когда поток переходит в спящий режим, происходит сохранение его контекста (сохраняется текущее состояние регистров процессорного ядра, на котором поток выполняется). При переводе потока в рабочий режим выполняется обратное действие – восстановление контекста потока (загрузка ранее сохраненных значений в регистры процессорного ядра).
Как уже было сказано в разделе 1.3, системный планировщик автоматически выполняет перевод потока в спящий режим, как только поток истратит выделенный ему квант времен (time slice), либо процессорное время потребуется потоку с более высоким приоритетом. Также поток может самостоятельно уйти в спящий режим, в тех случаях, если он выполнил свою работу, либо перешёл в режим ожидания завершения ввода/вывода, либо в режим ожидания объекта синхронизации. Каждый раз, когда поток переходит в спящий режим, происходит сохранение его контекста (сохраняется текущее состояние регистров процессорного ядра, на котором поток выполняется). При переводе потока в рабочий режим выполняется обратное действие – восстановление контекста потока (загрузка ранее сохраненных значений в регистры процессорного ядра).
Важно уточнить, что поток не может самостоятельно выйти из спящего режима. В этом ему помогает системный планировщик. Происходит это примерно так:
1) аппаратный системный таймер подаёт на процессор сигнал "прерывание от системного таймера";
2) в ОС запускается код обработки прерывания от системного таймера, который передаёт управление программе "системный планировщик";
3) системный планировщик производит анализ информации о запущенных и спящих потоках (скорее всего, у него имеется список всех потоков, запущенных в ОС, а в этом списке есть вся необходимая информация о потоках) и принимает решение и том, какой поток нужно разбудить, а какой наоборот перевести в спящий режим.
Важно уточнить, что поток не может самостоятельно выйти из спящего режима. Выход потока из спящего режима происходит в двух случаях:
а) другой поток произвёл вызов функции ОС, которая приостановила текущий поток и переключила контект на другой наиболее подходящий поток (таких функций много, в том числе `Sleep` и `SwitchToThread`);
б) в результате работы системного планировщика.
Частота срабатывания (разрешение) системного таймера составляет от 1 мс до 16 мс. Причем, по умолчанию используется значение 16 мс. Но это значение очень часто переопределяется программным способом (многие программы его меняют на 1 мс). На практике мы видим только одно из двух значений: 1 мс либо 16 мс. Для того, чтобы определить разрешение системного таймера, Вы можете воспользоваться утилитой Марка Руссиновича "Clockres.exe". Значение "Current timer interval" показывает разрешение системного таймера.
Запуск системного планировщика производится периодически при каждом срабатывании системного таймера (system clock). Раньше системный таймер представлял собой отдельную микросхему, которая выдавала на процессор сигнал прерывания приблизительно раз в 16 миллисекунд. Что представляет собой современный системный таймер, я не знаю. Вероятно, он встроен в процессор или эмулируется программно. Предлагаю не забивать голову этим вопросом, иначе можно потратить много дней на поиски этой информации.
Почему важно знать разрешение системного таймера? Очень просто: чем меньше разрешение системного таймера, тем чаще срабатывает прерывание от системного таймера и планировщик задач чаще выполняет мониторинг потоков. Но при этом сам планировщик задач создаёт более высокую нагрузку на процессор. Разумеется, чем больше разрешение системного таймера, тем реже срабатывает планировщик и нагрузка на процессор будет меньше.
Я предполагаю, что логика работы планировщика примерно такая:
1) системный таймер подаёт сигнал прерывания в процессорное ядро;
2) если в этот момент на ядре выполнялся какой-либо поток, то он прерывается. Вероятно, процессор самостоятельно анализирует приоритет потока и принимает решение о том, можно ли прервать его работу (вероятно он не будет прерывать работу потока с наивысшим базовым приоритетом);
3) в ОС запускается код обработки прерывания от системного таймера, который передаёт управление программе "системный планировщик";
4) системный планировщик производит анализ информации о запущенных и спящих потоках (он ведёт очередь потоков для каждого уровня приоритета) и принимает решение о том, какой поток нужно разбудить, а какой наоборот перевести в спящий режим с связи с окончанием выделенного ему кванта времени.
:information_source: Разрешение системного таймера влияет на функции `GetTickCount` и `GetTickCount64`, которые являются частью WinAPI. Предполагаю, что значение, которые возвращают эти функции, определяется в момент прерывания от системного таймера. Если разрешение системного таймера составляет 16 мс, то в течение 16 мс Вы можете вызывать одну из этих функций и каждый раз она будет возвращать Вам одно и то же значение. В связи с этим я не советую использовать эти функции для измерения интервалов времени (хотя вроде для этого они и были предназначены). Если Вас устраивает точность замеров 1 мс, то лучше используйте обычную функцию `Now`.
Повторюсь, что я не знаю точно, как работает данный механизм. Могу предположить, что при поступлении сигнала прерывания от системного таймера происходит прерывание сразу на всех ядрах процессора и запускается параллельно несколько копий планировщика, по одной на каждое ядро.
# 6.2 Роль кэшей процессора <a name="cpu_cache"></a>
Частота срабатывания (разрешение) системного таймера составляет от 1 мс до 16 мс. Причем, по умолчанию используется значение 16 мс. Но это значение очень часто переопределяется программным способом (некоторые программы его меняют на 1 мс). На практике приходится выдеть только одно из двух значений: 1 мс либо 16 мс. Для того, чтобы определить разрешение системного таймера, Вы можете воспользоваться утилитой Марка Руссиновича "Clockres.exe". Значение "Current timer interval" показывает разрешение системного таймера.
Важно знать, что ядро процессора не может работать с памятью ОЗУ напрямую (будем считать, что ОЗУ - это память, которая представлена платами ОЗУ, например DDR4). Ему доступны непосредственно только регистры и кэш первого уровня ядра.
Почему важно знать разрешение системного таймера? Очень просто: чем выше разрешение системного таймера (самое высокое при интервале 1 мс), тем чаще срабатывает прерывание от системного таймера и планировщик задач чаще выполняет мониторинг потоков. Но при этом сам планировщик задач создаёт более высокую нагрузку на процессор (аккумулятор в ноутбуке при этом будет быстрее разряжаться). Разумеется, чем меньше разрешение системного таймера (самое низкое при интервале 16 мс), тем реже срабатывает планировщик и нагрузка на процессор будет меньше.
Если процессор обращается к ячейке памяти, а её значения нет в кэше первого уровня ядра, то кэш первого уровня запросит эти данные у кэша второго уровня ядра. В свою очередь, если у кэша второго уровня ядра нет этих данных, то выполняется обращение к кэшу третьего уровня процессора. Если и у кэша третьего уровня этих данных нет, то он берёт их из памяти ОЗУ. Если же у кэша третьего уровня эти данные имеются, но он не уверен в их актуальности, то выполняется обращение к кэшам других ядер процессора.
:warning: **Внимание!** Не следует увеличивать частоту срабатывания системного таймера! Программы от этого работать быстрее не будут! Наоборот, общая производительность снизится (по имеющимся оценкам, более чем на 2%).
:information_source: **Частота срабатывания системного таймера сильно влияет на работу функции `Sleep`!** Если таймер срабатывает очень часто (1000 раз в секунду), то функция Sleep работает с максимальной точностью (плюс/минус 0.5 мс). Если таймер срабатывает раз в 16 мс, то и точность работы функции Sleep составит примерно 16 мс.
:information_source: Разрешение системного таймера **никак не влияет** на функции `GetTickCount` и `GetTickCount64`. Их точность соответствует максимальному интервалу системного таймера, т.е. примерно 16 мс. В связи с этим я не советую использовать эти функции для измерения интервалов времени (хотя вроде для этого они и были предназначены). Если Вас устраивает точность замеров 1 мс, то лучше используйте обычную функцию `Now`. Если требуется производить замеры в большей точностью, то используйте функцию `QueryPerformanceCounter`, либо более удобный модуль "TimeIntervals.pas", который ранее уже был рассмотрен.
# 6.2 Стоимость переключения контекста потоков <a name="context_cost></a>
Переключение контекста потока имеет весьма большую стоимость (несколько тысяч тактов ядра процессора). Она мало зависит от того, на каких ядрах процессора работает поток в тот или иной момент времени. Операции сохранения и восстановления состояния регистров при переключении контекста практически не играют никакой роли. Думаю, что и операция переключения между режимами процессора "user-mode" и "kernel-mode" большой роли не играют. Скорее всего наибольшее время занимает код, исполняемый операционной системной в режиме "kernel-mode", который анализирует состояние остальных потоков в системе, состояние объектов синхронизации ядра (в том числе мьютексов, эвентов, семафоров). Чем больше в системе запущенных потоков (работающих либо спящих) и чем больше объектов синхронизации ядра, тем больше времени будет занимать операция переключения контекста. А если к этому добавить ещё и срабатывание системного таймера 1000 раз в секунду, то код планировщика и код переключения контекста могут занять большую часть ресурсов процессора.
<!--# 6.2 Роль кэшей процессора <a name="cpu_cache"></a>
Важно знать, что ядро процессора не может работать с памятью ОЗУ напрямую (будем считать, что ОЗУ - это память, которая представлена платами ОЗУ, например DDR4). Ему доступны непосредственно только регистры и кэш первого уровня ядра L1 (иногда его называют "память ядра").
Если процессор обращается к ячейке памяти, а её значения нет в кэше первого уровня ядра, то кэш первого уровня запросит эти данные у кэша второго уровня ядра L2. В свою очередь, если у кэша второго уровня ядра нет этих данных, то выполняется обращение к кэшу третьего уровня процессора L3. Если и у кэша третьего уровня этих данных нет, то он берёт их из памяти ОЗУ. Если же у кэша третьего уровня эти данные имеются, но он не уверен в их актуальности, то выполняется обращение к кэшам других ядер процессора.
На самом деле, алгоритмы работы кэшей и механизм обеспечения согласованности (когерентности) данных между процессорами и их ядрами являются внутренней кухней процессора. Каждый производитель решает эти задачи по своему.
@ -2020,13 +2038,14 @@ end;
4. Кэш первого уровня ядра - это самая быстрая часть оперативной памяти.
5. Если код, который выполняется на ядре процессора, выполнил запись в оперативную память, это означает, что он выполнил запись в кэш первого уровня ядра. Вовсе не обязательно, что новое значение будет передано в ОЗУ. Решение об этом принимает кэш третьего уровня. Чем больше размер кэша третьего уровня, тем реже он обращается к памяти ОЗУ.
А при чём здесь планирование потоков? А вот причем! Каждый раз, когда планировщик решает разбудить поток, он принимает решение о том, на каком ядре процессора поток будет запущен. Если свободно ядро, на котором поток работал до перевода в спящий режим, то поток продолжит работать на том же ядре. В этом случае операция восстановления контекста потока будет наиболее дешёвой, поскольку вся необходимая информация скорее всего осталась в кэше первого уровня ядра. Если это ядро занято, то планировщик попытается найти другое свободное ядро процессора. Но в его кэше первого уровня отсутствуют данные, необходимые для восстановления контекста потока, поэтому потребуется больше времени для возобновления работы потока.
А при чём здесь планирование потоков? А вот причем! Каждый раз, когда планировщик решает разбудить поток, он принимает решение о том, на каком ядре процессора поток будет запущен. Если свободно ядро, на котором поток работал до перевода в спящий режим, то поток (вероятнее всего) продолжит работать на том же ядре. В этом случае операция восстановления контекста потока будет наиболее дешёвой, поскольку вся необходимая информация скорее всего осталась в кэше первого уровня ядра. Если это ядро занято, то планировщик попытается найти другое свободное ядро процессора. Но в его кэше первого уровня отсутствуют данные, необходимые для восстановления контекста потока, поэтому потребуется больше времени для возобновления работы потока.
В том случае, если планировщик не нашёл свободных ядер, то он анализирует приоритеты потоков. Если он обнаружит, что на одном из ядер выполняется поток с меньшим приоритетом, то он может прервать работу такого потока и запустить на этом ядре поток с большим приоритетом.
-->
# 6.3 Коротко о приоритетах потоков в Windows <a name="about_priority_in_windows"></a>
# 6.3 Приоритеты потоков в Windows <a name="about_priority_in_windows"></a>
При разработке многопоточной программы для Windows существует возможность назначать потоку относительный приоритет (свойство `TThread.Priority`). По умолчанию используется приоритет `tpNormal`. Тип `TThreadPriority` объявлен следующим образом:
При разработке многопоточной программы для Windows существует возможность назначать потоку уровень приоритета (свойство `TThread.Priority`). По умолчанию используется уровень приоритета `tpNormal`. Тип `TThreadPriority` объявлен следующим образом:
```pascal
{$IFDEF MSWINDOWS}
@ -2034,20 +2053,22 @@ end;
tpTimeCritical) platform;
{$ENDIF MSWINDOWS}
```
Обычно программисту нет смысла изменять приоритеты потоков. Приоритет потока никак не влияет на скорость исполнения программного кода. Приоритет потока никак не влияет на размер кванта времени (однако размер кванта времени зависит от того, является ли приложение активным или нет).
Обычно программисту нет смысла изменять уровни приоритетов потоков. Приоритет потока никак не влияет на скорость исполнения программного кода. Приоритет потока никак не влияет на размер кванта времени (однако размер кванта времени зависит от того, является ли приложение активным или нет).
:information_source: **Внимание!** У приложения, которое находится на переднем плане, длительность кванта времени увеличивается примерно в 3 раза (это не имеет отношения к базовому приоритету процесса). В том случае, если два приложения будут привязаны к одному ядру процессора, то одну и ту же вычислительную задачу быстрее (примерно в 3 раза) сможет решить приложение, которое находится на переднем плане. На моих компьютерах с Windows 7 длительность кванта времени у приложений на заднем плане (думаю, что и у служб тоже) составляет 32 мс, а у приложений на переднем плане - 96 мс.
:information_source: **Внимание!** У приложения, которое находится на переднем плане, длительность кванта времени увеличивается примерно в 3 раза (это не имеет отношения к классу приоритета процесса). В том случае, если два приложения будут привязаны к одному ядру процессора, то одну и ту же вычислительную задачу быстрее (примерно в 3 раза) сможет решить приложение, которое находится на переднем плане. На моих компьютерах с Windows 7 длительность кванта времени у приложений на заднем плане (думаю, что и у служб тоже) составляет 32 мс, а у приложений на переднем плане - 96 мс.
Относительный приоритет потока влияет на выделение процессорного времени как между потоками в рамках одного процесса, так и между потоками различных процессов.
Уровень приоритета потока влияет на выделение процессорного времени как между потоками в рамках одного процесса, так и между потоками различных процессов.
Изменять приоритет потока не имеет смысла, если выполняется задача, в которой основное время уходит на ожидание какого-либо события.
Изменять уровень приоритета потока не имеет смысла, если выполняется задача, в которой основное время уходит на ожидание какого-либо события.
Если в Вашей программе работают 2 потока, у одного приоритет `tpNormal`, а у второго `tpLower` и оба потока привязаны к одному и тому же ядру процессора, то первому потоку гораздо чаще будет предоставляться процессорное время (например, 98%). С другой стороны, если у этих же потоков не будет привязки к одному и тому же ядру, то они получат одинаковое процессорное время. Скорее всего, оба варианта – это не то, чего Вы хотите добиться при изменении относительного приоритета потока.
Если в Вашей программе работают 2 потока, у одного уровень приоритета `tpNormal`, а у второго `tpLower` и оба потока привязаны к одному и тому же ядру процессора, то первому потоку гораздо чаще будет предоставляться процессорное время (например, 98%). С другой стороны, если у этих же потоков не будет привязки к одному и тому же ядру, то они получат одинаковое процессорное время. Скорее всего, оба варианта – это не то, чего Вы хотите добиться при изменении уровня приоритета потока.
Существует 2 крайних уровня приоритетов: `tpIdle` и `tpTimeCritical`. Поток с приоритетом `tpIdle` получит процессорное время только в том случае, если нет других активных потоков с более высоким приоритетом. Поток с приоритетом `tpTimeCritical` будет забирать себе всё процессорное время, т.е. потоки с более низким приоритетом не получат квант времени, если выполняется поток с приоритетом `tpTimeCritical`. Это верно для одноядерного процессора. Если процессор многоядерный (сейчас это обычная ситуация), то, скорее всего, будут выполняться одновременно и поток с приоритетом `tpIdle` и поток с приоритетом `tpTimeCritical`.
:information_source: **Внимание!** *В каталоге CalcTimeQuant находится программа, позволяющая производить эксперименты с приоритетами и замеры длительности квантов времени.*
:information_source: Помимо терминов "уровень приоритета потока" и "класс приоритета процесса", существует также термин "базовый приоритет потока". Как определить базовый приоритет потока, смотрите в [таблице приоритетов](https://docs.microsoft.com/en-us/windows/win32/procthread/scheduling-priorities).
# 7. Где можно и где нельзя создавать и уничтожать потоки <a name="bad_places_for_create_free_threads"></a>
Данные замечания актуальны при разработке программ под Windows. Существует ряд мест, где не следует создавать либо уничтожать потоки.