Теория и практика Java: Более гибкая, масштабируемая блокировка в JDK 5.0

Новые классы блокировки усовершенствуют synchronized - но не спешите списывать synchronized со счетов

JDK 5.0 предлагает разработчикам некоторые интересные новые варианты для разработки высокоэффективных совместимых приложений, например, класс reentrantLock в java.util.concurrent.lock предлагается как замена для средства synchronized языка Java - он имеет ту же семантику памяти, ту же семантику блокировки, более высокую производительность в условиях конкуренции, а также возможности, не предлагаемые средством synchronized. Значит ли это, что мы должны забыть о synchronized и вместо этого использовать исключительно reentrantLock? Эксперт по параллелизму Брайан Гетц возвращается после летнего перерыва и дает ответ на этот вопрос.

Брайан Гетц, главный консультант, Quiotix

Брайан Гетц (Brian Goetz) последние 15 лет работает профессиональным разработчиком ПО. Сейчас он является главным консультантом в фирме Quiotix, занимающейся разработкой ПО и консалтингом и находящейся в Лос-Альтос, Калифорния. Он также является членом нескольких экспертных групп JCP (JCP Expert Groups). См. его опубликованные и готовые к публикации статьи в популярных отраслевых изданиях. Вы можете связаться с ним по адресу brian@quiotix.com.



14.03.2007

Многопоточность и параллелизм не представляют собой ничего нового, но одно из нововведений в устройстве языка Java состояло в том, что это был первый широко распространенный язык программирования, включающий модель межплатформенной работы в потоках и формальную модель памяти непосредственно в спецификацию языка. Базовые библиотеки классов включают класс Thread для создания, запуска и манипулирования потоками, а язык включает конструкции для передачи ограничений параллелизма через потоки synchronized и volatile. Упрощая разработку платформонезависимых параллельных классов, это ни в коей мере не делает написание параллельных классов тривиальным, а лишь немного упрощает его.

Краткий обзор synchronized

Объявление блока кода синхронным (synchronized) имеет два важных последствия, которые обычно называют atomicity (атомарность) и visibility (видимость). Атомарность значит, что только один поток одновременно может выполнять код, защищенный данным объектом-монитором (блокировкой), позволяя предотвратить многочисленные потоки от столкновений друг с другом во время обновления общего состояния. Видимость - понятие более тонкое; она связана с особенностями кэширования памяти и оптимизацией программы в процессе компилирования. Обычно потоки могут свободно кэшировать значения для переменных так, чтобы они не обязательно сразу же были бы видны другим потокам (будь то в регистрах, в процессорных кэшах, или через переупорядочение команд или другую оптимизацию программы в процессе компилирования), но если разработчик использовал синхронизацию, как показано в коде внизу, во время выполнения будет проверяться, что обновления переменных, выполненные одним потоком до выхода из блока synchronized, сразу же будут видны другому потоку, когда он будет входить в блок synchronized, защищенный тем же монитором (блокировкой). Подобное правило существует и для переменных volatile. (См. дополнительную информацию по синхронизации и Java Memory Model (модели памяти Java) в Resources).

synchronized (lockObject) { 
  // update object state
}

Таким образом, синхронизация "заботится" обо всем, необходимом для надежного обновления множественных общих переменных без ситуации гонки или повреждения данных (при условии, что границы синхронизации обозначены правильно), и гарантирует, что другие потоки, которые синхронизируются должным образом, увидят новейшие значения этих переменных. Определение четкой межплатформенной модели памяти (которая была усовершенствована в JDK 5.0 для устранения некоторых ошибок в первоначальном определении) дает возможность создать параллельные классы "Write Once, Run Anywhere" (Пишем один раз, используем везде), следуя следующему правилу:

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

