Статьи

ReentrantLock и Обедающие Философы

Классическая проблема параллелизма — это проблема Философов-Столов , которая рассматривает проблему тупиковых ситуаций и решений, связанных с упорядочением и управлением замками. Я хотел бы использовать эту проблему как способ исследования некоторых новых возможностей, предлагаемых добавлением ReentrantLock в Java 5.

Проблема состоит в том, что за круглым столом сидят пять философов и пять палочек для еды, по одному между каждой парой философов. Философы неоднократно чередуют еду и мышление. Чтобы поесть, они должны сначала взять одну, а затем другую из палочек для еды.

Если они произвольно берут палочки для еды, со временем неизбежно возникает тупик, так что все философы держат левую палочку и ждут правой (или наоборот). Простейший пример этого — два философа и две палочки для еды. Если оба философа возьмут свою левую палочку для еды, они оба будут ждать правую вечность.

Давайте начнем с палочки для еды:

public class Chopstick {
private final int id;
public Chopstick(int id) {
this.id = id;
}

// equals, hashcode, and toString() omitted
}

Мы хотим абстрагироваться от того, как получаются палочки для еды, поэтому я также собираюсь создать ChopstickOrder:

public interface ChopstickOrder {
Chopstick[] getOrder(Chopstick left, Chopstick right);
}

Для начала будут две реализации: RandomOrder (который случайным образом выбирает левый или правый для начала) и LeftRightOrder (который всегда выбирает левый, затем правый).

Тогда нам нужен философ:

class Philosopher implements Runnable {
public final int id;
private final Chopstick[] chopsticks;
protected final ChopstickOrder order;

public Philosopher(int id, Chopstick[] chopsticks, ChopstickOrder order) {
this.id = id;
this.chopsticks = chopsticks;
this.order = order;
}

public void run() {
while(true) {
eat();
}
}

protected void eat() {
Chopstick[] chopstickOrder = order.getOrder(getLeft(), getRight());
synchronized(chopstickOrder[0]) {
synchronized(chopstickOrder[1]) {
Util.sleep(1000);
}
}
}

Chopstick getLeft() { return chopsticks[id]; }
Chopstick getRight() { return chopsticks[(id+1) % chopsticks.length]; }
}
}

Здесь философом можно управлять, и он просто ест вечно, где еда включает в себя выбор, какую палочку для еды сначала взять, подбирать, потом другую, потом есть.

Чтобы проверить, мы раскручиваем N палочек для еды и N философов и заставляем их есть, используя LeftRightOrder или даже Случайный порядок. Это в конечном итоге тупик.

Классическое решение — указать порядок получения ресурсов. Если палочки для еды пронумерованы, и каждый философ сначала пробует палочку для еды с меньшим номером, мы гарантированно избежим тупика. Например, если N = 2, каждый философ захватывает палочку 0 перед палочкой 1, поэтому они никогда не смогут удерживать 1 без удержания 0.

Так что все было настроено. Если вы посмотрите на приведенный выше код Philosopher в методе eat (), то увидите, что мы «схватили» палочку для еды, синхронизировав ее, заблокировав монитор палочки. В Java 5 у нас появился новый способ блокировки критических секций — интерфейс Lock и реализация ReentrantLock .

(Вам может быть интересно, что означает здесь «повторное поступление». Это просто означает, что как только поток удерживает блокировку, если он пытается восстановить блокировку (повторно вводит код блокировки)), он будет продолжаться. Другими словами, он ведет себя точно так же, как Java мониторы и знакомое поведение синхронизированных методов и блоков в Java.)

ReentrantLock предоставляет вам все функции синхронизированных, а также намного больше опций. Сначала давайте перепишем eat () с помощью ReentrantLock:

public class GraciousPhilosopher extends Philosopher {

private static Map chopstickLocks = new ConcurrentHashMap ();

public GraciousPhilosopher(int id, Chopstick[] chopsticks, ChopstickOrder order) {
super(id, chopsticks, order);

// Every philosopher creates a lock for their left chopstick
chopstickLocks.put(getLeft(), new ReentrantLock());
}

protected void eat() {
Chopstick[] chopstickOrder = order.getOrder(getLeft(), getRight());
Lock firstLock = chopstickLocks.get(chopstickOrder[0]);

Lock secondLock = chopstickLocks.get(chopstickOrder[1]);
firstLock.lock();
try {
secondLock.lock();
try {
Util.sleep(1000);
} finally {
secondLock.unlock();
}
} finally {
firstLock.unlock();
}
}
}

Здесь мы просто заменяем синхронизированный с lock () и конец синхронизированного блока на try {} finally {unlock ()}. Пока что нет никакой разницы в поведении во время выполнения, однако.

Но мы можем использовать дополнительные возможности ReentrantLock для выполнения некоторых других полезных задач. Во-первых, нам не нужно навсегда блокировать вызов блокировки. Вместо этого мы можем сделать время ожидания, используя tryLock () . Одна форма этого метода возвращается немедленно, если блокировка уже удержана, а другая может подождать некоторое время, пока блокировка не станет доступной, прежде чем сдаться. В обоих случаях мы могли бы эффективно зацикливаться и повторять попытку tryLock (), пока она не будет выполнена успешно.

Другой хороший вариант — lockInterruptibly () . Вызов этого метода позволяет ожидать неопределенно долго, но реагировать на прерывание потока. Можно написать внешний монитор, который либо отслеживает тупик, либо позволяет пользователю принудительно прерывать один из рабочих потоков. Нечто подобное может быть предоставлено через JMX, чтобы позволить пользователю оправиться от тупика. (Конечно, я бы рекомендовал исправить тупиковую ситуацию в первую очередь!)

Конечно, использование ReentrantLock по сравнению с синхронизацией сопряжено с некоторыми потерями производительности. Однако реализация очень эффективна и все еще обеспечивает приемлемую производительность для многих распространенных случаев использования.