Я только что видел выступление Херба Саттера: C ++ и после 2012 года: Херб Саттер — атомная<> Оружие, 2 из 2
Он показывает ошибку в реализации деструктора std :: shared_ptr:
if( control_block_ptr->refs.fetch_sub(1, memory_order_relaxed ) == 0 )
delete control_block_ptr; // B
Он говорит, что из-за memory_order_relaxed, удаление может быть помещено перед fetch_sub.
В 1:25:18 — Релиз не держит строку B ниже, где она должна быть
Как это возможно? Существует отношение «происходит до / последовательно», поскольку они оба находятся в одном потоке. Возможно, я ошибаюсь, но между fetch_sub и delete существует также перенос-зависимость.
Если он прав, какие пункты ISO поддерживают это?
Представьте себе код, который освобождает общий указатель:
auto tmp = &(the_ptr->a);
*tmp = 10;
the_ptr.dec_ref();
Если у dec_ref () нет семантики «release», то вполне нормально, чтобы компилятор (или процессор) перемещал вещи от до dec_ref () к нему (например):
auto tmp = &(the_ptr->a);
the_ptr.dec_ref();
*tmp = 10;
И это небезопасно, так как dec_ref () также может быть вызван из другого потока в то же время и удалить объект.
Таким образом, он должен иметь семантику «release», чтобы вещи до dec_ref () оставались там.
Теперь давайте представим, что деструктор объекта выглядит так:
~object() {
auto xxx = a;
printf("%i\n", xxx);
}
Также мы немного изменим пример и будем иметь 2 потока:
// thread 1
auto tmp = &(the_ptr->a);
*tmp = 10;
the_ptr.dec_ref();
// thread 2
the_ptr.dec_ref();
Тогда «агрегированный» код будет выглядеть так:
// thread 1
auto tmp = &(the_ptr->a);
*tmp = 10;
{ // the_ptr.dec_ref();
if (0 == atomic_sub(...)) {
{ //~object()
auto xxx = a;
printf("%i\n", xxx);
}
}
}
// thread 2
{ // the_ptr.dec_ref();
if (0 == atomic_sub(...)) {
{ //~object()
auto xxx = a;
printf("%i\n", xxx);
}
}
}
Однако, если у нас есть только семантика «release» для atomic_sub (), этот код можно оптимизировать следующим образом:
// thread 2
auto xxx = the_ptr->a; // "auto xxx = a;" from destructor moved here
{ // the_ptr.dec_ref();
if (0 == atomic_sub(...)) {
{ //~object()
printf("%i\n", xxx);
}
}
}
Но в этом случае деструктор не всегда будет печатать последнее значение «a» (этот код больше не является свободным). Вот почему нам также нужно получить семантику для atomic_sub (или, строго говоря, нам нужен барьер получения, когда счетчик становится 0 после декремента).
В ток-шоу Херб memory_order_release
не memory_order_relaxed
, но расслабленным будет еще больше проблем.
Если не delete control_block_ptr
доступ control_block_ptr->refs
(чего, вероятно, нет), тогда атомарная операция не переносит зависимость для удаления. Операция удаления может не касаться какой-либо памяти в блоке управления, она может просто вернуть этот указатель на распределитель свободного хранилища.
Но я не уверен, говорит ли Херб о компиляторе, перемещающем удаление перед атомарной операцией, или просто ссылается на то, когда побочные эффекты становятся видимыми для других потоков.
Похоже, он говорит о синхронизации действий над самим разделяемым объектом, которые не отображаются в его блоках кода (и как результат — сбивают с толку).
Вот почему он положил acq_rel
— потому что все действия над объектом должны происходить до его уничтожения, все в порядке.
Но я все еще не уверен, почему он говорит об обмене delete
с fetch_sub
,
Это поздний ответ.
Давайте начнем с этого простого типа:
struct foo
{
~foo() { std::cout << value; }
int value;
};
И мы будем использовать этот тип в shared_ptr
, следующее:
void runs_in_separate_thread(std::shared_ptr<foo> my_ptr)
{
my_ptr->value = 5;
my_ptr.reset();
}
int main()
{
std::shared_ptr<foo> my_ptr(new foo);
std::async(std::launch::async, runs_in_separate_thread, my_ptr);
my_ptr.reset();
}
Два потока будут работать параллельно, оба совместно владеют foo
объект.
С правильным shared_ptr
реализация
(то есть один с memory_order_acq_rel
), эта программа определила поведение.
Единственное значение, которое эта программа напечатает, 5
,
С неправильной реализацией (используя memory_order_relaxed
) там
нет таких гарантий. Поведение не определено, потому что гонка данных
foo::value
вводится. Беда возникает только в тех случаях, когда деструктор
вызывается в основном потоке. С расслабленным порядком памяти, пишите
в foo::value
в другом потоке может не распространяться на деструктор в основном потоке.
Значение, отличное от 5
может быть напечатан.
Так что же такое гонка данных? Что ж, проверьте определение и обратите внимание на последний пункт:
Когда оценка выражения записывает данные в ячейку памяти, а другая оценка считывает или изменяет эту же ячейку памяти, считается, что выражения конфликтуют. Программа, которая имеет две противоречивые оценки, имеет гонку данных, если только
- обе конфликтующие оценки являются атомарными операциями (см. std :: atomic)
- одна из конфликтующих оценок происходит раньше другой (см. std :: memory_order)
В нашей программе один поток напишет в foo::value
и одна нить будет
читать из foo::value
, Они должны быть последовательными; запись
в foo::value
всегда должно происходить до прочтения. Интуитивно это
имеет смысл, что они будут, как деструктор должен быть последним
вещь, которая происходит с объектом.
memory_order_relaxed
не предлагает такие гарантии заказа, хотя и так memory_order_acq_rel
необходимо.