Чи можете ви пояснити, чому декілька потоків потребують блокування в одноядерному процесорі?


18

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


Оскільки транзакційна пам'ять програмного забезпечення ще не є основною.
dan_waterworth

@dan_waterworth Оскільки транзакційна пам'ять програмного забезпечення погано виходить з ладу на нетривіальних рівнях складності? ;)
Мейсон Уілер

Б'юсь об заклад, Річ Хікі з цим не згоден.
Роберт Харві

@ MasonWheeler, тоді як нетривіальне блокування працює надзвичайно добре і ніколи не було джерелом тонких помилок, які важко відстежити? STM добре працює з нетривіальними рівнями складності, але це проблематично, коли є суперечки. У тих випадках, що - щось на зразок цього , що є більш обмежувальної формою STM краще. До речі, зі зміною заголовка знадобилося мені, коли я розробив, чому я коментував, як це робив.
dan_waterworth

Відповіді:


32

Це найкраще проілюструвати на прикладі.

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

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

  1. Прочитайте кількість звернень з пам'яті в регістр процесора
  2. Збільшення цієї кількості.
  3. Запишіть це число назад у пам’ять

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

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

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


але що робити, якщо заблокований лічильник є атомним, чи був необхідний замок?
pythonee

@pythonee: якщо приріст лічильника є атомним, можливо, ні. Але в будь-якій багатопотоковій програмі розумного розміру у вас будуть неатомні завдання, які потрібно виконувати на спільному ресурсі.
Док Браун

1
Якщо ви не використовуєте компілятор, який є власним для того, щоб зробити приріст атомним, це, мабуть, не так.
Майк Ларсен

Так, якщо читання / модифікація (збільшення) / запис є атомною, блокування не є необхідним для цієї операції. Інструкція DEC-10 AOSE (додайте одну і пропустіть, якщо результат == 0) була спеціально атомною, щоб її можна було використовувати як семафор для тестування та встановлення. У посібнику зазначається, що це було досить добре, тому що машині знадобиться кілька днів безперервного підрахунку, щоб прокрутити 36-бітний реєстр на всьому протязі. ЗАРАЗ, однак, не все, що ви робите, буде "додавати його в пам'ять".
Джон Р. Стром

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

4

Розглянемо цю цитату:

Деякі люди, зіткнувшись з проблемою, думають: "Я знаю, я буду використовувати нитки", а потім двоє завищують поблеми

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


Я думав, що цитата - це регулярні вирази, а не теми?
user16764

3
Цитата виглядає набагато більш застосовною для моїх потоків (при цьому слова / символи друкуються поза порядком через проблеми з ниткою). Але на даний момент у виході є додаткове "s", що говорить про те, що у коду є три проблеми.
Теодор Мердок

1
його побічний ефект. Дуже періодично ви можете додати 1 плюс 1 і отримати 4294967295 :)
gbjbaanb

3

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

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

  • Кооперативна багатозадачність - Використовувана в Win9x вимагала від кожної програми явно відмовитися від контролю. У цьому випадку вам не потрібно буде турбуватися про блокування, оскільки поки Нитка А виконує якийсь алгоритм, ви будете впевнені, що він ніколи не буде перерваний
  • Переважна багатозадачність - використовується в більшості сучасних ОС (Win2k та новіших версій). Це використовує часові шрифти і перерве потоки, навіть якщо вони все ще працюють. Це набагато надійніше, тому що одна нитка ніколи не може повісити всю вашу машину, що було реальною можливістю при спільній багатозадачності. З іншого боку, тепер вам потрібно турбуватися про блокування, оскільки в будь-який момент часу один з ваших потоків може бути перерваний (тобто попередньо вилучений), і ОС може запланувати запуск іншого потоку. Кодуючи багатопотокові програми з такою поведінкою, ви ОБОВ'ЯЗКОВО врахувати, що між кожним рядком коду (або навіть кожною інструкцією) може працювати інший потік. Тепер, навіть з одним ядром, блокування стає дуже важливим для забезпечення постійного стану ваших даних.

0

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

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

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

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

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


0

За винятком встановлення "bool", немає гарантії (принаймні в с), що читання або запис змінної вимагає лише однієї інструкції, а точніше - не можна переривати її в середині читання / написання


скільки інструкцій знадобиться встановлення 32-бітного цілого числа?
DXM

1
Чи можете ви трохи розширити свою першу заяву. Ви маєте на увазі, що атомним чином читати / писати можна лише бул, але це не має сенсу. "Буль" насправді не існує в апаратному забезпеченні. Зазвичай він реалізується як байт або слово, тож як тільки boolця властивість могла мати? А ви думаєте про завантаження з пам’яті, про зміну та повернення до пам’яті, чи це ви говорите на рівні реєстру? Всі записи / записи в регістри безперебійні, але завантаження пам'яті тоді зберігання пам’яті не є (як одне це 2 інструкції, то принаймні ще 1 для зміни значення).
Корбін

1
Концепція єдиної інструкції в процесорі з гіперкодированою / багатоядерною / передбачуваною гілкою / з декількома кешами є дещо хитрою, але стандарт говорить, що лише "bool" повинен бути захищеним від перемикання контексту посеред читання / запису єдиної змінної. Існує прискорення :: Atomic, яке обертає мютекс навколо інших типів, і я думаю, що c ++ 11 додає ще кілька гарантій для нарізки
Мартін Бекетт

Пояснення the standard says that only 'bool' needs to be safe against a context switch in the middle of a read/write of a single variableдійсно слід додати до відповіді.
Вовк

0

Спільна пам'ять.

Це визначення ... ниток : купа одночасних процесів із спільною пам'яттю.

Якщо спільної пам'яті немає, вони зазвичай називаються процесами old-school-UNIX .
Можливо, їм потрібно буде блокувати час від часу під час доступу до спільного файлу.

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


0

ЦП виконує одну інструкцію за раз, але що робити, якщо у вас є два або більше процесора?

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

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

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

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

Відповідно розробленими списками можна маніпулювати атомними інструкціями, так що блоки не потрібні. Алгоритми для цього називаються "замкнути безкоштовно". Однак зауважте, що атомні інструкції - це фактично форма блокування. Вони спеціально реалізовані в апаратному забезпеченні та працюють за допомогою зв'язку між процесорами. Вони дорожчі за аналогічні інструкції, які не є атомними.

На мультипроцесорах, яким не вистачає розкошів атомних інструкцій, примітиви для взаємного виключення повинні будуватися з простого доступу до пам'яті та циклів опитування. Такі проблеми працювали на зразок Едсгера Дікстра та Леслі Лампорт.


FYI, я читав про алгоритми без блокування для обробки оновлених списків оновлень списку, використовуючи лише одну порівняння та заміну. Крім того, я прочитав білу книгу про об'єкт, який, здавалося б, був би набагато дешевшим в апаратному відношенні, ніж подвійне порівняння та заміна (що було реалізовано в 68040, але не здійснювалося в інших процесорах 68xxx): збільшити навантаження -зв’язане / зберігання-умовне, щоб дозволити два пов'язані вантажі та умовні магазини, але за умови, що доступ, який відбувається між двома магазинами, не відкотить перший. Це набагато простіше втілити, ніж подвійне порівняння та зберігання…
supercat

... але запропонує аналогічні переваги при спробі керувати оновленнями з подвійним списком списку. Наскільки я можу сказати, подвійне навантаження не набуло сили, але вартість обладнання здавалася б досить дешевою, якби був попит.
supercat
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.