Главная страница

При участии Тима Перлса, Джошуа Блоха, Джозева Боубира, Дэвида Холмса и Дага Ли Параллельное программирование в java на


Скачать 4.97 Mb.
НазваниеПри участии Тима Перлса, Джошуа Блоха, Джозева Боубира, Дэвида Холмса и Дага Ли Параллельное программирование в java на
Анкорjava concurrency
Дата28.11.2019
Размер4.97 Mb.
Формат файлаpdf
Имя файлаJava concurrency in practice.pdf
ТипДокументы
#97401
страница3 из 34
1   2   3   4   5   6   7   8   9   ...   34
Глава 2 Потокобезопасность
Возможно, для вас будет сюрпризом, что параллельное программирование — это не столько о потоках или блокировках, скорее, если провести аналогию, не более чем гражданское строительство, о заклёпках или двутавровых балках. Конечно, строительство мостов, которые затем не рухнут вниз, требует правильного использования множества заклёпок и двутавровых балок, также, как и параллельные программы требуют корректного использования потоков и блокировок. Но всё это просто механизмы – средства для достижения цели.
Написание потокобезопасного кода является, в своей основе, только управлением доступом к состоянию, и, в частности, к разделяемому (shared), изменяемому
состоянию (mutable state).
Неофициально, состояние объекта - это его данные, хранимые в переменных состояния, таких как экземпляры или статические поля. Состояние объекта может включать поля от различных, зависимых объектов; Состояние объекта
HashMap частично хранится в самом объекте
HashMap
, но также во множестве объектов
Map.Entry
. Состояние объекта включает в себя любые данные, которые могут оказывать влияние на его внешнее видимое поведение.
Под разделяемым состоянием мы понимаем то, что переменная может быть доступна из множества потоков; под изменяемым состоянием мы понимаем то, что значение переменной может изменяться в течение всей её жизни. Мы можем говорить о потокобезопасности, как коде, но на самом деле, мы пытаемся защитить
данные от неконтролируемого параллельного доступа.
Необходимость объекта быть потокобезопасным зависит от того, будет ли он доступен из нескольких потоков. Это свойство того, как объект используется в программе, а не того, что он выполняет. Для создания потокобезопасного объекта требуется использовать синхронизацию, обеспечивающую координацию доступа к его изменяемому состоянию; невыполнение этого требования может привести к повреждению данных и другим нежелательным последствиям.
Всякий раз, когда более чем один поток обращается к данной переменной состояния, и один из них может записать что-то в нее, все они должны координировать свой доступ к ней с помощью синхронизации. Главным механизмом синхронизации в Java является ключевое слово synchronized
, которое обеспечивает эксклюзивную блокировку, но термин “синхронизация” также включает в себя использование переменных volatile,
явных блокировок и атомарных переменных.
Следует избегать соблазна думать, что существуют “особые” ситуации, в которых это правило не применяется. Программа, которая опускает необходимую синхронизацию, может казаться работающей, проходить свои тесты и хорошо работать в течение многих лет, но она все еще неисправна и может потерпеть крах в любой момент.
Если несколько потоков могут получить доступ к изменяемому состоянию переменной без соответствующей синхронизации, ваша программа имеет заложенные ошибки. Существует три пути для её исправления:

Не разделяйте переменные между потоками;

Сделайте переменные состояния неизменяемыми;


