У меня есть несколько рабочих потоков, выполняющих критичную ко времени обработку через равные промежутки времени (около 1 кГц). Каждый цикл рабочие просыпаются, чтобы выполнять работу по дому, каждый из которых должен (в среднем) завершить до начала следующего цикла. Они работают с одним и тем же объектом, который иногда может быть изменен основным потоком.
Чтобы предотвратить гонки, но разрешить изменение объекта до следующего цикла, я использовал спин-блокировку вместе с атомным счетчиком, чтобы записать, сколько потоков все еще выполняет работу:
class Foo {
public:
void Modify();
void DoWork( SomeContext& );
private:
std::atomic_flag locked = ATOMIC_FLAG_INIT;
std::atomic<int> workers_busy = 0;
};
void Foo::Modify()
{
while( locked.test_and_set( std::memory_order_acquire ) ) ; // spin
while( workers_busy.load() != 0 ) ; // spin
// Modifications happen here ....
locked.clear( std::memory_order_release );
}
void Foo::DoWork( SomeContext& )
{
while( locked.test_and_set( std::memory_order_acquire ) ) ; // spin
++workers_busy;
locked.clear( std::memory_order_release );
// Processing happens here ....
--workers_busy;
}
Это позволяет немедленно завершить всю оставшуюся работу при условии, что начался хотя бы один поток, и всегда будет блокироваться, прежде чем другой работник сможет начать работу для следующего цикла.
atomic_flag
доступ к ним осуществляется с помощью заказов на приобретение и освобождение, что является приемлемым способом реализации спин-блокировок в C ++ 11. В соответствии с документация на cppreference.com:
memory_order_acquire
: Операция загрузки с этим порядком памяти выполняет приобретать операцию в затронутом месте памяти: доступ к памяти в текущем потоке не может быть переупорядочен до этой загрузки. Это гарантирует, что все записи в других потоках, которые выпускают одну и ту же атомарную переменную, видны в текущем потоке.
memory_order_release
: Операция сохранения с этим порядком памяти выполняет отпустить операцию: никакие обращения к памяти в текущем потоке не могут быть переупорядочены после этого хранилища. Это гарантирует, что все записи в текущем потоке видны в других потоках, которые получают ту же самую атомарную переменную, и записи, которые переносят зависимость в атомарную переменную, становятся видимыми в других потоках, которые используют тот же атомарный.
Как я понимаю выше, этого достаточно для синхронизации защищенного доступа между потоками, чтобы обеспечить поведение мьютекса без чрезмерного консервативного упорядочения памяти.
Что я хочу знать, так это то, можно ли еще больше упорядочить память, потому что побочным эффектом этого паттерна является то, что я использую мьютекс спин-блокировки для синхронизации другой атомарной переменной.
Призывы к ++workers_busy
, --workers_busy
а также workers_busy.load()
все в настоящее время имеют порядок памяти по умолчанию, memory_order_seq_cst
, Учитывая, что единственное интересное использование для этого атома является разблокировать Modify()
с --workers_busy
(который не синхронизирован мьютексом спин-блокировки), можно ли использовать такой же порядок памяти с фиксацией и освобождением с этой переменной с использованием «расслабленного» приращения? то есть
void Foo::Modify()
{
while( locked.test_and_set( std::memory_order_acquire ) ) ;
while( workers_busy.load( std::memory_order_acquire ) != 0 ) ; // <--
// ....
locked.clear( std::memory_order_release );
}
void Foo::DoWork( SomeContext& )
{
while( locked.test_and_set( std::memory_order_acquire ) ) ;
workers_busy.fetch_add( 1, std::memory_order_relaxed ); // <--
locked.clear( std::memory_order_release );
// ....
workers_busy.fetch_sub( 1, std::memory_order_release ); // <--
}
Это правильно? Возможно ли дальнейшее ослабление любого из этих порядков памяти? И имеет ли это значение?
Так как вы говорите, что нацелены только на x86, Вы в любом случае гарантированная строго упорядоченная память; избегая memory_order_seq_cst
полезен (он может вызывать дорогие и ненужные ограничения памяти), но помимо этого, большинство других операций не будет налагать никаких особых накладных расходов, поэтому вы не получите ничего от дополнительного расслабления, кроме возможного неправильного переупорядочения команд компилятора. Это должно быть безопасно и не медленнее, чем любое другое решение, использующее атомарность C ++ 11:
void Foo::Modify()
{
while( locked.test_and_set( std::memory_order_acquire ) ) ;
while( workers_busy.load( std::memory_order_acquire ) != 0 ) ; // acq to see decrements
// ....
locked.clear( std::memory_order_release );
}
void Foo::DoWork( SomeContext& )
{
while(locked.test_and_set(std::memory_order_acquire)) ;
workers_busy.fetch_add(1, std::memory_order_relaxed); // Lock provides acq and rel free
locked.clear(std::memory_order_release);
// ....
workers_busy.fetch_sub(1, std::memory_order_acq_rel); // No lock wrapping; acq_rel
}
В худшем случае на x86 это накладывает некоторые ограничения на порядок компиляции; он не должен вводить дополнительные заборы или инструкции по блокировке, которые не нужно блокировать.
Вы должны избегать использования версии теста на c ++ и устанавливать блокировку. Вместо этого вы должны использовать атомарные инструкции, предоставленные компилятором. Это на самом деле имеет большое значение. Это будет работать с gcc и это тестирование и тестирование и установка блокировки, которая немного более эффективна, чем стандартное тестирование и установка блокировки.
unsigned int volatile lock_var = 0;
#define ACQUIRE_LOCK() {
do {
while(lock_var == 1) {
_mm_pause;
}
} while(__sync_val_compare_and_swap(&lock_var, 0, 1) == 1);
}
#define RELEASE_LOCK() lock_var = 0
//
_Mm_pause рекомендуется Intel для этих процессоров, поэтому есть время обновить блокировку.
Ваш поток выйдет из цикла do while только тогда, когда получит блокировку, а затем войдет в критическую секцию.
Если вы посмотрите на документацию по __sync_val_compare_and_swap, то заметите, что она основана на инструкции xchgcmp и будет иметь слово lock над ней в сгенерированной сборке для блокировки шины во время выполнения этой инструкции. Это гарантирует атомное чтение, изменение, запись.