Ядро Linux предоставляет множество примитивов блокировки. Каждый из них ведет себя уникальным образом, что делает его более или менее подходящим для конкретного варианта использования. Некоторые примитивы блокировки подходят для случаев, когда задача может спать, другие можно использовать в тех случаях, когда вы не хотите блокировать считыватели вашего критического участка кода и так далее.

Выбор запирающего механизма часто сводится к:

  • Ожидается, что конкуренция будет высокой или низкой
  • Находится ли блокировка в контексте процесса или вне его
  • Можно ли спать

Мы склонны воспринимать эти вещи как должное, поскольку мы можем просто использовать эти блокирующие примитивы и знать, что условия гонки, о которых мы беспокоимся, не возникнут, но знаете ли вы, как это сделать? они работают под капотом? Нет? Большой! вы в правильном месте 😃

В этой записи блога я расскажу, как механизм spinlock реализован в ядре Linux, и познакомлю вас с исходным кодом.

Что такое спинлоки?

Spinlock, вероятно, является «самым простым» блокирующим примитивом, который защищает критические участки кода. Это концептуально, как это работает:

Ключевым моментом, который следует иметь в виду, является то, что если задача B не может получить блокировку, она будет ждать в цикле ('spin'), пока блокировка не будет снята с задачи A, что автоматически подразумевает, что задача B не может уйти в сон. Это поведение определяет, где его не следует использовать.

Spinlock предоставляет набор удобных функций разработчикам ядра. Некоторые из наиболее часто используемых функций:

Как использовать Spinlocks в моем коде?

Использовать спин-блокировки в коде довольно просто.

Если по какой-то причине нужно отключить прерывания на текущем процессоре:

Отказ от ответственности: используется конфигурация ядра

Что делает код ядра трудным для чтения, так это тот факт, что код добавляется/удаляется в зависимости от того, какие конфигурации используются. Сказав это, это конфигурация, с которой я собрал свое ядро:

CONFIG_PREEMPT_RT = не задано
CONFIG_LOCKDEP = не задано
CONFIG_PREEMPT_COUNT=y
CONFIG_DEBUG_SPINLOCK = не задано
CONFIG_MMIOWB = не задано
CONFIG_PROFILE_ALL_BRANCHES = не задано
CONFIG_PARAVIRT_SPINLOCKS = не установить
CONFIG_SMP = y

Теперь, когда вы понимаете основы, мы можем углубиться в каждую функцию, чтобы понять, как они работают. Я сосредоточусь на функциях spinlock_tstruct, spin_lock_init() и spin_lock(), поскольку они являются наиболее важными.

структура spinlock_t

Определение спин-блокировки и его члены зависят от того, какие конфигурации ядра установлены. В нашем случае структура spinlock_t выглядит следующим образом:

Если параметр CONFIG_PREEMPT_RT не установлен, ядро ​​сопоставляет спин-блокировку с raw_spinlock_t, который по существу является оболочкой для специфичной для архитектуры спин-блокировки, называемой arch_spinlock_t:

Зависящий от архитектуры arch_spinlock_t в x86 определяется следующим образом:

PS. Потратьте некоторое время на ознакомление с элементами структуры и их именами, которые пригодятся позже в этом сообщении в блоге.

Вы могли заметить, что:

  1. Я упомянул платформу x86
  2. в структуре есть #ifdef __LITTLE_ENDIAN

Если это как-то привлекло ваше внимание, вы не одиноки 😅. Опять же, мы все знаем, что порядок следования байтов в x86 — это прямой порядок следования байтов.

Как оказалось, arch_spinlock_t, используемый в архитектуре x86, находится не в linux/arch/x86/, а в общем заголовочном файле с именем include/asm- универсальный/qspinlock_types.h.

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

spin_lock_init (блокировка)

Это функция, которая инициализирует переменную spinlock_t. В качестве альтернативы можно использовать этот удобный макрос, который объявляет и инициализирует спин-блокировку.

Вот что расширяет макрос spin_lock_init():

Обратите внимание, как atomic_t -›val из arch_spinlock_t инициализируется значением 0 (помните это).

Кроме того, если вы опытный программист на C, то "делать {}, пока (0)" является для вас второй натурой, но если вы все еще пытаетесь освоить C, эти ссылки могут вам помочь:

Короче говоря, "do {} while(0)" решает несколько хорошо известных проблем, таких как проглатывание точки с запятой, дублирование имен переменных и т. д. при работе с макросами, состоящими из составные высказывания.

spin_lock(блокировка)

Это функция, которая пытается получить блокировку. Если это удается с первой попытки, то он просто возвращается, в противном случае он выбирает «медленный путь» и зацикливается («вращается»), пытаясь получить блокировку, пока не добьется успеха.