Используйте синхронизацию, когда бы ни обращались к состоянию переменной.
Если вы не задумывались при проектировании класса о параллельном доступе к нему, некоторые из этих подходов могут потребовать внесения значительных изменений в его дизайн, так что исправление проблемы может быть не таким тривиальным, как озвучивание этого совета. Гораздо проще сразу проектировать
класс, который будет потокобезопасным, чем позже его модифицировать для
обеспечения потокобезопасности.
В большой программе довольно сложно выяснить, могут ли несколько потоков получить доступ к данной переменной. К счастью, те же объектно- ориентированные методы, которые помогают создавать хорошо организованные, поддерживаемые классы, - такие как инкапсуляция и скрытие данных - также могут помочь в создании потокобезопасных классов. Чем меньше кода имеет доступ к определенной переменной, тем проще обеспечить, чтобы все это использовало правильную синхронизацию, и тем легче рассуждать об условиях, при которых к данной переменной можно получить доступ. Язык Java не принуждает вас к инкапсуляции состояния – вполне допустимо хранить состояние в публичных полях (даже в статических публичных полях) или публиковать ссылку на другой внутренний объект – но, чем лучше инкапсулировано состояние программы, тем проще сделать ее потокобезопасной и помочь сопровождающим
15
впоследствии сохранить ее такой.
При проектировании потокобезопасных классов хорошие объектно- ориентированные методы, — инкапсуляция, неизменяемость и четкая спецификация инвариантов – ваши лучшие друзья.
Настанут времена, когда хорошие объектно-ориентированные методы проектирования станут противоречить реальным требованиям; в этих случаях может потребоваться поступиться правилами хорошего дизайна ради производительности или ради совместимости с устаревшим кодом (legacy code).
Иногда абстракция и инкапсуляция расходятся с производительностью, хотя и не так часто, как полагают многие разработчики — но всегда рекомендуется сначала сделать код правильным, а затем уже заняться его ускорением. Даже в этом случае, оптимизацию следует проводить только тогда, когда это продиктовано требованиями и показателями производительности, и только в том случае, если измерения, проведённые в реалистичных условиях
16
, говорят вам, что ваша оптимизация действительно изменила ситуацию.
Если вы решите, что просто должны разрушить инкапсуляцию, ещё не всё потеряно. Вашу программу всё еще возможно сделать потокобезопасной, это просто будет намного сложнее. Кроме того, потокобезопасность вашей программы станет более хрупкой, увеличится не только стоимость и риски разработки, но также возрастут расходы и риски на сопровождение. В главе 4 характеризуется
15
Давно известно, что львиную долю жизненного цикла программы, занимает сопровождение - исправление ошибок, различные доработки, добавление «бантиков и рюшечек».
16
В параллельном коде этой практики следует придерживаться даже больше, чем обычно. Поскольку ошибки параллелизма очень сложно воспроизвести и отладить, преимущество получения небольшого прироста производительности в некоторых, редко используемых, ветках кода, вполне может быть карликовым, из-за риска того, что программа потерпит крах в этой области.
условия, при которых можно безопасно ослабить инкапсуляцию переменных состояния.
До сих пор мы использовали термины “потокобезопасный класс” и
потокобезопасная
программа” почти взаимозаменяемо.
Является ли потокобезопасной программа, полностью построенная из потокобезопасных классов? Не обязательно - программа, полностью состоящая из потокобезопасных классов, может быть не потокобезопасной, а потокобезопасная программа может содержать классы, не являющиеся потокобезопасными. Вопросы, связанные с составом потокобезопасных классов, также рассматриваются в главе 4. В любом случае концепция потокобезопасного класса имеет смысл только в том случае, если класс инкапсулирует свое собственное состояние. “Потокобезопасность” может быть термином, применяемым к коду, но речь идет о состоянии, поэтому термин может быть применен только ко всему телу кода, инкапсулирующему состояние, которое может быть объектом или всей программой.
2.1 Что такое потокобезопасность?
Определение потокобезопасности удивительно запутанно. Более формальные попытки настолько сложны, что предлагают небольшое практическое руководство или требуют интуитивного понимания, другие же, неофициальные описания, могут показаться "вещью в себе". Быстрый поиск в Google возвращает множество "определений", похожих на эти:
. . . могут быть вызваны из нескольких потоков программы, без нежелательных взаимодействий между потоками.
. . . могут вызываться более чем одним потоком за раз, не требуя каких- либо других действий со стороны вызывающих.
Учитывая такие определения, неудивительно, что мы находим потокобезопасность запутанной! Они звучат подозрительно похоже на “класс потокобезопасен, если он может быть безопасно использован из множества потоков”. Фактически, вы не можете оспорить это утверждение, но оно также почти не предлагает практической помощи. Как отличить потокобезопасный класс от непотокобезопасного? Что мы вообще подразумеваем под термином "безопасный"?
Ядром любого разумного объяснения потокобезопасности является концепция
корректности. Если наше определение потокобезопасности нечеткое, это только потому, что нам не хватает определения термина “корректность”.
Корректность означает, что класс соответствует своей спецификации.
Хорошая Спецификация определяет инварианты, ограничивающие состояние объекта и постусловия, описывающие эффекты от его операций. Поскольку мы часто не пишем адекватных спецификаций для наших классов, как мы можем узнать, что они корректны? Мы не можем, но это в любом случае не мешает нам их использовать, так как однажды мы убедили самих себя, что “код работает”. Это
“доверие коду” относительно близко к тому, как многие из нас добиваются корректности, так позволим себе предположить, что корректность однопоточных программ, это что-то вроде “мы это знаем, когда мы видим это”. Примем оптимистичное определение "корректности" как чего-то, что можно распознать, теперь мы можем определить потокобезопасность несколько менее запутанным способом: класс потокобезопасен, когда он продолжает вести себя корректно в моменты одновременного обращения из нескольких потоков.

Класс является потокобезопасным, если он ведет себя правильно при доступе из нескольких потоков, независимо от планирования или чередования выполнения этих потоков средой выполнения, и без дополнительной синхронизации или другой координации со стороны вызывающего кода.
Поскольку любая однопоточная программа также является допустимой (valid) многопоточной программой, она не может быть потокобезопасной, если не является корректной в однопоточной среде
17
. Если объект реализован корректно, никакая последовательность операций - вызовов публичных (public) методов и операций чтения или записи открытых полей - не должна нарушать ни один из его инвариантов или постусловий. Никакой набор операций, выполняемых
последовательно или параллельно на экземплярах потокобезопасного класса, не
может привести к тому, что экземпляр окажется в недопустимом состоянии
(invalid).
Потокобезопасные классы инкапсулируют любые необходимые синхронизации, так что клиенты не должны заботиться об этом.
2.1.1 Пример: сервлет без сохранения состояния (stateless)
В главе 1 мы перечислили ряд фрэймворков, которые создают потоки и вызывают ваши компоненты из этих потоков, оставляя вас ответственными за то, чтобы сделать ваши компоненты потокобезопасными. Очень часто, требования к потокобезопасности основаны не на необходимости напрямую использовать потоки, а вследствие решения использовать средства, подобные фреймворку сервлетов. Мы собираемся разработать простой пример — основанную на сервлете службу факторизации — и медленно расширять его, добавляя функции, пока сохраняется его потокобезопасность.
В листинге 2.1 показан наш простой сервлет факторизации. Он распаковывает факторизуемое число из запроса сервлета, факторизует его, и упаковывает результаты в ответ сервлета
@ThreadSafe public class StatelessFactorizer implements Servlet { public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i); encodeIntoResponse(resp, factors);
}
}
Листинг 2.1 Сервлет без сохранения состояния
Класс
StatelessFactorizer, подобно большинству сервлетов, не имеет состояния
(stateless): он не имеет полей и не ссылается на поля в других классах. Переходное состояние для конкретного вычисления существует только в локальных
17
Если вам не нравится свободное использование термина “корректность”, вы можете предпочесть думать о потокобезопасном классе, как о том, который в многопоточной среде подвержен ошибкам не более, чем в однопоточной.
переменных, которые хранятся в стеке потока и доступны только исполняющему потоку. Один поток, обращающийся к StatelessFactorizer, не может оказать влияние на результат другого потока, обращающегося к тому же StatelessFactorizer; поскольку два потока не разделяют состояние, это выглядит, как если бы они обращались к различным экземплярам. Поскольку действия потока, обращающегося к объекту без состояния, не могут повлиять на корректность операций в других потоках, объекты без состояния являются потокобезопасными.
Объекты без состояния всегда потокобезопасны.
Тот факт, что большинство сервлетов может быть реализовано без какого-либо состояния, позволяет не учитывать требование потокобезопасности, что значительно снижает нагрузку по их созданию. Только когда сервлеты хотят переписывать значения от одного запроса к другому, только тогда требование потокобезопасности становится проблемой.
2.2 Атомарность
Что происходит, когда мы добавляем один элемент состояния к тому, что было объектом без состояния? Предположим, мы хотим добавить "счетчик посещений", который измеряет количество обработанных запросов. Очевидным подходом является добавление поля типа long к сервлету и увеличение его значения при каждом запросе, как показано в классе UnsafeCountingFactorizer в листинге 2.2.
@NotThreadSafe public class UnsafeCountingFactorizer implements Servlet { private long count = 0; public long getCount() { return count; } public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
++count; encodeIntoResponse(resp, factors);
}
}
Листинг 2.2 Сервлет подсчитывающий запросы без необходимой синхронизации. Не делайте так.
К сожалению, класс
UnsafeCountingFactorizer не является потокобезопасным, хотя он будет прекрасно работать в однопоточной среде. Так же, как класс UnsafeSequence на странице 22, он чувствителен к потерянным
обновлениям. Оператор инкремента,
++count
, может выглядеть как одно действие из-за его компактного синтаксиса, но он не является атомарным, что означает, что он не выполняется как одна, неделимая операция. Вместо этого он является сокращением для последовательности из трех дискретных операций: извлечения текущего значения, добавление к нему единицы и запись нового значение обратно.
Это пример операции read-modify-write, в которой результирующее состояние является производным от предыдущего состояния.

