Эта публикация — логическое продолжение поста «Как настройка переменной окружения TZ позволяет избежать тысяч системных вызовов». Здесь мы рассмотрим характерную ситуацию, когда микрооптимизации (например, удаление системного вызова) очень сильно влияют на производительность. Ранее мы описали переменную окружения, которую может настраивать приложение, чтобы избежать тысяч дополнительных системных вызовов. Эту статью встретили справедливыми вопросами с некоторой долей скепсиса: Трудно сказать, что каждый из разработчиков вкладывает в понятие «заметное улучшение» применительно к конкретному приложению. Разработчики ядра и драйверов часто тратят немало времени на микрооптимизацию кода и структур данных, чтобы как можно полнее использовать кеш процессора и снизить потребление самого CPU. Даже если большинство программистов сочтут выгоду очень незначительной. Нужно ли уделять меньше внимания таким оптимизациям? Кто-то может вообще сказать, что микрооптимизации нельзя расценивать как заметные. В рамках этой статьи определим заметное как легко измеряемое и совершенно очевидное. Можем ли мы показать реальный пример, когда удаление из кода фрагмента (code path) медленного (без vDSO) системного вызова приводит к легко измеряемому и совершенно очевидному результату? Есть множество реальных примеров: от снифферов пакетов до сред выполнения (runtime) программных языков. Рассмотрим такой печально известный случай, как влияние sigprocmask на runtime языка Ruby. sigprocmask — системный вызов, используемый для проверки или настройки сигнальной маски текущего процесса. Он позволяет программе блокировать или разрешать сигналы, что бывает полезно, когда нужно выполнить критически важный кусок кода, работу которого нельзя прерывать. Это не особенно сложный системный вызов. Код ядра, относящийся к sigprocmask, говорит нам, что вызов записывает sigset_t в C-структуру, содержащую состояние текущего процесса (в ядре называется как task_struct). Это очень быстрая операция. Давайте создадим простую тестовую программу, которая в маленьком цикле вызывает sigprocmask. Измерять будем с помощью strace и time: Компилируем с помощью gcc -o test test.c. Сначала выполняем с time, а затем — со strace и time. На моей тестовой системе: В случае со strace для каждого вызова sigprocmask (в вашей системе наверняка будет отображаться как rt_sigprocmask) будут продемонстрированы приблизительные значения длительности выполнения. Они очень малы. На моей тестовой системе я получил в большинстве случаев значения в районе 0,000003 секунды — с неожиданным всплеском до 0,000074 секунды. Довольно трудно измерить точное время выполнения системного вызова по целому ряду причин. Они выходят далеко за пределы вопроса, обсуждаемого в этой статье. Так что можем предположить, что все измерения были проведены в равной мере некорректно. Итак, что мы уже знаем: Так зачем нам избавляться от дополнительных sigprocmask, будет ли это стоить наших усилий? Иногда чужой код, который мы используем в своих приложениях (системные библиотеки, ядро, glibc и т. д.), делает неожиданные вещи или имеет неочевидные на первый взгляд побочные эффекты. В качестве примера ниже я покажу, как косвенное использование sigprocmask в тестовой программе приводит к серьёзному снижению производительности. А затем продемонстрирую, как это проявляется в реальном приложении. С Ruby 1.8.7 был связан один из самых ярких примеров того, как дополнительные вызовы sigprocmask приводили к очевидному и легко измеряемому снижению производительности. Это отмечалось в тех случаях, когда код компилировался с одним конкретным флагом configure. Начнём со значений конфигурационных флагов по умолчанию, которые использовались на большинстве ОС (Debian, Ubuntu и т. д.) во времена широкого распространения Ruby 1.8.7.  Взгляните на тестовый код: Тут всё просто: создаём два потока выполнения, каждый из которых 10 миллионов раз добавляет и удаляет данные из массива. Если запустить со значениями конфигурационных флагов по умолчанию и strace, то получим удивительные результаты: Виртуальная машина Ruby сгенерировала больше 20 миллионов системных вызовов sigprocmask. Вы скажете: «Но ведь они заняли очень мало времени! Что в этом такого?» Как уже упоминалось, не так просто измерить длительность системного вызова. Перезапустим тестовую программу с time вместо strace и посмотрим, сколько времени займёт её завершение на моей системе: Около 6 секунд реального выполнения. То есть около 3,3 миллиона вызовов sigprocmask в секунду. Ого. А если настроить один флаг configure, то виртуальная машина Ruby будет собирать систему, избегая вызовов sigprocmask! Перезапустим тесты со strace и time, но в этот раз немножко подкрутим Ruby, чтобы он избегал вызовов sigprocmask: Круто! Мы снизили количество вызовов sigprocmask с 20 миллионов до 3. Похоже, у strace были проблемы с вычислением времени выполнения системных вызовов :( Посмотрим, что скажет time: Примерно на 40 % быстрее (реальное время выполнения), чем в предыдущем примере, и менее одного вызова sigprocmask в секунду. Отличный результат, но возникает несколько вопросов: Давайте сначала рассмотрим реальный пример, а потом разберёмся с подробностями. Найденный в Puppet баг точно демонстрирует влияние дополнительных вызовов sigprocmask на работу виртуальной машины Ruby: Я только что столкнулся с серьёзной проблемой, связанной с производительностью. Puppet очень медленно работает. Первый пример: В это время совершались сотни, если не тысячи вызовов rt_sigprocmask(SIG_BLOCK, NULL, [], 8). И всё это ради отображения версии. Если вы почитаете переписку, то обнаружите и другие комментарии, в которых люди жалуются на низкую производительность Puppet. Той же проблеме посвящены и вопросы на Stackoverflow. Но это проблема не только Ruby и Puppet. О подобных багах пишут и пользователи других проектов, сообщая о полной загрузке процессора и сотнях тысяч вызовов sigprocmask. Дело в том, что вызовы sigprocmask осуществляются двумя функциями из glibc (не являющимися системными вызовами): getcontext и setcontext. Они применяются для сохранения и восстановления состояния процессора. Их широко используют программы и библиотеки, реализующие обработку исключений или потоки выполнения в пользовательском пространстве. В случае с Ruby 1.8.7 setcontext и getcontext нужны в реализации потоков выполнения в пользовательском пространстве — для переключения контекста между потоками. Вы можете подумать, что эти две функции должны выполняться довольно быстро. Ведь они всего лишь сохраняют или восстанавливают маленький набор регистров процессора. Да, сохранение — операция очень быстрая. Но, судя по всему, реализация этих функций в glibc такова, что для сохранения и восстановления сигнальной маски используются системные вызовы sigprocmask. Вспомним, что Linux предоставляет механизм (vDSO), который вместо ядра отвечает за выполнение определённых системных вызовов. Это позволяет снизить стоимость их выполнения. К сожалению, sigprocmask не входит в их число. Все системные вызовы sigprocmask приводят к переходу (transition) из пользовательского пространства в ядро. Стоимость такого перехода гораздо выше стоимости других операций в setcontext и getcontext (представляющих простые записи в память). Если очень часто вызывать эти функции, то вы будете выполнять медленную операцию (в этом случае — системный вызов sigprocmask, который не проходит через vDSO) каждый раз, когда вам нужно сделать что-то быстро (например, сохранить или восстановить набор регистров процессора для переключения потоков выполнения). Во времена широкого использования Ruby 1.8.7 по умолчанию использовался флаг --enable-pthread, активирующий на уровне ОС отдельный поток выполнения, который запускался по таймеру (timer thread). Он стартовал по мере необходимости для перехвата (pre-empt) виртуальной машины Ruby. Таким образом машина узнавала, что пришло время переключиться между потоками выполнения в пользовательском пространстве, которые были сопоставлены (mapped) с потоками, созданными в Ruby-программах. Также обращение к --enable-pthread приводило к тому, что скрипт configure находил и использовал функции getcontext и setcontext. Без --enable-pthread скрипт configure будет искать и использовать _setjmp и _longjmp (обратите внимание на подчёркивания). Эти функции не сохраняют и не восстанавливают сигнальную маску, а следовательно, и не генерируют системные вызовы sigprocmask. Итак: И всё это из-за одного флага, который включает или отключает единственный системный вызов. Микрооптимизации важны. Но степень их важности, конечно, зависит от специфики самого приложения. Имейте в виду, что библиотеки и код, от которых вы зависите, могут делать то, чего вы не ожидаете (например, выполнять медленный системный вызов). Если вы будете знать, как выявлять и исправлять подобные проблемы, то это окажет огромное влияние на ваших пользователей. А в некоторых случаях — на пользователей ваших пользователей. ¯\_(ツ)_/¯