Це найкраще проілюструвати на прикладі.
Припустимо, у нас є проста задача, яку ми хочемо виконувати кілька разів паралельно, і ми хочемо відстежувати глобально кількість разів, яку виконували завдання, наприклад, підрахунок звернень на веб-сторінці.
Коли кожен потік потрапить до точки, в якій він збільшує кількість, його виконання буде виглядати приблизно так:
- Прочитайте кількість звернень з пам'яті в регістр процесора
- Збільшення цієї кількості.
- Запишіть це число назад у пам’ять
Пам’ятайте, що кожна нитка може призупинитися в будь-якій точці цього процесу. Отже, якщо потік A виконує крок 1, а потім призупиняється, слідуючи за потоком B, виконуючи всі три кроки, коли потік A поновлюється, його регістри матимуть неправильну кількість звернень: його регістри будуть відновлені, він із задоволенням збільшить старе число звернень, і збережіть цю збільшену кількість.
Крім того, будь-яка кількість інших потоків могла запускатися під час припинення потоку A, тому підрахунок нитки A в кінці може бути набагато нижче правильного підрахунку.
З цієї причини необхідно переконатися, що якщо потік виконує етап 1, він повинен виконати етап 3, перш ніж будь-який інший потік буде дозволений виконувати етап 1, який може бути виконаний усіма потоками, які чекають отримання єдиного блокування, перш ніж розпочати цей процес , і звільнення блокування лише після завершення процесу, так що цей "критичний розділ" коду не може бути неправильно переплетений, що призводить до неправильного підрахунку.
Але що робити, якщо операція була атомною?
Так, у країні магічних єдинорогів та веселок, де приріст операції є атомним, тоді блокування не було б необхідним для наведеного вище прикладу.
Важливо усвідомити, однак, що ми проводимо дуже мало часу у світі магічних єдинорогів та веселок. Майже в кожній мові програмування приріст операції розбивається на вищевказані три етапи. Це тому, що навіть якщо процесор підтримує атомний приріст, ця операція є значно дорожчою: вона повинна читати з пам'яті, змінювати число і записувати його назад в пам'ять ... і зазвичай операція з збільшення атома - це операція, яка може вийти з ладу, значить, просту послідовність, описану вище, потрібно замінити циклом (як ми побачимо нижче).
Оскільки, навіть у багатопотоковому коді, багато змінних зберігаються локальними для одного потоку, програми є набагато ефективнішими, якщо вони вважають, що кожна змінна є локальною для одного потоку, і нехай програмісти дбають про захист спільного стану між потоками. Особливо з огляду на те, що атомних операцій зазвичай недостатньо для вирішення проблем з ниткою, як ми побачимо пізніше.
Летючі змінні
Якщо ми хотіли уникнути блокування для цієї конкретної проблеми, спершу ми повинні усвідомити, що кроки, зображені в нашому першому прикладі, насправді не є тим, що відбувається в сучасному складеному коді. Оскільки компілятори припускають, що лише один потік модифікує змінну, кожен потік буде зберігати власну кешовану копію змінної, поки регістр процесора не потрібен для чогось іншого. Поки він має кешовану копію, він припускає, що не потрібно повертатися до пам'яті та читати її знову (що було б дорого). Вони також не записують змінну назад у пам'ять, доки вона зберігається в регістрі.
Ми можемо повернутися до ситуації , яку ми дали в першому прикладі (з усіма тими ж проблемами , що пронизують ми визначили вище), позначивши змінну як летючі , який повідомляє компілятор , що це змінний модифікуються іншими, і тому повинні бути лічені з або записані в пам'ять, коли це доступ до нього або змінено.
Таким чином, змінна, позначена як мінлива, не доставить нас у край атомних операцій, вона лише зблизиться з нами, як ми думали, що ми вже були.
Зростання приросту атомним
Як тільки ми використовуємо мінливу змінну, ми можемо зробити нашу прирістну операцію атомною, використовуючи умовно задану операцію низького рівня, яку підтримують більшість сучасних процесорів (часто називають зіставленням, встановленням або порівнянням та заміною ). Такий підхід застосовується, наприклад, у класі AtomicInteger Java :
197 /**
198 * Atomically increments by one the current value.
199 *
200 * @return the updated value
201 */
202 public final int incrementAndGet() {
203 for (;;) {
204 int current = get();
205 int next = current + 1;
206 if (compareAndSet(current, next))
207 return next;
208 }
209 }
Вищеописаний цикл повторно виконує наступні кроки, поки етап 3 не буде успішним:
- Прочитайте значення мінливої змінної безпосередньо з пам'яті.
- Збільшення цієї вартості.
- Змініть значення (в основній пам'яті) тоді і тільки тоді, коли його поточне значення в основній пам'яті є таким же, як значення, яке ми спочатку читали, використовуючи спеціальну атомну операцію.
Якщо крок 3 не вдається (оскільки значення було змінено іншим потоком після кроку 1), він знову зчитує змінну безпосередньо з основної пам'яті та повторює спробу.
Хоча операція порівняння та заміни є дорогою, це трохи краще, ніж використання блокування в цьому випадку, оскільки якщо нитка призупинена після кроку 1, інші потоки, які досягають кроку 1, не повинні блокувати і чекати першого потоку, який може запобігти дорогому переключенню контексту. Коли перший потік відновиться, він не зможе в першій спробі записати змінну, але зможе продовжити, перечитавши змінну, що, ймовірно, є менш дорогим, ніж контекстний комутатор, який був би необхідний при блокуванні.
Таким чином, ми можемо дістатися до краю атомних приростів (або інших операцій над однією змінною), не використовуючи фактичні блокування, за допомогою порівняння та заміни.
Отже, коли блокування суворо необхідне?
Якщо вам потрібно змінити більше однієї змінної в атомній операції, тоді буде потрібне блокування, ви не знайдете для цього спеціальної інструкції процесора.
Поки ви працюєте над однією змінною, і ви готові до будь-якої роботи, яку ви зробили, щоб вийти з ладу, і вам доведеться читати змінну і починати заново, однак порівняння і заміна буде досить хорошим.
Розглянемо приклад, коли кожен потік спочатку додає 2 до змінної X, а потім помножує X на два.
Якщо X спочатку одна, а два потоки запущені, ми очікуємо, що результат буде (((1 + 2) * 2) + 2) * 2 = 16.
Однак, якщо потоки перемежовуються, ми можемо, навіть якщо всі операції є атомними, натомість обидва додавання відбуваються спочатку, і множення настає після, в результаті чого (1 + 2 + 2) * 2 * 2 = 20.
Це відбувається тому, що множення і додавання не є комутаційними операціями.
Отже, самих операцій, які є атомними, недостатньо, ми повинні зробити комбінацію операцій атомною.
Ми можемо це зробити або за допомогою блокування для серіалізації процесу, або ми могли використати одну локальну змінну для зберігання значення X, коли ми розпочали наш обчислення, другу локальну змінну для проміжних кроків, а потім використати порівняти-замінити на встановіть нове значення, лише якщо поточне значення X таке ж, як і вихідне значення X. Якщо ми не вдається, нам доведеться починати заново, читаючи X і виконуючи обчислення знову.
Задіяно кілька компромісів: у міру того, як обчислення будуть тривалішими, стає набагато більш імовірним, що запущена нитка буде призупинена, а значення буде змінено іншим потоком, перш ніж відновитись, тобто помилки стають набагато більш імовірними, що призводить до марних витрат час процесора. У крайньому випадку великої кількості потоків з дуже довгими обчисленнями, ми можемо 100 потоків прочитати змінну і зайнятися обчисленнями; в цьому випадку лише перший, який закінчить, вдасться написати нове значення, інші 99 все одно завершити свої обчислення, але після завершення виявити, що вони не можуть оновити значення ... після цього кожен з них прочитає значення і почне обчислення. Ми, ймовірно, будемо, щоб решта 99 потоків повторили ту саму проблему, витрачаючи величезну кількість процесорного часу.
Повна серіалізація критичної секції за допомогою замків була б набагато кращою в цій ситуації: 99 ниток призупиняться, коли вони не отримують замок, і ми запускаємо кожну нитку в порядку прибуття до точки блокування.
Якщо серіалізація не є критичною (як у нашому випадку збільшення), а обчислення, які були б втрачені, якщо оновлення числа не вдасться, є мінімальним, можливо, можна отримати значну перевагу від використання операції порівняння та заміни, оскільки ця операція коштує дешевше, ніж блокування.