Нас рисунке 1.1 на стр. 22 показано, что может произойти, если два потока попытаются увеличить счетчик одновременно и без синхронизации. Если счетчик первоначально имел значение 9, в некоторый неудачный времени каждый из потоков мог прочитать значение, увидеть, что оно равно 9, добавить к нему единичку, и установить счетчику значение 10. Очевидно, произошло не то, что предполагалось; по ходу дела, приращение было потеряно, и счетчик посещений теперь постоянно меньше на единицу.
Вы можете подумать, что наличие немного неточного количества обращений к веб-службе является приемлемой потерей точности, и иногда допустимо. Но, если счетчик используется для создания последовательностей (sequences) или уникальных идентификаторов объектов, возврат одного и того же значения из нескольких вызовов может вызвать серьезные проблемы с целостностью данных
18
Ситуация, при которой существует возможность получения некорректных результатов в неудачный момент времени, настолько важна в параллельном программировании, что получила название: состояние гонки (race condition).
2.2.1 Условия гонок
Класс
UnsafeCountingFactorizer имеет несколько условий гонок, которые делают его результаты ненадежными. Условие гонки возникает, когда правильность вычисления зависит от относительного момента времени или от чередования нескольких потоков во время выполнения; другими словами, получение правильного ответа зависит от удачного момента времени
19
. Наиболее распространенным типом состояния гонки является check-then-act, где потенциально устаревшее наблюдение используется для принятия решения о том, что делать дальше.
Мы часто сталкиваемся с условиями гонок в реальной жизни. Допустим, вы планируете встречу с другом в полдень, в Starbucks на Университетской авеню. Но когда вы доберетесь туда, вы поймете, что на Университетской авеню расположены два Starbucks, и вы не уверены, в котором из них договорились встретиться. В 12:10 вы не видите своего друга в Starbucks A, поэтому идёте в
Starbucks B, чтобы посмотреть, не пришёл ли он туда, но его там тоже нет. Есть несколько возможностей: ваш друг опаздывает, и он не в Starbucks; или ваш друг прибыл в Starbucks после того, как вы покинули его; или ваш друг был в Starbucks
B, но пошел искать вас, и теперь находится на пути к Starbucks A. Предположим худшее и скажем, что это была последняя возможность. Сейчас 12:15, вы оба были в Starbucks, и вы оба задаетесь вопросом, встали ли вы. Чем вы сейчас занимаетесь?
Вернитесь к другому Starbucks? Сколько раз вы собираетесь ходить туда и обратно? Если Вы не договорились о протоколе, вы оба можете провести день, ходя вверх и вниз по Университетской Авеню, разочарованные и унылые.
18
Подход, принятый в классах
UnsafeSequence и
UnsafeCountingFactorizer
, имеет другие серьезные проблемы, включая возможность устаревших данных (раздел 3.1.1).
19
Термин "состояние гонки (race condition)" часто путают со связанным термином "гонка данных
(data race)", который возникает, когда синхронизация не используется для координации всего доступа к разделяемому не финальному полю (shared nonfinal field). Вы рискуете получить гонку данных всякий раз, когда поток пишет значение в переменную, которая затем может быть прочитана другим потоком, или читает переменную, которая могла быть в последний раз записана другим потоком, если оба потока не используют синхронизацию; код с гонками данных не имеет никакой определенной полезной семантики в модели памяти Java. Не все условия гонки - это гонки данных, и не все гонки данных - это условия гонки, но оба условия могут привести к непредсказуемому сбою параллельных программ.
Класс
UnsafeCountingFactorizer имеет как условия гонки, так и гонки данных. Подробнее о гонках данных см. главу 16

