You are on page 1of 12

1. Багатопотокове програмування.

(Закінчення)
1.9. Клас Phaser
Клас Phaser дозволяє синхронізувати потоки, що представляють окрему фазу або
стадію виконання загального дії. Phaser визначає об'єкт синхронізації, який чекає,
поки не завершиться певна фаза. Потім Phaser переходить до наступної стадії
або фази і знову чекає її завершення.
Для створення об'єкта Phaser використовується один з конструкторів:
Phaser()
Phaser(int parties)
Phaser(Phaser parent)
Phaser(Phaser parent, int parties)
Параметр parties вказує на кількість сторін (грубо кажучи, потоків), які повинні
виконувати всі фази дії. Перший конструктор створює об'єкт Phaser без будь-яких
сторін. Другий конструктор реєструє передану в конструктор кількість сторін.
Третій і четвертий конструктори також встановлюють батьківський об'єкт Phaser.
Основні методи класу Phaser:
 int register(): реєструє сторону, яка виконує фази, і повертає номер
поточної фази - зазвичай фаза 0
 int arrive(): повідомляє, що сторона завершила фазу і повертає номер
поточної фази
 int arriveAndAwaitAdvance(): аналогічний методу arrive, тільки при цьому
змушує phaser очікувати завершення фази усіма іншими сторонами
 int arriveAndDeregister(): повідомляє про завершення всіх фаз стороною і
знімає її з реєстрації. Повертає номер поточної фази або негативне число,
якщо синхронизатор Phaser завершив свою роботу
 int getPhase(): повертає номер поточної фази
