Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:

Джош Блох

.pdf
Скачиваний:
57
Добавлен:
08.03.2016
Размер:
27.13 Mб
Скачать

Глава 10 Потоки

когда инвариант, защищенный синхронизированной областью, был временно недействителен. Поскольку блокировки в языке Java яв­ ляются возвращающимися, такие вызовы не приведут к блокировке. Как и в первом примере, который завершился исключением, вызы­ ваемый поток уже содержит блокировку, так что поток завершится успешно, если он попытается повторно применить блокировку, даже если другая, концептуально несвязанная операция будет выполнять­ ся с данными, защищенными блокировкой. Последствия такого сбоя могут быть катастрофическими. По сути, блокировка нам удалась. Возвращающиеся блокировки позволяют упростить создание много­ поточных объектно ориентированных программ, но они могут сбои живучести превратить в сбои безопасности.

К счастью, обычно не слишком сложно решить такую проблему путем перемещения запуска чужого метода за пределы синхронизи­ рованных блоков. Для метода not ifуElementAdded это подразумева­ ет копию состояния списка observers, через который затем можно безопасно пройти без блокировки. При выполнении этих измене­ ний оба предыдущих примера будут выполняться без ошибок и бло­ кировок:

//Чужой метод перемещен за пределы синхронизированного блока -

//открытые вызовы

private void notifyElementAdded(E element) { List<SetObserver<E>> snapshot = null; synchronized(observers) {

snapshot = new ArrayList<SetObserver<E»(observers);

}

for (SetObserver<E> observer : snapshot) observer.added(this, element);

}

На самом деле есть лучший способ переместить запуск чужого метода за рамки синхронизированного блока. В версии 1.5 библио­ теки Java дают нам параллельную коллекцию (статья 69), известную как CopyOnWriteArrayList, который специально сделан для этой цели. Это вариант ArrayList, в котором все операции записи реализуются

370

С татья 67

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

Методы add и addAll набора ObservableSet нет необходимости ме­ нять, если список изменится при использовании CopyOnWriteArrayList. Вот как выглядит оставшаяся часть класса. Обратите внимание, что здесь нет явной синхронизации:

// Безопасный набор observable с CopyOnWriteArrayList private final List<SetObserver<E>> observers =

new CopyOnWriteArrayList<SetObserver<E»();

public void addObserver(SetObserver<E> observer) { observers.add(observer);

}

public boolean removeObserver(SetObserver<E> observer) { return observers.remove(observer);

}

private void notifyElementAdded(E element) { for (SetObserver<E> observer : observers) observer.added(this, element);

}

Чужой метод, запущенный за пределами синхронизированной области, известен как откры ты й вызов [LeaOO 2.4.1.3]. Кроме того, что открытые вызовы предотвращают сбои, они могут существенно увеличить параллельность. Чужой метод может выполняться в тече­ ние произвольно длительного периода. Если чужой метод был запу­ щен из синхронизируемой области, другие потоки не получат доступ к защищенным ресурсам без надобности.

Как правило, вам нужно выполнять как можно меньше действий внутри синхронизируемой области. Получить блокировку, про­ смотреть общие данные, преобразовать их как надо и затем убрать блокировку. Если вам требуется выполнение длительных действий,

371

Глава 10 Потоки

найдите способ, чтобы переместить эти действия за пределы синхро­ низируемой области, не нарушая инструкций (статья 66).

Первая часть этой статьи была посвящена правильности. Те­ перь вкратце рассмотрим вопрос производительности. Хотя затраты на синхронизацию уменьшились еще на заре появления языка Java, тем не менее важно не переусердствовать с синхронизацией. В муль­ типроцессорной среде реальные затраты на излишнюю синхрониза­ цию — это не время, затрачиваемое процессором на получение блоки­ ровок, а утерянные возможности параллельного запуска и задержки, вызванные необходимостью убедиться в том, что каждое ядро видит всю память постоянно. Другие скрытые затраты от излишней син­ хронизации — это то, что она может ограничить возможность вирту­ альной машины оптимизировать код при выполнении.