Отказ от ответственности. Здесь многое предстоит распаковать, и, честно говоря, каждая строка в предыдущем фрагменте кода заслуживает отдельной записи в блоге. Учитывая, что это о спин-блокировках, я объясню основную цель каждой строки и оставлю ссылки, которые вы используете, чтобы узнать больше о предмете.

preempt_disable

вытеснение ядра — это свойство, которым обладает ядро ​​Linux, при котором ЦП может быть прерван в середине выполнения кода ядра и назначен другим задачам (из которых он позже возвращается, чтобы завершить свои задачи ядра).

Существует 3 модели вытеснения, которые пользователи могут выбирать при компиляции ядра, как показано ниже.

Макрос preempt_disable() расширяется до следующего:

Вывод макроса preempt_disable() заключается в том, что он увеличивает внутреннюю структуру, которая считывается планировщиком Linux, поэтому планировщик знает, что текущая задача не может быть вытеснена до тех пор, пока preempt_enable() вызывается.

Если вы хотите узнать больше о вытеснении ядра, в LWN есть пара старых, но очень хороших статей на эту тему:

spin_acquire(…);

Эта функция является частью системы lockdep ядра, что является захватывающей идеей. Система Lockdep отслеживает блокировки по мере их получения и выводит предупреждающие сообщения, когда потенциальная взаимоблокировка может произойти во время выполнения.

Я не могу преувеличить, насколько полезной эта система может быть на этапе разработки любого кода ядра, поскольку отладка условий гонки иногда может быть абсурдно сложной.

В нашем случае, учитывая, что CONFIG_LOCKDEP не установлен, эта строка преобразуется в отсутствие операций, поэтому я не буду углубляться в это. Если вы хотите узнать больше о предмете, вот где вы должны найти хорошую информацию:

LOCK_CONTENDED(…);

Как упоминалось ранее, из-за используемых конфигураций ядра этот макрос преобразуется в do_raw_spin_lock(lock);, что, по сути, является местом, где происходит волшебство.

queued_spin_lock(…);

Именно в этой функции мы можем увидеть, откуда спин-блокировка получила свое название. Он пытается изменить значение spinlock_t -> raw_spinlock_t -> arch_spinlock_t -> atomic_t (val), и если это удается, блокировка фактически получена. Если нет, то будет выбран медленный путь, поэтому он может бесконечно зацикливаться, пока не получит шанс получить блокировку.

вероятно(x)

Это макрос, который расширяется до __builtin_expect(!!(x), 1). По сути, это дает компилятору GCC указания на оптимизацию прогнозирования ветвлений, чтобы мы могли воспользоваться преимуществами некоторых функций кеша ЦП/упреждающей выборки, которые есть в некоторых архитектурах (разумеется, с чрезмерным упрощением).

Если вы хотите узнать больше об этом, вы можете взглянуть на документы GCC:

atomic_try_cmpxchg_acquire(…);

Само собой разумеется, что для того, чтобы иметь дело с несколькими параллельными задачами, пытающимися конкурировать за желанный ресурс (блокировку), необходимо убедиться, что действие блокировки выполняется в одной инструкции.

atomic_try_cmpxchg_acquire делает именно это, и, как и следовало ожидать, это зависит от архитектуры. В случае архитектуры x86–64, если вы будете следовать лабиринту кода макроса, вы в конечном итоге найдете этот фрагмент кода:

Инструкция сборки x86 CMPXCHG в одной инструкции сравнивает atomic_t-›val с 0, если это так, то для _Q_LOCKED_VAL (что равно 1) устанавливается значение atomic_t- ›вал.

Еще одна вещь, о которой важно упомянуть над приведенным выше кодом, — это переменная lock в строке 12. Константа LOCK_PREFIX_HERE передается по цепочке вызовов функций до тех пор, пока на него не ссылаются как на аргумент блокировки в строке 12.

Для однопроцессорных систем (UP) это будет пустая строка, но для симметричных многопроцессорных систем (SMP) к ней будет добавлен префикс инструкции x86 LOCK, чтобы гарантировать операция выполняется атомарно среди всех процессоров, как показано ниже:

Заключение

Фу! Это было довольно много для одного сообщения в блоге, я прав? 😅

Чтобы этот пост в блоге не был излишне длинным, я закончу часть 1 здесь и создам часть 2 для медленного пути, так как это довольно много кода (около 250–300 строк).

Я надеюсь, что вам понравилось то, что вы прочитали, и если я что-то пропустил, просто дайте мне знать, и я это исправлю 😉

Всем удачного кодинга! 😃

Пауло Алмейда