Фича под названием перезапускаемые последовательности была добавлена в версию ядра 4.18 в 2018 году. Она позволяет повысить производительность в определённых категориях многопоточных приложений. Притом, что кому-то перезапускаемые последовательности действительно пригодились, такой код считается достаточно специализированным — как правило, разработчики приложений этим инструментом не пользуются. Но со временем перезапускаемые последовательности выросли и, по-видимому, тренд к их росту сохраняется, так как эта фича привязана к новым возможностям, предоставляемым в ядре. Но по мере того, как перезапускаемые последовательности стало всё сложнее считать нишевой фичей, с ними стали возникать заметные проблемы. Если исправить одну из них, это может повлечь заметные изменения ABI, которые будут видимы в пользовательском пространстве.

❯ Обзор перезапускаемых последовательностей

По мере того, как процессоры в системе становятся всё более многоядерными, всё сильнее хочется писать и выполнять сильно распараллеленные программы. Но конкурентный код, основанный на блокировках, рано или поздно сталкивается с проблемами масштабируемости и в пользовательском пространстве, и в ядре. Поэтому становится всё интереснее попробовать неблокирующие алгоритмы. В ядре конкурентный неблокирующий доступ — это технология с явными преимуществами, так как выполняемый в ядре код при таком подходе можно защитить от остановок, связанных с прерываниями, вытеснением или переброской на другое ядро процессора в ходе выполнения критических секций. В пользовательском пространстве таких гарантий нет. Поэтому любой неблокирующий алгоритм для пользовательского пространства должен корректно работать в такой среде, где выполнение кода может прерваться в любой момент.

Перезапускаемые последовательности — одно из средств решения этой проблемы. Чтобы использовать эту фичу, в приложении требуется выделить критическую секцию, в которой выполняется определённая работа, и кульминацией этой работы должна быть одиночная атомарная инструкция, закрепляющая все сделанные изменения. Одним из самых ранних примеров использования перезапускаемых последовательностей является аллокатор памяти. В своей критической секции такой аллокатор выбирал бы объект для выделения, руководствуясь списком доступных ядер процессора, а затем удалял бы его из списка при помощи атомарной операции «сравнение с заменой».

Очевидно, возникли бы проблемы, если бы в этой критической секции наступило прерывание, которое произошло бы между идентификацией объекта для последующего выделения и финальным этапом, на котором закрепляются изменения. Чтобы обойти эту проблему, приложение сообщает ядру о критической секции. Для этого заполняется структура (struct rseq_cs), в которой описывается диапазон адресов инструкций, относящихся к этой секции, плюс специальный адрес выхода (abort address). Если ядро прерывает выполнение релевантного потока с целью вытеснить его, перебросить на другое ядро процессора, либо подать сигнал, то, вернувшись к работе в пользовательском пространстве, оно проверит, существует ли эта структура. Если актуальный в данный момент указатель инструкции находится в критической секции, то ядро прикажет, чтобы выполнение перешло к адресу выхода. После такой отменённой попытки поток может выполнить очистку и перезапустить операцию.

Конечно, остаётся надеяться, что большинство прогонов через критическую секцию завершится без прерываний, и код выполнит свою работу условно скорейшим из возможных способов. Дополнительные издержки, связанные с обработкой операции выхода, приходится учитывать только в тех случаях, когда прерывание по какой-то причине произошло именно в критической секции.

Если вы собираетесь использовать перезапускаемые последовательности, то для этого необходимо настроить область разделяемой памяти. Для этого используется системный вызов rseq(). Сама критическая секция почти без вариантов должна быть написана на ассемблере. До самого недавнего времени перезапускаемые последовательности не поддерживались в библиотеке GNU C (glibc), хотя, эта ситуация изменилась. Учитывая, как нужно постараться, чтобы правильно использовать эту возможность, которая, кстати, присутствует только в Linux — неудивительно, что лишь немногие разработчики приложений к ней прибегают.

❯ Не такая она уже и нишевая