При роботі з класом Phaser зазвичай спочатку створюється його об'єкт. Далі нам
треба зареєструвати всі сторони, що беруть учать у фазі. Для реєстрації в кожній
стороні викликається метод register(). Можна обійтися і без цього методу,
передавши потрібну кількість сторін в конструктор Phaser.
Потім кожна сторона виконує певний набір дій, що становлять фазу. А
синхронизатор Phaser чекає, поки всі сторони не завершать виконання фази. Щоб
повідомити синхронізатору, що фаза завершена, сторона повинна викликати
метод arrive() або arriveAndAwaitAdvance(). Після цього синхронизатор
переходить до наступної фази.
Застосуємо Phaser в додатку:
import java.util.concurrent.Phaser;
public class Program {
public static void main (String [] args) {
Phaser phaser = new Phaser(1);
new Thread(new PhaseThread(phaser,"PhaseThread 1")).start();
new Thread(new PhaseThread(phaser,"PhaseThread 2")).start();
int phase = phaser.getPhase(); //чекаємо завершення фази 0
phaser.arriveAndAwaitAdvance();
System.out.println("Фаза" + phase + "завершена");
phase = phaser.getPhase(); // чекаємо завершення фази 1
phaser.arriveAndAwaitAdvance();
System.out.println("Фаза" + phase + "завершена");
phase = phaser.getPhase(); //чекаємо завершення фази 2
phaser.arriveAndAwaitAdvance();
System.out.println("Фаза" + phase + "завершена");
phaser.arriveAndDeregister ();
}
}
class PhaseThread implements Runnable {
Phaser phaser;
String name;
PhaseThread (Phaser p, String n) {
this.phaser = p;
this.name = n;
phaser.register();
}
public void run() {
System.out.println(name+"виконує фазу"+phaser.getPhase());
phaser.arriveAndAwaitAdvance(); //повідомляємо, що перша
// фаза досягнута
System.out.println(name+"виконує фазу"+phaser.getPhase());
phaser.arriveAndAwaitAdvance(); //повідомляємо, що друга
// фаза досягнута
System.out.println(name+"виконує фазу"+phaser.getPhase());
phaser.arriveAndDeregister(); //повідомляємо про завершення
// фаз і видаляємо з реєстрації об'єкти
}
}
Отже, тут у нас фази виконуються трьома сторонами - головним потоком і двома
потоками PhaseThread. Тому при створенні об'єкта Phaser йому передається
число 1 - головний потік, а в конструкторі PhaseThread викликається метод
register(). Ми в принципі могли б не використати метод register, але тоді нам
треба було б вказати Phaser phaser = new Phaser(3), так як у нас три сторони.
Фаза в кожній стороні представляє мінімальний примітивний набір дій: для потоків
PhaseThread це виведення повідомлення, а для головного потоку - підрахунок
поточної фази за допомогою методу getPhase(). При цьому відлік фаз
починається з нуля. Кожна сторона завершує виконання фази викликом методу
phaser.arriveAndAwaitAdvance(). При виклику цього методу поки остання сторона
не завершить виконання поточної фази, всі інші сторони блокуються.
Після завершення виконання останньої фази відбувається скасування реєстрації
усіх сторін за допомогою методу arriveAndDeregister().
В результаті робота програми дасть наступний вивід на консоль:
PhaseThread 1 виконує фазу 0
PhaseThread 2 виконує фазу 0
PhaseThread 1 виконує фазу 1
PhaseThread 2 виконує фазу 1
Фаза 0 завершена
Фаза 1 завершена
PhaseThread 1 виконує фазу 2
PhaseThread 2 виконує фазу 2
Фаза 2 завершена
В даному випадку виходить трохи плутаний вивід. Так, повідомлення про
виконання фази 1 виводиться після повідомлення про закінчення фази 0. Що
пов'язано з багатопоточністю - фази завершилися, але в одному потоці ще не
виведено повідомлення про завершення, тоді як інші потоки вже розпочали
виконання наступної фази. У будь-якому випадку все це відбувається вже після
завершення фази.
Але щоб було більш наочно, ми можемо використовувати sleep в потоках:
public void run() {
System.out.println(name+"виконує фазу"+phaser.getPhase());
phaser.arriveAndAwaitAdvance(); //перша фаза досягнута
try { Thread.sleep(200); }
catch (InterruptedException ex) {
System.out.println(ex.getMessage());
}
System.out.println(name+"виконує фазу"+phaser.getPhase());
phaser.arriveAndAwaitAdvance();//друга фаза досягнута
try { Thread.sleep (200); }
catch (InterruptedException ex) {
System.out.println (ex.getMessage ());
}
System.out.println(name+"виконує фазу"+phaser.getPhase());
phaser.arriveAndDeregister(); //завершена третя фаза,
// видаляємо з реєстрації об'єкти
}
В цьому випадку вивід буде більш звичним, хоча на роботу фаз це ніяк не вплине.
PhaseThread 1 виконує фазу 0
PhaseThread 2 виконує фазу 0
Фаза 0 завершена
PhaseThread 2 виконує фазу 1
PhaseThread 1 виконує фазу 1
Фаза 1 завершена
PhaseThread 2 виконує фазу 2
PhaseThread 1 виконує фазу 2
Фаза 2 завершена
1.10. Блокування. ReentrantLock
Для управління доступом до загального ресурсу в якості альтернативи оператору
synchronized ми можемо використовувати блокування. Функціональність
блокувань укладена в пакеті java.util.concurrent.locks.
Спочатку потік намагається отримати доступ до загального ресурсу. Якщо він
вільний, то на нього накладає блокування. Після завершення роботи блокування з
загального ресурсу знімається. Якщо ж ресурс не вільний і на нього вже накладено
блокування, то потік очікує, поки це блокування не буде знято в іншому потоці.
Класи блокувань реалізують інтерфейс Lock, який визначає наступні методи:
 void lock(): очікує, поки не буде отримана блокування
 void lockInterruptibly() throws InterruptedException: очікує, поки не буде
отримана блокування, якщо потік не перерваний
 boolean tryLock(): намагається отримати блокування, якщо блокування
отримане, то повертає true. Якщо блокування не отримане, то повертає
false. На відміну від методу lock() не очікує отримання блокування, якщо
воно недоступне
 void unlock(): знімає блокування
 Condition newCondition(): повертає об'єкт Condition, який пов'язаний з
