MyTetra Share
Делитесь знаниями!
Как работает спин-лок? Пример простейшего спин-лока на C++
Время создания: 10.12.2024 14:10
Автор: Xintrea
Текстовые метки: c++, си++, спинлок, спин, лок, spinlock, spin-lock, многопоток, поток, multithreading, блокировка, синхронизация, мьютекс, mutex, семафор, semaphore
Раздел: Компьютер - Программирование - Язык C++ (Си++)
Запись: xintrea/mytetra_syncro/master/base/1733829000my9n7reqtc/text.html на raw.github.com

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



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



Спин-лок реализуется только средствами самого языка C++ без использования сложных механизмов управления потоками, существующими в ОС. Поэтому его легко можно использовать во встраиваемых системах, запуская на "голом" железе.



Как работает спин-лок?


1. Поток пытается захватить спин-лок.


2. Если спин-лок уже захвачен другим потоком, поток не блокируется. Вместо этого он выполняет активное ожидание (spin-waiting), то есть в цикле проверяет, освободился ли спин-лок.


3. Как только спин-лок становится свободным, поток захватывает его, и продолжает свое выполнение.


4. В конце своей работы поток должен освободить (разблокировать) спин-лок.



Преимущества спин-локов


Минимальные накладные расходы: нет необходимости переключаться между контекстами потоков или выполнять системные вызовы для блокировки.


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



Недостатки спин-локов


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


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



Пример реализации спин-лока на C++


В данном примере используется переменная типа std::atomic_flag. У переменной этого типа есть два внутренних состояния: "сброшено" и "установлено", что формально соответствует true и false. Привести содержание флага к bool-значению, например для проверки его состояния, можно методами этого объекта test() или test_and_set().



#include <atomic>

#include <thread>

#include <iostream>


class SpinLock {

// Флаг блокировки спин-лока

// Изначально флаг чистый (clear) т. е. сброшен (по-сути, false)

// Инициализация значением ATOMIC_FLAG_INIT приводит к тому, что флаг

// инициализируется как сброшенный

// Внимание! Это не присваивание (у std::atomic_flag присваивания нет)

std::atomic_flag lockFlag = ATOMIC_FLAG_INIT;


public:

void lock() {

while ( lockFlag.test_and_set( std::memory_order_acquire ) ) {

// Цикл ожидания, пока флаг не будет сброшен где-то

// в другом месте кода. Описание работы см. ниже по тексту

}

}


void unlock() {

// Сброс флага (разблокировка спин-лока)

lockFlag.clear(std::memory_order_release);

}

};


SpinLock spinlock;


void criticalSection(int threadId) {

spinlock.lock();


std::cout << "Выполнение критической секции в потоке " << threadId << "\n";

std::this_thread::sleep_for(std::chrono::milliseconds(100));

std::cout << "Завершение критической секции в потоке " << threadId << "\n";


spinlock.unlock();

}


int main() {

std::thread t1(criticalSection, 1); // Запуск функции в потоке №1

std::thread t2(criticalSection, 2); // Запуск функции в потоке №2


t1.join(); // Ожидание завершения потока №1

t2.join(); // Ожидание завершения потока №2


return 0;

}



В этом примере метод test_and_set() работает со значением флага lockFlag следующим образом:



  • Если флаг был "сброшен", то флаг переводится в состояние "установлен", но сам метод test_and_set() возвращает исходное bool-значение этого флага, т. е. false (вот так специально сделан данный метод). В результате цикл while() не выполняется, метод lock()завершает свою работу, но при этом флаг уже находится в состоянии "установлен". После выхода из lock(), код потока продолжает свою работу внутри функции criticalSection(), и в это выполнение потока инструкций другие потоки не смогут параллельно "влезть", т. к. они будут попадать в цикл внутри метода lock(), ведь флаг находится в состоянии "установлен". Другими словами, текущий поток захватывает блокировку, и только он может ее отключить путем "очистки" флага блокировки, что и делается при вызове метода spinlock.unlock() в конце критической секции.
  • Если флаг был в состоянии "установлен", то test_and_set() возвращает исходное bool-значение true. В результате поток исполнения зацикливается в ожидании в цикле while(). Выйти из этого ожидания поток сможет только если другой код в другом потоке сбросит флаг.



В параметре метода test_and_set() указывается порядок синхронизации памяти, который можно рассматривать как режим установки флага из выключенного состояния во включенное. В данном коде используется режим std::memory_order_acquire. Вот что говорится о таких режимах в документации C++ (максимально сложно и непонятно):



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

Поведение по умолчанию для всех атомарных операций в библиотеке обеспечивает последовательную согласованность (sequentially consistent ordering). Это поведение по умолчанию может негативно сказываться на производительности, но атомарным операциям библиотеки можно передать дополнительный аргумент std::memory_order, чтобы указать точные ограничения, выходящие за рамки атомарности, которые компилятор и процессор должны обеспечить для этой операции.

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



Из этого потока слов и терминов важно понять следующее: в дефолтном режиме потокобезопасное изменение памяти (оно же переключение флага) происходит по сложному алгоритму, исключающему не-атомарное изменение. Поэтому такое изменение очень медленное. Если заранее известны условия изменений, то можно упростить проверки данного алгоритма, что и делается путем использования режима std::memory_order_acquire.



Важное уточнение


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


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



Когда использовать спин-локи?


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


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


Так же в этом разделе:
 
MyTetra Share v.0.65
Яндекс индекс цитирования