Если вы настроили разделяемую область памяти при помощи rseq(), то поток может сообщить ядру, когда именно он работает в критической секции, но обмен информацией в этой области является двунаправленным. Всякий раз, когда ядро выполняет поток, оно обновляет эту область так, чтобы отражать, на каком именно ядре ЦП и узле NUMA в итоге оказался поток. Пользуясь этой информацией, glibc ускоряет работу таких функций как sched_getcpu(), которая может получать необходимую информацию, не вызывая ядро. Рассматриваются и другие способы использования перезапускаемых последовательностей в glibc. Матье Деснойе (Mathieu Desnoyers), автор этой фичи, вот уже некоторое время выступает за то, чтобы её активнее использовали внутри glibc и улучшали её масштабируемость.

Недавно фиксировался интерес ещё к одной фиче, призванной помочь писать в пользовательском пространстве эффективные критические секции. Речь о продлении квантов времени. Идея заключается в том, что поток, работающий в критической секции (и, возможно, удерживающий блокировку) может вежливо указывать на это ядру и просить, чтобы его не вытесняли, даже если его квант времени истёк. Ядро может проявить благосклонность и выделить потоку ещё немного времени, чтобы тот успел завершить работу и освободить блокировку, так, чтобы другие потоки могли продолжать работу. Очевидно, что фичу такого рода целесообразно привязать к перезапускаемым последовательностям, чтобы установленный патч для продления временных квантов брал это дополнительное время «на доработку» именно в разделяемой области.

Возможность продления временных квантов влияет на основной планировщик времени в ЦП, и вполне вероятно, что она станет использоваться шире. Поэтому неудивительно, что возрос интерес и к перезапускаемым последовательностям. Томас Глейкснер углубился в изучение патчей и, сам особо не в восторге от своих слов, сформулировал предложение по поводу создания улучшенного интерфейса для продления квантов времени. Вероятно, предложенные им изменения будут внесены в ядро, по мере продолжения этой работы, и заслуживают отдельной статьи. Но Глейкснер и в коде перезапускаемых последовательностей нашёл отдельные вещи, которые можно было бы улучшить.

❯ Проблемы с перезапускаемыми последовательностями

Наблюдения, сделанные им в этой области, привели к публикации отдельного набора патчей, специально предназначенных для решения тех проблем с перезапускаемыми последовательностями, которые он нашёл. В результате удаётся значительно повысить производительность — и это хорошо, что мы стали обращать внимание на то, какими издержками приходится расплачиваться за применение перезапускаемых последовательностей. Например, Йенс Аксбо отреагировал на эту серию, сказав: «Могу сказать, что по данным моего последнего тестирования на rseq приходится 2-3% всего процессорного времени». Разработчики ядра прилагают массу усилий для устранения и гораздо более незначительных факторов, снижающих производительности. Среди интересных вещей, которые предлагает исправить Глейкснер, есть примеры, показывающие, как из-за сочетания сложившихся практик и просчётов может замедляться работа.

Существует несколько вариантов, как поток из пользовательского пространства может потерять доступ к ЦП. Код перезапускаемых последовательностей в современных версиях ядра ведёт учёт поля, rseq_event_mask, имеющегося в структуре task_struct каждого потока, и именно так отслеживает эти варианты. Предусмотрены отдельные биты для вытеснения, для доставки сигнала и для перехода на другое ядро процессора. Поэтому, например, если поток доставляет задаче сигнал, то он установит бит RSEQ_EVENT_SIGNAL в rseq_event_mask. Когда придёт время вернуться в пользовательское пространство после произошедшего события, будет вызвана функция rseq_need_restart(), проверяющая, произошло ли прерывание в критической секции. Если так, то код обратится к rseq_event_mask, чтобы проверить, следует ли продолжать работу в критической секции, либо поток нужно перенаправить на адрес выхода.

Глейкснер отметил пару интересных проблем, возникающих с этим кодом. Во-первых, ядро постоянно устанавливает биты в rseq_event_mask, но очищаются эти биты только в rseq_need_restart(), и только в том случае, если оказывается, что именно в этот момент поток работает в критической секции. В противном случае биты там просто накапливаются. Скорее всего, это приведёт к мнимому перезапуску в следующий раз, когда прерывание придётся на выполнение потока в критической секции. Из-за этого будет частично потерян тот выигрыш в производительности, который обеспечивается благодаря перезапускаемым последовательностям.

