Объркване относно грешка при внедряване в деструктора shared_ptr

Току-що видях лекцията на Хърб Сътър: C++ and Beyond 2012: Herb Sutter - atomic‹> Weapons, 2 of 2

Той показва грешка в изпълнението на std::shared_ptr деструктор:

if( control_block_ptr->refs.fetch_sub(1, memory_order_relaxed ) == 0 )
    delete control_block_ptr; // B

Той казва, че поради memory_order_relaxed, delete може да бъде поставено преди fetch_sub.

В 1:25:18 - Освобождаването не задържа линия B отдолу, където трябва да бъде

Как е възможно това? Има връзка случва-преди / последователност-преди, защото и двете са в една нишка. Може и да греша, но също така има carries-a-dependency-to между fetch_sub и delete.

Ако е прав, кои ISO елементи поддържат това?


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


Отговори (4)


Представете си код, който освобождава споделен указател:

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

Ако dec_ref() няма семантика за "освобождаване", е напълно добре за компилатор (или CPU) да премести нещата от преди 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 atomic е напълно безопасно по отношение на страничния ефект на освобождаване на паметта от delete? - person tmyklebu; 10.02.2015
comment
С други думи, семантиката на освобождаване е достатъчна за обекти с тривиални деструктори. Самото изтриване очевидно не може да бъде пренаредено като цялостна операция - само части за четене могат да бъдат преместени нагоре извън него. частите за запис не могат да се преместват нагоре, защото спекулативните записи не са разрешени и първо трябва да се провери условието if. - person wonder.mice; 10.02.2015
comment
Разбирам, че освобождаването е достатъчно за тривиални dtors, но достатъчно ли е и отпуснато? - 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 за надпревара с данни, защото става видимо навсякъде преди не е включено в дефиницията за inter-thread happens-before. Сигурни ли сте, че това е точното четене на спецификацията? - 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