Что ещё лучше, в последних JVM (виртуальных Java-машинах) затраты производительности при невостребованной синхронизации (когда ни один поток не пытается получить блокировку, в то время, когда другой поток уже ей владеет) весьма невелики. (Это не всегда было так; синхронизация в первых JVM еще не была оптимизирована, из-за чего и возникло верное в то время, но неверное сейчас представление о том, что синхронизация, востребована она или нет, требует больших затрат производительности.)


Усовершенствование synchronized

Итак, кажется, синхронизация - это хорошо, правда? Тогда почему группа JSR 166 потратила так много времени на разработку среды java.util.concurrent.lock? Ответ прост - синхронизация, это хорошо, но не совершенно. Она имеет некоторые функциональные ограничения - невозможно прервать поток, который ожидает блокировки, так же как невозможно опрашивать или пытаться получить блокировку, не будучи готовым к долгому ожиданию. Синхронизация также требует, чтобы блокировка была снята в том же стековом фрейме, в котором была начата, это правило верно почти все время (и хорошо взаимодействует с обработкой исключительных ситуаций), за исключением небольшого количества случаев, когда блокировка с неблочной структурой может быть большим преимуществом.

Класс reentrantLock

Среда Lock в java.util.concurrent.lock - абстракция для блокировки, допускающая осуществление блокировок, которые реализуются как классы Java, а не как возможность языка. Это дает простор разнообразным вариантам применения Lock, которые могут иметь различные алгоритмы планирования, рабочие характеристики, или семантику блокировки. Класс reentrantLock, который реализует Lock, имеет те же параллелизм и семантику памяти, что и synchronized, но также имеет дополнительные возможности, такие как опрос о блокировании (lock polling), ожидание блокирования заданной длительности и прерываемое ожидание блокировки. Кроме того, он предлагает гораздо более высокую эффективность функционирования в условиях жесткой состязательности. (Другими словами, когда много потоков пытаются получить доступ к ресурсу совместного использования, JVM потребуется меньше времени на установление очередности потоков и больше времени на ее выполнение.)

Что мы понимаем под блокировкой с повторным входом (reentrant)? Просто то, что есть подсчет сбора данных, связанный с блокировкой, и если поток, который удерживает блокировку, снова ее получает, данные отражают увеличение, и тогда для реального разблокирования нужно два раза снять блокировку. Это аналогично семантике synchronized; если поток входит в синхронный блок, защищенный монитором, который уже принадлежит потоку, потоку будет разрешено дальнейшее функционирование, и блокировка не будет снята, когда поток выйдет из второго (или последующего) блока synchronized, она будет снята только когда он выйдет из первого блока synchronized, в который он вошел под защитой монитора.

Если посмотреть на пример кода в листинге 1, сразу бросается в глаза различие между Lock и синхронизацией - блокировка должна быть снята в последнем блоке. Иначе, если бы защищенный код показал исключительное состояние (ошибку), блокировка не была бы снята! Эта особенность может показаться тривиальной, но, фактически, она очень важна. Если забыть снять блокировку в последнем блоке, это будет бомбой замедленного действия в вашей программе, причину которой будет очень трудно обнаружить, и в конце концов она взорвется. Используя синхронизацию, JVM гарантирует, что блокировка автоматически снимаются.

Листинг 1. Защита блока кода reentrantLock.
Lock lock = new reentrantLock();

lock.lock();
try { 
  // update object state
}
finally {
  lock.unlock(); 
}

Как дополнительное преимущество можно отметить, что реализация reentrantLock гораздо более масштабируемая в условиях состязательности, чем реализация synchronized. (Вероятно, в состязательном режиме работы синхронных средств будут дальнейшие усовершенствования в следующей версии JVM.) Это значит, что когда много потоков соперничают за право получения блокировки, общая пропускная способность обычно лучше у reentrantLock, чем у synchronized.


Сравнение масштабируемости reentrantLock и синхронизации

