JavaRush /Java блог /Random UA /Основи Паралелізму: взаємоблокування та монітори об'єктів...
Snusmum
34 рівень
Хабаровск

Основи Паралелізму: взаємоблокування та монітори об'єктів (розділи 1, 2) (переклад статті)

Стаття з групи Random UA
Вихідна стаття: http://www.javacodegeeks.com/2015/09/concurrency-fundamentals-deadlocks-and-object-monitors.html Автор: Martin Mois Ця стаття - частина нашого курсу Основи Паралелізму в Java . У цьому курсі ви поринете в магію паралелізму. Ви пізнаєте основи паралелізму та паралельного коду, познайомитеся з такими концепціями як атомарність, синхронізація нітебезпека. Погляньте на нього тут !

Зміст

1. Живучість  1.1 Взаємоблокування  1.2 Голодування 2. Монітори об'єктів спільно з wait() і notify()  2.1  Вкладені синхронізовані блоки спільно з wait() і notify() 2.2  Умови в синхронізованих блоках  3. Проектування для  багатонитків3. Локальне сховище нитки
1. Живість
При розробці додатків, які використовують паралелізм для досягнення поставленої мети, ви можете зіткнутися з ситуаціями, в яких різні нитки можуть блокувати один одного. Якщо в цій ситуації програма працює повільніше, ніж очікувалося, ми б сказали, що вона відпрацьовує за часом не так, як передбачалося. У цьому розділі ми познайомимося з проблемами, які можуть загрожувати живучості багатониткової програми.
1.1 Взаємне блокування
Термін взаємоблокування добре відомий розробникам ПЗ і навіть більшість звичайних користувачів використовують його час від часу, хоч і не завжди в правильному розумінні. Строго кажучи, цей термін означає, що кожна з двох (або більше) ниток чекають від іншої нитки, щоб вона звільнила заблокований нею ресурс, тоді як перша сама заблокувала ресурс, доступу до якого чекає друга: Для кращого розуміння проблеми поглянемо на Thread 1: locks resource A, waits for resource B Thread 2: locks resource B, waits for resource A наступний код: public class Deadlock implements Runnable { private static final Object resource1 = new Object(); private static final Object resource2 = new Object(); private final Random random = new Random(System.currentTimeMillis()); public static void main(String[] args) { Thread myThread1 = new Thread(new Deadlock(), "thread-1"); Thread myThread2 = new Thread(new Deadlock(), "thread-2"); myThread1.start(); myThread2.start(); } public void run() { for (int i = 0; i < 10000; i++) { boolean b = random.nextBoolean(); if (b) { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); } } } else { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); } } } } } } Як можна бачити з наведеного коду, дві нитки стартують і намагаються заблокувати два статичні ресурси. Але для взаємоблокування нам потрібна різна послідовність для обох ниток, тому ми використовуємо екземпляр об'єкта Random, щоб вибрати якийсь ресурс нитка хоче заблокувати першим. Якщо логічна змінна b має значення істина, то першим блокується resource1, а потім нитка намагається отримати блокування для resource2. Якщо b - брехня, тоді нитка блокує resource2, а потім намагається захопити resource1. Цій програмі не потрібно довго виконуватись для досягнення першого взаємоблокування, тобто. програма повисне назавжди, якщо ми не перервемо її: [thread-1] Trying to lock resource 1. [thread-1] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 1. [thread-2] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 2. [thread-1] Trying to lock resource 1. У даному запуску tread-1 заволодів блокуванням resource2 і чекає блокування resource1, тоді як tread-2 заблокував resource1 і чекає на resource2. Якби ми задали значення логічної змінної b у наведеному вище коді рівним істині, то не змогли б спостерігати ніякого взаємоблокування, тому що послідовність, в якій tread-1 і thread-2 запитують блокування, завжди була б однією і тією ж. У цій ситуації одна з двох ниток отримала б блокування першого і потім просила б друге, яке, як і раніше, доступне, оскільки інша нитка чекає першого блокування. Загалом можна виділити такі необхідні умови виникнення взаємоблокування: - Спільне виконання: Існує ресурс, який може бути доступний лише одній нитці в довільний момент часу. - Утримання ресурсу: Під час захоплення одного ресурсу нитка намагається отримати ще одне блокування якогось унікального ресурсу. - Відсутність пріоритетного переривання обслуговування: Відсутня механізм, що звільняє ресурс, якщо одна нитка утримує блокування певного проміжку часу. - Кругове очікування: Під час виконання виникає сукупність ниток, у якій дві (або більше) ниток чекають один від одного на звільнення ресурсу, який був заблокований. Хоча список умов і виглядає довгим, часто добре налагоджені багатониткові програми мають проблеми взаємоблокування. Але ви можете запобігти їм, якщо зумієте зняти одну з умов, наведених вище: - Спільне виконання: ця умова часто не може бути знята, коли ресурс повинен використовуватися тільки кимось одним. Але це не обов'язково має стати причиною. При використанні DBMS систем можливим рішенням замість використання песимістичного блокування по деякому рядку таблиці, яка має бути оновлена, можна використовувати техніку, яка називається Оптимістичною Блокуванням . - Спосіб уникнути утримання ресурсу під час очікування іншого ексклюзивного ресурсу полягає в тому, щоб блокувати всі необхідні ресурси на початку алгоритму та звільняти теж усе, якщо неможливо їх заблокувати разом. Звичайно, це не завжди можливо, можливо ресурси, що вимагають блокування заздалегідь невідомі або такий підхід просто призведе до марної витрати ресурсів. - Якщо блокування не може бути отримане негайно, способом обходу можливого взаємоблокування є введення таймауту. Наприклад, клас ReentrantLockіз SDK забезпечує можливість завдання терміну дії для блокування. - Як ми побачабо з наведеного вище прикладу, взаємоблокування не виникає, якщо послідовність запитів не відрізняється у різних ниток. Це легко проконтролювати, якщо ви можете помістити весь блокуючий код одним методом, через який повинні пройти всі нитки. У більш розвинених програмах ви навіть можете задумати реалізацію системи виявлення взаємоблокувань. Тут вам знадобиться реалізувати деяку подобу моніторингу ниток, у якій кожна нитка повідомляє про успішне отримання права блокування та свою спробу отримати блокування. Якщо нитки та блокування змодельовані як орієнтований граф, ви можете виявити, коли дві різні нитки утримують ресурси, намагаючись одночасно отримати доступ до інших заблокованих ресурсів. Якщо ви зможете змусити блокуючі нитки звільнити необхідні ресурси, то зможете вирішити ситуацію взаємоблокування автоматично.
1.2 Голодування
Планувальник вирішує, яку з ниток, що перебувають у стані RUNNABLE , він повинен виконати наступну. Рішення ґрунтується на пріоритеті нитки; тому нитки з меншим пріоритетом отримують менше процесорного часу, порівняно з тими, які мають пріоритет вище. Те, що виглядає розумним рішенням, може стати причиною проблем при зловживанні. Якщо більшу частину часу виконуються нитки з високим пріоритетом, то низькі пріоритетні нитки начебто починають "голодати", оскільки не отримують достатньо часу для того, щоб виконати свою роботу належним чином. Тому рекомендується ставити пріоритет нитки лише тоді, коли для цього є вагомі причини. Неочевидний приклад голодування нитки дає, наприклад, метод finalize(). Він надає в мові Java можливість виконати код перед тим, як об'єкт буде видалений збирачем сміття. Але якщо ви подивитеся на пріоритет фінальної нитки, то помітите, що вона запускається не з найвищим пріоритетом. Отже, виникають передумови для ниткового голодування, коли методи finalize() вашого об'єкта витрачають занадто багато часу порівняно з рештою коду. Інша проблема з часом виконання виникає від того, що не визначено, в якому порядку нитки блокуються synchronized. Коли багато паралельних ниток проходять деякий код, який оформлений у блок synchronized, може статися так, що одним ниткам доведеться чекати довше, ніж іншим, перш ніж увійти до блоку. Теоретично вони можуть ніколи туди не потрапити. Вирішення цієї проблеми - так зване "справедливе" блокування. Справедливі блокування зважають на час очікування ниток, коли визначають, кого пропустити наступним. Приклад реалізації справедливого блокування є Java SDK: java.util.concurrent.locks.ReentrantLock. Якщо використовується конструктор з логічним прапором, встановленим у значення істина, то ReentrantLock дає доступ нитки, яка чекає довше за інших. Це гарантує відсутність голоду, але водночас призводить до проблеми ігнорування пріоритетів. Через це процеси з меншим пріоритетом, які часто очікують на цьому бар'єрі, можуть виконуватися частіше. Нарешті, що важливо, клас ReentrantLock може розглядати лише нитки, які очікують блокування, тобто. нитки, які запускалися досить часто і досягли бар'єру. Якщо пріоритет нитки занадто низький, то для неї це не відбуватиметься часто, і тому високопріоритетні нитки, як і раніше, проходитимуть блокування частіше.
2. Монітори об'єктів спільно з wait() та notify()
У багатониткових обчислення звичайною ситуацією є наявність деяких робочих ниток, які чекають, що їх виробник створить для них якусь роботу. Але, як ми дізналися, активне очікування в циклі з перевіркою деякого значення не є добрим варіантом з погляду процесорного часу. Використання в цій ситуації методу Thread.sleep() також не дуже підходить, якщо ми хочемо розпочати нашу роботу негайно після вступу. Для цього мова програмування Java має іншу структуру, яка може бути використана в даній схемі: wait() та notify(). Метод wait(), успадкований усіма об'єктами від класу java.lang.Object, може бути використаний для припинення виконання нитки і очікування доти, поки інша нитка не розбудить нас, використовуючи метод notify(). Для того, щоб працювати правильно, нитка, що викликає метод wait(), повинна утримувати блокування, яке воно попередньо отримало, використовуючи ключове слово synchronized. При виклику wait() блокування звільняється і нитка чекає, доки інша нитка, яка тепер заволоділа блокуванням, не викличе notify() для того ж екземпляра об'єкта. У багатонитковому додатку природно може бути більше однієї нитки, що очікує повідомлення на якомусь об'єкті. Тому існує два різні методи для побудування ниток: notify() і notifyAll(). У той час як перший метод будить одну з ниток, що очікують, метод notifyAll() пробуджує їх усі. Але знайте, що, як і у випадку ключового слова synchronized, відсутнє правило, яке визначає, яка нитка буде збуджена наступною під час виклику notify(). У простому прикладі з виробником та споживачем це не має значення, оскільки нам не важливо, яка саме нитка прокинулася. Наступний код показує як механізм wait() і notify() може бути використаний для організації очікування нитками-споживачами нової роботи, яка додається в чергу ниткою-виробником: package a2; import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; public class ConsumerProducer { private static final Queue queue = new ConcurrentLinkedQueue(); private static final long startMillis = System.currentTimeMillis(); public static class Consumer implements Runnable { public void run() { while (System.currentTimeMillis() < (startMillis + 10000)) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } if (!queue.isEmpty()) { Integer integer = queue.poll(); System.out.println("[" + Thread.currentThread().getName() + "]: " + integer); } } } } public static class Producer implements Runnable { public void run() { int i = 0; while (System.currentTimeMillis() < (startMillis + 10000)) { queue.add(i++); synchronized (queue) { queue.notify(); } try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { queue.notifyAll(); } } } public static void main(String[] args) throws InterruptedException { Thread[] consumerThreads = new Thread[5]; for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i] = new Thread(new Consumer(), "consumer-" + i); consumerThreads[i].start(); } Thread producerThread = new Thread(new Producer(), "producer"); producerThread.start(); for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i].join(); } producerThread.join(); } } Метод main() запускає п'ять ниток-споживачів і одну нитку-виробника, а потім чекає на закінчення їх роботи. Після нитка-виробник додає нове значення в чергу і повідомляє всі очікуючі нитки про те, що щось сталося. Споживачі отримують блокування черги (прим. один довільний споживач) і потім засинають, щоб бути піднятими пізніше, коли черга знову заповниться. Коли виробник закінчує свою роботу, він повідомляє всіх споживачів, щоб розбудити. Якби ми не зробабо останній крок, то нитки-споживачі завжди чекали б наступного повідомлення, оскільки ми не задали тайм-аут для очікування. Замість цього ми можемо використовувати метод wait(long timeout), щоб бути збудженими принаймні через деякий час.
2.1 Вкладені блоки synchronized спільно з wait() та notify()
Як було сказано в попередньому розділі, виклик wait() для монітора об'єкта лише знімає блокування цього монітора. Інші блокування, що утримувалися тією ж ниткою, не звільняються. Як це легко зрозуміти, у повсякденній роботі може статися так, що нитка, що викликає wait(), утримує блокування далі. Якщо інші нитки також очікують на ці блокування, то може виникнути ситуація взаємоблокування. Давайте подивимося на блокування в наступному прикладі: public class SynchronizedAndWait { private static final Queue queue = new ConcurrentLinkedQueue(); public synchronized Integer getNextInt() { Integer retVal = null; while (retVal == null) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } } return retVal; } public synchronized void putInt(Integer value) { synchronized (queue) { queue.add(value); queue.notify(); } } public static void main(String[] args) throws InterruptedException { final SynchronizedAndWait queue = new SynchronizedAndWait(); Thread thread1 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { queue.putInt(i); } } }); Thread thread2 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { Integer nextInt = queue.getNextInt(); System.out.println("Next int: " + nextInt); } } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); } } Як ми довідалися раніше , додавання synchronized в сигнатуру методу рівносильне створенню блоку synchronized(this){}. У наведеному вище прикладі ми випадково додали ключове слово synchronized у метод, а потім синхронізували чергу по монітору об'єкта queue, щоб відправити цю нитку в сон на час очікування наступного значення з queue. Потім поточна нитка звільняє блокування по queue, але не блокування по this. Метод putInt() повідомляє сплячу нитку, що нове значення було додано. Але випадково ми додали ключове слово synchronized і до цього методу. Тепер, коли друга нитка заснула, вона, як і раніше, утримує блокування. Тому перша нитка не може увійти в метод putInt(), поки це блокування утримується другою ниткою. В результаті маємо ситуацію взаємоблокування і програму, що зависла. Якщо ви виконаєте наведений вище код, це станеться одразу після початку роботи програми. У повсякденному житті така ситуація може бути настільки очевидною. Блокування, утримувані ниткою, можуть залежати від параметрів та умов, що виникають під час роботи, і блок synchronized, що викликає проблему, може не бути таким близьким у коді до місця, де ми помістабо виклик wait(). Це робить проблематичним пошук таких проблем, особливо якщо вони можуть виникати через деякий час або за високого навантаження.
2.2 Умови в блоках synchronized
Часто вам потрібно перевірити виконання певної умови, перш ніж зробити якусь дію із синхронізованим об'єктом. Коли у вас є, наприклад, черга ви хочете дочекатися її заповнення. Отже, ви можете написати метод, який перевіряє заповненість черги. Якщо вона ще порожня, то ви відправляєте поточну нитку в сон доти, доки вона не буде збуджена: public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { retVal = queue.poll(); if (retVal == null) { System.err.println("retVal is null"); throw new IllegalStateException(); } } return retVal; } Наведений вище код синхронізується по queue перш ніж викликати wait() і потім чекає в циклі while, поки в queue не з'явиться принаймні один елемент. Другий блок synchronized знову використовує queue як монітор об'єкта. Він викликає метод poll() черги, щоб отримати значення. У демонстраційних цілях викидається виключення IllegalStateException, коли полл повертає null. Це відбувається, коли в queue немає елементів для вилучення. Коли ви запустите цей приклад, то побачите, що IllegalStateException викидається дуже часто. Хоч ми і коректно синхронізувалися монітором queue, виняток був викинутий. Причина в тому, що у нас є два різні блоки synchronized. Уявіть, що ми маємо дві нитки, які прибули до першого блоку synchronized. Перша нитка увійшла до блоку і провалилася у сон, бо queue порожня. Те саме істинно і для другої нитки. Тепер, коли обидві нитки прокинулися (завдяки виклику notifyAll(), викликаному іншою ниткою для монітора), вони обидві побачабо значення(елемент) у черзі, доданий виробником. Потім обидві прибули до другого бар'єру. Тут перша нитка увійшла та витягла значення з черги. Коли друга нитка входить, queue вже порожня. Тому, як значення, що повертається з queue, вона отримує null і викидає виняток. Для запобігання подібним ситуаціям вам необхідно виконувати всі операції, що залежать від стану монітора, в тому самому блоці synchronized: public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } return retVal; } Тут ми виконуємо метод poll() у тому самому блоці synchronized, що і метод isEmpty(). Завдяки блоку synchronized ми впевнені, що лише одна нитка виконує метод для цього монітора у певний момент часу. Тому ніяка інша нитка не може видалити елементи з queue між викликами isEmpty() та poll(). Продовження перекладу тут .
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