Премʼєра

Премʼєра

Навчальна вправа

Вступ

Атомарність

Одна інструкція може мати вигляд однієї неподільної дії, хоча насправді нею не є. Візьмімо додавання пʼяти до значення в памʼяті:

add qword [rel counter], 5

Під капотом процесор не має способу додавати безпосередньо до памʼяті. Він розбиває цю одну інструкцію на три менші кроки, які називають мікроопераціями:

  1. читання поточного значення з памʼяті;
  2. зміна цього значення в регістрі, додавання до нього пʼяти;
  3. запис результату назад.
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 пропонує набір інструкцій, які розвʼязують цю проблему, роблячи інструкцію неподільною. Вона поводиться як єдине ціле не лише для ядра, яке її виконує, а й для кожного іншого ядра. Операція, яка тримається як одне ціле, яку жодне інше ядро не може розділити, називається атомарною.

Note

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

Потоки, що переплітаються, можуть зазнати гонки під час 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 використовують з операндом у памʼяті, вона завжди атомарна.

Caution

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 цієї памʼяті:

  1. арифметичні операції, як-от add, sub, inc, dec, neg;
  2. порозрядні операції, як-от and, or, xor, not;
  3. бітові операції bts, btr, btc;
  4. деякі інші спеціальні інструкції, як-от xadd і cmpxchg, описані нижче.
Caution

Тримати ділянку памʼяті монопольно й не пускати до неї всі інші ядра не буває безкоштовним. Операція з префіксом 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 підтримує сильне впорядкування памʼяті між звичайними завантаженнями й збереженнями, так що на кожному ядрі:

  1. завантаження ніколи не переставляється після пізнішого завантаження;
  2. збереження ніколи не переставляється після пізнішого збереження;
  3. завантаження ніколи не переставляється після пізнішого збереження.

Єдине можливе переставляння - це коли збереження начебто завершується після пізнішого завантаження з іншої адреси.

Інструкція з префіксом lock або xchg з операндом у памʼяті є повним барʼєром: ніщо не переміщується через нього в жодному напрямку. Саме тому їх достатньо, щоб забезпечити повне впорядкування в більшості ситуацій.

Активне очікування та pause

Інструкції, які встановлюють прапорець і водночас повертають його попередній стан, називаються test-and-set. Їх можна використати як основу спінлока, щоб ядро мало монопольний доступ до якоїсь частини коду.

Ось загальний алгоритм, який використовує інструкцію xchg із двійковим прапорцем:

  1. Прапорець починається зі значення 0.
  2. Щоб захопити блокування, ядро міняє значення прапорця на 1.
  3. Якщо повернене значення дорівнює 1, це означає, що блокування утримує інше ядро. Тоді поточне ядро чекає й намагається захопити блокування знову.
  4. Якщо повернене значення дорівнює 0, це означає, що блокування було вільним. Тепер xchg встановила його в 1, і інші ядра чекатимуть, доки поточне ядро його не звільнить.
  5. Коли поточне ядро завершує роботу, оновлення прапорця значенням 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 кожне звичайне збереження його має.

Note

Будь-яку інструкцію, яка атомарно перевіряє й встановлює ділянку памʼяті, можна використати для спінлока. Наприклад, замість xchg можна використати lock bts, щоб установити конкретний біт і перевірити, чи він уже був установлений.

Зауважмо, що прапорці, як-от CF, який змінює bts, є частиною rflags, регістра. Це означає, що вони належать кожному потоку окремо.

Вказівки

Премʼєрний вечір у «Гранд Маркі», і вся команда працює одночасно. Натовп вливається крізь усі двері, кілька кас продають квитки з одного рулону, премʼєра майже розпродана, а в проєкційній будці котушки мають рухатися без перерв.

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

Усі спільні значення в цій вправі - 64-бітові цілі числа зі знаком.

На нас чекають пʼять завдань.

Note

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

1. Порахувати відвідувачів

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

Реалізуйте функцію admit_group, яка атомарно додає розмір групи до лічильника.

Ця функція приймає такі аргументи, у цьому порядку:

  • admissions: адреса памʼяті спільного лічильника відвідувачів, 64-бітове ціле число зі знаком.
  • group_size: кількість людей у групі, 64-бітове ціле число зі знаком, завжди більше за 0.
admissions = 120, group_size = 4
// admissions is now 124

Тести пропускають групи крізь кілька дверей одночасно. Підсумковий лічильник має дорівнювати сумі всіх пропущених груп.

Ця функція не повертає жодного значення. Лічильник слід оновлювати на місці.

2. Змінити котушку

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

Реалізуйте функцію change_reel, яка атомарно встановлює наступну котушку й повертає ту, що була встановлена.

Ця функція приймає такі аргументи, у цьому порядку:

  • mounted: адреса памʼяті ідентифікатора встановленої котушки, 64-бітове ціле число зі знаком.
  • next_reel: ідентифікатор котушки, яку треба встановити, 64-бітове ціле число зі знаком.
mounted = 7, next_reel = 9
// => 7, and mounted is now 9

Ця функція повертає ідентифікатор раніше встановленої котушки як 64-бітове ціле число зі знаком. Тести змушують кілька потоків одночасно міняти котушки на тому самому кріпленні й перевіряють, що кожна передана котушка повертається рівно один раз.

3. Продати квиток

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

Реалізуйте функцію 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-бітове ціле число зі знаком. Тести продають з кількох кас одночасно: кожне повернене число має бути унікальним, а підсумковий лічильник має дорівнювати кількості продажів.

4. Заповнити премʼєру

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

Реалізуйте функцію 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-бітове ціле число зі знаком.

5. Пильнувати будку

У проєкційній будці вміщується одна людина. Слово в памʼяті вказує, чи вона зайнята: 0 означає вільно, 1 означає зайнято. Усю ніч туди заходять працівники, кожен зі своїм завданням всередині: підписати журнал, заправити проєктор, здати котушку до архіву. Яким би не було завдання, правило будки те саме, і стежити за його дотриманням - наша робота: одна людина всередині за раз, від початку її завдання до кінця.

Реалізуйте функцію visit_booth, яка:

  1. чекає, доки будка звільниться;
  2. займає будку;
  3. виконує завдання відвідувача;
  4. знову звільняє будку після завдання й перед поверненням.

Поки будку займає хтось інший, вона має ввічливо чекати за допомогою pause.

Ця функція приймає такі аргументи, у цьому порядку:

  • booth: адреса памʼяті слова зайнятості, 64-бітове ціле число зі знаком, що дорівнює 0 або 1.
  • task: завдання відвідувача, функція, яка не приймає параметрів і не повертає значення.

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

Caution

Завдання відвідувача - це звичайна функція, яка дотримується угоди про виклики:

  • вона може перезаписати будь-який регістр, який має зберегти викликач, тож усе, що нам ще знадобиться після завдання, слід тримати там, куди виклик не дістанеться;
  • вона також вимагає, щоб стек у точці виклику був вирівняний на 16 байтів.

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

Редагувати через GitHub Посилання відкривається в новому вікні або вкладці
x86-64 Assembly Exercism

Час розпочати Премʼєра?

Зареєструйтеся на Exercism, щоб вивчати й опановувати x86-64 Assembly, а також 22 концепції130 вправ та справжнє наставництво від людей, і все це безкоштовно.