поточним блокуванням
Організація блокування в загальному випадку досить проста: для отримання
блокування викликається метод lock(), а після закінчення роботи з загальними
ресурсами викликається метод unlock(), який знімає блокування.
Об'єкт Condition дозволяє управляти блокуванням.
Блокування називається реєнтерабельним, тому що потік виконання може
повторно захоплювати блокування, яким він вже володіє. Для блокування
передбачений лічильник захоплень, що відслідковує вкладені виклики методу
lock(). І для кожного виклику lock() в потоці повинен бути викликаний метод
unlock(), щоб, врешті-решт, зняти блокування. Завдяки цьому засобу код,
захищений блокуванням, може викликати інший метод, який використовує те ж
саме блокування.
Як правило, для роботи з блокуваннями використовується клас ReentrantLock з
пакета java.util.concurrent.locks. Даний клас реалізує інтерфейс Lock.
Для прикладу візьмемо код з теми про оператор synchronized і перепишемо
даний код з використанням блоку ReentrantLock:
import java.util.concurrent.locks.ReentrantLock;
public class Program {
public static void main(String[] args) {
CommonResource commonResource = new CommonResource();
ReentrantLock locker = new ReentrantLock(); //створюємо блок
for (int i = 1; i <6; i++) {
Thread t = new Thread(new CountThread(commonResource,
locker));
t.setName("Thread" + i);
t.start();
}
}
}
class CommonResource { int x = 0; }
class CountThread implements Runnable {
CommonResource res;
ReentrantLock locker;
CountThread(CommonResource res, ReentrantLock lock) {
this.res = res;
locker = lock;
}
public void run() {
locker.lock (); // встановлюємо блокування
try {
res.x = 1;
for (int i = 1; i <5; i ++) {
System.out.printf("%s %d \n",
Thread.currentThread().GetName(), res.x);
res.x ++;
Thread.sleep (100);
}
}
catch (InterruptedException e) {
System.out.println(e.getMessage());
}
finally {
locker.unlock(); // знімаємо блокування
}
}
}
Тут також використовується загальний ресурс CommonResource, для управління
яким створюється п'ять потоків. На вході в критичну секцію встановлюється
блокування:
locker.lock();
Після цього тільки один потік має доступ до критичної секції, а решта потоків
чекають зняття блокування. У блоці finally після закінчення основної роботи
потоку з ресурсом це блокування знімається. Причому робиться це обов'язково в
блоці finally, так як в разі виникнення помилки всі інші потоки виявляться
заблокованими.
У підсумку ми отримаємо вивід на консоль, аналогічний тому, який був у випадку з
оператором synchronized:
Thread 4 1
Thread 4 2
Thread 4 3
Thread 4 4
Thread 3 1
Thread 3 2
Thread 3 3
Thread 3 4
Thread 2 1
Thread 2 2
Thread 2 3
Thread 2 4
Thread 1 1
Thread 1 2
Thread 1 3
Thread 1 4
Thread 5 1
Thread 5 2
Thread 5 3
Thread 5 4
Блокування читання / запису
У пакеті java.util.concurrent.locks визначається крім уже розглянутого класу
ReentrantLock також клас ReentrantReadWriteLock. Останній зручний в тих
випадках, коли є більше потоків для читання зі структури даних і менше потоків
для запису в неї. У подібних випадках має сенс дозволити спільний доступ для
потоків виконання, які читають дані. Записуючі потоки як і раніше повинні мати
винятковий доступ.
1.11. Умови в блокуваннях
Застосування умов в блокування дозволяє домогтися контролю над управлінням
доступу до потоків. Умова блокування це об'єкт інтерфейсу Condition з пакета
java.util.concurrent.locks.
Застосування об'єктів Condition багато в чому аналогічно використанню методів
wait/notify/notifyAll класу Object, які були розглянуті в одній з минулих тем.
Зокрема, ми можемо використовувати такі методи інтерфейсу Condition:
 await: потік очікує, поки не буде виконана деяка умова і поки інший потік не
викличе методи signal/signalAll. Багато в чому аналогічний методу wait
класу Object
 signal: сигналізує, що потік, у якого раніше був викликаний метод await(),