Вам необходимо сделать неизменяемый класс потокобезопас­ ным (статья 70), если предполагается его параллельное использо­ вание и вы можете достичь большей параллельности выполняя син­ хронизацию изнутри, чем путем блокирования всего объекта извне. В противном случае не синхронизируйте изнутри. Пусть клиенты синхронизируются извне, где это приемлемо. Вначале, при появле­ нии платформы Java многие классы нарушали данные инструкции. Например, экземпляры StringBuffer почти всегда используются одним потоком, но все-таки выполняют синхронизацию изнутри. Именно поэтому в версии 1.5 он был заменен на StringBuilder, кото­ рый представляет собой несинхронизируемую версию StringBuffer. Если вы сомневаетесь, то не синхронизируйте ваш класс, но укажите в документации, что он не потокобезопасен (статья 70).

Если вы синхронизируете класс изнутри, вы можете пользовать­ ся различными приемами для достижения параллельности, такими как разделение блокировки, распределение блокировки и контроль параллельности без блокировки. Эти приемы выходят за рамки этой книги, но описываются другими [Goetz06, LeeOO].

Если класс или статический метод связан с изменяемым ста­ тическим полем, он должен иметь внутреннюю синхронизацию,

372

С тать я 68

даже если обычно применяется только с одним потоком. В про­ тивоположность совместно используемому экземпляру здесь клиент не имеет возможности произвести внешнюю синхрони­ зацию, поскольку нет никакой гарантии, что другие клиенты бу­ дут делать то же самое. Эту ситуацию иллюстрирует статический

метод generateSerialNumber (статья 66).

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

В современном многопроцессорном мире как никогда важно, чтобы не злоупотреблять синхронизацией. Синхронизируйте класс изнутри, только если для этого есть хорошая причина, и четко доку­ ментируйте ваше решение (статья 70).

Предпочитайте использование экзекуторов и заданий вместо потоков

В первой редакции книги содержался код для простой рабочей очереди [BlochOl, статья 49]. Этот класс позволял клиентам ставить в очередь рабочие задачи для асинхронной обработки фоновым пото­ ком. Когда рабочая очередь становится более не нужна, клиенты мо­ гут запустить метод, чтобы попросить фоновый поток завершить са­ мого себя после завершения работы, уже стоящей в очереди. Данная реализация была не более чем игрушкой, но для ее реализации тре­

373

ExecutorComple-

Глава 10 Потоки

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

В версии 1.5 платформы появился java. util, concurrent. Этот па­ кет содержит структуру экзекуторов, являющуюся довольно гибкой, с основанной на интерфейсах возможностью выполнения задач. Со­ здание рабочих очередей, которое сейчас реализовано лучше описан­ ной в первой редакции книги, требует всего лишь одной строки кода:

ExecutorService executor = Executors. newSingleThreadExecutor();

Вот как можно запустить выполнение:

executor.execute(runnable);

А вот как корректно его завершить (если у вас не получится, то, скорее всего, ваша виртуальная машина не закроется):

executor.shutdown();

С помощью службы экзекуторов вы можете сделать больше ве­ щей. Например, вы можете подождать, пока завершится определен­ ная задача (как в «фоновом потоке SetObserver», описанном в ста­ тье 67), или подождать, пока одна или все задачи из коллекции задач завершатся (используя методы invokeAny или invokeAll). Можно по­ дождать, пока служба экзекуторов корректно завершится (исполь­ зуя метод awaitTermination), вы сможете вывести результаты задач один за другим по мере их завершения (используя

tionService) и.т.д.

Если вы хотите, чтобы запросы из очереди обрабатывались более чем одним потоком, просто вызовите другой метод статической гене­ рации, который создает другой вид службы экзекуторов, называемый пул потоков. Вы можете создавать пул потоков с фиксированным количеством переменных потоков. Класс java, utils, comcurrent. Ex­ ecutors содержит методы статической генерации, предоставляющие большинство экзекуторов, которые вам когда-либо понадобятся. Если вы захотите что-то необычное, вы можете использовать не­

314

С тать я 68

посредственно класс ThreadPoolExecutor. Этот класс позволяет вам контролировать почти каждый аспект работы пула потоков.