Tim Peierls сконструировал простой сравнительный тест для измерения относительной масштабируемости synchronized в сравнении с Lock, используя простой линейный конгруэнтный генератор псевдослучайных чисел (PRNG). Этот пример хорош, поскольку PRNG в действительности выполняет некую реальную работу каждый раз, когда вызывается nextRandom(), так что эта программа в действительности тестирует разумное, реальное применение synchronized и Lock, а не специально написанный для получения определённых результатов или ничего не делающий код (как многие так называемые тесты-бенчмарки.)

В этом тесте у нас есть интерфейс для PseudoRandom, который имеет единственный метод, nextRandom(int bound). Этот интерфейс имеет значительное сходство с функциональностью класса java.util.Random. Поскольку PRNG используют последнее число, сгенерированное как ввод при генерировании следующего случайного числа, а последнее сгенерированное число считается переменной экземпляра, важно, чтобы та часть кода, которая обновляет это состояние, не была прервана другими потоками, поэтому мы используем некоторую форму блокировки, чтобы это обеспечить. (Класс java.util.Random также делает это.) Мы сконструировали два способа реализации PseudoRandom; один, который использует синхронизацию, и другой, использующий java.util.concurrent.reentrantLock. Программа-драйвер порождает множество потоков, каждый из которых с огромной быстротой "кидает кости", а затем подсчитывает, сколько "оборотов" в секунду различные версии смогли выполнить. Результаты для различных количеств потоков суммируются на рисунках 1 и 2. Этот тест не совершенен и запускался только на двух системах (двухпроцессорной системе Xeon с технологией Hyperthreading под Linux, и однопроцессорной системе под Windows), но достаточно хорош, чтобы предположить, что reentrantLock имеет преимущество в масштабируемости над synchronized.

Рисунок 1. Пропускная способность при синхронизации и блокировке, один процессор
Рисунок 1. Пропускная способность при синхронизации и блокировке, один процессор
Рисунок 2. Пропускная способность (нормализованная) при синхронизации и блокировке, четыре процессора
Рисунок 2. Пропускная способность (нормализованная) при синхронизации и блокировке, четыре процессора

Диаграммы на рисунке 1 и рисунке 2 показывают пропускную способность в вызовах в секунду, нормализованную до случая synchronized с одним потоком для различных реализаций. Каждая реализация показывает относительно одинаковые результаты при стабильной пропускной способности, результатом чего обычно бывает то, что процессоры полностью используются и тратят определенный процент своего времени на выполнение реальной работы (обработка случайных чисел) и некоторый процент времени - на планирование затрат. Вы заметите, что synchronized-версия показывает значительно худшие результаты при любом состязании, в то время как версия Lock тратит гораздо меньше времени на планирование затрат, оставляя место для гораздо более высокой пропускной способности и более эффективного использования процессора.


Переменные условия

Корневой класс Object включает некоторые специальные методы для передачи по потокам - wait(), notify() и notifyAll(). Это усовершенствованные возможности параллелизма, и многие разработчики никогда их не используют - что, возможно, хорошо, поскольку они довольно сложны, и их легко использовать неправильно. К счастью, с добавлением java.util.concurrent к JDK 5.0, разработчикам необходимо использовать эти методы даже в меньшем количестве случаев.

Уведомление и блокировка взаимодействуют между собой - чтобы wait (ожидать) или notify (уведомить) объект, вам нужно удерживать блокировку этого объекта. Так же как Lock - это обобщение для синхронизации, среда разработки Lock включает обобщение wait и notify, называющееся Condition. Объект Lock действует как фабричный объект (factory object) для переменных условия, связанных с этой блокировкой, и в отличие от стандартных методов wait и notify, здесь может быть более одной переменной условия, связанной с данным Lock. Это облегчает разработку многих параллельных алгоритмов. Например, Javadoc для Condition показывает пример реализации со связанным буфером с использованием двух переменных условия: "not full (не полный)" и "not empty (не пустой)", что легче читается (и эффективнее), чем эквивалентная реализация с одним набором wait на блокировку. Методы Condition, которые аналогичны wait, notify и notifyAll, называются await, signal и signalAll, поскольку они не могут обойти соответствующие методы в Object.