Проблема с подходом “я просто выйду на улицу и посмотрю, находится ли он на другом её конце” заключается в том, что пока вы идете по улице, ваш друг, возможно, уже ушёл. Вы смотрите вокруг Starbucks A, видите, что “его здесь нет” и идете искать его. И Вы можете сделать то же самое для Starbucks B, но в другой
момент времени. Чтобы пройти по улице, вам потребуется несколько минут, и в течение этих нескольких минут, состояние системы могло измениться.
Пример Starbucks иллюстрирует состояние гонки, потому что достижение желаемого результата (встреча с вашим другом) зависит от относительного времени событий (когда каждый из вас прибывает в тот Starbucks или другой, как долго вы ждете там перед переключением и т. д.). Наблюдение, что он не в
Starbucks A, становится потенциально недействительным, как только вы выходите из входной двери; он мог бы войти через заднюю дверь, и Вы об этом не узнаете.
Именно это аннулирование (invalidation) наблюдений характеризует большинство условий гонки - использование потенциально устаревшего наблюдения для принятия решения или выполнения вычислений. Этот тип условия гонок называется check-then-act:: вы наблюдаете что-то, что является истинным (файл X не существует), а затем принимаете решение на основе этого наблюдения (создание
X); но, фактически, это наблюдение может стать недействительным в момент времени между тем, когда вы наблюдали это и моментом времени, когда вы воздействовали на это (в промежутке между этими моментами времени, кто-то другой создал файл X), что вызовет проблемы (неожиданные исключения
(unexpected exception), перезапись данных, повреждения файлов).
2.2.2 Пример: состояние гонки в отложенной инициализации
Распространенной идиомой, использующей check-then-act, является ленивая
инициализация. Целью ленивой инициализации является отсрочка инициализации объекта до тех пор, пока он не будет фактически необходим, в то же время, гарантируя, что он будет инициализирован только один раз. Класс
LazyInitRace в листинге 2.3 иллюстрирует идиому ленивой инициализации. Метод getInstance сначала проверяет, был ли объект
ExpensiveObject уже инициализирован, и в случае положительного ответа, он возвращает существующий экземпляр; в противном случае, он создает новый экземпляр и возвращает его после сохранения ссылки на него, чтобы последующие вызовы могли сэкономить на формировании объекта.
@NotThreadSafe public class LazyInitRace { private ExpensiveObject instance = null; public ExpensiveObject getInstance() { if (instance == null) instance = new ExpensiveObject(); return instance;
}
}
Листинг 2.3 Условие гонок при ленивой инициализации. Не делайте так.
Класс
LazyInitRace имеет условия гонки, которые могут подорвать его корректность. Скажем, что потоки A и B одновременно выполняют метод
getInstance
. A видит, что экземпляр имеет значение null и создает новый объект
ExpensiveObject
. B также проверяет значение экземпляра на null
. Имеет ли экземпляр значение null в этой точке, непредсказуемо зависит от момента
времени, включая капризы планировщика и того времени, которое требуется A для создания экземпляра
ExpensiveObject и установки значения поля instance
. Если экземпляр имеет значение null
, когда B проверяет его, оба потока, вызывающих метод getInstance,
могут получать два разных результата, несмотря на то, что метод getInstance всегда должен возвращать один и тот же экземпляр.
Операция подсчета очков в
UnsafeCountingFactorizer имеет состояние гонки другого вида. Операция read-modify-write, такая как приращение счетчика, определяет преобразование состояния объекта с точки зрения его предыдущего состояния. Чтобы увеличить счетчик, вы должны знать его предыдущее значение и убедиться, что никто больше не изменяет или не использует это значение, пока вы находитесь в процессе его обновления.
Подобно большинству ошибок параллелизма, условия гонки не всегда приводят к сбою: также требуется некоторый неудачный момент времени. Но условия гонки могут вызвать серьезные проблемы. Если
LazyInitRace используется для создания экземпляра реестра, применяемого в масштабах всего приложения, то возврат различных экземпляров из нескольких вызовов может привести к потере регистраций или к тому, что несколько действий будут иметь несогласованные представления наборов зарегистрированных объектов. Если класс
UnsafeSequence используется для генерации идентификаторов сущностей в фреймворке хранения данных (persistence framework), два различных объекта могут в конечном итоге получить один и тот же идентификатор, при этом вызвав нарушение ограничения целостности идентификаторов
20
2.2.3 Составные действия
Как класс
LazyInitRace
, так и класс
UnsafeCountingFactorizer содержат в себе последовательность операций, которые должны быть атомарными или неделимыми по отношению к другим операциям в том же состоянии. Чтобы избежать условий гонки, должен быть способ предотвратить использование переменной другими потоками, пока мы находимся в процессе ее изменения, так мы сможем гарантировать, что другие потоки смогут читать или изменять состояние только до начала или после завершения изменения, но не в процессе.
Операции A и B являются атомарными по отношению друг к другу, если с позиции потока, выполняющего операцию A, когда другой поток выполняет операцию B, либо вся операция B выполняется, либо никакие действия из неё. Атомарная операция - это операция единая по отношению ко всем операциям, работающим в том же состоянии, включая саму себя
21
Если бы операция инкремента в классе
UnsafeSequence была атомарной, условие гонки, показанное на рис. 1.1, не могло бы произойти, и каждое выполнение операции инкремента всегда бы увеличивало значение счетчика ровно на единицу. Для обеспечения потокобезопасности, операции check-then-act
20
Как правило, каждая запись в хранилище имеет уникальный идентификатор, и нарушение этого ограничения приводит к ошибке “unique key violation…” или аналогичной.
21 Операция не сможет оказать воздействие на саму себя, либо она будет выполнена, либо нет