Выбор службы экзекуторов для определенного приложения мо­ жет быть довольно запутанным. Если вы пишете небольшую про­ грамму или легко нагруженный сервер, использование Executor. newCachedThreadPool обычно является хорошим выбором, посколь­ ку он не требует конфигурации и «все делает правильно». Но выбор кэшированного пула потоков для тяжело нагруженных серверов бу­ дет неудачным. В кэшированном пуле потоков поставленные задачи не ставятся в очередь, а отправляются сразу на выполнение. Если нет доступных потоков, то просто создается новый. Если сервер на­ гружен настолько, что использует процессор на полную мощность, то создание новых потоков по мере поступления задач только ухуд­ шит ситуацию. Следовательно, при тяжело нагруженном сервере вам лучше использовать Executor. newFixedTh read Pool, который дает нам фиксированное число потоков, или использовать непосредственно класс ThreadPoolExecutor для максимального контроля.

Вам не только следует воздержаться от написания собственных рабочих очередей, но также и от работы непосредственно с потока­ ми. Ключевым понятием больше не является Thread, который служил ранее в качестве и рабочей единицы и механизма его выполнения. Теперь рабочая единица и механизм разделены. Ключевым поняти­ ем теперь является рабочая единица, которая называется задачей. Есть два вида задач: Runnable и близкий ему Callable (который по­ хож на Runnable, за исключением того, что он возвращает значение). Общий механизм выполнения задач называется службой экзекуто­ ров. Если вы мыслите задачами и позволяете экзекуторам выполнять их для вас, вы получите огромную выгоду в гибкости в плане выбора подходящей политики выполнения. По сути, структура экзекуторов делает для выполнения то, что структура коллекций делает для нако­ пления.

У структуры экзекуторов есть замена для java, utils.Timer, которой является ScheduledThreadPoolExecutor. Хотя использовать

375

notify. З н а я о трудноси

Глава 10 Потоки

Timer легче, экзекутор пула потоков с расписанием является более гибким. Timer использует только один поток для выполнения задач. Если единственный поток Timer приведет к исключению, Timer пре­ кратит работу. А экзекутор пула потоков с расписанием поддержи­ вает многопоточность и корректно восстанавливается после необра­ ботанного исключения.

Полное описание структуры потоков выходит за рамки дан­ ной книги, но заинтересованный читатель может найти его в Java Concurrency in Practice [Goetz06].

Предпочитайте использовать утилиты параллельности, нежели шаП и notify

В первой редакции этой книги была статья, посвященная кор­ ректному использованию wait и notify (BlocjOl, статья 50). Содер­ жащиеся в ней советы все еще актуальны и подытожены в конце этой статьи, но эти советы теперь имеют намного меньшее значение. Это произошло потому, что сейчас намного меньше причин для использо­ вания wait и notify. В версию 1.5 платформы Java включены утилиты параллельности верхнего уровня, которые делают те вещи, которые вы раньше писали вручную поверх wait и

использования wait и notify, вам вместо этого следует использо­ вать высокоуровневые утилиты параллельности.

Эти утилиты, содержащиеся в java, utils, concurrent, делятся на три категории: структура экзекуторов, которая была кратко опи­ сана в статье 68, параллельные коллекции и синхронизаторы. Парал­ лельные коллекции и синхронизаторы кратко описаны в этой статье.

Параллельные коллекции дают нам высокопроизводительные па­ раллельные реализации стандартных интерфейсов коллекций, таких как List, Queue и Мар. Для обеспечения высокого уровня параллель­ ности эти реализации сами управляют своей синхронизацией изнутри

(статья 67). Следовательно, невозможно исключить параллельную

376

String, imtern:

С тать я 69

деятельность из параллельной коллекции, ее блокировка не будет

иметь действие, а только затормозит выполнение программы.

Это значит, что клиенты не могут атомарно создавать запуски методов на параллельных коллекциях. Некоторые из этих интерфей­ сов коллекций расширены операциями изменения в зависимости о т состояния ( stater-dependent modify operations), которые объе­ диняют в себе несколько примитивных операций в одну атомарную операцию. Например, ConcurrentMap расширяет Мар и добавляет не­ сколько методов, включая putlfAbsent( key, value), которые встав­ ляют схему в ключ, если таковой не было, и возвращает предыдущее значение, ассоциированное с ключом или null, если такого нет. Это облегчает реализацию традиционных потокобезопасных схем. Н а­ пример, этот метод имитирует поведение

/ / Параллельная традиционная схема поверх ConcurrentMap - / / не оптимальна

private static final ConcurrentMap<String, String> map = new ConcurrentHashMap<String, String>();