Это не справедливо

Если вы внимательно рассмотрите Javadoc, вы увидите, что одним из аргументов конструктора reentrantLock является булево значение, которое позволяет решить, хотите ли вы справедливой (равнодоступной) или несправедливой (неравнодоступной)) блокировки. Справедливая блокировка - это когда потоки получают блокировку в том порядке, в каком они ее запрашивали; несправедливая блокировка может допускать barging, когда поток может получить блокировку раньше другого, который запрашивал ее первым.

А почему бы нам не сделать все блокировки справедливыми? Ведь справедливость - это хорошо, а несправедливость - плохо, правда? (Не случайно дети, когда хотят оспорить решение, говорят "Это несправедливо". Мы считаем, что справедливость очень важна, и они это знают.) В действительности гарантия равнодоступности для блокировок очень сильна и достигается за счет значительных потерь в производительности. Учет использования системных ресурсов и синхронизация, необходимые для обеспечения равнодоступности означают, что соперничающие равнодоступные блокировки будут иметь гораздо более низкую пропускную способность, чем неравнодоступные блокировки. По умолчанию следует установить для равнодоступности значение false, если для правильности вашего алгоритма не критично, чтобы потоки обслуживались точно в порядке очереди.

Как насчет синхронизации? Равнодоступны ли блокировки встроенного монитора? Ответ, к удивлению многих, - нет, и никогда не были. И никто не жаловался по поводу зависания потоков, поскольку JVM гарантирует, что всем потокам будет в конечном счете дана блокировка, которую они ждут. Статистическая гарантия равнодоступности обычно достаточна для большинства случаев и требует гораздо меньших затрат, чем детерминированная гарантия равнодоступности. Тот факт, что reentrantLock "неравнодоступны" по умолчанию просто показывает то, что было верно для синхронизации. Если вы не волновались об этом в случае синхронизации, не беспокойтесь и по поводу reentrantLock.

Рисунки 3 и 4 содержат те же данные, что рисунок 1 и рисунок 2 с дополнительным набором данных для новой версии нашего теста случайных чисел, которая использует равнодоступную блокировку вместо barging Lock (баржируемой блокировки), используемой по умолчанию. Как можно видеть, равнодоступность небесплатна. Заплатите за нее, если она вам нужна, но не используйте ее по умолчанию.

Рисунок 3. Относительная пропускная способность при синхронизации, barging Lock и fair Lock, четыре процессора
Рисунок 3. Относительная пропускная способность при синхронизации, barging Lock и fair Lock, четыре процессора
Рисунок 4. Относительная пропускная способность при синхронизации, barging Lock и fair Lock, один процессор
Рисунок 4. Относительная пропускная способность при синхронизации, barging Lock и fair Lock, один процессор

Лучше во всех отношениях?

Оказывается, что reentrantLock лучше в любом случае, чем synchronized - он может делать все то же, что synchronized, имеет ту же память и семантику параллелизма и возможности, которых у synchronized нет, и обладает более высокой производительностью под нагрузкой. Стоит ли забыть о synchronized, отправив все это на свалку хороших идей, которые впоследствии были усовершенствованы? Или даже переписать существующий код synchronized в терминах reentrantLock? Фактически, некоторые вводные книги по Java-программированию придерживаются этого подхода в главах по многопоточности, приводя примеры непосредственно в терминах Lock, упоминая синхронизацию только мимоходом. Я думаю, это не идет на пользу.

Еще рано забывать о синхронизации

