• ,

Основы Параллелизма: взаимоблокировки и мониторы объектов (разделы 1, 2) (перевод статьи)

Исходная статья: 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 Неизменяемый объект
 3.2 Проектирование API
 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, когда poll возвращает 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().

Продолжение перевода здесь.

Комментариев нет

Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.