може продовжити роботу. Застосування аналогічне використанню методу
notify класу Object
 signalAll: сигналізує всім потокам, у яких раніше був викликаний метод
await(), що вони можуть продовжити роботу. Аналогічний методу notifyAll()
класу Object
Ці методи викликаються з блоку коду, який потрапляє під дію блокування
ReentrantLock. Спочатку, використовуючи це блокування, нам треба отримати
об'єкт Condition:
ReentrantLock locker = new ReentrantLock();
Condition condition = locker.newCondition();
Як правило, спочатку перевіряється умова доступу. Якщо виконується умова, то
потік очікує, поки умова не зміниться:
while (условие)
    condition.await();
Після виконання всіх дій іншим потокам подається сигнал про зміну умови:
condition.signalAll();
Важливо в кінці викликати метод signal / signalAll, щоб уникнути можливості
взаємного блокування потоків.
Для прикладу візьмемо завдання з теми про методи wait / notify і змінимо її,
застосовуючи об'єкт Condition.
Отже, у нас є склад, де можуть одночасно бути розміщено не більше 3 товарів. І
виробник повинен зробити 5 товарів, а покупець повинен ці товари купити. У той
же час покупець не може купити товар, якщо на складі немає ніяких товарів:
public class Program {
public static void main(String[] args) {
Store store = new Store();
Producer producer = new Producer(store);
Consumer consumer = new Consumer(store);
new Thread(producer).start();
new Thread(consumer).start();
}
}
// Клас Магазин, який зберігає вироблені товари
class Store {
private int product = 0;
ReentrantLock locker;
Condition condition;
Store() {
locker = new ReentrantLock(); // створюємо блокування
// отримуємо умову, пов'язану з блокуванням
condition = locker.newCondition();
}
public void get() {
locker.lock();
try {
// поки немає доступних товарів на складі, очікуємо
while (product <1)
condition.await();
product--;
System.out.println("Покупець купив 1 товар");
System.out.println("Товарів на складі:" + product);
//сигналізуємо
condition.signalAll();
}
catch (InterruptedException e) {
System.out.println (e.getMessage());
}
finally {
locker.unlock();
}
}
public void put() {
locker.lock();
try {
//поки на складі 3 товара, чекаємо звільнення місця
while (product> = 3)
condition.await ();
product++;
System.out.println( "Виробник додав 1 товар");
System.out.println( "Товарів на складі:" + product);
// сигналізуємо
condition.signalAll();
}
catch (InterruptedException e) {
System.out.println(e.getMessage());
}
finally {
locker.unlock();
}
}
}
// клас Виробник
class Producer implements Runnable {
Store store;
Producer (Store store) {
this.store = store;
}
public void run () {
for (int i = 1; i <6; i ++) {
store.put();
}
}
}
// Клас Споживач
class Consumer implements Runnable {
Store store;
Consumer (Store store) {
this.store = store;
}
public void run ) {
for (int i = 1; i <6; i ++) {
store.get();
}
}
}
В результаті ми отримаємо вивід на консоль, схожий на такий:
Виробник додав 1 товар
Товарів на складі: 1
Виробник додав 1 товар
Товарів на складі: 2
Виробник додав 1 товар
Товарів на складі: 3
Покупець купив 1 товар
Товарів на складі: 2
Покупець купив 1 товар
Товарів на складі: 1
Покупець купив 1 товар
Товарів на складі: 0
Виробник додав 1 товар
Товарів на складі: 1
Виробник додав 1 товар
Товарів на складі: 2
Покупець купив 1 товар
Товарів на складі: 1
Покупець купив 1 товар
Товарів на складі: 0
1.12. Поля і змінні типу volatile. Атомарність операцій
Що може статися під час запису в одне або два поля об'єкта з різних потоків.
• Комп'ютери з декількома процесорами можуть тимчасово утримувати значення з
пам'яті в регістрах або локальних кешах. Внаслідок цього в потоках, що
виконуються на різних процесорах, можуть бути доступні різні значення з тієї ж
області пам'яті!
• Компілятори можуть змінювати порядок виконання команд для досягнення
максимальної продуктивності. Робиться припущення, що значення в пам'яті
змінюються тільки явними командами в коді. Але значення в пам'яті може бути
змінено з іншого потоку виконання!
Ключове слово volatile позначає неблокуючий механізм синхронізованого доступу
до поля екземпляра. Якщо поле оголошується як volatile, то компілятор і
віртуальна машина беруть до уваги той факт, що поле може бути паралельно
оновлено в другому потоці виконання.
Припустимо, у об'єкта є поле ознаки done типу boolean, який встановлюється в
одному потоці виконання і опитується в іншому. Для цієї мети можна організувати
вбудоване блокування наступним чином:
private boolean done;
public synchronized boolean isDone() {return done; }
public synchronized void setDone() {done = true; }
Застосовувати вбудоване блокування об'єкта - ймовірно, не найкраща ідея.
Адже методи isDone() і setDone() можуть блокуватися, якщо інший потік
виконання заблокував об'єкт. Тому в даному випадку має сенс оголосити поле як
volatile наступним чином:
private volatile boolean done;
public boolean isDone() {return done; }
public void setDone() {done = true; }
УВАГА! Змінні типу volatile не гарантують ніякої атомарности операцій.
public void flipDone () {done =!done; } // НЕ атомарна операція!
Поля і змінні типу final
Ще одна можливість надійного доступу до поділюваного поля - оголошення як
final, тобто константа.
Атомарність операцій
Спільні змінні можуть бути оголошені як volatile, за умови, що над ними не
виконується жодна операція, крім присвоювання. У пакеті
java.util.concurrent.atomic є цілий ряд класів, в яких ефективно використовуються
команди машинного рівня, що гарантують атомарність інших операцій без
застосування блокувань. Наприклад, в класі AtomicInteger є методи
incrementAndGet() і decrementAndGet(), які атомарно інкрементують або
декрементують ціле значення. Цим гарантується правильне обчислення та
повернення значення навіть при одночасному доступі до одного і того ж
екземпляру з декількох потоків виконання. Так, безпечно сформувати
послідовність чисел можна наступним чином:
public static AtomicLong nextNumber = new AtomicLong();
// В деякому потоці виконання ...
long id = nextNumber.incrementAndGet();
1.13. Взаємні блокування
Блокування і умови не можуть вирішити всіх проблем, які виникають при багато
потоковій обробці. Розглянемо наступну ситуацію.
1. Рахунок 1: сума 200 дол.
2. Рахунок 2: сума 300 дол.
3. Потік 1: переводить суму 300 дол. з рахунку 1 на рахунок 2.
4. Потік 2: переводить суму 400 дол. з рахунку 2 на рахунок 1.
Потоки 1 і 2 блокуються, оскільки залишків на рахунках 1 та 2 недостатньо для
виконання транзакції. Така ситуація називається взаємним блокуванням. (Якщо на
рахунку допустимий мінус – взаємне блокування не відбувається).
Ще один приклад взаємного блокування
public class DeadlockDemo {
// Два об'єкти-ресурсу
public final static Object one = new Object(),
two = new Object();
public static void main(String s[]) {
// Створюємо два потоки, які будуть
// конкурувати за доступ до об'єктів one і two
Thread t1 = new Thread(){
public void run() {
// Блокування першого об'єкта
synchronized(one) {
System.out.println( "t1 get one!");
Thread.yield();
// Блокування другого об'єкта
System.out.println("t1 try two!");
synchronized(two) {
System.out.println("t1 Success!");
}
}
}
};
Thread t2 = new Thread () {
public void run () {
// Блокування другого об'єкта
synchronized(two) {
System.out.println("t2 get two!");
Thread.yield();
// Блокування першого об'єкта
System.out.println("t2 try one!");
synchronized(one) {
System.out.println ( "t2 Success!");
}
}
}
};
// Запускаємо потоки
t1.start();
t2.start();
}
}
Вивід:
t1 get one!
t2 get two!
t2 try one!
t1 try two!
Перевірка блокувань і час очікування
Потік виконання блокується на невизначений час, коли в ньому викликається
метод lock() або synchronized-метод чи блок для захоплення блокування, яким
володіє інший потік. Метод tryLock() намагається захопити блокування і негайно
повертає true у разі успіху або false в іншому випадку. Тоді потік може
переключитися на виконання яких-небудь інших корисних дій.
if (myLock.tryLock())
// тепер потік володіє блокуванням
try {...}
finally {myLock.unlock(); }
else
// зробити ще що-небудь корисне
Метод tryLock() можна викликати, вказавши час очікування в якості параметра.
Метод lock() не може бути перерваний. Якщо потік виконання перерваний під час
очікування захоплення блокування, перерваний потік залишається заблокованим
до тих пір, поки доступне блокування. Якщо ж виникне взаємне блокування, метод
lock() взагалі не завершиться.
Але якщо викликати метод tryLock() із зазначенням часу очікування в якості
параметра, то при спробі перервати потік виконання, що знаходиться в стані
очікування, буде згенеровано виняток типу InterruptedException. Це дає
можливість знімати взаємні блокування в програмі.(void lockInterruptibly() також
генерує виключення при спробі перервати потік).

