Как происходит переключение контекста процесса
2.2.5 Переключение контекста
2.2.5 Переключение контекста
Смена выполняемого процесса вместе с соответствующими действиями называется переключением контекста.
В случае типичного кругового алгоритма, при изменение статуса процесса планировщик ОС выполняет две операции:
- Инициализирует системный таймер, чтобы сгенерировать прерывание по истечению определённого промежутка времени
- Проводит переключение контекста
Как происходит переключение контекста процесса
Как уже говорилось ранее, ядро сохраняет контекст процесса, помещая в стек новый контекстный уровень. В частности, это имеет место, когда система получает прерывание, когда процесс вызывает системную функцию или когда ядро выполняет переключение контекста. Каждый из этих случаев подробно рассматривается в этом разделе.
6.4.1 Прерывания и особые ситуации
- Сохраняет текущий регистровый контекст выполняющегося процесса и создает в стеке (помещает в стек) новый контекстный уровень.
- Устанавливает «источник» прерывания, идентифицируя тип прерывания (например, прерывание по таймеру или от диска) и номер устройства, вызвавшего прерывание (например, если прерывание вызвано дисковым запоминающим устройством). При возникновении прерывания система получает от машины число, которое использует в качестве смещения в таблице векторов прерывания. Содержимое векторов прерывания в разных машинах различно, но, как правило, в них хранится адрес программы обработки прерывания, соответствующей источнику прерывания, и указывается путь поиска параметра для программы. В качестве примера рассмотрим таблицу векторов прерывания, приведенную на Рисунке 6.9. Если источником прерывания явился терминал, ядро получает от аппаратуры номер прерывания, равный 2, и вызывает программу обработки прерываний от терминала, именуемую ttyintr.
Номер прерывания Программа обработки прерывания 0 clockintr 1 diskintr 2 ttyintr 3 devintr 4 softintr 5 otherintr
- Вызов программы обработки прерывания. Стек ядра для нового контекстного уровня, если рассуждать логически, должен отличаться от стека ядра предыдущего контекстного уровня. В некоторых разработках стек ядра текущего процесса используется для хранения элементов, соответствующих программам обработки прерываний, в других разработках эти элементы хранятся в глобальном стеке прерываний, благодаря чему обеспечивается возврат из программы без переключения контекста.
- Программа завершает свою работу и возвращает управление ядру. Ядро исполняет набор машинных команд по сохранению регистрового контекста и стека ядра предыдущего контекстного уровня в том виде, который они имели в момент прерывания, после чего возобновляет выполнение восстановленного контекстного уровня. Программа обработки прерываний может повлиять на поведение процесса, поскольку она может внести изменения в глобальные структуры данных ядра и возобновить выполнение приостановленных процессов. Однако, обычно процесс продолжает выполняться так, как если бы прерывание никогда не происходило.
алгоритм inthand /* обработка прерываний */ входная информация: отсутствует выходная информация: отсутствует
На Рисунке 6.10 кратко изложено, каким образом ядро обрабатывает прерывания. С помощью использования в отдельных случаях последовательности машинных операций или микрокоманд на некоторых машинах достигается больший эффект по сравнению с тем, когда все операции выполняются программным обеспечением, однако имеются узкие места, связанные с числом сохраняемых контекстных уровней и скоростью выполнения машинных команд, реализующих сохранение контекста. По этой причине определенные операции, выполнения которых требует реализация системы UNIX, являются машинно-зависимыми.
На Рисунке 6.11 показан пример, в котором процесс запрашивает выполнение системной функции (см. следующий раздел) и получает прерывание от диска при ее выполнении. Запустив программу обработки прерывания от диска, система получает прерывание по таймеру и вызывает уже программу обработки прерывания по таймеру. Каждый раз, когда система получает прерывание (или вызывает системную функцию), она создает в стеке новый контекстный уровень и сохраняет регистровый контекст предыдущего уровня.
6.4.2 Взаимодействие с операционной системой через вызовы системных функций
Такого рода взаимодействие с ядром было предметом рассмотрения в предыдущих главах, где шла речь об обычном вызове функций. Очевидно, что обычная последовательность команд обращения к функции не в состоянии переключить выполнения процесса с режима задачи на режим ядра. Компилятор с языка Си использует библиотеку функций, имена которых совпадают с именами системных функций, иначе ссылки на системные функции в пользовательских программах были бы ссылками на неопределенные имена. В библиотечных функциях обычно исполняется команда, переводящая выполнение процесса в режим ядра и побуждающая ядро к запуску исполняемого кода системной функции. В дальнейшем эта команда именуется «внутренним прерыванием операционной системы». Библиотечные процедуры исполняются в режиме задачи, а взаимодействие с операционной системой через вызов системной функции можно определить в нескольких словах как особый случай программы обработки прерывания. Библиотечные функции передают ядру уникальный номер системной функции одним из машинно-зависимых способов — либо как параметр внутреннего прерывания операционной системы, либо через отдельный регистр, либо через стек — а ядро таким образом определяет тип вызываемой функции.
Рисунок 6.11. Примеры прерываний
Обрабатывая внутреннее прерывание операционной системы, ядро по номеру системной функции ведет в таблице поиск адреса соответствующей процедуры ядра, то есть точки входа системной функции, и количества передаваемых функции параметров (Рисунок 6.12). Ядро вычисляет адрес (пользовательский) первого параметра функции, прибавляя (или вычитая, в зависимости от направления увеличения стека) смещение к указателю вершины стека задачи (аналогично для всех параметров функции). Наконец, ядро копирует параметры задачи в пространство процесса и вызывает соответствующую процедуру, которая выполняет системную функцию. После исполнения процедуры ядро выясняет, не было ли ошибки. Если ошибка была, ядро делает соответствующие установки в сохраненном регистровом контексте задачи, при этом в регистре PS обычно устанавливается бит переноса, а в нулевой регистр заносится номер ошибки. Если при выполнении системной функции не было ошибок, ядро очищает в регистре PS бит переноса и заносит возвращаемые функцией значения в регистры 0 и 1 в сохраненном регистровом контексте задачи. Когда ядро возвращается после обработки внутреннего прерывания операционной системы в режим задачи, оно попадает в следующую библиотечную инструкцию после прерывания. Библиотечная функция интерпретирует возвращенные ядром значения и передает их программе пользователя.
алгоритм syscall /* алгоритм запуска системной функции */ входная информация: номер системной функции выходная информация: результат системной функции < найти запись в таблице системных функций, соответствую- щую указанному номеру функции; определить количество параметров, передаваемых функции; скопировать параметры из адресного пространства задачи в пространство процесса; сохранить текущий контекст для аварийного завершения (см. раздел 6.44); запустить в ядре исполняемый код системной функции; если (во время выполнения функции произошла ошибка) < установить номер ошибки в нулевом регистре сохра- ненного регистрового контекста задачи; включить бит переноса в регистре PS сохраненного регистрового контекста задачи; >в противном случае занести возвращаемые функцией значения в регистры 0 и 1 в сохраненном регистровом контексте задачи; >
В качестве примера рассмотрим программу, которая создает файл с разрешением чтения и записи в него для всех пользователей (режим доступа 0666) и которая приведена в верхней части Рисунка 6.13. Далее на рисунке изображен отредактированный фрагмент сгенерированного кода программы после компиляции и дисассемблирования (создания по объектному коду эквивалентной программы на языке ассемблера) в системе Motorola 68000. На Рисунке 6.14 изображена конфигурация стека для системной функции создания. Компилятор генерирует программу помещения в стек задачи двух параметров, один из которых содержит установку прав доступа (0666), а другой — переменную «имя файла» (**). Затем из адреса 64 процесс вызывает библиотечную функцию creat (адрес 7a), аналогичную соответствующей системной функции. Адрес точки возврата из функции 6a, этот адрес помещается процессом в стек. Библиотечная функция creat засылает в регистр 0 константу 8 и исполняет команду прерывания (trap), которая переключает процесс из режима задачи в режим ядра и заставляет его обратиться к системной функции. Заметив, что процесс вызывает системную функцию, ядро выбирает из регистра 0 номер функции (8) и определяет таким образом, что вызвана функция creat. Просматривая внутреннюю таблицу, ядро обнаруживает, что системной функции creat необходимы два параметра; восстанавливая регистровый контекст предыдущего уровня, ядро копирует параметры из пользовательского пространства в пространство процесса. Процедуры ядра, которым понадобятся эти параметры, могут найти их в определенных местах адресного пространства процесса. По завершении исполнения кода функции creat управление возвращается программе обработки обращений к операционной системе, которая проверяет, установлено ли поле ошибки в пространстве процесса (то есть имела ли место во время выполнения функции ошибка); если да, программа устанавливает в регистре PS бит переноса, заносит в регистр 0 код ошибки и возвращает управление ядру. Если ошибок не было, в регистры 0 и 1 ядро заносит код завершения. Возвращая управление из программы обработки обращений к операционной системе в режим задачи, библиотечная функция проверяет состояние бита переноса в регистре PS (по адресу 7): если бит установлен, управление передается по адресу 13c, из нулевого регистра выбирается код ошибки и помещается в глобальную переменную errno по адресу 20, в регистр 0 заносится -1, и управление возвращается на следующую после адреса 64 (где производится вызов функции) команду. Код завершения функции имеет значение -1, что указывает на ошибку в выполнении системной функции. Если же бит переноса в регистре PS при переходе из режима ядра в режим задачи имеет нулевое значение, процесс с адреса 7 переходит по адресу 86 и возвращает управление вызвавшей программе (адрес 64); регистр 0 содержит возвращаемое функцией значение.
char name[] = «file»; main()
Фрагменты ассемблерной программы, сгенерированной в системе Motorola 68000 Адрес Команда - - # текст главной программы - 58: mov &Ox1b6,(%sp) # поместить код 0666 в стек 5e: mov &Ox204,-(%sp) # поместить указатель вершины # стека и переменную "имя файла" # в стек 64: jsr Ox7a # вызов библиотечной функции # создания файла - - # текст библиотечной функции создания файла 7a: movq &Ox8,%d0 # занести значение 8 в регистр 0 7c: trap &Ox0 # внутреннее прерывание операци- # онной системы 7e: bcc &Ox6 # если бит переноса очищен, # перейти по адресу 86 80: jmp Ox13c # перейти по адресу 13c 86: rts # возврат из подпрограммы - - # текст обработки ошибок функции 13c: mov %d0,&Ox20e # поместить содержимое регистра # 0 в ячейку 20e (переменная # errno) 142: movq &-Ox1,%d0 # занести в регистр 0 константу # -1 144: mova %d0,%a0 146: rts # возврат из подпрограммы
Рисунок 6.14. Конфигурация стека для системной функции creat
Несколько библиотечных функций могут отображаться на одну точку входа в список системных функций. Каждая точка входа определяет точные синтаксис и семантику обращения к системной функции, однако более удобный интерфейс обеспечивается с помощью библиотек. Существует, например, несколько конструкций системной функции exec, таких как execl и execle, выполняющих одни и те же действия с небольшими отличиями. Библиотечные функции, соответствующие этим конструкциям, при обработке параметров реализуют заявленные свойства, но в конечном итоге, отображаются на одну и ту же функцию ядра.
(**) Очередность, в которой компилятор вычисляет и помещает в стек параметры функции, зависит от реализации системы.
6.4.3 Переключение контекста
Если обратиться к диаграмме состояний процесса (Рисунок 6.1), можно увидеть, что ядро разрешает производить переключение контекста в четырех случаях: когда процесс приостанавливает свое выполнение, когда он завершается, когда он возвращается после вызова системной функции в режим задачи, но не является наиболее подходящим для запуска, или когда он возвращается в режим задачи после завершения ядром обработки прерывания, но так же не является наиболее подходящим для запуска. Как уже было показано в главе 2, ядро поддерживает целостность и согласованность своих внутренних структур данных, запрещая произвольно переключать контекст. Прежде чем переключать контекст, ядро должно удостовериться в согласованности своих структур данных: то есть в том, что сделаны все необходимые корректировки, все очереди выстроены надлежащим образом, установлены соответствующие блокировки, позволяющие избежать вмешательства со стороны других процессов, что нет излишних блокировок и т.д. Например, если ядро выделяет буфер, считывает блок из файла и приостанавливает выполнение до завершения передачи данных с диска, оно оставляет буфер заблокированным, чтобы другие процессы не смогли обратиться к буферу. Но если процесс исполняет системную функцию link, ядро снимает блокировку с первого индекса перед тем, как снять ее со второго индекса, и тем самым предотвращает возникновение тупиковых ситуаций (взаимной блокировки).
Ядро выполняет переключение контекста по завершении системной функции exit, поскольку в этом случае больше ничего не остается делать. Кроме того, переключение контекста допускается, когда процесс приостанавливает свою работу, поскольку до момента возобновления может пройти немало времени, в течение которого могли бы выполняться другие процессы. Переключение контекста допускается и тогда, когда процесс не имеет преимуществ перед другими процессами при исполнении, с тем, чтобы обеспечить более справедливое планирование процессов: если по выходе процесса из системной функции или из прерывания обнаруживается, что существует еще один процесс, который имеет более высокий приоритет и ждет выполнения, то было бы несправедливо оставлять его в ожидании.
Процедура переключения контекста похожа на процедуры обработки прерываний и обращения к системным функциям, если не считать того, что ядро вместо предыдущего контекстного уровня текущего процесса восстанавливает контекстный уровень другого процесса. Причины, вызвавшие переключение контекста, при этом не имеют значения. На механизм переключения контекста не влияет и метод выбора следующего процесса для исполнения.
1. Принять решение относительно необходимости переклю- чения контекста и его допустимости в данный момент. 2. Сохранить контекст "прежнего" процесса. 3. Выбрать процесс, наиболее подходящий для исполнения, используя алгоритм диспетчеризации процессов, приве- денный в главе 8. 4. Восстановить его контекст.
Текст программы, реализующей переключение контекста в системе UNIX, из всех программ операционной системы самый трудный для понимания, ибо при рассмотрении обращений к функциям создается впечатление, что они в одних случаях не возвращают управление, а в других — возникают непонятно откуда. Причиной этого является то, что ядро во многих системных реализациях сохраняет контекст процесса в одном месте программы, но продолжает работу, выполняя переключение контекста и алгоритмы диспетчеризации в контексте «прежнего» процесса. Когда позднее ядро восстанавливает контекст процесса, оно возобновляет его выполнение в соответствии с ранее сохраненным контекстом. Чтобы различать между собой те случаи, когда ядро восстанавливает контекст нового процесса, и когда оно продолжает исполнять ранее сохраненный контекст, можно варьировать значения, возвращаемые критическими функциями, или устанавливать искусственным образом текущее значение счетчика команд.
На Рисунке 6.16 приведена схема переключения контекста. Функция save_context сохраняет информацию о контексте исполняемого процесса и возвращает значение 1. Кроме всего прочего, ядро сохраняет текущее значение счетчика команд (в функции save_context) и значение 0 в нулевом регистре при выходе из функции. Ядро продолжает исполнять контекст «прежнего» процесса (A), выбирая для выполнения следующий процесс (B) и вызывая функцию resume_context для восстановления его контекста. После восстановления контекста система выполняет процесс B; прежний процесс (A) больше не исполняется, но он оставил после себя сохраненный контекст. Позже, когда будет выполняться переключение контекста, ядро снова изберет процесс A (если только, разумеется, он не был завершен). В результате восстановления контекста A ядро присвоит счетчику команд то значение, которое было сохранено процессом A ранее в функции save_context, и возвратит в регистре 0 значение 0. Ядро возобновляет выполнение процесса A из функции save_context, пусть даже при выполнении программы переключения контекста оно не добралось еще до функции resume_context. В конечном итоге, процесс A возвращается из функции save_context со значением 0 (в нулевом регистре) и возобновляет выполнение после строки комментария «возобновление выполнение процесса начинается отсюда».
if (save_context()) /* сохранение контекста выполняющегося процесса */ < /* выбор следующего процесса для выполнения */ - - - resume_context(new_process); /* сюда программа не попадает ! */ >/* возобновление выполнение процесса начинается отсюда */
6.4.4 Сохранение контекста на случай аварийного завершения
Существуют ситуации, когда ядро вынуждено аварийно прерывать текущий порядок выполнения и немедленно переходить к исполнению ранее сохраненного контекста. В последующих разделах, где пойдет речь о приостановлении выполнения и о сигналах, будут описаны обстоятельства, при которых процессу приходится внезапно изменять свой контекст; в данном же разделе рассматривается механизм исполнения предыдущего контекста. Алгоритм сохранения контекста называется setjmp, а алгоритм восстановления контекста — longjmp (***). Механизм работы алгоритма setjmp похож на механизм функции save_context, рассмотренный в предыдущем разделе, если не считать того, что функция save_context помещает новый контекстный уровень в стек, в то время как setjmp сохраняет контекст в пространстве процесса и после выхода из него выполнение продолжается в прежнем контекстном уровне. Когда ядру понадобится восстановить контекст, сохраненный в результате работы алгоритма setjmp, оно исполнит алгоритм longjmp, который восстанавливает контекст из пространства процесса и имеет, как и setjmp, код завершения, равный 1.
6.4.5 Копирование данных между адресным пространством системы и адресным пространством задачи
До сих пор речь шла о том, что процесс выполняется в режиме ядра или в режиме задачи без каких-либо перекрытий (пересечений) между режимами. Однако, при выполнении большинства системных функций, рассмотренных в последней главе, между пространством ядра и пространством задачи осуществляется пересылка данных, например, когда идет копирование параметров вызываемой функции из пространства задачи в пространство ядра или когда производится передача данных из буферов ввода-вывода в процессе выполнения функции read. На многих машинах ядро системы может непосредственно ссылаться на адреса, принадлежащие адресному пространству задачи. Ядро должно убедиться в том, что адрес, по которому производится запись или считывание, доступен, как будто бы работа ведется в режиме задачи; в противном случае произошло бы нарушение стандартных методов защиты и ядро, пусть неумышленно, стало бы обращаться к адресам, которые находятся за пределами адресного пространства задачи (и, возможно, принадлежат структурам данных ядра). Поэтому передача данных между пространством ядра и пространством задачи является «дорогим предприятием», требующим для своей реализации нескольких команд.
fubyte: # пересылка байта из # пространства задачи prober $3,$1,*4(ap) # байт доступен? beql eret # нет movzbl *4(ap),r0 ret eret: mnegl $1,r0 # возврат ошибки (-1) ret
На Рисунке 6.17 показан пример реализованной в системе VAX программы пересылки символа из адресного пространства задачи в адресное пространство ядра. Команда prober проверяет, может ли байт по адресу, равному (регистр указателя аргумента + 4), быть считан в режиме задачи (режиме 3), и если нет, ядро передает управление по адресу eret, сохраняет в нулевом регистре -1 и выходит из программы; при этом пересылки символа не происходит. В противном случае ядро пересылает один байт, находящийся по указанному адресу, в регистр 0 и возвращает его в вызывающую программу. Пересылка 1 символа потребовала пяти команд (включая вызов функции с именем fubyte).
(***) Эти алгоритмы не следует путать с имеющими те же названия библиотечными функциями, которые могут вызываться непосредственно из пользовательских программ (см. [SVID 85]). Однако действие этих функций похоже.
Переключение контекста
В вычислениях , A переключение контекста является процессом сохранения состояния в процессе или нити , так что он может быть восстановлен и возобновить выполнение на более позднем этапе. Это позволяет нескольким процессам совместно использовать один центральный процессор (ЦП) и является важной особенностью многозадачной операционной системы .
Точное значение фразы «переключение контекста» варьируется. В контексте многозадачности это относится к процессу сохранения состояния системы для одной задачи, так что эта задача может быть приостановлена, а другая задача возобновлена. Переключение контекста также может происходить в результате прерывания , например, когда задаче требуется доступ к дисковой памяти , освобождая процессорное время для других задач. Некоторые операционные системы также требуют переключения контекста для перехода между пользовательским режимом и задачами режима ядра . Процесс переключения контекста может отрицательно сказаться на производительности системы. [1] : 28
Переключение контекста обычно требует больших вычислительных ресурсов, и большая часть разработки операционных систем направлена на оптимизацию использования переключений контекста. Переключение с одного процесса на другой требует определенного времени для выполнения администрирования — сохранения и загрузки регистров и карт памяти, обновления различных таблиц и списков и т. Д. Что на самом деле задействовано в переключении контекста, зависит от архитектур, операционных систем и количество совместно используемых ресурсов (потоки, принадлежащие одному процессу, совместно используют много ресурсов по сравнению с несвязанными не взаимодействующими процессами. Например, в ядре Linux переключение контекста включает переключение регистров, указателя стека (это типичный регистр указателя стека ), счетчик программ , промыв резервный буфер трансляции (TLB) и загрузка таблицы страниц следующего выполняемого процесса (если старый процесс не разделяет память с новым). [2] [3] Кроме того, аналогичное переключение контекста происходит между пользовательскими потоками , особенно зелеными потоками , и часто очень легкое, сохраняя и восстанавливая минимальный контекст. В крайних случаях, таких как переключение между горутинами в Go , переключение контекста эквивалентно выходу сопрограммы , что лишь незначительно дороже, чем вызов подпрограммы .
Чаще всего в рамках некоторой схемы планирования один процесс должен быть отключен от ЦП, чтобы другой процесс мог работать. Это переключение контекста может быть инициировано процессом, который становится неработоспособным, например, при ожидании завершения операции ввода-вывода или синхронизации . В системе с упреждающей многозадачностью планировщик также может отключать процессы, которые еще могут выполняться. Чтобы предотвратить нехватку процессорного времени другим процессам, упреждающие планировщики часто настраивают прерывание по таймеру, которое срабатывает, когда процесс превышает свой временной интервал . Это прерывание гарантирует, что планировщик получит контроль над переключением контекста.
Современные архитектуры управляются прерываниями . Это означает, что если ЦП запрашивает данные, например, с диска, ему не нужно ждать, пока чтение закончится; он может отправить запрос (устройству ввода-вывода) и продолжить выполнение другой задачи. Когда чтение закончено, CPU может быть прерван (в данном случае аппаратным обеспечением, которое отправляет запрос прерывания на PIC ) и представлен с чтением. Для прерываний устанавливается программа, называемая обработчиком прерывания , и именно обработчик прерывания обрабатывает прерывание с диска.
Algo & DMA: технологии биржевой торговли
Биржевые технологии, алгоритмическая торговля, Java и программирование

Многозадачность операционной системы и low-latency
На современной серверной машине low-latency процесс работает на многозадачной операционной системе. Это может быть Solaris, чаще Linux, FreeBSD или Windows Server. Помимо нашего процесса в ОС работают еще и другие процессы, которым ОС тоже должна выделять машинное время. Собственно потому ОС и называется многозадачной.
В данной статье пойдет речь о том, как многозадачная операционная система и особенности ее архитектуры влияют на исполнение нашего процесса, и как мы можем побороть это влияние с целью достижения low-latency.
Переключение контекста (context switch)
Конкретный процесс работает определенное время до тех пор пока:
- не произойдет вызов операции ввода-вывода (i/o operation). Тогда процесс останавливается и ждет окончания — поступления данных. Это может быть ожидание очередной порции байтов из сети или загрузка с диска каких-то данных или страницы памяти (page fault), или окончания записи данных в систему вывода: это может быть запись данных в сеть или запись их на диск (page swap);
- не истечет выделенное ядром ОС время (time slice), после чего ядро приостанавливает исполнение процесса. Такое переключение происходит примерно каждые 20~50 миллисекунд.
В каждый конкретный момент времени процессор способен выполнять код только одного процесса (о многоядерных процессорах мы поговорим чуть позднее). За переключение процессов/потоков в современной многозадачной ОС отвечает специальный модуль ядра, который называется «планировщик» (scheduler).
Следуя определенным правилам он формирует очередь из ожидающих процессов/потоков и при окончании time slice одного процесса выбирает следующий процесс из очереди, выделяет ему определенный time slice и запускает его на исполнение. Для переключения процессов, надо:
- остановить процесс
- сохранить где-то его текущее состояние
- загрузить состояние следующего процесса
- начать исполнение следующего процесса с той точки, где он был недавно остановлен
Состояние процесса в конкретный момент времени называется «контекстом» (context). Это состояние описывается значениями регистров и счетчиков процессора в данный момент времени. Если эти все значения сохранить, а потом позже их загрузить, то процесс даже не поймет и не заметит, что его останавливали на какое-то время, он продолжит работать так, словно никакой остановки и не было. Этот процесс называется «переключением контекста» (context switch).
Переключаясь между процессами через определенные очень короткие промежутки времени, сохраняя и восстанавливая контекст каждого процесса, операционная система создает иллюзию у пользователя, что несколько процессов работают параллельно и одновременно, хотя на самом деле в каждый конкретный момент времени работает только один процесс. Так собственно и работает многозадачность в многозадачной операционной системе на однопроцессорной машине.
В Windows и Solaris потоки поддерживаются по-другому, поэтому речь о них идти не будет. В Linux, если наше приложение — многопоточное, каждый поток для операционной системы является отдельным особым процессом. Переключение между потоками приложения для Linux выглядит точно также, как переключение между обычными процессами с одной существенной разницей. Context switch между потоками одного приложения выполняется быстрее, чем context switch между двумя независимыми процессами. Это связано с тем, что все потоки одного приложения имеют доступ ко всей памяти данного процесса, следовательно при переключении контекста не надо менять контекст памяти, а достаточно только поменять контекст стека. На это тратится намного меньше времени. Так что, если в очереди на исполнение процессы в планировщике стоят так:
proc1:thread1
proc1:thread2
proc2:thread7
proc1:thread6
то переключение контекста proc1:thread1 -> proc1:thread2 осуществится быстрее, чем переключение proc2:thread7 -> proc1:thread6.
Переключение контекста на многоядерных и многопроцессорных машинах
Ушло в прошлое время одноядерных процессоров. Сейчас все процессоры выпускаются в многоядерном варианте. По сути в одном корпусе процессора вы получается сразу несколько CPU, которые могут выполнять код одновременно независимо друг от друга. Если одноядерному процессору надо было быстро переключаться между процессорами для создания иллюзии их параллельного одновременного исполнения, то на многоядерном процессоре в каждый конкретный момент времени может исполняться несколько процессов одновременно, параллельно, по-настоящему без всяких иллюзий.
Машины серверного класса имеют два и более процессоров для обеспечения надежности. Причем каждый процессор еще и является многоядерным. Например, 2х-процессорная машина с 2-мя 16 ядерными процессорами способна одновременно, параллельно выполнять 32 процесса/потока. А если на процессорах включена функция гиперпоточности — то 64 процесса/потока! 20 лет назад для достижения такого результата нам бы понадобилась машина с 64 процессорами класса Pentium. И 20 лет назад такая машина считалась бы суперкомпьютером, стоила бы миллионы американских долларов и доступна была бы только оборонке или богатому научному учреждению.
Планировщик
Пришествие многоядерных процессоров и многопроцессоных машин потребовало внесения изменений и в алгоритмы планировщика операционной системы. Теперь планировщику нужно планировать исполнение процессов так, чтобы одни ядра процессора не простаивали, в то время как другие — работают со 100% загрузкой.
С самой первой версии Linux 1991 года вплоть до версии 2.4 планировщик был прост, понятен и тривиален, но плохо масштабировался при большом количестве процессов и при работе на многопроцессорных машинах. В версии ядра 2.5 была предпринята попытка создания нового планировщика под названием O(1), который работал намного лучше, повторял в чем-то работу планировщиков других UNIX-систем, но все равно имел свои недостатки. В версии ядра 2.6 были предприняты попытки написать еще один планировщик, который бы работал лучше, чем O(1). Этот эксперимент завершился в версии ядра 2.6.23, когда был представлен планировщик Completely Fair Scheduler (или сокращенно CFS).
Перенос потока с одного ядра на другое
Планировщик может перебрасывать процесс/поток перед его новым запуском с одного ядра на другой, или даже с одного процессора на другой, если в машине несколько многоядерных процессоров.
Например, дана двухпроцессорная машина с двумя 16 ядерными процессорами. Нумерация ядер в Linux будет выглядеть так CPU0, … CPU7 — ядра первого процессора, CPU8 … CPU15 — ядра второго процессора. Скажем, наш главный процесс работает изначально на CPU0 (т.е. первом ядре первого процессора). Это процесс порождает 6 потоков. Обычно операционная система пытается каждый поток запустить на отдельном ядре, т.е. все 7 потоков (родительский и 6 порожденных) займут ядра с CPU0 до CPU6. Но потом планировщик будет останавливать эти потоки, чтобы дать на каждом ядре исполнится коду других потоков. Если мы посмотрим на наше приложение скажем через 15 минут, мы заметим, что наши потоки «разбрелись» по всем доступным ядрам процессора: родительский оказался на CPU5, а какой-то из порожденных потоков — на CPU15 (т.е. на последнем ядре второго процессора).
Как это скажется на производительности?
Ивалидация кэша
Каждое ядро процессора оснащено кэшем первого (L1) и второго уровней (L2), а сам процессор имеет общий кэш третьего уровня (L3), которым пользуются все ядра данного процессора. В кэше хранится копия данных, недавно извлеченная из памяти. Если процесс был остановлен планировщиком и потом запущен на другом ядре, кэши этого ядра не будут иметь этой копии данных, так как на этом ядре они еще не извлекались. А данные в кэше, оставшиеся от предыдущего потока, ему не нужны.
Первым делом поток обратится к кэшам за данными, не найдет их так и произойдет событие, которое называется cache miss процессов остановится, полезет в кэш L1, потом в кэш L2, потом в кэш L3, потом в память, извлечет из нее данные, поместит их в кэш, и только тогда продолжит исполнение процесса. На это требуется определенное время. Это все приведет к небольшой задержке. На графиках производительности это все будет выглядеть как jitter.
Если потом поток будет снова перенесен планировщиком на новое ядро, вся история повторится, это снова приведет к задержке. Так как переключение процессов на современных компьютерах происходит каждые 20-50 миллисекунд, мы будем каждые 20-50 миллисекунд получать задержку только по той причине, что планировщик запускает наш процесс всякий раз на каком-то другом ядре. Хорошо, если данные, с которыми предстоит процессу работать окажутся в кэше хотя бы третьего уровня (L3), тогда задержка будет несколько десятков наносекнуд, а если в памяти? А если в той странице памяти, которая отсутствует в физической памяти и должна быть прочитана с диска (page fault)?
Система предсказания переходов
Подсистема предсказания переходов (branch prediction) все еще может хранить информацию о предыдущих исполнениях этого потока. Значит, когда этот же поток снова начинает исполняться на этом же ядре, процессору не придется собирать эту информацию с нуля.
Affinity и Isolation
В полном идеале хорошо было бы, чтобы каждый конкретный поток не делил ядро с другим потоком, т.е. количество активных потоков было бы равно количеству выделенных для процесса ядер. Как минимум было бы хорошо, чтобы каждый поток работал на своем ядре и не переносился планировщиком на другое ядро. Для решения этой проблемы есть несколько инструментов.
Affinity
Первый инструмент называется affinity. Дословно — «приклеивание» процесса определенному ядру. Об этом приеме можно найти достаточно подробное описание в Википедии. Благодаря affinity планировщик точно знает, что данный процесс после «пробуждения» должен работать только на этом ядре или ядрах, и ни на каких других.
Например, вот как функция affinity выглядит в Windows 10. Обратите внимание, что так мы управляем affinity процесса, но не потоков этого процесса:


На Linux привязки процесса к процессору можно добиться с помощью утилиты taskset. Более тонкая привязка какого-то потока к определенному ядру уже осуществляется:
- программно через JNI/C++: например, как это сделано в OpenHFT/ThreadAffinity
- с помощью утилит isocpu и cgroups.
Первый вариант предпочтительнее, так как при перезапуске процесса, не требуется выстраивать affinity вручную. С помощью конфигурационного файла можно определить affinity каждого потока и при запуске эти настройки автоматически «разбросают» потоки по ядрам.
Isolation
Второй инструмент — изоляция ядер (isolation). Мало приклеить процесс к определенному ядру, надо еще сообщить планировщику, чтобы он не запускал на этом ядре другие процессы, которые могут «загрязнить» кэши процесссора своими данными. На Linux изоляция процессора под определенную задачу осуществляется с помощью утилиты isolcpus.
На двухпроцессорной машине можно отвести все ядра одного процессора под low-latency приложение, а все сопутствующие сервисы и приложения операционной системы — запускать на втором процессоре. Так с помощью affinity и изоляции в полном распоряжении вашего low-latency приложения окажется 16 ядер одного процессора. Если в вашем приложении 16 потоков, то каждый поток беспрерывно будет исполняться на своем ядре и не будет приводить к переключению контекстов.
Paging и Page Fault
Процесс в память загружается не целиком, в памяти процесса в конкретный момент находится только несколько сегментов кода по 4 килобайта каждый. Если ваш код в процессе исполнения делает переход на часть кода, которого нет в этих страницах, происходит так называемый «page fault», исполнение программы приостанавливается, ядро дозагружает нужную страницу с диска и потом исполнение продолжается дальше.
Swapping
Если все работающие процессы не помещаются в памяти, операционная система запускает механизм свопа: останавливает неактивные процессы и выгружает из целиком на диск. В случае если процесс снова надо вернуть в память и выполнить его, происходит обратная операция: весь процесс загружается в память и продолжает свое исполнение как если бы он там все время находился. Выгрузка процесса на диск и загрузка его с диска в память — очень дорогие операции. Всякий, кто когда-либо работал, например с Windows, на маленьком объеме памяти помнит, как при переключении между задачами система буквально замирала, а жесткий диск неистово дёргался.
Paging и Swapping схожи друг с другом, но это разные функции.
Как это применить к производительности
Из вышесказанного можно сделать следующие выводы:
- уменьшить количество работающих процессов, отключить все ненужные процессы и понизить приоритет тех, которые отключить нельзя. В идеале low-latency приложение должно работать на машине одно, и не конкурировать с другими low-latency процессами за ресурсы.
- не допускать никакого свопа процессов, особенно нашего low-latency процесса — у операционной системы всегда должно быть достаточно свободной памяти для работы в течение всего операционного дня
- использовать оптимизированный планировщик операционной системы, который более эффективно планирует исполнение процессов, так как поставляемый по умолчанию — слишком общ. Разные планировщики по-разному определяют приоритеты процессов и по разному определяют, какой процесс должен исполняться следующим
- увеличить размеры страниц памяти, чтобы процесс загружал в память большие куски себя и не лез на диск за недостающими сегментами
- выделить для low-latency процесса определенное количество ядер и запретить другим процессам использовать эти ядра (process isolation)
- не позволять потокам процесса переходить с одного ядра на другой (affinity)
- писать на диск или в сеть только в самом крайнем случае, лучше всего делегировать это одному потоку, который исполняется на отдельном ядре и не мешает другим критическим потокам исполняться