(схожая с отложенной инициализацией) и read-modify-write (схожая с операцией инкремента) всегда должны быть атомарными. Мы обобщённо относим
последовательности действий check-then-act и read-modify-write к типу
«составных» (compound actions). Составные действия - это последовательности операций, которые должны быть выполнены атомарно, чтобы оставаться потокобезопасными. В следующем разделе мы рассмотрим блокировку (locking) - встроенный механизм Java для обеспечения атомарности. На данный момент мы исправим проблему другим способом, используя существующий потокобезопасный класс, как показано в классе
CountingFactorizer, в листинге
2.4.
@ThreadSafe public class CountingFactorizer implements Servlet { private final AtomicLong count = new AtomicLong(0); public long getCount() { return count.get(); } public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
count.incrementAndGet(); encodeIntoResponse(resp, factors);
}
}
Листинг 2.4 Сервлет, подсчитывающий количество запросов, используя класс
AtomicLong
Пакет java.util.concurrent.atomic содержит классы
атомарных
переменных (atomic variable), позволяющие атомарно выполнять переходы состояний в операциях с числами и объектными ссылками. Заменяя счетчик типа long на счётчик типа
AtomicLong
, мы гарантируем, что все действия (actions), которые обращаются к состоянию счетчика, являются атомарными
22
. Поскольку состояние сервлета определяется состоянием счетчика, а счетчик потокобезопасен, наш сервлет также потокобезопасен.
Мы могли добавить счетчик запросов к сервлету факторинга и поддерживать потокобезопасность, используя существующий потокобезопасный класс для управления состоянием счетчика - класс
AtomicLong
. При добавлении
единственного элемента состояния в класс без сохранения состояния (stateless), результирующий класс будет потокобезопасным, если состояние полностью управляется потокобезопасным объектом. Но, как мы увидим в следующем разделе, переход от одной переменной состояния к нескольким, не обязательно также прост, как переход от нуля к единице.
Там, где это целесообразно, используйте для управления состоянием вашего класса существующие потокобезопасные объекты, такие как AtomicLong.
Гораздо проще рассуждать о возможных состояниях и переходах состояний для существующих потокобезопасных объектов, чем для произвольных
22
Класс
CountingFactorizer вызывает метод incrementAndGet для увеличения значения счётчика, который вернёт инкрементированное значение; в этом случае результат игнорируется.
переменных состояния, и это упрощает сопровождение и проверку безопасности потоков.
2.3 Блокировка
Мы могли добавить одну переменную состояния к своему сервлету, для сохранения потокобезопасности используя потокобезопасный объект, управляющий всем состоянием сервлета. Но если мы хотим добавить к своему сервлету больше состояний, можем ли мы просто добавить потокобезопасных переменных состояния?
Представьте, что мы хотим улучшить производительность нашего сервлета, кэшируя последний вычисленный результат, на случай, если два последовательных клиента запросят факторизацию одного и того же числа. (Это не похоже на эффективную стратегию кэширования, мы предлагаем вариант лучше в разделе 5.6)
Для реализации этой стратегии необходимо запомнить две вещи: последнее факторизуемое число и его факторы.
Мы использовали класс
AtomicLong для управления состоянием счетчика потокобезопасным способом; можем ли мы использовать его двоюродного брата, класс
AtomicReference
23
, для управления последним факторизуемым числом и его факторами? Пример попытки показан в классе
UnsafeCachingFactorizer в листинге 2.5.
К сожалению, такой подход не работает. Несмотря на то, что атомизированные ссылки индивидуально потокобезопасны, класс
UnsafeCachingFactorizer имеет условия гонки, которые могут привести к неправильному ответу.
@NotThreadSafe public class UnsafeCachingFactorizer implements Servlet { private final AtomicReference lastNumber
= new AtomicReference(); private final AtomicReference lastFactors
= new AtomicReference(); public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req); if (i.equals(lastNumber.get())) encodeIntoResponse(resp, lastFactors.get()); else {
BigInteger[] factors = factor(i);
lastNumber.set(i);
lastFactors.set(factors); encodeIntoResponse(resp, factors);
}
}
}
Листинг 2.5 Сервлет, пытается кэшировать свой последний результат без адекватного использования атомарности. Не делайте так.
23
Так же, как
AtomicLong является потокобезопасным классом-держателем (holder class) для длинного целого числа,
AtomicReference является потокобезопасным классом-держателем для ссылки на объект.
Атомарные переменные и их преимущества описаны в главе 15.

