субота, 27 серпня 2011 р.

Lock та Condition

Минулого разу я розглянув Atomic-типи та методику їх застосування. Сьогодні я продовжу огляд класів з пакету java.util.concurrent. Отже Lock та Condition.

У JDK 7 три реалізації Lock’ів, але нас цікавить лише ReentrantLock, тому далі мова йде саме про цей клас. Інші два використовуються вкупі з ReadWriteLock, і з точки зору можливостей не дуже цікаві. ReadWriteLock буде темою однієї з наступних статей.

Чим корисні високорівневі локи?
Як відомо у Java кожен об’єкт є простеньким монітором. Більш точно Java-монітор є простим монітором Хоара з однією внутрішньою умовною змінною. З іншого боку - клас ReentrantLock, який виконує ту саму функцію. Я спочатку задавався питанням, навіщо вводити ще й високорівневу конструкцію, яка робить те саме, адже завжди можна ввести змінну типу Object і використати її для синхронізації. 

Але сенс у тому є - перевагаю локів є можливість умовного блокування tryLock(), можливість будувати повноцінні монітори з багатьма множинами очікування використовуючи інтерфейс Condition та багато інших зручностей.

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

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


Умовні змінні
Для створення умовних змінних призначений метод newCondition. Умовні змінні призначенні для координації потоків по певній умови - звідси й назва (англ. conditional variables), тому гарною практикою є вибір імені змінної, що прямо пов’язує її з відповідною умовою. Для прикладу у коді ArrayBlockingQueue (Oracle JDK 7), умовні змінні називаються notEmpty та notFull для сповіщення про стан, коли внутрішній буфер відповідно має об’єкт у черзі на читання та має вільне місце на прийом нових об’єктів   

/** Main lock guarding all access */
final ReentrantLock lock;
/** Condition for waiting takes */
private final Condition notEmpty;
/** Condition for waiting puts */
private final Condition notFull;

Умовні змінні таким чином інкапсулють певну умову, що використовуюється для координації потоків. ArrayBlockingQueue є прикладом повноцінного монітора Хоара.  


Умовні змнні (інтерфейс Condition) мають методи, що повинні вам бути знайомі: await() - аналог Object#wait(), signal() та signalAll() - аналоги Object#notify() та Object#notifyAll(), але на відміну від останніх signal() та signalAll() мають послаблені вимоги до стану лока - реалізація може не вимагати захоплення монітора. Однак внутрішня реалізація все таки вимгає, щоб визиваючий код містився у критичній секції.

Крім того є три аналоги Object#wait(long):
  • Condition#await(long, TimeUnit) - потік чекатиме синхронізації вказану кількість одиниць часу у TimeUnit’ах
  • Condition#awaitNanos(long) - потік чекатиме синхронізації вказаний час у наносекундах 
  • Сondition#awaitUntil(Date) - потік чекатиме синхронізації до вказаної дати
Три забагато, бо TimeUnit підтримує наносекунди.
TimeUnit - взагалі дуже зручний утилітний клас для роботи з одиницями часу навіть у звичайних програмах. Дозволяє уникати магічних часових констант типу 24, 60, 3600 (інші рахувати впадло). Натомість пропонується наступне:
package part2;

import java.util.concurrent.TimeUnit;

public class TimeUnitDemo {

    public static void main(String[] args) throws InterruptedException {
        // без TimeUnit
        Thread.sleep(10*24*3600*1000);
        
        // з TimeUnit
        Thread.sleep(TimeUnit.DAYS.toMillis(10));
        
        // ще краще ;)
        TimeUnit.DAYS.sleep(10);
    }
}


І справжнє полегшення, - метод Condition#awaitUninterruptibly() - дозволяє відкласти обробку переривання потоку - виклику Thread#interrupt(), - і виконати її після завершення цього методу. Решта методів негайно кидають InterruptedException.  Готувати так:
 
package part2;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class UninterruptiblyWaitForTheEnd {

    public static void main(String[] args) {
        final Lock lock = new ReentrantLock();
        final Condition doomsDay = lock.newCondition();

        // Запускаєм счьотчік
        new Thread(new Runnable() {
            @Override
            public void run() {
                lock.lock();
                try {
                    TimeUnit.DAYS.sleep(10);
                    doomsDay.signal();
                } catch (InterruptedException e) {
                    // піздєц отмєняєтся по тєхніческім прічінам
                } finally {
                    lock.unlock();
                }
            }
        }).start();

        // Хоч від JVM взагалі нема гарантій, 
        // що до цього моменту 10 секунд не вийшло 
        // і не наступив піздєц,
        // ми - наївні пацани, - сподіваємось, що це не так
        boolean theEndHasCame;
        lock.lock();
        try {
            do {
                doomsDay.awaitUninterruptibly();

                if (theEndHasCame = hasTheEndCome()) {
                    // написати у Твіттер, що настав кінець світу
                    System.exit(0); // місію виконано
                }

                if (Thread.interrupted()) {
                    // далі йде обробка виклику interrupt() 
                    // з матюками у лог на тему яка у нас важлива місія
                }
            } while (!theEndHasCame);
        } finally {
            lock.unlock();
        }
    }

    public static boolean hasTheEndCome() {
        // На жаль http://www.doomsdayclock.org/ не має REST-інтерфейсу
        return true;
    }
}


Взагалі тут варто звернути увагу на те, що звільнення локу винесено в блок finally - це по-перше. По-друге, я не забув про spurious wakeups, по-третє - пам’ятайте що Thread#interrupted() скидує стан переривання потоку. 


Чи можна користуватись новим блоком try?
На жаль у JDK 7 локи не можуть бути використані як ресурси нового блоку try і автоматично звільнені не будуть. Можна це обійти, написавши невелику обгортку чи просто наслідувати ReentrantLock для підтримки AutoCloseable, але в питаннях паралельних я не бачу в цьому нічого хорошого. Робити так чи ні - вирішіть самі.

Три слова про чесність (fairness)
Остання важлива особливість локів - це можливість створювати їх відносно чесними. Конструктор ReentrantLock'а має необов'язковий параметр fairness. Грубо кажучи, чесні локи дозволяють вхід до критичної секції потокам в порядку викликів lock(), більш точно - завжди перевага надається потоку який довше чекає. Звичайні локи не дають таких гарантій і за рахунок цього вони трохи ефективніші. Відносно чесними такі локи є тому, що операційна система не зобов'язана чесно розподіляти процесор між потоками - це залежить від ОС та параметрів ядра. Непогане дослідження з цифрами можна знайти у статті The fairness of java.util.concurrent.locks.ReentrantLock

* * *

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


До зустрічі!

1 коментар:

  1. Спасибо за статью, очень интересная тема.

    Добавлю про fair и non-fair, на ibm когда-то видел статью на эту тему, с графиками, сравнительными таблицами, так вот разница в быстродействии достигала 25%. Но так же стоит упомянуть чем плох non-fair: в теории, при определенно большом количестве потоков, некоторые из них могут так и не дождаться своей очереди выполнения, другими словами зависнуть, это общеизвестная проблема в среде конкурентного программирования и есть даже специальное название для такой проблемы, вот подзабыл только.

    ВідповістиВидалити