Я чув, що i ++ не є потокобезпечним, чи є я безпечним для потоків?


90

Я чув, що i ++ не є потокозахищеним твердженням, оскільки при складанні він зводиться до зберігання вихідного значення десь як тимчасового, збільшуючи його, а потім замінюючи, що може бути перервано контекстним перемикачем.

Однак мені цікаво про ++ i. Наскільки я можу зрозуміти, це призведе до однієї інструкції збірки, наприклад, "додати r1, r1, 1", а оскільки це лише одна інструкція, її не можна переривати контекстним перемикачем.

Хтось може уточнити? Я припускаю, що використовується платформа x86.


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

5
Змінна класу в класі C ++, що підтримує кількість об’єктів?
paxdiablo

1
Хороше відео про цю справу, яку я щойно дивився сьогодні, тому що мені сказав інший хлопець: youtube.com/watch?v=mrvAqvtWYb4
Йоханнес Шауб - litb

1
перемiщено як C / C ++; Java тут не розглядається, C # подібний, але йому бракує такої жорстко визначеної семантики пам'яті.
Тім Вілліскрофт,

1
@Oscar Reyes Скажімо, у вас є два потоки, обидва використовують змінну i. Якщо нитка одна збільшує нитку лише тоді, коли вона знаходиться в певній точці, а інша лише зменшує нитку, коли вона знаходиться в іншій точці, вам доведеться турбуватися про безпеку нитки.
самоз

Відповіді:


157

Ви чули неправильно Цілком може бути, що "i++"це безпечно для потоку для певного компілятора та конкретної архітектури процесора, але це зовсім не передбачено стандартами. Насправді, оскільки багатопотоковість не є частиною стандартів ISO C або C ++ (a) , ви не можете вважати нічого безпечним для потоків, виходячи з того, що, на вашу думку, буде скомпільовано.

Цілком здійсненно, що ++iможна скомпілювати у довільну послідовність, таку як:

load r0,[i]  ; load memory into reg 0
incr r0      ; increment reg 0
stor [i],r0  ; store reg 0 back to memory

що не буде безпечним для потоків на моєму (уявному) процесорі, який не має інструкцій щодо збільшення пам’яті. Або він може бути розумним і скомпілювати його в:

lock         ; disable task switching (interrupts)
load r0,[i]  ; load memory into reg 0
incr r0      ; increment reg 0
stor [i],r0  ; store reg 0 back to memory
unlock       ; enable task switching (interrupts)

де lockвідключає та unlockдозволяє переривання. Але навіть тоді це не може бути безпечним для потоку в архітектурі, яка має більше одного з цих центральних процесорів, що мають спільну пам’ять ( lockможе вимикати переривання лише для одного центрального процесора).

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

Такі речі, як Java synchronizedта pthread_mutex_lock()(доступні для C / C ++ в деяких операційних системах) - це те, що вам потрібно вивчити (а) .


(а) Це питання було задано до того, як були завершені стандарти С11 та С ++ 11. Зараз ці ітерації запровадили підтримку потоків у мовних специфікаціях, включаючи атомарні типи даних (хоча вони, і потоки загалом, є необов’язковими, принаймні в C).


8
+1 за підкреслення того, що це не специфічна для платформи проблема, не кажучи вже про чітку відповідь ...
RBerteig

2
поздоровлення за ваш срібний значок C :)
Йоганнес Шауб - litb,

Я думаю, вам слід чітко визначити, що жодна сучасна ОС не дозволяє програмам користувальницького режиму вимикати переривання, а pthread_mutex_lock () не є частиною C.
Бастієн Леонард,

@Bastien, жодна сучасна ОС не працювала б на центральному процесорі, який не мав би інструкції щодо збільшення пам’яті :-) Але ваша думка про C.
paxdiablo

5
@Bastien: Бик. Процесори RISC, як правило, не мають інструкцій щодо збільшення пам’яті. Триплет навантаження / додавання / зберігання - це те, як ви це робите, наприклад, на PowerPC.
derobert

42

Ви не можете зробити загальну заяву ні про ++ i, ні про i ++. Чому? Подумайте про збільшення 64-розрядного цілого числа в 32-розрядної системі. Якщо основна машина не має інструкції з чотирьох слів «завантажувати, збільшувати, зберігати», збільшення цього значення потребуватиме декількох інструкцій, кожна з яких може бути перервана перемикачем контексту потоку.

