Может ли Unlock, основанный на атомных операциях, разблокировать спин-блокировку, чтобы установить флаг блокировки на ноль?

Скажем, например, у меня есть эксклюзивная реализация спин-блокировки на основе atomic-ops, как показано ниже:

bool TryLock(volatile TInt32 * pFlag)
{
   return !(AtomicOps::Exchange32(pFlag, 1) == 1);
}

void Lock (volatile TInt32 * pFlag) 
{  
    while (AtomicOps::Exchange32(pFlag, 1) ==  1) {
        AtomicOps::ThreadYield();
    }
}

void    Unlock (volatile TInt32 * pFlag)
{
    *pFlag = 0; // is this ok? or here as well a atomicity is needed for load and store    
}

куда AtomicOps::Exchange32 реализуется на окнах с помощью InterlockedExchange и на Linux с помощью __atomic_exchange_n,

2 ответа

Для реализации спин-блокировки вам понадобятся два барьера памяти:

  • "приобрести барьер" или "импортный барьер" в TryLock() а также Lock(), Вынуждает операции, выполненные, в то время как приобретенная спин-блокировка, быть видимой только после pFlag значение обновлено.
  • "выпускной барьер" или "экспортный барьер" в Unlock(), Вынуждает выполненные операции, пока спин-блокировка не была выпущена, быть видимой прежде pFlag значение обновлено.

Вам также нужны два барьера компилятора по тем же причинам.

Смотрите эту статью для деталей.


Этот подход для общего случая. На x86/64:

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

Более подробная информация представлена здесь.


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

  • он будет вставлять барьеры получения / освобождения памяти в архитектуры, где они требуются (или полный барьер, если барьеры получения / выпуска не поддерживаются, но архитектура слабо упорядочена);
  • это вставит барьеры компилятора на всех архитектурах.

Код:

bool TryLock(volatile bool* pFlag)
{
   // acquire memory barrier and compiler barrier
   return !__atomic_test_and_set(pFlag, __ATOMIC_ACQUIRE);
}

void Lock(volatile bool* pFlag) 
{  
    for (;;) {
        // acquire memory barrier and compiler barrier
        if (!__atomic_test_and_set(pFlag, __ATOMIC_ACQUIRE)) {
            return;
        }

        // relaxed waiting, usually no memory barriers (optional)
        while (__atomic_load_n(pFlag, __ATOMIC_RELAXED)) {
            CPU_RELAX();
        }
    }
}

void Unlock(volatile bool* pFlag)
{
    // release memory barrier and compiler barrier
    __atomic_clear(pFlag, __ATOMIC_RELEASE);
}

Для цикла "расслабленного ожидания" смотрите этот и этот вопросы.

См. Также барьеры памяти ядра Linux в качестве хорошей ссылки.


В вашей реализации:

  • Lock() звонки AtomicOps::Exchange32() который уже включает в себя барьер компилятора и, возможно, приобретение или полный барьер памяти (мы не знаем, потому что вы не предоставили фактические аргументы __atomic_exchange_n()).
  • Unlock() пропускает как память, так и барьеры компилятора, поэтому он сломан.

Также рассмотрите возможность использования pthread_spin_lock() если это вариант.

В большинстве случаев для освобождения ресурса просто сбросьте блокировку на ноль (как вы делаете) почти нормально (например, на процессоре Intel Core), но вам также необходимо убедиться, что компилятор не будет обмениваться инструкциями (см. Ниже, см. также пост Г.В.). Если вы хотите быть строгим (и портативным), есть две вещи, которые необходимо учитывать:

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

Что делает процессор: некоторые процессоры (например, Intel Itanium, используемые на профессиональных серверах, или ARM-процессоры, используемые в смартфонах) имеют так называемую "модель расслабленной памяти". На практике это означает, что процессор может принять решение об изменении порядка операций. Опять же, этого можно избежать, используя специальные инструкции (барьер нагрузки и барьер магазина). Например, в процессоре ARM инструкция DMB гарантирует, что все операции сохранения завершены до следующей инструкции (и ее необходимо вставить в функцию, которая снимает блокировку)

Вывод: очень сложно сделать код правильным, если у вас есть некоторая поддержка компилятора / ОС для этих функций (например, stdatomics.h, или же std::atomic в C++0x) гораздо лучше полагаться на них, чем писать свои собственные (но иногда у вас нет выбора). В конкретном случае стандартного процессора Intel Core, я думаю, что вы делаете правильно, если вы вставляете компилятор-барьер в операцию релиза (см. Пост gv).

О порядке компиляции во время компиляции и во время выполнения см.: https://en.wikipedia.org/wiki/Memory_ordering

Мой код для некоторых атомарных / спин-блокировок реализован на разных архитектурах: http://alice.loria.fr/software/geogram/doc/html/atomics_8h.html(но я не уверен, что это на 100 % правильно)

Другие вопросы по тегам