Одна інструкція може мати вигляд однієї неподільної дії, хоча насправді нею не є. Візьмімо додавання пʼяти до значення в памʼяті:
add qword [rel counter], 5
Під капотом процесор не має способу додавати безпосередньо до памʼяті. Він розбиває цю одну інструкцію на три менші кроки, які називають мікроопераціями:
mov rax, qword [rel counter] ; 1. load the current value
add rax, 5 ; 2. add five
mov qword [rel counter], rax ; 3. store the result back
Це поширений шаблон під назвою read-modify-write (RMW).
Зауважмо, що читання (або завантаження) і запис (або збереження) є окремими подіями, тож між ними є проміжок часу.
Зазвичай ми цього не помічаємо, бо на одному ядрі кожна інструкція гарантовано справляє свій ефект повністю, перш ніж почнеться наступна.
Тому цей проміжок невидимий, і add поводиться як єдине ціле.
Однак сучасні процесори рідко бувають одноядерними, і застосунки часто працюють одразу на багатьох ядрах, без жодного впорядкування між ними.
Коли кілька ядер працюють в один і той самий момент, інше ядро може прочитати або записати counter усередині цього проміжку, після завантаження цим ядром і перед його збереженням.
Два потоки завантажують те саме старе значення, кожен додає пʼять і кожен зберігає свій результат.
Відбулося два додавання, але значення зросло лише на пʼять.
Одне оновлення було тихо втрачено.
Це гонка даних, поширена проблема в багатопотоковому коді.
Не кожне значення піддається такій небезпеці.
Кожен потік має власні регістри й власний стек, тож значення, що зберігається в регістрі, або локальна змінна у стеку потоку, належить лише цьому потоку й не може зазнати гонки.
Захисту потребує лише памʼять, яку потоки спільно використовують, як-от counter вище.
x86-64 пропонує набір інструкцій, які розвʼязують цю проблему, роблячи інструкцію неподільною. Вона поводиться як єдине ціле не лише для ядра, яке її виконує, а й для кожного іншого ядра. Операція, яка тримається як одне ціле, яку жодне інше ядро не може розділити, називається атомарною.
Застосунки, що працюють на багатьох ядрах, часто називають багатопотоковими. Однак потік не те саме, що ядро. Два потоки можуть виконуватися одночасно на одному ядрі, переплітаючись між собою, або паралельно на різних ядрах.
Потоки, що переплітаються, можуть зазнати гонки під час read-modify-write, навіть якщо його розбито на кілька інструкцій, адже операційна система може перемикати потоки між будь-якими двома інструкціями. Проте проміжок усередині однієї інструкції виявляється лише в справді паралельному коді. Оскільки операційна система перемикає потоки лише між інструкціями, ніколи всередині однієї, на одному ядрі окрема інструкція безпечна за самою своєю природою.
Справжню атомарність на багатьох ядрах і забезпечують інструкції, описані нижче.
Інструкція xchg міняє місцями два операнди.
Операнд-призначення набуває попереднього значення операнда-джерела, а операнд-джерело набуває попереднього значення операнда-призначення.
Концептуально це можна уявити як дві інструкції mov, що виконуються одночасно.
Як завжди, її можна застосувати з двома операндами-регістрами або з одним операндом у памʼяті та одним операндом-регістром:
mov eax, 1
xchg dword [rdi], eax ; [rdi] = 1, eax = the old value of [rdi]
mov ecx, 2
mov edx, 3
xchg edx, ecx ; edx = 2, ecx = 3
Коли xchg використовують з операндом у памʼяті, вона завжди атомарна.
xchg стає атомарною автоматично, коли один з операндів розташований у памʼяті.
Це також означає, що в такій ситуації операція виконується значно повільніше.
Якщо атомарність не потрібна, обміняти значення можна через вільний регістр звичайними інструкціями mov.
lock
Найпоширеніший спосіб зробити інструкцію атомарною в x86-64 - додати префікс lock.
Він зливає читання, зміну й запис в один неподільний крок.
Це означає, що ядро утримує памʼять монопольно протягом усього процесу, тож жодне інше ядро не може прочитати чи записати це місце в проміжку.
lock add qword [rel counter], 5 ; the read, the modify, and the write are one step
lock працює лише тоді, коли призначення розташоване в памʼяті, і лише з інструкціями, які виконують read-modify-write цієї памʼяті:
add, sub, inc, dec, neg;and, or, xor, not;bts, btr, btc;xadd і cmpxchg, описані нижче.Тримати ділянку памʼяті монопольно й не пускати до неї всі інші ядра не буває безкоштовним.
Операція з префіксом lock помітно повільніша за свою звичайну форму, а коли кілька ядер змагаються за одну ділянку, то повільніша ще більше.
Цей префікс варто приберегти для памʼяті, яку, як очікується, змінюватиме більше ніж один потік. Уникаймо його, якщо памʼять не є спільною або якщо її лише читають.
Звичайна lock add оновлює памʼять, але відкидає старе значення.
Часто саме старе значення й потрібне, наприклад, щоб видати кожному потоку окремий номер.
Інструкція xadd (x від exchange) повертає попереднє значення разом із додаванням.
Вона записує суму в призначення, а початкове значення призначення залишає в регістрі-джерелі.
mov rax, 1
lock xadd qword [rdi], rax ; [rdi] = [rdi] + rax = [rdi] + 1
; rax = the old value of [rdi]
Із префіксом lock така операція стає атомарним fetch-and-add.
Коли її виконують багато потоків над одним лічильником, кожен виклик повертає різне старе значення.
Як і з lock add, лічильник зрештою дорівнює точній кількості викликів.
Однак, на відміну від lock add, кожне проміжне значення теж повертається, по одному кожному виклику.
xadd додає, а xchg перезаписує, але жодна з них не може зробити так, щоб нове значення залежало від поточного й застосовувалося лише тоді, коли під ним нічого не змінилося.
Саме це умовне оновлення й надає cmpxchg, compare-and-exchange, і воно є найзагальнішим із цих примітивів.
cmpxchg dest, src використовує rax як неявний акумулятор і порівнює його з dest:
dest == rax, то dest = src і ZF = 1.dest != rax, то rax = dest і ZF = 0.Зауважмо, що dest оновлюється лише тоді, коли дорівнює очікуваному значенню, попередньо завантаженому в rax.
Ця рівність гарантує, що dest досі містить значення, з якого обчислено нове, тож оновлення на основі застарілого читання ніколи не застосовується.
Саме тому cmpxchg є будівельним блоком для атомарного оновлення, яке також називають compare-and-swap (CAS):
mov rax, qword [rdi] ; rax = the value we expect to find
.retry:
lea rcx, [rax + 10] ; rcx = the new value we want to install
lock cmpxchg qword [rdi], rcx ; if [rdi] still equals rax, store rcx and set ZF
; otherwise reload rax with the current value, clear ZF
jnz .retry ; ZF is cleared, so another thread won the race. Recompute and retry
Цей цикл повторів є серцем оновлень без блокувань.
Проміжок між читанням і compare-and-exchange - це саме той момент, коли може втрутитися інший потік, і cmpxchg ловить це, відмовляючись зберігати значення, обчислене із застарілого читання.
Досі кожна операція торкалася однієї ділянки. Коли потоки узгоджують роботу через більш ніж одну ділянку, постає нове питання: у якому порядку записи одного потоку стають видимими іншому. Відповідь на це питання дають правила упорядкування памʼяті процесора.
У концепції коду без розгалужень ішлося про те, що сучасне ядро не перебирає інструкції по одній. Воно тримає в роботі багато інструкцій одночасно і забігає наперед там, де може. Це означає, що запис може стати видимим для інших ядер пізніше, ніж припускає програма, тоді як інструкції після нього вже виконалися наперед.
x86-64 підтримує сильне впорядкування памʼяті між звичайними завантаженнями й збереженнями, так що на кожному ядрі:
Єдине можливе переставляння - це коли збереження начебто завершується після пізнішого завантаження з іншої адреси.
Інструкція з префіксом lock або xchg з операндом у памʼяті є повним барʼєром: ніщо не переміщується через нього в жодному напрямку.
Саме тому їх достатньо, щоб забезпечити повне впорядкування в більшості ситуацій.
pause
Інструкції, які встановлюють прапорець і водночас повертають його попередній стан, називаються test-and-set. Їх можна використати як основу спінлока, щоб ядро мало монопольний доступ до якоїсь частини коду.
Ось загальний алгоритм, який використовує інструкцію xchg із двійковим прапорцем:
0.1.1, це означає, що блокування утримує інше ядро.
Тоді поточне ядро чекає й намагається захопити блокування знову.0, це означає, що блокування було вільним.
Тепер xchg встановила його в 1, і інші ядра чекатимуть, доки поточне ядро його не звільнить.0 звільняє блокування.acquire:
mov eax, 1
xchg dword [rdi], eax ; try to take the lock; eax = its old value
test eax, eax
jnz .held ; old value was 1: someone else holds it
ret ; old value was 0: the lock is ours
.held:
pause ; wait before trying again
jmp acquire
Інструкція pause у циклі очікування не змінює того, що обчислює код.
Вона підказує процесору, що це активне очікування.
Тоді процесор може зменшити споживання енергії для потоку, що чекає, і поступитися сусідньому потоку, який ділить із ним те саме ядро.
Цикл активного очікування без pause усе одно правильний, просто марнотратний.
Коли потік завершує роботу, він може звільнити блокування звичайним збереженням 0 за допомогою mov.
Більше нічого, крім mov, не потрібно, бо в x86-64 завантаження й збереження ніколи не переставляються після пізнішого збереження.
Про збереження, яке ніколи не обганяє попередні звернення, кажуть, що воно має release ordering, і на x86 кожне звичайне збереження його має.
Будь-яку інструкцію, яка атомарно перевіряє й встановлює ділянку памʼяті, можна використати для спінлока.
Наприклад, замість xchg можна використати lock bts, щоб установити конкретний біт і перевірити, чи він уже був установлений.
Зауважмо, що прапорці, як-от CF, який змінює bts, є частиною rflags, регістра.
Це означає, що вони належать кожному потоку окремо.
Премʼєрний вечір у «Гранд Маркі», і вся команда працює одночасно. Натовп вливається крізь усі двері, кілька кас продають квитки з одного рулону, премʼєра майже розпродана, а в проєкційній будці котушки мають рухатися без перерв.
Усе, що команда використовує спільно, живе за адресою памʼяті, і тести викликають наші функції з кількох потоків одночасно. Значення, яке читають, змінюють і записують назад окремими кроками, за такого навантаження втрачатиме оновлення, тож кожна функція має виконувати своє оновлення атомарно.
Усі спільні значення в цій вправі - 64-бітові цілі числа зі знаком.
На нас чекають пʼять завдань.
Оновлення в цій вправі слід виконувати атомарними інструкціями. Тести запускають наші функції паралельно й перевіряють, що жодне оновлення не втрачено, тож неатомарне читання-зміна-запис дуже ймовірно їх не пройде.
Двері відчинені, і біля кожних з них білетер відриває квитки. Кожну пропущену групу зараховують до одного спільного лічильника відвідувачів, і хоча всі двері рахують одночасно, підсумкова кількість у залі все одно має вийти правильною.
Реалізуйте функцію admit_group, яка атомарно додає розмір групи до лічильника.
Ця функція приймає такі аргументи, у цьому порядку:
admissions: адреса памʼяті спільного лічильника відвідувачів, 64-бітове ціле число зі знаком.group_size: кількість людей у групі, 64-бітове ціле число зі знаком, завжди більше за 0.admissions = 120, group_size = 4
// admissions is now 124
Тести пропускають групи крізь кілька дверей одночасно. Підсумковий лічильник має дорівнювати сумі всіх пропущених груп.
Ця функція не повертає жодного значення. Лічильник слід оновлювати на місці.
На проєкторі завжди стоїть рівно одна котушка. Встановлення наступної котушки й зняття поточної мають бути одним рухом: якщо біля проєктора стоять двоє кіномеханіків, два окремі кроки могли б віддати ту саму котушку обом або впустити одну на підлогу.
Реалізуйте функцію change_reel, яка атомарно встановлює наступну котушку й повертає ту, що була встановлена.
Ця функція приймає такі аргументи, у цьому порядку:
mounted: адреса памʼяті ідентифікатора встановленої котушки, 64-бітове ціле число зі знаком.next_reel: ідентифікатор котушки, яку треба встановити, 64-бітове ціле число зі знаком.mounted = 7, next_reel = 9
// => 7, and mounted is now 9
Ця функція повертає ідентифікатор раніше встановленої котушки як 64-бітове ціле число зі знаком. Тести змушують кілька потоків одночасно міняти котушки на тому самому кріпленні й перевіряють, що кожна передана котушка повертається рівно один раз.
Кожна каса продає з одного спільного лічильника проданих квитків. Тут недостатньо самого додавання до лічильника, як це роблять двері: кожен продаж має ще й отримати своє число. Жодні два продажі не можуть мати спільне число, і після напливу лічильник має точно дорівнювати кількості проданих квитків.
Реалізуйте функцію sell_ticket, яка атомарно додає одиницю до лічильника й повертає номер щойно проданого квитка.
Номер квитка - це значення лічильника після цього продажу: якщо лічильник дорівнював 41, цей продаж дає квиток 42.
Ця функція приймає такий аргумент:
tickets_sold: адреса памʼяті спільного лічильника проданих квитків, 64-бітове ціле число зі знаком.tickets_sold = 0
// => 1, and tickets_sold is now 1
tickets_sold = 41
// => 42, and tickets_sold is now 42
Ця функція повертає номер квитка як 64-бітове ціле число зі знаком. Тести продають з кількох кас одночасно: кожне повернене число має бути унікальним, а підсумковий лічильник має дорівнювати кількості продажів.
Премʼєра має фіксовану кількість місць, і кожна каса продає квитки на неї. Продаж може відбутися лише тоді, коли ще є вільне місце, і на показі не можна продати більше квитків, ніж є місць, навіть коли всі каси одночасно хапаються за останнє місце. Якщо перевіряти лічильник, а потім окремим кроком додавати до нього, між цими кроками утворюється проміжок, тож перевірка й оновлення мають виконуватися разом: або обидва вдалі, або обидва ні.
Реалізуйте функцію claim_seat, яка атомарно займає наступне місце, якщо воно ще вільне.
Коли місце зайнято, функція повертає номер цього місця, тобто кількість зайнятих місць після цього заняття.
Коли на показ усе розпродано, вона повертає 0 і залишає лічильник без змін.
Ця функція приймає такі аргументи, у цьому порядку:
seats_taken: адреса памʼяті лічильника вже зайнятих місць, 64-бітове ціле число зі знаком, ніколи не більше за capacity.capacity: кількість місць на показі, 64-бітове ціле число зі знаком, завжди більше за 0.seats_taken = 0, capacity = 150
// => 1, and seats_taken is now 1
seats_taken = 82, capacity = 150
// => 83, and seats_taken is now 83
seats_taken = 149, capacity = 150
// => 150, and seats_taken is now 150
seats_taken = 150, capacity = 150
// => 0, and seats_taken stays 150
Ця функція повертає номер місця або 0, якщо на показ усе розпродано, як 64-бітове ціле число зі знаком.
У проєкційній будці вміщується одна людина.
Слово в памʼяті вказує, чи вона зайнята: 0 означає вільно, 1 означає зайнято.
Усю ніч туди заходять працівники, кожен зі своїм завданням всередині: підписати журнал, заправити проєктор, здати котушку до архіву.
Яким би не було завдання, правило будки те саме, і стежити за його дотриманням - наша робота: одна людина всередині за раз, від початку її завдання до кінця.
Реалізуйте функцію visit_booth, яка:
Поки будку займає хтось інший, вона має ввічливо чекати за допомогою pause.
Ця функція приймає такі аргументи, у цьому порядку:
booth: адреса памʼяті слова зайнятості, 64-бітове ціле число зі знаком, що дорівнює 0 або 1.task: завдання відвідувача, функція, яка не приймає параметрів і не повертає значення.Варто зважити, що тести запускають завдання, які звертаються до будки й до спільного журналу. Це означає, що будка має бути зайнята протягом усього завдання, кожен візит має виконати своє завдання рівно один раз, а коли відвідувачів кілька одночасно, журнал має вийти точним.
Завдання відвідувача - це звичайна функція, яка дотримується угоди про виклики:
Порушення будь-якого з цих правил може проявитися як аварійне завершення, а не як невдала перевірка.
Зареєструйтеся на Exercism, щоб вивчати й опановувати x86-64 Assembly, а також 22 концепції130 вправ та справжнє наставництво від людей, і все це безкоштовно.