1.14. Чому методи stop() і suspend() не рекомендовані до


застосування
У класі Thread визначені методи
suspend() - припинення потоку
resume() - відновлення потоку
stop() - зупинка потоку
Нині вони оголошені застарілими користуватися ними в нових програмах нa java
не рекомендується.
Метод suspend() здатний породжувати серйозні системні збої. Припустимо, що
потік виконання отримав блокування для дуже важливих структур даних. Якщо в
цей момент призупинити виконання даного потоку, блокування не будуть зняті.
Якщо потік виконання, що викликав метод suspend(), спробує захопити те ж саме
блокування, програма перейде в стан взаємного блокування: призупинений потік
очікує, коли його відновлять, а потік, який призупинив його, чекає зняття
блокування.
Метод stop() припиняє виконання будь-яких незавершених методів в потоці,
включаючи і run(). При цьому негайно знімає блокування цього потоку з усіх
об'єктів, які він блокував. Це може привести до того, що об'єкти залишаться в
неузгоджену стані і будуть використані в іншому потоці.
Рекомендується код управління виконанням потоку складати таким чином, щоб
метод run() періодично перевіряв деяку змінну-прапорець. Коли ця змінна містить
ознаку "виконується", метод run() повинен продовжувати виконання. Якщо змінна
містить ознаку "призупинити", потік виконання повинен бути припинений. Якщо
змінна отримує ознака "зупинити", то потік виконання повинен завершитися.
1.14. Блокуючі черги
Багато труднощів, пов'язаних з потоками виконання, можна подолати,
застосувавши одну або кілька черг. Зокрема, потік-поставщик вводить елементи в
чергу, а потоки-споживачі вибирають їх із черги. Таким чином, черга дозволяє
безпечно передавати дані з одного потоку в інший.
Наприклад, у програмі банківських переказів замість того щоб звертатися до
об'єкта рахунки банку безпосередньо, потоки виконання, що виробляють грошові
перекази, вводять в чергу об'єкти команд на грошовий переказ. А інший потік
виконання видаляє ці об'єкти з черги і сам виконує грошові перекази. І тільки цей
потік має доступ до внутрішнього вмісту об'єкта банку. В результаті ніякої
синхронізації не потрібно. У бібліотеці існують потокобезпечні класи черг, які самі
подбають про блокування і умови.
У пакеті java.util.concurrent надається кілька варіантів блокуючих черг. Наведемо
кілька з них.
За замовчуванням у черзі типу LinkedBlockingQueue відсутня верхня межа
ємності, але така межа ємності може бути вказана. LinkedBlockingDeque - це
варіант двосторонньої черги. PriorityBlockingQueue - блокує чергу за
пріоритетом, а не просто діє за принципом "першим прийшов - першим
обслужений". Елементи видаляються з такою черги по їх пріоритетам.

You might also like