Путаница по поводу ошибки реализации в деструкторе shared_ptr

Я только что посмотрел доклад Херба Саттера: C ++ и последующие версии 2012: Херб Саттер - atomic‹> Оружие, 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 также существует зависимость.

Если он прав, какие пункты ИСО подтверждают это?


person qble    schedule 14.02.2013    source источник


Ответы (4)


Представьте себе код, который освобождает общий указатель:

auto tmp = &(the_ptr->a);
*tmp = 10;
the_ptr.dec_ref();

Если dec_ref () не имеет семантики «выпуска», для компилятора (или ЦП) вполне нормально перемещать вещи из перед dec_ref () в после него (например):

auto tmp = &(the_ptr->a);
the_ptr.dec_ref();
*tmp = 10;

И это небезопасно, так как dec_ref () также может быть вызван из другого потока одновременно и удалить объект. Таким образом, он должен иметь семантику «выпуска», чтобы вещи оставались там до 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);
        }
    }
}

Однако, если у нас есть только семантика выпуска для 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 после декремента).

person wonder.mice    schedule 10.02.2015
comment
Очень красивый пример. Это проблема только для объектов с нетривиальными dtors, которые должны работать с изменяемым состоянием объекта, верно? Значит, атомар relaxed полностью безопасен в отношении побочного эффекта освобождения памяти delete? - person tmyklebu; 10.02.2015
comment
Другими словами, семантики выпуска достаточно для объектов с тривиальными деструкторами. Само удаление, очевидно, не может быть переупорядочено как целая операция - из него могут быть перемещены только части чтения. части записи не могут быть перемещены вверх, потому что спекулятивные записи не разрешены и сначала необходимо проверить условие if. - person wonder.mice; 10.02.2015
comment
Я понимаю, что релиз достаточен для тривиальных врачей, но достаточно ли расслабленного? - person tmyklebu; 10.02.2015
comment
Нет. Первая часть примера объясняет, зачем нам нужен релиз. - person wonder.mice; 10.02.2015
comment
Не уверен, что куплюсь на это. Порядок памяти не имеет значения для однопоточных программ; это имеет значение только тогда, когда несколько потоков работают с одним и тем же участком памяти. Первая часть вашего ответа объясняет, что *tmp может относиться к объекту, который был только что удален, что я не думаю, что это правда, учитывая, что все происходит в одном потоке. - person tmyklebu; 10.02.2015
comment
И это небезопасно, поскольку dec_ref () также может быть вызван из другого потока одновременно и удалить объект. - person wonder.mice; 10.02.2015
comment
*tmp = 10 происходит раньше the_ptr.dec_ref() путем последовательности. Если другой поток удаляет объект, то the_ptr.dec_ref() становится видимым везде перед dec_ref() другого потока. Другой поток dec_ref() происходит перед delete путем упорядочения. Похоже, что спецификация объявляет взаимодействие delete и *tmp = 10 как гонку данных, потому что становится видимой повсюду до того, как она не включена в определение межпотока, произошедшего раньше. Вы уверены, что это точное чтение спецификации? - person tmyklebu; 10.02.2015
comment
Почему *tmp = 10 произойдет раньше, чем the_ptr.dec_ref(), если используется расслабленное? С ослабленным *tmp = 10 может стать видимым для других потоков после того, как эти потоки уже наблюдали результаты the_ptr.dec_ref(). - person wonder.mice; 07.04.2015

Это запоздалый ответ.

Начнем с этого простого типа:

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 является обязательным.

person Pubby    schedule 07.07.2016

В разговоре Херб показывает memory_order_release, а не memory_order_relaxed, но расслабиться было бы еще больше проблем.

Если delete control_block_ptr не обращается к control_block_ptr->refs (что, вероятно, не имеет), тогда атомарная операция не несет зависимости от удаления. Операция удаления может не затрагивать какую-либо память в блоке управления, она может просто вернуть этот указатель в распределитель freestore.

Но я не уверен, говорит ли Херб о том, что компилятор перемещает удаление перед атомарной операцией, или просто ссылается на то, когда побочные эффекты становятся видимыми для других потоков.

person Jonathan Wakely    schedule 14.02.2013
comment
говоря о компиляторе, перемещающем удаление перед атомарной операцией - 1:23:34: код остается ниже и выше ;;; или просто ссылаясь на то, когда побочные эффекты становятся видимыми для других потоков. - какие побочные эффекты? читать-изменять-писать каждый раз видеть последнее значение в порядке изменения - person qble; 14.02.2013
comment
но расслабиться было бы еще больше проблем. - какие проблемы? - person qble; 14.02.2013
comment
какие проблемы? Расслабленная операция - это вообще не операция синхронизации. - person Jonathan Wakely; 14.02.2013
comment
какие побочные эффекты? чтение-изменение-запись каждый раз видят последнее значение в порядке модификации, но, как я сказал в своем ответе, атомарная операция и delete не обращаются к одному и тому же месту в памяти, они обращаются к разным объектам. - person Jonathan Wakely; 14.02.2013
comment
Расслабленная операция - это вообще не операция синхронизации. - а какая здесь синхронизация нужна? - person qble; 14.02.2013
comment
Привет, обращаются к отдельным объектам, может быть, да, нет никакой зависимости, но все еще происходит раньше - person qble; 14.02.2013
comment
Существует связь "происходит до", но в стандарте говорится, что наблюдаемые результаты абстрактной машины - это операции с переменными и атомарными операциями, поскольку delete control_block_ptr не является ни тем, ни другим, в отсутствие барьера памяти для предотвращения движения кода компилятор может переместить его до декремент, и вы не заметите разницы ... кроме того, что здесь это вызовет ошибку, но компилятор этого не знает. Операция деблокирования не предотвращает перенос последующих операций до операции деблокирования. - person Jonathan Wakely; 14.02.2013
comment
Что ж, давайте сделаем fetch-sub даже неатомарным, нормальным декрементом, давайте рассмотрим один поток. Как компилятор может безоговорочно перемещать удаление до if? Как его можно удалить заранее? Что он будет делать, если тест не пройдет? Будет ли он восстанавливать объект? - person qble; 14.02.2013

Похоже, он говорит о синхронизации действий над самим разделяемым объектом, которые не показаны в его блоках кода (и как результат - сбивают с толку).

Поэтому поставил acq_rel - ведь все действия над объектом должны произойти до его уничтожения, все по порядку.

Но я все еще не уверен, почему он говорит о замене delete на fetch_sub.

person qble    schedule 14.02.2013
comment
Да, основная причина, по которой требуется операция получения-выпуска, - это межпоточная синхронизация, он лишь мимоходом упоминает движение кода и не объясняет его. - person Jonathan Wakely; 14.02.2013
comment
нужен для межпотоковой синхронизации - он нужен для кода, не показанного на слайде ... - person qble; 14.02.2013
comment
Да, показана только его часть: блок управления может содержать настраиваемое средство удаления, и средство удаления может быть вызвано в другом потоке, поэтому блок управления не должен быть уничтожен, пока средство удаления не завершит уничтожение объекта. - person Jonathan Wakely; 14.02.2013