Определение потокобезопасности требует сохранения инвариантов независимо от моментов времени или чередования операций в нескольких потоках. Одним из инвариантов класса safeCachingFactorizer является то, что произведение факторов, кэшированных в переменной lastFactors
, равно значению, кэшированному в lastNumber
; наш сервлет корректен, только если этот инвариант всегда выполняется. Когда несколько переменных участвуют в инварианте, они не являются независимыми: значение одной ограничивает допустимое значение(я) других. Таким образом, при обновлении значения одной переменной, вы должны обновить значения других переменных в той же атомарной операции.
В некоторые неудачные моменты времени, класс
UnsafeCachingFactorizer может нарушать эти инварианты. Используя атомарные ссылки, мы не можем обновлять как lastNumber
, так и lastFactors одновременно, даже если каждый вызов set атомарен; все еще есть окно уязвимости, когда один был изменен, а другой нет, и в течение этого времени другие потоки могли видеть, что инвариант не удерживается. Точно так же, два значения не могут быть выбраны одновременно: между моментом времени, когда поток A получает два значения, поток B мог бы изменить их, и снова A может заметить, что инвариант не удерживается.
Чтобы сохранить состояние согласованным, обновляйте связанные переменные состояний в одной атомарной операции.
2.3.1 Внутренние блокировки
Java предоставляет встроенный механизм блокировки для обеспечения атомарности: блок synchronized
. (Также существует еще один важный аспект блокировки и других механизмов синхронизации – видимость (visibility) - который рассматривается в главе 3). Блок synchronized состоит из двух частей: ссылки на объект, который будет служить блокировкой, и блока кода, который должен быть защищен этой блокировкой. Метод synchronized является сокращением для блока synchronized
, охватывающего всё тело метода, и чья блокировка является объектом, в котором вызывается метод. (Статический метод synchronized использует объект
Class для блокировки). synchronized (lock) {
// Access or modify shared state guarded by lock
}
Каждый объект может неявно выступать в качестве блокировки в целях синхронизации; эти встроенные блокировки называются
внутренними
блокировками (intrinsic locks) или мониторами (monitor locks). Блокировка автоматически приобретается (acquired) исполняющим потоком перед входом в блок synchronized и автоматически освобождается, когда поток выходит из блока synchronized
, как при нормальном пути выполнения, так и при возбуждении исключения в блоке. Единственный способ получить встроенную блокировку - войти в блок synchronized или в метод, защищенный этой блокировкой.
Встроенные блокировки Java выступают в роли мьютексов (mutex
24
), это означает, что блокировка может принадлежать не более чем одному потоку. Когда
24
Mutual exclusion locks – взаимоисключающая блокировка.
поток A пытается приобрести блокировку удерживаемую потоком B, поток A должен подождать, или заблокироваться, до тех пор, пока B не освободит её. Если поток B никогда не освободит блокировку, поток A будет ждать вечно.
Поскольку, за раз, только один поток может выполнить блок кода, защищенный данной блокировкой, блоки synchronized
, защищенные одной и той же блокировкой, выполняются атомарно относительно друг друга. В контексте параллелизма термин “атомарность” означает то же самое, что и в транзакционных приложениях - это группа операторов, выполняемая как единая, неделимая единица. Ни один поток, выполняющий блок synchronized
, не может наблюдать за другим потоком, находящимся в процессе выполнения блока synchronized
, защищенного одной и той же блокировкой.
Механизм синхронизации позволяет легко восстановить потокобезопасность в сервлете факторинга. В листинге 2.6 выполняется синхронизация
25
метода service
, вследствие чего, в метод service
, в конкретный момент времени, может входить только один поток. Класс
SynchronizedFactorizer сейчас потокобезопасен; однако, этот подход является довольно экстремальным, так как препятствует возможности использовать сервлет факторинга нескольким клиентам одновременно - это приводит к недопустимо низкой отзывчивости. Эта проблема - проблема производительности, а не проблема безопасности потоков - рассматривается в разделе 2.5.
@ThreadSafe public class SynchronizedFactorizer implements Servlet {
@GuardedBy("this") private BigInteger lastNumber;
@GuardedBy("this") private BigInteger[] lastFactors; public synchronized void service(ServletRequest req,
ServletResponse resp) {
BigInteger i = extractFromRequest(req); if (i.equals(lastNumber)) encodeIntoResponse(resp, lastFactors); else {
BigInteger[] factors = factor(i); lastNumber = i; lastFactors = factors; encodeIntoResponse(resp, factors);
}
}
}
Листинг 2.6 Сервлет, кэширующий последний результат запроса, имеет неприемлемо низкий уровень параллелизма. Не делайте так.
2.3.2 Повторная входимость
Когда поток запрашивает блокировку, которая уже удерживается другим потоком, запрашивающий поток блокируется. Но, поскольку внутренние блокировки
25
Добавление оператора
synchronized
являются реентерабельными (reentrant), если поток пытается получить блокировку, которую ранее он уже захватил, запрос выполняется успешно. Реентерабельность
(reentrancy) означает, что блокировки приобретаются для каждого потока, а не для каждого вызова
26
. Реентерабельность реализуется путем связывания с каждой блокировкой счетчика полученных захватов и владеющего потока. Если число захватов равно нулю, блокировка считается отпущенной. Когда поток получает ранее отпущенную блокировку, JVM записывает владельца и устанавливает счетчику захватов значение единица. Если тот же самый поток снова получает блокировку, счетчик увеличивается, и когда владеющий блокировкой поток выходит из блока synchronized
, счетчик уменьшается. Когда значение счетчика достигает нуля, блокировка снимается.
Реентерабельность способствует инкапсуляции поведения блокировок и, таким образом, упрощает разработку объектно-ориентированного параллельного кода.
Без реентерабельных блокировок (reentrant locks), очень естественно смотрящийся код в листинге 2.7, в котором подкласс переопределяет synchronized метод, а затем вызывает метод суперкласса, вызовет взаимоблокировку (deadlock).
Поскольку методы doSomething в классах
Widget и
LoggingWidget синхронизированы (
synchronized)
, каждый пытается получить блокировку класса
Widget перед продолжением. Но если бы встроенные блокировки не были реентерабельными, вызов метода super.doSomething никогда не смог бы получить блокировку, потому что блокировка считалась бы уже захваченной, и поток был бы вынужден постоянно ожидать блокировку, которую никогда не смог захватить.
Реентерабельность спасает нас от взаимоблокировок в ситуациях, подобных этой. public class Widget { public synchronized void doSomething() {
}
} public class LoggingWidget extends Widget { public synchronized void doSomething() {
System.out.println(toString() + ": calling doSomething");
super.doSomething();
}
}
Листинг 2.7. Код, который без реентерабельности будет вызывать взаимоблокировку.
2.4 Защита состояния с помощью блокировок
Поскольку блокировки обеспечивают сериализованный
27
доступ (serialized
access) к веткам кода, которые они защищают, их можно использовать для создания протоколов, гарантирующих монопольный доступ к разделяемому состоянию.
Следование этим протоколам может гарантировать согласованность состояний.
26
Это отличается от поведения блокировок по умолчанию для мьютексов pthreads (потоки POSIX), при котором блокировки предоставляются при каждом вызове.
27
Сериализация доступа к объекту не имеет ничего общего с сериализацией объекта (превращением объекта в поток байтов); сериализация доступа означает, что потоки обращаются к объекту по очереди, а не одновременно.