До того ж, ++iне завжди "додайте до значення". У такій мові, як C, збільшення вказівника насправді додає розмір речі, на яку вказують. Тобто, якщо iє покажчиком на 32-байтову структуру, ++iдодає 32 байти. Тоді як майже всі платформи мають атомну інструкцію "збільшення значення за адресою пам'яті", не всі мають атомну інструкцію "додати довільне значення до значення за адресою пам'яті".


35
Звичайно, якщо ви не обмежитеся нудними 32-розрядними цілими числами, такою мовою, як C ++, ++, я справді можу бути викликом веб-служби, яка оновлює значення в базі даних.
Eclipse

16

Вони обидва небезпечні для потоків.

Процесор не може робити математику безпосередньо з пам'яттю. Це робиться опосередковано, завантажуючи значення з пам'яті та обчислюючи регістри ЦП.

i ++

register int a1, a2;

a1 = *(&i) ; // One cpu instruction: LOAD from memory location identified by i;
a2 = a1;
a1 += 1; 
*(&i) = a1; 
return a2; // 4 cpu instructions

++ i

register int a1;

a1 = *(&i) ; 
a1 += 1; 
*(&i) = a1; 
return a1; // 3 cpu instructions

В обох випадках існує умова перегонів, що призводить до непередбачуваного значення i.

Наприклад, припустимо, що є два одночасних потоки ++ i, кожен з яких використовує регістри a1, b1 відповідно. І, з перемиканням контексту, виконаним таким чином:

register int a1, b1;

a1 = *(&i);
a1 += 1;
b1 = *(&i);
b1 += 1;
*(&i) = a1;
*(&i) = b1;

В результаті i не стає i + 2, воно стає i + 1, що є неправильним.

Щоб виправити це, модемні центральні процесори надають певні інструкції БЛОКУВАННЯ, РОЗБЛОКУВАННЯ процесора протягом інтервалу, коли перемикання контексту вимкнено.

У Win32 використовуйте InterlockedIncrement (), щоб зробити i ++ для захисту потоків. Це набагато швидше, ніж покладатися на мьютекс.


6
"Процесор не може робити математику безпосередньо з пам’яттю" - Це не точно. Є процесори, де ви можете робити математику "безпосередньо" на елементах пам'яті, без необхідності завантажувати її в реєстр спочатку. Напр. MC68000
darklon

1
Інструкції LOCK і UNLOCK CPU не мають нічого спільного з перемикачами контексту. Вони блокують лінії кешу.
Девід Шварц,

11

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

Редагувати: Одне, що можна припустити з більшістю архітектур, це те, що якщо ви маєте справу з правильно вирівняними окремими словами, у вас не вийде жодне слово, що містить комбінацію двох значень, які були змішані разом. Якщо дві записи трапляються одна над одною, одна переможе, а іншу відкине. Якщо ви будете обережні, ви можете скористатися цим і переконатися, що ++ i або i ++ є безпечними для потоку в ситуації одного запису / багаторазового зчитування.


Фактично неправильно в середовищах, де доступ int (читання / запис) є атомним. Є алгоритми, які можуть працювати в таких середовищах, хоча відсутність бар'єрів пам'яті може означати, що ви іноді працюєте над застарілими даними.
MSalters

2
Я просто кажу, що атомність не гарантує безпеку потоків. Якщо ви достатньо розумні, щоб розробити безблоковані структури даних або алгоритми, тоді продовжуйте. Але ви все одно повинні знати, які гарантії вам надає ваш компілятор.
Eclipse,

10

Якщо ви хочете збільшити атом в C ++, ви можете використовувати бібліотеки C ++ 0x ( std::atomicтип даних) або щось на зразок TBB.

Було колись, коли керівні принципи кодування GNU казали, що оновлення типів даних, які вміщуються в одному слові, було "зазвичай безпечним", але ця порада неправильна для машин SMP, неправильна для деяких архітектур і неправильна при використанні оптимізуючого компілятора.


Щоб пояснити коментар "оновлення типу даних з одним словом":

Можливо, щоб два процесори на машині SMP писали в одне і те ж місце пам’яті за один і той же цикл, а потім спробували розповсюдити зміни на інші центральні процесори та кеш. Навіть якщо пишеться лише одне слово даних, тож запис займає лише один цикл, вони також відбуваються одночасно, тому ви не можете гарантувати, який запис буде успішним. Ви не отримаєте частково оновлених даних, але одне написання зникне, оскільки немає іншого способу вирішити цю справу.

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