В то время как reentrantLock - очень внушительная разработка и имеет некоторые существенные преимущества над синхронизацией, я полагаю, что поспешный вывод о том, что синхронизация - не заслуживающая внимания возможность, - это серьезная ошибка. Классы блокировки в java.util.compatible.lock - это передовой инструментарий для продвинутых пользователей и сложных ситуаций. В общем, вам следует использовать синхронизацию, если нет особой необходимости в одной из продвинутых возможностей Lock, или если вы продемонстрировали доказательства (а не просто подозрение) того, что синхронизация в данной конкретной ситуации - это помеха масштабируемости.

Почему я проповедую такой консерватизм в принятии явно "лучшей" разработки? Синхронизация все же имеет некоторые преимущества над классами блокировки в java.util.compatible.lock. Например, невозможно забыть снять блокировку при использовании синхронизации; JVM сделает это за вас, когда вы выйдете из блока synchronized. Легко забыть использовать блок finally для снятия блокировки, что связано с большим ущербом для вашей программы. Ваша программа пройдёт все тесты и зависнет во время эксплуатации, и будет очень трудно понять, почему (что само по себе является существенной причиной для того, чтобы совсем не позволять начинающим разработчикам использовать Lock.

Другая причина состоит в том, что когда JVM управляет синхронизмом с автоподстройкой (lock acquisition) и освобождением (release) с использованием синхронизации, JVM может включать информацию о блокировке при генерировании дампов потоков. Они могут быть бесценны при отладке, поскольку могут идентифицировать источник зависаний или других неожиданных проявлений. Классы Lock - это просто обычные классы, и JVM еще не знает, какими объектами Lock владеют отдельные потоки. Более того, синхронизация знакома почти каждому разработчику Java и работает на всех версиях JVM. До тех пор, пока JDK 5.0 не станет стандартом, что, вероятно, случится года через два, использование классов Lock будет означать использование возможностей, присутствующих не в каждой JVM и знакомых не каждому разработчику.

Когда выбрать reentrantLock вместо synchronized

Итак, когда же следует использовать reentrantLock? Ответ довольно прост - тогда, когда вам действительно нужно что-то, что обеспечивается данным классом, но не обеспечивается synchronized, как, например, ожидание блокировки заданной длительности, прерываемое ожидание блокировки, блокировки неблочной структуры, множественные переменные условия или опрос о блокировке (lock polling). reentrantLock также обладает преимуществами масштабируемости, и вам следует его использовать, если у вас действительно ситуация, которая предполагает напряженное соперничество, но помните, что подавляющее большинство блоков synchronized почти никогда не проявляют соперничества, не говоря уже о напряженном соперничестве. Я бы советовал разработку с синхронизацией до тех пор, пока синхронизация не докажет свою несостоятельность, вместо того, чтобы просто принять на веру, что "эффективность функционирования будет выше", если использовать reentrantLock. Помните, что это продвинутый инструментарий для продвинутых пользователей. (А по-настоящему продвинутые пользователи обычно предпочитают самые простые инструменты, которые только можно найти, за исключением тех случаев, когда ими нельзя обойтись.) Как всегда, сначала сделайте программу правильно, а лишь затем думайте, нужно ли делать ее работу быстрее.


Выводы

Среда Lock - совместимая замена синхронизации, которая предлагает многочисленные возможности, не предоставляемые synchronized, так же как применения, предлагающие более высокую производительность в условиях состязательности. Однако, существование этих очевидных преимуществ не является весомой причиной для постоянного использования reentrantLock вместо synchronized. Лучше принимайте решение, основываясь на том, нужна ли вам мощь reentrantLock. В огромном количестве случаев она вам не нужна - синхронизация работает прекрасно, работает на всех JVM, понятна широкому кругу разработчиков и менее подвержена ошибкам. Сохраните Lock для тех случаев, когда вам это действительно понадобится. В таких случаях вас порадует, что она у вас есть.

Ресурсы

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java
ArticleID=201964
ArticleTitle=Теория и практика Java: Более гибкая, масштабируемая блокировка в JDK 5.0
publish-date=03142007