Составные действия (compound action) над разделяемым состоянием, такие как увеличение счетчика попаданий (read-modify-write) или отложенная инициализация
(check-then-act), должны быть атомарными, чтобы избежать условий гонки.
Удержание блокировки на протяжении всего времени выполнения составного действия, может сделать его атомарным. Однако недостаточно просто обернуть составное действие блоком synchronized
; если синхронизация используется для координации доступа к переменной, она необходима во всех местах, где
происходит обращение к этой переменной. Более того, при использовании блокировок для координации доступа к переменной, одна и та же блокировка должна использоваться везде, где доступна эта переменная.
Распространенной ошибкой является предположение, что синхронизацию необходимо использовать только при записи в общие переменные; это просто не
соответствует действительности. (Причины для этого замечания станут яснее в разделе 3.1)
К каждой изменяемой переменной состояния, к которой может обращаться более одного потока, все обращения должны выполняться с одной и той же блокировкой. В этом случае мы можем сказать, что переменная защищена этой блокировкой.
В классе
SynchronizedFactorizer,
в листинге 2.6, переменные lastNumber и lastFactors защищены внутренней блокировкой объекта сервлета; это отмечено аннотацией
@GuardedBy
Между внутренней блокировкой объекта и его состоянием нет связи; поля объекта не обязательно должны быть защищены встроенной блокировкой, хотя это вполне допустимое соглашение о блокировке, используемое многими классами.
Захват блокировки, связанной с объектом, не препятствует другим потокам в получении доступа к этому объекту – единственно, что, захват блокировки предотвращает захват этой же блокировки любыми другими потоками. Тот факт, что каждый объект имеет встроенную блокировку, является просто удобством, поэтому вам не нужно явно создавать объекты блокировки
28
. Создание протоколов
блокировки (locking protocols) или политик синхронизации, обеспечивающих безопасный доступ к разделяемому состоянию, и их согласованное использование в рамках всего кода программы, зависит от вас.
Каждая разделяемая, изменяемая переменная должна быть защищена только одной блокировкой. Пишите код прозрачно для тех, кто будет его сопровождать: блокировка должна явно соотноситься с переменной.
Общее соглашение о блокировках заключается в инкапсуляции изменяемого состояния в объекте и защите его от параллельного доступа - путем синхронизации любой ветки кода, обращающейся к изменяемому состоянию - с помощью встроенной блокировки объекта. Такой подход используется во множестве потокобезопасных классов, таких как
Vector и других синхронизированных коллекциях. В таких случаях, все переменные состояния объекта, защищаются встроенной блокировкой объекта. Однако в этом шаблоне нет ничего особенного, и ни компилятор, ни среда выполнения не применяют этот (или любой другой)
28
Оглядываясь назад можно сказать, что такое проектное решение, вероятно, было плохим: оно не только может ввести в заблуждение, но и заставляет разработчиков JVM идти на компромиссы между размером объекта и производительностью блокировки.
шаблон блокировки
29
. Протокол блокировки можно с легкостью случайно разрушить, добавив новый метод или ветку кода и забыв использовать синхронизацию.
Не все данные должны быть защищены блокировками - только изменяемые данные, которые будут доступны из нескольких потоков. В главе 1 мы описывали, как добавление простого асинхронного события, такого как
TimerTask
, может создать требования к потокобезопасности, которые скажутся на всём коде программы, особенно, если состояние программы плохо инкапсулировано.
Рассмотрим однопоточную программу, обрабатывающую большой объём данных.
Однопоточные программы не требуют синхронизации, потому что нет потоков, обращающихся к разделяемым данным. Теперь представьте, что вы хотите добавить функцию для создания периодических снимков прогресса программы, таким образом, чтобы ей не пришлось начинать заново, в случае если она упадёт с ошибкой (crashes) или должна будет быть остановлена. Вы можете сделать это с помощью класса
TimerTask
, который отключается каждые десять минут, сохраняя состояние программы в файле.
Поскольку класс
TimerTask будет вызван из другого потока (один из которых управляется классом
Timer
), любые данные, участвующие в снимке, становятся доступны из двух потоков: основного потока программы и потока класса
Timer
Это означает, что не только код класса
TimerTask должен использовать синхронизацию при доступе к состоянию программы, но и любая другая ветка кода остальной части программы, касающаяся тех же данных. То, что раньше не требовало синхронизации, теперь требует синхронизации во всей программе.
Когда переменная защищена блокировкой - это означает, что доступ к этой переменной каждый раз выполняется с этой блокировкой - вы гарантируете, что только один поток, в конкретный момент времени, может получить доступ к этой переменной. Когда класс имеет инварианты, которые включают более одной переменной состояния, существует дополнительное требование: каждая переменная, участвующая в инварианте, должна быть защищена одной и той же блокировкой. Это позволяет вам получить доступ к ним или обновить их в одной атомной операции, сохраняя инвариант
30
. Это правило демонстрируется в классе
SynchronizedFactorizer
: и кэшированное число, и кэшированный фактор защищены внутренней блокировкой объекта сервлета.
Для каждого инварианта, включающего более одной переменной, все переменные, участвующие в этом инварианте, должны быть защищены одной
и той же блокировкой.
Если синхронизация является “лекарством” от условий гонок, почему бы просто не объявить каждый метод как synchronized
? Оказывается, такое беспорядочное применение оператора synchronized, может привести либо к слишком большой, либо к слишком малой синхронизации приложения. Простой синхронизации каждого метода, как в классе
Vector
, недостаточно для отображения составных действий на атомарность класса
Vector
: if (!vector.contains(element)) vector.add(element);
29
Инструменты аудита кода, такие как FindBugs, могут определять ситуации, когда переменная часто, но не всегда, доступна с блокировкой, что может указывать на ошибку.
30
В данном случае, под инвариантом понимается согласованность состояния объекта.