І хоча оптимізаційний компілятор не впливає на те, як компілюється навантаження / сховище, він може змінюватися, коли відбувається завантаження / зберігання, спричиняючи серйозні проблеми, якщо ви очікуєте, що ваші читання та записи відбуватимуться в тому ж порядку, в якому вони з’являються у вихідному коді ( найвідоміший із перевірених блокувань не працює у ванілі C ++).

ПРИМІТКА. У моїй оригінальній відповіді також було сказано, що 64-розрядна архітектура Intel була порушена при роботі з 64-розрядними даними. Це неправда, тому я відредагував відповідь, але моє редагування стверджувало, що мікросхеми PowerPC зламані. Це вірно при зчитуванні безпосередніх значень (тобто констант) у регістри (див. Два розділи з назвою "Завантаження покажчиків" у списку 2 та списку 4). Але є інструкція щодо завантаження даних з пам'яті за один цикл ( lmw), тому я видалив цю частину своєї відповіді.


Читання та запис є атомними на більшості сучасних процесорів, якщо ваші дані вирівняні природним чином і мають правильний розмір, навіть із SMP та оптимізуючими компіляторами. Однак існує багато застережень, особливо з 64-розрядними машинами, тому може бути громіздко забезпечити відповідність даних вашим вимогам на кожній машині.
Ден Олсон,

Дякуємо за оновлення. Правильно, читання та запис є атомними, оскільки ви заявляєте, що вони не можуть бути заповнені наполовину, але ваш коментар підкреслює, як ми підходимо до цього факту на практиці. Те саме з бар’єрами пам’яті, вони не впливають на атомний характер операції, а на те, як ми підходимо до неї на практиці.
Ден Олсон,


4

Якщо ваша мова програмування нічого не говорить про потоки, але працює на багатопотоковій платформі, як будь-яка конструкція мови може бути безпечною для потоків?

Як зазначали інші: вам потрібно захищати будь-який багатопотоковий доступ до змінних за допомогою викликів конкретної платформи.

Є бібліотеки, які абстрагують специфіку платформи, а майбутній стандарт С ++ адаптував свою модель пам'яті для роботи з потоками (і, отже, може гарантувати безпеку потоків).


4

Навіть якщо воно зведене до однієї інструкції збірки, збільшуючи значення безпосередньо в пам'яті, воно все одно не є безпечним для потоку.

При збільшенні значення в пам'яті апаратне забезпечення виконує операцію "читання-модифікація-запис": воно зчитує значення з пам'яті, збільшує його і записує назад у пам'ять. Апаратне забезпечення x86 не може збільшити безпосередньо в пам’яті; оперативна пам'ять (і кеші) здатна лише читати і зберігати значення, а не змінювати їх.

Тепер припустимо, у вас є два окремих ядра, або на окремих сокетах, або спільне використання одного сокета (із загальним кешем або без нього). Перший процесор зчитує значення, і перш ніж він може записати оновлене значення, другий процесор зчитує його. Після того, як обидва процесори запишуть значення назад, воно буде збільшено лише один раз, а не двічі.

Існує спосіб уникнути цієї проблеми; Процесори x86 (і більшість багатоядерних процесорів, які ви знайдете) здатні виявляти такий тип конфлікту в апаратному забезпеченні та секвенувати його, так що вся послідовність читання-модифікації-запису виглядає атомною. Однак, оскільки це дуже дорого, це робиться лише за запитом коду, на x86 зазвичай через LOCKпрефікс. Інші архітектури можуть робити це іншими способами, маючи подібні результати; наприклад, прив'язане до навантаження / умовне зберігання та атомне порівняння та обмін (останні процесори x86 також мають цей останній).

Зауважте, що використання volatileтут не допомагає; він лише повідомляє компілятору, що змінна могла бути змінена зовні, і зчитування цієї змінної не повинно бути кешовано в реєстрі або оптимізовано. Це не змушує компілятор використовувати атомні примітиви.

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


2

Ніколи не припускайте, що приріст буде скомпільований до атомної операції. Використовуйте InterlockedIncrement або будь-які подібні функції, що існують на вашій цільовій платформі.

Редагувати: Я щойно розглянув це конкретне питання, і збільшення на X86 є атомним на однопроцесорних системах, але не на багатопроцесорних. Використання префікса блокування може зробити його атомарним, але набагато портативнішим лише для використання InterlockedIncrement.


