Lock-free stack - Является ли это правильным использованием С++ 11 расслабленной атомистики? Можно ли это доказать?
Я написал контейнер для очень простой части данных, которую нужно синхронизировать по потокам. Мне нужна максимальная производительность. Я не хочу использовать блокировки.
Я хочу использовать "расслабленную" атомику. Частично для этого немного лишней омры, и отчасти для того, чтобы действительно понять их.
Я много работал над этим, и я нахожусь в точке, где этот код передает все тесты, которые я бросаю на него. Это не совсем "доказательство", хотя, и поэтому мне интересно, есть ли что-то, чего я пропускаю, или какие-либо другие способы проверить это?
Здесь мое предположение:
- Важно только, чтобы Node был правильно нажат и выскочил, и что Stack никогда не может быть признан недействительным.
- Я считаю, что порядок операций в памяти важен только в одном месте:
- Между самими операциями compare_exchange. Это гарантируется даже при расслабленной атомизации.
- Проблема "ABA" решается путем добавления идентификационных номеров к указателям. В 32-разрядных системах это требует двойного слова compare_exchange, а в 64-битных системах неиспользуемые 16 бит указателя заполняются номерами идентификаторов.
- Следовательно: стек всегда будет в допустимом состоянии. (справа?)
Вот что я думаю. "Обычно", то, как мы рассуждаем о коде, который мы читаем, - это посмотреть на порядок, в котором он написан. Память может быть прочитана или записана на "не в порядке", но не таким образом, чтобы недействительность правильности программы.
Это изменяется в многопоточной среде. Для чего нужны заботы памяти, так что мы все еще можем смотреть на код и быть в состоянии рассуждать о том, как он будет работать.
Итак, если все может выйти из строя, что я делаю с расслабленной атомикой? Разве это не слишком далеко?
Я так не думаю, но вот почему я здесь прошу о помощи.
Операции compare_exchange сами обеспечивают гарантию последовательного постоянства друг с другом.
Единственный раз, когда чтение или запись атома - это получить начальное значение головы перед compare_exchange. Он устанавливается как часть инициализации переменной. Насколько я могу судить, было бы неважно, возвращает ли эта операция "правильное" значение.
Текущий код:
struct node
{
node *n_;
#if PROCESSOR_BITS == 64
inline constexpr node() : n_{ nullptr } { }
inline constexpr node(node* n) : n_{ n } { }
inline void tag(const stack_tag_t t) { reinterpret_cast<stack_tag_t*>(this)[3] = t; }
inline stack_tag_t read_tag() { return reinterpret_cast<stack_tag_t*>(this)[3]; }
inline void clear_pointer() { tag(0); }
#elif PROCESSOR_BITS == 32
stack_tag_t t_;
inline constexpr node() : n_{ nullptr }, t_{ 0 } { }
inline constexpr node(node* n) : n_{ n }, t_{ 0 } { }
inline void tag(const stack_tag_t t) { t_ = t; }
inline stack_tag_t read_tag() { return t_; }
inline void clear_pointer() { }
#endif
inline void set(node* n, const stack_tag_t t) { n_ = n; tag(t); }
};
using std::memory_order_relaxed;
class stack
{
public:
constexpr stack() : head_{}{}
void push(node* n)
{
node next{n}, head{head_.load(memory_order_relaxed)};
do
{
n->n_ = head.n_;
next.tag(head.read_tag() + 1);
} while (!head_.compare_exchange_weak(head, next, memory_order_relaxed, memory_order_relaxed));
}
bool pop(node*& n)
{
node clean, next, head{head_.load(memory_order_relaxed)};
do
{
clean.set(head.n_, 0);
if (!clean.n_)
return false;
next.set(clean.n_->n_, head.read_tag() + 1);
} while (!head_.compare_exchange_weak(head, next, memory_order_relaxed, memory_order_relaxed));
n = clean.n_;
return true;
}
protected:
std::atomic<node> head_;
};
Чем отличается этот вопрос по сравнению с другими? Расслабленная атомистика. Они имеют большое значение для вопроса.
Итак, что вы думаете? Есть ли что-то, что мне не хватает?
Ответы
Ответ 1
push
не работает, поскольку после < <22 > вы не обновляете node->_next
. Возможно, что node, который вы первоначально сохранили с помощью node->setNext
, выталкивается из верхней части стека другим потоком, когда следующая попытка compareAndSwap
выполняется успешно. В результате какой-то поток считает, что он вытащил node из стека, но этот поток вернул его в стек. Это должно быть:
void push(Node* node) noexcept
{
Node* n = _head.next();
do {
node->setNext(n);
} while (!_head.compareAndSwap(n, node));
}
Кроме того, поскольку next
и setNext
используют memory_order_relaxed
, нет гарантии, что _head_.next()
здесь возвращает node, который был последним нажат. Возможно утечка узлов из верхней части стека. Та же проблема, очевидно, существует и в pop
: _head.next()
может возвращать node, который был ранее, но больше не находится в верхней части стека. Если возвращаемое значение равно nullptr
, вы можете не появиться, если стек фактически не пуст.
pop
также может иметь поведение undefined, если два потока попытаются вытащить последний node из стека одновременно. Они оба видят одинаковое значение для _head.next()
, один поток успешно завершает pop. Другая нить входит в цикл while - поскольку наблюдаемый указатель node не является nullptr
, но цикл compareAndSwap
скоро обновляет его до nullptr
, поскольку стек теперь пуст. На следующей итерации цикла этот nullptr будет отменен, чтобы получить его указатель _next
, и наступает много веселья.
pop
также явно страдает от ABA. Два потока могут видеть тот же самый node в верхней части стека. Скажем, один поток попадает в точку оценки указателя _next
, а затем блокирует. Другой поток успешно выталкивает node, подталкивает 5 новых узлов, а затем снова выталкивает этот оригинальный node еще до того, как просыпается другой поток. Этот другой поток compareAndSwap
будет успешным - верхний стек node будет одним и тем же, но сохранит старое значение _next
в _head
вместо нового. Пять узлов, нажатых другим потоком, просочились. Это также относится к memory_order_seq_cst
.
Ответ 2
Оставив в стороне сложность реализации поп-операции, я думаю, что memory_order_relaxed
неадекватен. Прежде чем нажимать node, предполагается, что в него будет записано какое-либо значение (значения), которое будет считано при появлении node. Вам нужен механизм синхронизации, чтобы убедиться, что значения действительно были написаны до того, как они будут прочитаны. memory_order_relaxed
не обеспечивает синхронизацию... memory_order_acquire
/memory_order_release
.
Ответ 3
Этот код полностью сломан.
Единственная причина, по которой это работает, заключается в том, что текущие компиляторы не очень агрессивны при переупорядочивании по атомным операциям, а процессоры x86 имеют довольно сильные гарантии.
Первая проблема заключается в том, что без синхронизации нет гарантии, что клиент этой структуры данных даже увидит, что поля объекта node будут инициализированы. Следующая проблема заключается в том, что без синхронизации операция push может читать произвольно старые значения для основного тега.
Мы разработали инструмент CDSChecker, который имитирует большинство типов поведения, которые позволяет модель памяти. Это с открытым исходным кодом и бесплатно. Запустите его в своей структуре данных, чтобы увидеть некоторые интересные исполнения.
Доказательство чего-либо о коде, использующем расслабленную атомику, является большой проблемой на данный момент. Большинство методов доказательства ломаются, потому что они типично индуктивны по своей природе, и у вас нет порядка для индукции. Поэтому вы получаете проблемы с чтением воздуха...