Попытка провести операцию put-if-missing содержит условие гонки, хотя оба метода, contains и add, атомарны. Хотя синхронизированные методы могут сделать отдельные операции атомарными, при объединении нескольких операций в составное действие требуется дополнительная блокировка. (См. раздел
4.4
, в нём рассказывается о некоторых методах для безопасного добавления дополнительных атомарных операций к потокобезопасным объектам.) В то же время, синхронизация каждого метода может привести к проблемам с живучестью или производительностью, как мы видели в классе
SynchronizedFactorizer
2.5 Живучесть и производительность
В классе
UnsafeCachingFactorizer
, мы ввели в сервлет факторинга механизмы кэширования, в надежде, что это улучшит производительность. Кэширование требует некоторого разделяемого состояния, которое, в свою очередь, требует синхронизации для обеспечения целостности этого состояния. Но то, каким образом мы использовали синхронизацию в классе
SynchronizedFactorizer
, только ухудшило его производительность. Политика синхронизации для класса
SynchronizedFactorizer заключается в защите каждой переменной состояния с помощью внутренней блокировки объекта сервлета, и эта политика была реализована путем синхронизации всего метода service
. Этот простой, грубый подход восстановил безопасность, но обошёлся дорого.
Поскольку метод service помечен как synchronized
, только один поток может выполнить его в один момент времени. Это подрывает предназначение фреймворка сервлетов – заключающееся в том, что сервлеты должны быть в состоянии обрабатывать несколько запросов одновременно - и может привести к разочарованию пользователей, если нагрузка достаточно высока. Если сервлет занят факторингом большого числа, другие клиенты будут вынуждены ожидать, пока текущий запрос не будет завершен, прежде чем сервлет сможет запуститься с новым числом. Если в системе имеется несколько CPUs, процессоры могут оставаться в состоянии простоя (idle) даже при высокой нагрузке. В любом случае, даже кратковременные запросы, например запросы, для которых кэшируется значение, могут занять неожиданно много времени, потому что вынуждены ожидать завершения выполнения предыдущих длительных запросов.
На рисунке 2.1 показано, что происходит при поступлении нескольких запросов к синхронизированному сервлету факторинга: они помещаются в очередь и обрабатываются последовательно.
Рисунок 2.1 Плохой уровень параллелизма в классе
SynchronizedFactorizer
L
factor
n
U
L
factor
m
U
L
factor
m
U

Мы бы описали это веб-приложение, как демонстрирующее низкий уровень параллелизма: количество одновременных вызовов ограничено не доступностью ресурсов обработки, а структурой самого приложения. К счастью, достаточно легко улучшить параллелизм сервлета, при этом сохранив его потокобезопасность, применив сужение области действия блока synchronized
. Вы должны позаботиться о том, чтобы область действия блока synchronized была не слишком ограничена; вы бы не хотели разделить операцию, которая должна быть атомарной, на выполнение более чем в одном блоке synchronized
. Разумно попытаться исключить из блоков synchronized длительные операции, которые не влияют на разделяемое состояние объекта, чтобы другие потоки не ограничивались в доступе к разделяемому состоянию, пока происходит выполнение длительной операции.
Класс
CachedFactorizer
, представленный в листинге 2.8, реструктурировал сервлет для использования двух отдельных блоков synchronized
, каждый из которых охватывает небольшой кусок кода. Один из них защищает последовательность действий check-then-act, которая проверяет, можем ли мы просто вернуть кэшированный результат, а другой защищает обновление переменных состояния - как кэшированного числа, так и кэшированного фактора.
В качестве бонуса, мы повторно ввели счетчик посещений и добавили счетчик
“попаданий в кэш”, обновляя их в начальном блоке synchronized
. Поскольку эти счетчики составляют разделяемое изменяемое состояние, мы должны использовать синхронизацию везде, где к ним происходит обращение.
Части кода, которые находятся за пределами блоков synchronized
, работают исключительно с локальными (находящимися в стеке) переменными, которые не разделяются между потоками и поэтому не требуют синхронизации.
@ThreadSafe public class CachedFactorizer implements Servlet {
@GuardedBy("this") private BigInteger lastNumber;
@GuardedBy("this") private BigInteger[] lastFactors;
@GuardedBy("this") private long hits;
@GuardedBy("this") private long cacheHits; public synchronized long getHits() { return hits; } public synchronized double getCacheHitRatio() { return (double) cacheHits / (double) hits;
} public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = null;
synchronized (this) {
++hits; if (i.equals(lastNumber)) {
++cacheHits; factors = lastFactors.clone();
}
} if (factors == null) {
factors = factor(i);
synchronized (this) { lastNumber = i; lastFactors = factors.clone();
}
} encodeIntoResponse(resp, factors);
}
}
Листинг 2.8 Сервлет кэширующий последний запрос и результат его выполнения.
Класс
CachedFactorizer более не использует тип
AtomicLong для подсчёта посещений, вместо этого вернувшись к использованию поля типа long
. Было бы безопаснее использовать тип
AtomicLong
, но это принесёт меньше пользы, чем было в случае класса
CountingFactorizer
. Атомарные переменные полезны для выполнения атомарных операций над одной переменной, но, поскольку мы уже используем блоки synchronized для построения атомарных операций, использование двух различных механизмов синхронизации может вызвать путаницу и не приведёт к приросту производительности или повышению уровня безопасности.
Реструктуризация класса
CachedFactorizer обеспечивает баланс между простотой (синхронизация всего метода) и параллелизмом (синхронизация возможно кратчайших веток выполнения кода). Захват и снятие блокировки несёт в себе накладные расходы, поэтому нежелательно слишком часто помещать код в блоки synchronized
(например, факторинг
++hits в своём собственном блоке synchronized
), даже если это не подвергает атомарность угрозе. Класс
CachedFactorizer удерживает блокировку в моменты доступа к переменным состояния и на время выполнения составных действий, но освобождает ее перед выполнением потенциально длительной операции факторинга. Это позволяет сохранять потокобезопасность, без чрезмерного влияния на параллелизм; ветки кода в каждом из блоков synchronized являются "достаточно короткими".
Решение о том, насколько большими или малыми должны быть блоки synchronized
, может потребовать компромиссов между конкурирующими силами, оказывающими влияние на дизайн, включая безопасность (которая не должна быть скомпрометирована), простоту и производительность. Иногда простота и производительность противоречат друг другу, хотя, как показывает класс
CachedFactorizer
, разумный баланс обычно может быть найден.
Часто существует противоречие между простотой и производительностью.
При реализации политики синхронизации, не поддавайтесь искушению преждевременно пожертвовать простотой (потенциально снижающей безопасность) ради производительности.
Всякий раз, когда вы используете блокировку, вы должны знать, что в блоке делает код и насколько вероятно, что потребуется много времени на его выполнение. Длительное удержание блокировки в связи с интенсивными вычислениями, либо из-за выполнения потенциально блокирующей операции, создает риск возникновения проблем с производительностью или живучестью.

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

1   2   3   4   5   6   7   8   9   ...   34


написать администратору сайта