Я написал контейнер для очень простого фрагмента данных, который нужно синхронизировать между потоками. Я хочу максимальной производительности. Я не хочу использовать замки.
Я хочу использовать «расслабленную» атомику. Частично для этого немного лишнего шума, и частично, чтобы действительно понять их.
Я много работал над этим, и я нахожусь на том этапе, когда этот код проходит все тесты, которые я к нему добавляю. Это не совсем «доказательство», и поэтому мне интересно, есть ли что-то, что я пропускаю, или какие-то другие способы, которыми я могу это проверить?
Вот моя предпосылка:
Вот что я думаю. «Обычно», то, как мы рассуждаем о коде, который читаем, — это смотреть в том порядке, в котором он написан. Память может быть прочитана или записана «не по порядку», но не таким образом, который нарушает правильность программы.
Это меняется в многопоточной среде. Вот для чего нужны ограждения памяти, чтобы мы могли еще посмотреть на код и рассуждать о том, как он будет работать.
Так что, если здесь все может выйти из строя, что я делаю с расслабленной атомикой? Разве это не слишком далеко?
Я так не думаю, но именно поэтому я здесь и прошу помощи.
Сами операции сравнения_обмена дают гарантию последовательного постоянства друг с другом.
Единственный другой раз, когда есть чтение или запись в атомарный элемент, — это получить начальное значение заголовка перед началом сравнения. Он устанавливается как часть инициализации переменной. Насколько я могу судить, было бы неважно, возвращает ли эта операция «правильное» значение.
Текущий код:
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_;
};
Чем отличается этот вопрос от других? Расслабленная атомика. Они имеют большое значение для вопроса.
Так что ты думаешь? Я что-то пропустил?
push
сломан, так как вы не обновляете node->_next
после compareAndSwap
отказ. Возможно, что узел, с которым вы изначально сохранили node->setNext
был вытолкнут из вершины стека другим потоком, когда следующий compareAndSwap
попытка удалась В результате некоторый поток думает, что он вытолкнул узел из стека, но этот поток вернул его обратно в стек. Так должно быть:
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()
здесь возвращается узел, который был добавлен последним. Возможна утечка узлов с вершины стека. Такая же проблема, очевидно, существует в pop
также: _head.next()
может вернуть узел, который был ранее, но больше не находится на вершине стека. Если возвращаемое значение nullptr
, вы можете потерпеть неудачу, когда стек фактически не пуст.
pop
может также иметь неопределенное поведение, если два потока пытаются извлечь последний узел из стека одновременно. Они оба видят одно и то же значение для _head.next()
, один поток успешно завершает поп. Другой поток входит в цикл while — поскольку наблюдаемый указатель узла не является nullptr
— но compareAndSwap
цикл скоро обновит его nullptr
поскольку стек сейчас пуст. На следующей итерации цикла этот nullptr разыменовывается, чтобы получить его _next
указатель и много веселья наступает.
pop
также явно страдает от АБА. Два потока могут видеть один и тот же узел в верхней части стека. Скажем, один поток доходит до оценки _next
указатель, а затем блоки. Другой поток успешно извлекает узел, выдвигает 5 новых узлов, а затем снова подталкивает этот исходный узел, прежде чем другой поток проснется. Это другой поток compareAndSwap
будет успешным — узел вершины стека тот же — но сохранить старый _next
значение в _head
вместо нового. Пять узлов, выдвинутых другим потоком, все просочились. Это было бы в случае с memory_order_seq_cst
также.
Оставляя в стороне трудности с реализацией поп-операции, я думаю, memory_order_relaxed
неадекватно. Прежде чем нажать на узел, предполагается, что в него будут записаны некоторые значения, которые будут считаны при извлечении узла. Вам нужен какой-то механизм синхронизации, чтобы убедиться, что значения действительно были записаны до того, как они будут прочитаны. memory_order_relaxed
не обеспечивает эту синхронизацию … memory_order_acquire
/memory_order_release
было бы.
Этот код полностью сломан.
Единственная причина, по которой это работает, заключается в том, что современные компиляторы не очень агрессивны с переупорядочением в атомарных операциях, и процессоры x86 имеют довольно серьезные гарантии.
Первая проблема состоит в том, что без синхронизации нет гарантии, что клиент этой структуры данных даже увидит поля объекта узла, которые будут инициализированы. Следующая проблема заключается в том, что без синхронизации операция push может считывать произвольно старые значения для тега заголовка.
Мы разработали инструмент CDSChecker, который имитирует большинство типов поведения, которые допускает модель памяти. Это с открытым исходным кодом и бесплатно. Запустите его на своей структуре данных, чтобы увидеть некоторые интересные исполнения.
Доказать что-либо о коде, который использует расслабленную атомизацию, является большой проблемой на данный момент. Большинство методов доказательства ломаются, потому что они, как правило, носят индуктивный характер, и у вас нет приказа индуктировать дальше. Таким образом, вы получаете из воздуха проблемы с чтением …