Уникаємо стану гонки у SharedArrayBuffer за допомогою Atomics

стану

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

Але розробники бібліотек, які мають досвід роботи з багатопотоковим програмуванням іншими мовами, можуть використовувати ці нові низькорівневі API для створення інструментів вищого рівня, і розробники додатків зможуть використовувати ці інструменти, щоб не звертатися безпосередньо до SharedArrayBuffers або Atomics.

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

Але, по-перше, що такий стан перегонів?

Стан гонки: приклад, який ви, можливо, бачили раніше

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

Спочатку fileExists встановлено значення false .

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

Але якщо спочатку виконається код у потоці 1, він виведе користувачу помилку у тому, що файл немає.

Але це помилкова помилка. Справа не в тому, що файл не існує. Реальна проблема - стан перегонів.

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

Різні види станів гонки та те, як Atomics допомагає з ними впорається

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

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

Стан гонки в одній операції

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

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

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

Усі потоки ділять довготривалу пам'ять. Але короткочасна пам'ять (регістри) не поділяється між потоками.

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

Якщо всі операції в потоці 1 виконуються першими, апотім виконаються всі операції в потоці 2, ми отримаємо результат, який очікуємо.

Але якщо вони чергуються в часі, значення, яке потік 2 отримав у свій регістр, синхронізується зі значенням у пам'яті. Це означає, що 2 потік не враховує розрахунок потоку 1. Натомість він просто затирає значення, яке потік 1 пише в пам'ять.

Однією із завдань атомарних операцій є виконання таких операцій, які вважають одиночними операціями, але які є такими для комп'ютера, як єдиного цілого.

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

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

Тепер, коли ми використовуємо Atomics.add різні етапи, пов'язані з інкрементуванням змінної, не будуть змішуватися між потоками. Натомість один потік завершить свою атомарну операцію і запобігає запуску іншого. Після цього інший потік розпочне свою власну атомарну операцію.

Методи Atomics, які допомагають уникнути гонки такого роду:

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

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

Стан гонки у кількох операціях

Таким чином, ці методи Atomics допомагають уникнути станів гонки під час «одинокових операцій». Але іноді ви хочете змінити кілька значень на об'єкті (використовуючи кілька операцій) і переконатися, що ніхто інший не вносить зміни до цього об'єкта в той же момент. В принципі це означає, що протягом кожного застосування набору змін до об'єкта цей об'єкт блокується і недоступний для інших потоків.

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

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

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

Як тільки потік 2 буде завершено, він зніме блокування. Механізм керування блокуваннями повідомить один або кілька потоків очікування про те, що можна перехопити управління.

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

Бібліотека управління блокуванням може використовувати багато різних методів в об'єкті Atomics, але найбільш важливими для цього варіанта євикористання є:

Стан гонки, спричинений переупорядкуванням команд

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

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

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

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

Поки що все так, як і очікувалося.

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

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

Ось один із прикладів кроків, через які проходить інструкція:

  1. Отримати наступну команду з пам'яті
  2. З'ясувати, що говорить нам інструкція (інакше кажучи, декодувати інструкцію) та отримати значення з регістрів
  3. Виконати інструкцію
  4. Записати результат назад у регістр

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

Проблема в тому щоіснує залежність між інструкцією №1 та інструкцією №2.

Ми могли б просто призупинити CPU, поки команда №1 не оновить subTotal в регістрі. Але це сповільнить роботу.

Щоб зробити виконання коду ефективнішим, багато компіляторів і процесорів роблять зміни порядку виконання коду. Вони шукають інші інструкції, які не використовують subTotal або total і поміщають їх між цими двома рядками.

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

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

Але коли у вас є інший потік, який працює одночасно на іншому процесорі, це не так. Інший потік не повинен чекати, доки функція не буде виконана, щоб побачити ці зміни. Він може бачити їх майже відразу після їхнього запису на згадку. Тому він може сказати, що цеDone був встановлений до обчислення total .

Якщо ви використовували isDone як прапор, який підрахований і може використовуватися в іншому потоці, то таке переупорядкування створило б стан гонки.

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

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

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

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

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

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

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

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

SharedArrayBuffer та Atomics ще молоді і такі бібліотеки поки що не створені. Але ці нові API забезпечують основу для створення.

Про Лін Кларк