Пожалуй, ещё более характерно, что именно через эти биты поток может указать, когда именно следует выйти из его критической секции. Каждая конкретная секция устроена так, что определённые события не несут угрозы и не могут нарушить работу алгоритма. Поток должен быть в состоянии обработать сигнал, так, чтобы это не ставило под угрозу конкурентный доступ к данным, которые необходимы в его критической секции. Но эта возможность была признана нежелательной в версии 6.0, вышедшей в 2022 году. Она привносила лишнюю сложность, но почти не имела ценности. Отказ от этой фичи был реализован относительно сурово: любой процесс, пытавшийся ею воспользоваться, немедленно вырубали сигналом SIGSEGV. Поэтому вполне вероятно, что сейчас этой фичей уже практически никто не пользуется.

Весь этот сложный механизм Глейкснер заменил всего одним булевым значением «произошло событие». Удалив всё лишнее, он также смог избавиться от некоторых обращений к памяти пользовательского пространства, что позволило ускорить работу. Обращения к пользовательскому пространству из ядра не бывают дешёвыми, а из-за необходимости сгладить влияние Spectre этот процесс ещё сильнее замедляется. В качестве отдельной оптимизации в этом ряду все оставшиеся обращения к пользовательскому пространству меняются на замаскированные пользовательские примитивы доступа (добавленные в версии 6.12), благодаря которым такие последствия сглаживать дешевле.

Есть два сценария, в соответствии с которыми поток, работающий в пользовательском пространстве, может переключиться в режим ядра. Во-первых, может произойти прерывание. Во-вторых, поток может выполнить системный вызов. Все случаи, которые предполагается обрабатывать с применением перезапускаемых последовательностей, возникают в результате того или иного прерывания. Потокам, работающим в критических секциях, вообще не полагается делать системных вызовов. Если вы попытаетесь сделать системный вызов в критической секции при работе в ядре, которое собрано с конфигурационной опцией DEBUG_RSEQ, то такой процесс-нарушитель немедленно будет завершён. Это как бы намекает, что пока перезапускамые последовательности используются неправильно. Но без этой опции (которая в продакшене не выставляется) ядро будет обрабатывать возврат от системного вызова точно так же, как и возврат от прерывания. Если, пока поток работал в критической секции, произошло какое-то событие, то управление будет передано обработчику выхода.

Благодаря работе Глейкснера, удаётся разграничить случаи обработки после прерывания и после системного вызова, так как возможность с продлением временного кванта применима только к первому. В качестве оптимизации он удалил обработку перезапускаемых последовательностей при возврате от системных вызовов. В соответствующем патче отмечено:

Так мы меняем существующее сейчас поведение, при котором критическая секция слепо и безусловно сносится, если произошёл системный вызов. Но ведь это проблема из пользовательского пространства, если программа выполняет системный вызов прямо из критической секции и рассчитывает, что это сработает. Определённо этот код никогда не тестировался в отладочной версии ядра, и в пользовательском пространстве можно контролировать ситуацию.

Если до сих пор есть пользователи, поступающие таким образом, и их код пока работает, то, возможно, когда вышеописанное изменение вступит в силу — их ждёт неприятный сюрприз. Вероятно, это породит интересную дискуссию на тему «пользовательское пространство всегда было поломано», но едва ли такой аргумент сочтут нормальным, если подобное изменение сломает действующую программу. Если начнутся проблемы, то велики шансы, что это изменение удастся откатить. Учитывая, что пока что перезапускаемые последовательности применяются относительно нешироко, есть надежда, что таких пользователей не существует.

Правда, на то, чтобы это проверить, нам может понадобиться время. Работа потребует ревью, предположительно, нужно будет посмотреть, как новая фича взаимодействует с продлением временных квантов. Чтобы было ещё веселее, Глейкснер сообщил, что нашёл и другие вещи, которые стоит исправить, так что ждём новых патчей. Но в сущности все актуальные изменения кажутся оправданными и действительно успешно решают проблемы, просочившиеся в код за годы работы. Практически любому образцу кода пойдёт на пользу дальнейшая разработка с применением перезапускаемых последовательностей, так как это позволит посмотреть на него свежим взглядом.


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале 

Комментарии (0)