При участии Тима Перлса, Джошуа Блоха, Джозева Боубира, Дэвида Холмса и Дага Ли Параллельное программирование в java на
Скачать 4.97 Mb.
|
Глава 16 Модель памяти Java На протяжении всей книги мы в основном избегали низкоуровневых деталей модели памяти Java (JMM) и вместо этого сосредоточились на вопросах проектирования более высокого уровня, таких как безопасная публикация, спецификация и соблюдение политик синхронизации. Они порождают свою безопасность от JMM, и вы можете упростить себе фактическое использование этих механизмов, когда поймёте, почему они так работают. Эта глава приподнимает занавес, чтобы выявить низкоуровневые требования и гарантии модели памяти Java и идеи, лежащие в основе некоторых правил проектирования более высокого уровня, предлагаемых в этой книге. 16.1 Что такое модель памяти и зачем она мне нужна? Предположим, что один поток присваивает значение переменной aVariable : aVariable = 3; Модель памяти озадачивается вопросом, “при каких условиях поток, который читает переменную aVariable , увидит значение 3?”. Это может прозвучать как глупый вопрос, но в отсутствии синхронизации, существует ряд причин, по которым поток может не сразу - или вообще никогда - увидеть результаты операции в другом потоке. Компиляторы могут генерировать инструкции в порядке, отличном от “очевидного”, прописанного в исходном коде, или хранить переменные в регистрах, а не в оперативной памяти; процессоры могут выполнять инструкции параллельно или не по порядку; кэши могут различаться порядком, в котором данные записываются в переменные или фиксируются в основной памяти; а значения, хранящиеся в локальных кэшах процессора, могут быть не видны другим процессорам. Эти факторы могут стать препятствием для того, чтобы поток видел самое последнее значение переменной и может привести к тому, что операции с памятью в других потоках будут выглядеть беспорядочными - если вы не используете адекватную синхронизацию. В однопоточной среде, все эти трюки, играющие в нашей программе роль окружения, скрыты от нас, предназначены для ускорения выполнения и не оказывают никакого иного влияния. Спецификация языка Java требует, чтобы среда JVM поддерживала в потоке семантику последовательного выполнения (within-thread as-if-serial semantics): до тех пор, пока программа получает тот же самый результат, как если бы она выполнялась в среде со строго последовательным порядком выполнения, все эти игры допустимы. И это тоже хорошо, потому что, в последние годы, эти перестановки влекут за собой значительное улучшение производительности при выполнении вычислений. Конечно, более высокие тактовые частоты способствовали повышению производительности, но также увеличился параллелизм - конвейерные суперскалярные вычислительные модули, динамическое планирование инструкций, спекулятивное выполнение и сложные многоуровневые кэши памяти. По мере того как процессоры становятся все более изощренными, так же есть и компиляторы, переупорядочивающие инструкции для облегчения оптимального выполнения и использующие сложные глобальные алгоритмы распределения регистров. И поскольку производители процессоров переходят на многоядерные процессоры, во многом из-за того, что увеличение тактовых частот становится экономически всё дороже, аппаратный параллелизм будет только увеличиваться. В многопоточной среде, иллюзия последовательность не может поддерживаться без существенных затрат производительности. Поскольку большинство потоков в параллельном приложении ограничено по времени, и каждый из них “занят своим делом”, чрезмерная координация между потоками только замедлит работу приложения, без получения реальной выгоды. Только в том случае, когда несколько потоков совместно используют одни и те же данные, необходимо координировать их действия, и JVM в этом отношении полагается на программу, чтобы та определяла, когда это будет происходить, с помощью синхронизации. Спецификация JMM определяет минимальные гарантии, которым должна следовать среда JVM, когда записывает значения в переменные, которые становятся видимыми для других потоков. Она был разработана, чтобы сбалансировать требование предсказуемости и простоты разработки программ с реалиями реализации высокопроизводительных JVM на широком спектре популярных процессорных архитектур. Некоторые аспекты JMM сначала могут вызвать беспокойство, если вы не знакомы с приемами, используемыми современными процессорами и компиляторами, для “выжимания” дополнительной производительности из вашей программы. 16.1.1 Основа моделей памяти В мультипроцессорной архитектуре с совместно используемой памятью, каждый процессор имеет собственный кэш, который периодически согласовывается с основной памятью. Процессорные архитектуры обеспечивают различную степень согласованности кэша (cache coherence); некоторые предоставляют минимальные гарантии, позволяющие разным процессорам видеть различные значения для одного и того же участка памяти, практически в любое время. Операционная система, компилятор и среда выполнения (а иногда и программа) должны компенсировать разницу между возможностями, предоставляемыми оборудованием, и тем, что требуется для обеспечения потокобезопасности. Обеспечение возможности того, чтобы каждый процессор знал, чем занят другой процессор, всегда обходится дорого. Большую часть времени эта информация не нужна, поэтому процессоры ослабляют гарантии согласованности памяти, с целью повышения производительности. Архитектура модели памяти сообщает программам, какие гарантии они могут ожидать от системы памяти, и определяет специальные инструкции, необходимые (называемые барьерами памяти (memory barriers) или ограждениями (fences)) для получения дополнительных гарантий координации памяти, необходимых при совместном использовании данных. Чтобы оградить разработчика Java от различий между моделями памяти в различных архитектурах, Java предоставляет свою собственную модель памяти, а JVM имеет дело с различиями между JMM и нижележащей моделью памяти платформы, путём вставки барьеров памяти в соответствующих местах. Одним из удобных способов мысленно представить себе модель выполнения программы – это принять, что существует единый порядок, в котором в программе выполняются все операции, независимо от того, на каком процессоре они выполняются, и принять, что каждое чтение переменной будет видеть последнюю запись этой переменной, в порядке выполнения любым процессором. Эта счастливая, хотя и не реалистичная, модель называется согласованной последовательностью (sequential consistency). Разработчики программного обеспечения часто ошибочно предполагают согласованную последовательность, но ни один современный мультипроцессор не предлагает согласованной последовательности, как, в прочем, и JMM. Классическая модель последовательных вычислений, модель фон Неймана, является лишь расплывчатым приближением того, как ведут себя современные мультипроцессоры. Суть в том, что современные мультипроцессоры с совместно используемой памятью (а также компиляторы) могут делать некоторые удивительные вещи, когда данные совместно используются разными потоками, за исключением ситуации, при которой вы указали им не использовать барьеры памяти. К счастью, программам Java нет необходимости указывать расположение барьеров памяти; они должны только определить момент, когда выполняется обращение к совместно используемому состоянию, посредством надлежащего использования синхронизации. 16.1.2 Переупорядочивание При описании условий гонки и сбоев атомарности в главе 2 , мы использовали диаграммы взаимодействия, изображающие “неудачный момент времени”, в который планировщик чередовал операции, что приводило к появлению неправильных результатов в недостаточно синхронизированных программах. Усугубляя проблему, спецификация JMM может позволить действиям казаться выполняемыми в различном порядке, с точки зрения разных потоков, что делает рассуждения о порядке в отсутствие синхронизации еще более сложными. Различные причины, по которым операции могут задерживаться или выполняться не по порядку, можно сгруппировать в общую категорию переупорядочивание (reordering). Класс PossibleReordering из листинга 16.1 демонстрирует, как трудно рассуждать о поведении даже самых простых параллельных программ, если они синхронизированы не правильно. Довольно легко себе представить, как класс PossibleReordering может печатать (1, 0) или (0, 1), или (1, 1): поток A может завершить выполнение до запуска потока B, поток B может завершить выполнение, до запуска потока A, или их действия могут чередоваться. Но, как ни странно, класс PossibleReordering также может напечатать (0, 0)! Действия в каждом потоке не зависят друг от друга и, соответственно, могут выполняться не по порядку. (Даже если они выполняются по порядку, задержка времени, с использованием которой кэши сбрасываются в основную память, может привести к тому, что с точки зрения потока B присваивания в потоке A будут выполняться в обратном порядке.) public class PossibleReordering { static int x = 0, y = 0; static int a = 0, b = 0; public static void main(String[] args) throws InterruptedException { Thread one = new Thread(new Runnable() { public void run() { a = 1; x = b; } }); Thread other = new Thread(new Runnable() { public void run() { b = 1; y = a; } }); one.start(); other.start(); one.join(); other.join(); System.out.println("( "+ x + "," + y + ")"); } } Листинг 16.1 Недостаточно синхронизированная программа, которая может вернуть неожиданные результаты. На рис. 16.1 показано возможное чередование с переупорядочением, приводящее к печати (0, 0). Рис. 16.1 Чередование отражает переупорядочивание в классе PossibleReordering Класс PossibleReordering представляет собой тривиальную программу, но всё ещё на удивление сложно перечислить ее возможные результаты. Изменение порядка на уровне памяти может привести к неожиданному поведению программ. Рассуждать о порядке в отсутствие синхронизации непомерно сложно; гораздо проще убедиться, что ваша программа использует синхронизацию надлежащим образом. Синхронизация препятствует компилятору, среде выполнения и железу в переупорядочивании операций с памятью таким образом, чтобы нарушить гарантии видимости, предоставляемые JMM. 173 16.1.3 О модели памяти Java менее чем в 500 словах Модель памяти Java определяется в терминах действий, которые включают чтение и запись переменных, блокировку и разблокировку мониторов, запуск и присоединение к потокам. Спецификация JMM определяет частичное упорядочение 174 , называемое happens-before, на всех действиях программы. Чтобы гарантировать, что поток, выполняющий действие B, сможет видеть результаты действия A (независимо от того, происходят ли действия A и B в разных потоках), между действиями A и B должна существовать связь happens-before. В отсутствие 173 В большинстве популярных процессорных архитектур модель памяти достаточно сильна, таким образом, затраты на volatile чтение стоят в одном ряду с затратами на не volatile чтение. 174 Частичное упорядочение ≺ - это отношение на множестве, являющееся антисимметричным, рефлексивным и транзитивным, но для любых двух элементов X и Y, не обязательно должно выполняться, что X ≺ Y или Y ≺ X. Мы используем частичное упорядочивание каждый день, для выражения предпочтений; мы можем иметь предпочтения от суши до чизбургеров и от Моцарта до Малера, но у нас не всегда имеются чёткие предпочтения между чизбургерами и Моцартом. x = b (0) a = 1 b = 1 y = a (0) ThreadB Thread A упорядочивающей связи happens-before между двумя операциями, среда JVM может свободно переупорядочивать их по своему усмотрению. Гонка данных случается, когда переменная считывается более чем одним потоком и записывается, по крайней мере, одним потоком, но операции чтения и записи не упорядочены связью happens-before. Корректно синхронизированная программа - это программа без гонки данных; правильно синхронизированные программы демонстрируют последовательную согласованность, что означает, что все действия в программе происходят в фиксированном, глобальном порядке. Правила для happens-before: Правило порядка программы. Каждое действие потока связывается через отношениеhappens-beforeс каждым действием в том потоке, что придёт после, согласно порядку программы. Правило блокировка монитора. Разблокировка блокировки на мониторе связана отношениемhappens-beforeс каждой последующей блокировкой на той же самой блокировке монитора. 175 Правило Volatile переменной. Запись поля volatile связана отношениемhappens-beforeсо всемипоследующими чтениями того же самого поля. 176 Правило запуска потока. Вызов метода Thread.startпотока связан отношениемhappens-beforeс каждым действием в запущенном потоке. Правило завершения потока. Любое действие в потоке связано отношением happens-before с любым другим потоком, определившим, что поток завершен, а также с успешным возвратом из метода Thread.join или из метода Thread.isAlive, вернувшим false. Правило прерывания. Поток, вызвавший метод interrupt другого потока, связан отношением happens-before с прерванным потоком, определившим прерывание(а также бросившим исключение InterruptedException, или вызвавшим методы isInterrupted или interrupted). Правило финализации. Завершение конструктора некоторого объекта связано отношением happens-beforeс запуском финализатора этого объекта. Транзитивность. Если поток A связан отношением happens-before с потоком B, и поток B связан отношением happens-before с потоком C, тогда поток Aсвязанотношениемhappens-before с потоком C. 175 Блокировки и разблокировки явных объектов Lock имеют ту же семантику памяти, что и внутренние блокировки. 176 Операции чтения и записи атомарных переменных имеют ту же семантику памяти, что и переменные volatile Хотя это лишь частично упорядоченные действия - захват и освобождение блокировки, чтение и запись volatile переменной - они полностью упорядочены. Это позволяет описать отношение happens-before в терминах “последовательных” захватов блокировок и чтений volatile переменных. На рис. 16.2 показано отношение happens-before, когда два потока синхронизируются с помощью общей блокировки. Все действия потока A упорядочены согласно правилу порядка программы, также как и все действия потока B. Поскольку поток A освобождает блокировку M и поток B впоследствии захватывает блокировку M, все действия в потоке A, до освобождения блокировки, упорядочены до всех действий в потоке B, после захвата блокировки. Когда два потока синхронизируются на разных блокировках, мы не можем ничего сказать о порядке выполнения действий между ними - между действиями в двух потока не существует отношения happens-before. Рисунок 16.2 Иллюстрация отношения happens-before в модели памяти Java 16.1.4 Комбинирование в синхронизации В связи с прочностью упорядочивания отношением happens-before, вы иногда можете комбинировать свойства видимости существующей синхронизации. Это влечет за собой объединение правила порядка программы для отношения happens- before с одним из других правил порядка (обычно, с правилом блокировки монитора или правилом переменной volatile ), чтобы упорядочить доступ к Поток A y = 1 lock M x = 1 unlock M lock M i = x unlock M i = y Поток B Все, что было до разбло- кировки M… …видно всем, после блоки- ровки M. переменной, в ином случае не защищаемой блокировкой. Этот подход очень чувствителен к порядку, в котором происходят обращения, и поэтому довольно хрупок; это продвинутый подход, который должен быть зарезервирован для выжимания последней капли производительности из наиболее критичных для производительности классов, подобных классу ReentrantLock Реализация защищенных методов класса AbstractQueuedSynchronizer классом FutureTask иллюстрирует комбинирование. Класс AQS поддерживает некоторое целое число, характеризующее состояние синхронизатора, которое класс FutureTask использует для хранения состояния задачи: выполняется (running), завершено (completed) или отменено (cancelled). Но класс FutureTask также поддерживает дополнительные переменные, такие как результат вычисления. Когда один поток вызывает метод set , чтобы сохранить результат, и другой поток вызывает метод get , чтобы получить его, обоим лучше быть упорядоченными через отношение happens-before. Этого можно добиться, объявив ссылку на результат как volatile , но можно использовать и существующую синхронизацию для достижения того же результата при меньших затратах. Класс FutureTask разрабатывался с особой тщательностью, чтобы гарантировать, что успешный вызов метода tryReleaseShared всегда связан отношением happens-before с последующим вызовом метода tryAcquireShared ; метод tryReleaseShared всегда выполняет запись в volatile переменную, значение которой считывается методом tryAcquireShared . В листинге 16.2 приведены методы innerSet и innerGet , вызывающиеся при сохранении или извлечении результата; поскольку метод innerSet выполняет запись в переменную result перед вызовом метода releaseShared (который, в свою очередь, вызывает метод tryReleaseShared ) и метод innerGet выполняет чтение из переменной result после вызова метода acquireShared (который, в свою очередь, вызывает метод tryAcquireShared ), правило порядка программы сочетается с правилом volatile переменной, чтобы убедиться, что выполнение записи в переменную result в методе innerSet связано отношением happens- before с чтением из переменной result в методе innerGet // Inner class of FutureTask private final class Sync extends AbstractQueuedSynchronizer { private static final int RUNNING = 1, RAN = 2, CANCELLED = 4; private V result; private Exception exception; void innerSet(V v) { while (true) { int s = getState(); if (ranOrCancelled(s)) return; if (compareAndSetState(s, RAN)) break; } result = v; releaseShared(0); done(); } |