1
InterlockedIncrement () - це функція Windows; усі мої коробки Linux та сучасні машини OS X базуються на x64, тому кажучи, що InterlockedIncrement () набагато портативніший, ніж код x86, досить хибний.
Піт Кіркхем,

Це набагато портативніше в тому ж сенсі, що C набагато портативніший, ніж збірка. Мета тут полягає в тому, щоб захистити себе від покладання на конкретно створену збірку для конкретного процесора. Якщо вас турбують інші операційні системи, то InterlockedIncrement легко упаковується.
Ден Олсон,

2

Згідно з цим уроком збірки на x86, ви можете атомарно додати регістр до місця пам'яті , тому потенційно ваш код може атомарно виконати '++ i' ou 'i ++'. Але, як сказано в іншому дописі, C ansi не застосовує атомність до операції '++', тому ви не можете бути впевнені в тому, що створить ваш компілятор.


1

Стандарт 1998 року C ++ не має нічого сказати про потоки, хоча наступний стандарт (який повинен бути випущений цього чи наступного року). Тому ви не можете сказати нічого розумного про безпеку операцій без посилання на реалізацію. Це не просто використовуваний процесор, а комбінація компілятора, ОС та моделі потоку.

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

Ніщо не є безпечним для потоків, якщо у вас немає документації, яка говорить, що це стосується конкретної системи, яку ви використовуєте.


1

Киньте i в потокове локальне сховище; це не атомно, але тоді це не має значення.


1

AFAIK, Відповідно до стандарту C ++, читання / запис в an intє атомними.

Однак все, що це робить, - це позбутися невизначеної поведінки, пов’язаної з гонкою даних.

Але все одно буде гонка даних, якщо обидва потоки намагатимуться збільшити i.

Уявіть такий сценарій:

Нехай i = 0спочатку:

Потік A зчитує значення з пам'яті та зберігає у власному кеші. Потік A збільшує значення на 1.

Потік B зчитує значення з пам'яті та зберігає у власному кеші. Потік B збільшує значення на 1.

Якщо це все єдиний потік, ви отримаєте i = 2в пам'яті.

Але в обох потоках кожен потік записує свої зміни, і тому Потік А записує i = 1назад у пам’ять, а Потік В записує i = 1в пам’ять.

Це чітко визначено, немає часткового руйнування чи будівництва чи будь-якого розриву об’єкта, але це все одно гонка даних.

Для атомного збільшення iви можете використовувати:

std::atomic<int>::fetch_add(1, std::memory_order_relaxed)

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


0

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

Не знаючи мови, відповідь полягає в тому, щоб перевірити його.


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

2
Погодьтеся з @Josh тут. Щось є безпечним лише для потоків, якщо це можна довести математично шляхом аналізу базового коду. Жодне тестування не може наблизитися до цього.
Рекс М

Це була чудова відповідь до останнього речення.
Роб К

0

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

У будь-якому випадку, навіть якщо оператор приросту атомний, це не гарантує, що решта обчислень буде послідовною, якщо ви не використовуєте правильні блокування.

Якщо ви хочете експериментувати самостійно, напишіть програму, де N потоків одночасно збільшують спільну змінну M разів у кожну ... якщо значення менше N * M, тоді деякий приріст було перезаписано. Спробуйте як з попереднім збільшенням, так і з подальшим збільшенням і скажіть нам ;-)


0

Для лічильника я рекомендую використовувати ідіому порівняння та обміну, яка не блокується та не захищає потоки.

Ось це на Java:

public class IntCompareAndSwap {
    private int value = 0;

    public synchronized int get(){return value;}

    public synchronized int compareAndSwap(int p_expectedValue, int p_newValue){
        int oldValue = value;

        if (oldValue == p_expectedValue)
            value = p_newValue;

        return oldValue;
    }
}

public class IntCASCounter {

    public IntCASCounter(){
        m_value = new IntCompareAndSwap();
    }

    private IntCompareAndSwap m_value;

    public int getValue(){return m_value.get();}

    public void increment(){
        int temp;
        do {
            temp = m_value.get();
        } while (temp != m_value.compareAndSwap(temp, temp + 1));

    }

    public void decrement(){
        int temp;
        do {
            temp = m_value.get();
        } while (temp > 0 && temp != m_value.compareAndSwap(temp, temp - 1));

    }
}

Здається схожим на функцію test_and_set.
samoz

1
Ви написали "не блокування", але чи не означає "синхронізоване" блокування?
Corey Trager
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.