list :: empty () багатопотокова поведінка?


9

У мене є список, з якого я хочу захоплювати елементи з різних потоків. Щоб уникнути блокування файлу mutex, який захищає список, коли він порожній, я перевіряю empty()перед блокуванням.

Це добре, якщо дзвінок на list::empty()неправильний 100% часу. Я хочу лише уникнути збоїв або зривів одночасних list::push()та list::pop()викликів.

Чи можу я припустити, що VC ++ та Gnu GCC лише іноді empty()помиляться і нічого гіршого?

if(list.empty() == false){ // unprotected by mutex, okay if incorrect sometimes
    mutex.lock();
    if(list.empty() == false){ // check again while locked to be certain
         element = list.back();
         list.pop_back();
    }
    mutex.unlock();
}

1
Ні, ви не можете цього припустити. Ви можете використовувати такий контейнер, як VC's concurrent_queue
Panagiotis Kanavos

2
@Fureeish Це має бути відповіддю. Я би додав, що std::list::sizeмає гарантовану постійну часову складність, що в основному означає, що розмір (кількість вузлів) потрібно зберігати в окремій змінній; давайте назвемо це size_. std::list::emptyто, ймовірно, повертає щось на зразок size_ == 0, і одночасне читання і запис size_викликає перегони даних, отже, UB.
Даніель Лангр

@DanielLangr Як вимірюється "постійний час"? Це один виклик функції або повна програма?
curiousguy

1
@curiousguy: DanielLangr відповів на ваше запитання "незалежно від кількості вузлів списку", тобто точне визначення O (1), яке означає, що кожен виклик виконується менше, ніж за певний постійний час, незалежно від кількості елементів. en.wikipedia.org/wiki/Big_O_notation#Orders_of_common_functions Інший варіант (до C ++ 11) буде лінійним = O (n), тобто цей розмір повинен буде рахувати елементи (пов'язаний список), що було б ще гірше одночасність (більш очевидна гонка даних, ніж неатомне читання / запис на лічильнику).
firda

1
@curiousguy: Беручи власний приклад із dV, складність у часі - однакові математичні межі. Усі ці речі або визначені рекурсивно, або у формі "Існує C такий, що f (N) <C для кожного N" - це визначення O (1) (для даної / кожної HW існує постійна C така що альго закінчується за менший за С-час на будь-якому вході). Амортизовані кошти в середньому , це означає, що деякі процеси можуть зайняти більше часу (наприклад, повторний хеш / повторний розподіл), але він усе ще є постійним в середньому (припускаючи всі можливі введення).
firda

Відповіді:


10

Це добре, якщо дзвінок на list::empty()неправильний 100% часу.

Ні, це не нормально. Якщо ви перевірите, чи список порожній поза деяким механізмом синхронізації (блокування мютексу), то у вас є гонка даних. Наявність перегонів за даними означає, що у вас не визначена поведінка. Невизначена поведінка означає, що ми більше не можемо міркувати про програму, і будь-який результат, який ви отримаєте, є "правильним".

Якщо ви цінуєте свою розумність, перед тим, як перевірити, прийміть показ продуктивності та заблокуйте мютекс. При цьому список може бути навіть не правильним контейнером для вас. Якщо ви можете повідомити нам, що саме ви робите з цим, ми можемо запропонувати кращий контейнер.


Особиста перспектива, виклик list::empty()- це дія читання, яка не має нічого спільногоrace-condition
Ngọc Khánh Nguyễn

3
@ NgọcKhánhNguyễn Якщо вони додають елементи до списку, це, безумовно, викликає перегони даних, коли ви одночасно пишете та читаєте розмір.
NathanOliver

6
@ NgọcKhánhNguyễn Це помилково. Умовою перегону є read-writeабо write-write. Якщо ви не вірю я, дати стандартний розділ на гонках даних читання
NathanOliver

1
@ NgọcKhánhNguyễn: Оскільки ані записування, ані читання гарантовано не є атомарними, тому вони можуть виконуватися одночасно, тому читання може отримати щось зовсім неправильне (називається розірваним читанням). Уявіть, що змінити 0x00FF на 0x0100 у маленькому 8-бітовому MCU-ендіані, починаючи з перезапису низьких 0xFF до 0x00, і читання отримує саме цей нуль, читаючи обидва байти (запис потоку сповільнено або призупинено), запис продовжується оновленням високого байта до 0x01, але нитка для читання вже отримала неправильне значення (ні 0x00FF, ні 0x0100, але несподіване 0x0000).
firda

1
@ NgọcKhánhNguyễn Це може бути в деяких архітектурах, але віртуальна машина C ++ не дає такої гарантії. Навіть якщо ваше обладнання зробило це, було б законно, щоб компілятор оптимізував код таким чином, щоб ви ніколи не побачили змін, оскільки, якщо не відбувається синхронізація потоків, можна припустити, що він працює лише одним потоком і оптимізує відповідний спосіб.
НатанОлівер

6

Є читання та запис (швидше за все, sizeчлену std::list, якщо припустити, що він названий так), які не синхронізовані в реагуванні один на одного . Уявіть, що одна нитка дзвонить empty()(у вашу зовнішню if()), тоді як інша нитка увійшла до внутрішньої if()та виконує pop_back(). Потім ви читаєте змінну, яка, можливо, змінюється. Це невизначена поведінка.


2

Як приклад того, як все може піти не так:

Досить розумний компілятор міг бачити, що mutex.lock()неможливо змінити list.empty()повернене значення і, таким чином, повністю пропустити внутрішню ifперевірку, в кінцевому підсумку призведе до pop_backсписку, у якого останній елемент був видалений після першого if.

Чому це можна зробити? У ньому немає синхронізації list.empty(), тому, якщо вона буде змінена одночасно, це являло би перегони даних. У стандарті сказано, що програми не мають перегонів даних, тому компілятор вважатиме це належним (інакше він не може майже не проводити жодних оптимізацій). Отже, він може припустити однонитковий погляд на несинхронізований list.empty()і зробити висновок, що він повинен залишатися постійним.

Це лише одна з декількох оптимізацій (або апаратної поведінки), які можуть порушити ваш код.


Поточні компілятори навіть не хочуть оптимізувати a.load()+a.load()...
curiousguy

1
@curiousguy Як би це оптимізували? Ви вимагаєте повної послідовної послідовності там, так що ви отримаєте це ...
Макс Ленгоф

@MaxLanghof Ви не вважаєте, що оптимізація a.load()*2очевидна? Навіть a.load(rel)+b.load(rel)-a.load(rel)не оптимізовано. Нічого немає. Чому ви очікуєте, що замки (які в основному мають послідовність послідовності) будуть оптимізованішими?
curiousguy

@curiousguy Тому що впорядкованість пам’яті неатомних доступу (тут до і після блокування) та атома зовсім відрізняються? Я не чекаю, що замок буде оптимізований "більше", я очікую, що несинхронізований доступ буде оптимізований більше, ніж послідовно послідовний доступ. Наявність блокування не має значення для моєї точки зору. І немає, компілятор не може оптимізують a.load() + a.load()до 2 * a.load(). Сміливо задайте питання щодо цього, якщо хочете дізнатися більше.
Макс Ленгоф

@MaxLanghof Я поняття не маю, що ти навіть намагаєшся сказати. Замки по суті послідовно послідовні. Чому б імплементація намагалася робити оптимізацію для деяких примітивів (замків), а не для інших (атома)? Чи очікуєте ви оптимізувати доступ до неатомічного доступу щодо використання атомів?
curiousguy
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.