public static String intern(String s) {

String previousValue = map.putIfAbsent(s, s);

В действительности можно сделать лучше. ConcurrentHashMap оптимизирован специально для операций извлечения, таких как get. Следовательно, имеет смысл запускать get вначале и вызывать put IfAbsent, если только get покажет, что это необходимо:

/ / Параллельная традиционная схема поверх ConcurrentMap - / / работает быстрее!

public static String inte rn(St ring s) { String result = map.get(s);

if (result == null) {

result = map.putIfAbsent(s, s); if (result =- null)

result = s;

}

return result;

}

377

используйте ConcurrentHashMap вместо

Глава 10 Потоки

Кроме отличной параллельности, ConcurrentHashMap еще и рабо­ тает быстрее. На моей машине оптимизированный метод intern рабо­ тает в шесть раз быстрее, чем String, intern (но имейте в виду, что String, intern должен использовать слабую ссылку, чтобы избежать утечку памяти с течением времени). Если у вас нет серьезной при­ чины сделать по-другому,

Collections. synchronizedMap или HashTable. Простая замена старой

синхронизированной схемы параллельными схемами может серьез­ но увеличить производительность параллельных приложений. Говоря более общо, предпочитайте использование параллельных коллекций вместо синхронизируемых извне коллекций.

Некоторые интерфейсы коллекций были расширены блокирующи­ ми операциями, которые ждут (или блокируют) до тех пор, пока они не смогут успешно выполниться. Например, BlockingQueue расширяет Queue и добавляет несколько методов, в том числе take, которые удаля­ ют и возвращают головной элемент из очереди, ожидая, если очередь пуста. Это позволяет использовать блокирующие очереди для рабочих очередей (также известные как очереди производитель-потребитель), в которые один или несколько производящих потоков ставят рабочие задания и из которых один или более потребляющих потоков убирают и обрабатывают задания, как только они становятся доступными. Как вы можете ожидать, большинство реализаций ExecutorService, в том

числе ThreadPoolExecutor, используют BlockingQueue (статья 68).

Синхронизаторы — объекты, которые дают возможность пото­ кам ждать друг друга, позволяя им координировать их деятельность. Наиболее часто используемые синхронизаторы Count Down Latch

и Semaphore. Наименее используемые CyclicBarrieer и Exchanger.

Синхронизаторы CountDownLatch — это одноразовые барьеры, которые позволяют одному или более потокам ждать, когда один или более поток что-то сделает. Единственный конструктор для Count­ DownLatch берет int, который является количеством запуска метода Count Down, и помещает его на замок до тех пор, пока всем ожидаю­ щим потокам будет разрешено продолжить.

378

С тать я 69

Удивительно легко создавать полезные вещи поверх простых примитивных. Например, предположим, что вы хотите создать про­ стую структуру для подсчета времени параллельного выполнения действия. Эта структура состоит из одного метода, который берет экзекутор для выполнения действия, уровень параллельности, пред­ ставляющий собой количество действий, которые должны параллель­ но выполняться, и runnable, представляющий собой действие. Все ра­ бочие потоки готовят сами себя для выполнения действия до того, как поток таймера запустит часы (это необходимо для получения точного результата). Когда последний рабочий поток будет готов к выполне­ нию действия, time «нажимает на спусковой крючок», позволяя ра­ бочим потокам выполнять действие. Как только последний рабочий поток завершит выполнение действия, поток таймера останавливает часы. Реализация этой логики непосредственно поверх wait или no­ tify будет запутанной, но поверх Count Down Latch она будет удиви­ тельно проста:

// Простая структура для подсчета времени параллельного выполнения public static long time(Executor executor, int concurrency,

final Runnable action) throws InterruptedException {

final CountDownLatch ready = new CountDownLatch(concurrency); final CountDownLatch start = new CountDownLatch(1);

final CountDownLatch done = new CountDownLatch(concurrency); for (int i = 0; i < concurrency; i++) {

executor.execute(new Runnable() { public void run() {

ready.countDown(); // Tell timer we’re ready try {

start.await();

// Wait till peers are ready action.run();

}catch (InterruptedException e) { Thread.currentThread().interrupt );

}finally {

done.countDown();

379

Соседние файлы в предмете [НЕСОРТИРОВАННОЕ]