Платформа Android 3.0 и более поздних версий оптимизирована для поддержки многопроцессорных архитектур. В этом документе описаны проблемы, которые могут возникнуть при написании многопоточного кода для симметричных многопроцессорных систем на C, C++ и языке программирования Java (далее для краткости называемом просто «Java»). Он задуман как учебник для разработчиков приложений для Android, а не как полное обсуждение этой темы.
Введение
SMP — это аббревиатура от «симметричный мультипроцессор». Он описывает схему, в которой два или более идентичных ядра ЦП имеют общий доступ к основной памяти. Еще несколько лет назад все устройства Android были UP (уни-процессорными).
Большинство — если не все — Android-устройства всегда имели несколько процессоров, но в прошлом только один из них использовался для запуска приложений, в то время как другие управляли различными частями аппаратного обеспечения устройства (например, радио). Процессоры могли иметь разную архитектуру, и программы, работающие на них, не могли использовать оперативную память для взаимодействия друг с другом.
Большинство Android-устройств, продаваемых сегодня, построены на базе SMP, что немного усложняет задачу разработчикам программного обеспечения. Условия гонки в многопоточной программе могут не вызывать видимых проблем на однопроцессоре, но могут регулярно давать сбои, когда два или более ваших потоков выполняются одновременно на разных ядрах. Более того, код может быть более или менее подвержен сбоям при запуске на разных архитектурах процессоров или даже на разных реализациях одной и той же архитектуры. Код, тщательно протестированный на x86, может плохо работать на ARM. Код может начать давать сбой при перекомпиляции более современным компилятором.
Остальная часть этого документа объяснит почему и расскажет, что вам нужно сделать, чтобы гарантировать правильное поведение вашего кода.
Модели согласованности памяти: почему SMP немного отличаются
Это быстрый, глянцевый обзор сложной темы. Некоторые области будут неполными, но ни одна из них не должна вводить в заблуждение или быть неправильной. Как вы увидите в следующем разделе, детали здесь обычно не важны.
См. раздел «Дальнейшее чтение» в конце документа, где приведены указания по более тщательному рассмотрению этого вопроса.
Модели согласованности памяти, или часто просто «модели памяти», описывают гарантии, которые язык программирования или аппаратная архитектура дает в отношении доступа к памяти. Например, если вы записываете значение в адрес A, а затем записываете значение в адрес B, модель может гарантировать, что каждое ядро ЦП увидит, что эти записи происходят в указанном порядке.
Моделью, к которой привыкло большинство программистов, является последовательная согласованность , которая описывается следующим образом ( Adve & Gharachorloo ) :
- Все операции с памятью выполняются по одной
- Все операции в одном потоке выполняются в порядке, описанном программой этого процессора.
Предположим временно, что у нас есть очень простой компилятор или интерпретатор, который не преподносит никаких сюрпризов: он преобразует присваивания в исходном коде для загрузки и сохранения инструкций в точно соответствующем порядке, по одной инструкции на доступ. Для простоты мы также предположим, что каждый поток выполняется на своем собственном процессоре.
Если вы посмотрите на фрагмент кода и увидите, что он выполняет некоторые операции чтения и записи из памяти, то в последовательно-согласованной архитектуре ЦП вы знаете, что код будет выполнять эти операции чтения и записи в ожидаемом порядке. Вполне возможно, что ЦП на самом деле меняет порядок инструкций и задерживает чтение и запись, но код, выполняющийся на устройстве, не может сказать, что ЦП делает что-либо, кроме простого выполнения инструкций. (Мы будем игнорировать ввод-вывод драйвера устройства, отображаемого в памяти.)
Чтобы проиллюстрировать эти положения, полезно рассмотреть небольшие фрагменты кода, обычно называемые лакмусовыми тестами .
Вот простой пример кода, выполняющегося в двух потоках:
Тема 1 | Тема 2 |
---|---|
A = 3 | reg0 = B |
В этом и всех последующих примерах ячейки памяти обозначаются заглавными буквами (A, B, C), а регистры ЦП начинаются с «reg». Вся память изначально равна нулю. Инструкции выполняются сверху вниз. Здесь поток 1 сохраняет значение 3 в ячейке A, а затем значение 5 в ячейке B. Поток 2 загружает значение из ячейки B в reg0, а затем загружает значение из ячейки A в reg1. (Обратите внимание, что мы пишем в одном порядке, а читаем в другом.)
Предполагается, что поток 1 и поток 2 выполняются на разных ядрах ЦП. Вы всегда должны делать это предположение, думая о многопоточном коде.
Последовательная согласованность гарантирует, что после завершения выполнения обоих потоков регистры будут находиться в одном из следующих состояний:
Регистры | Штаты |
---|---|
рег0=5, рег1=3 | возможно (поток 1 запускался первым) |
рег0=0, рег1=0 | возможно (поток 2 запускался первым) |
рег0=0, рег1=3 | возможно (параллельное выполнение) |
рег0=5, рег1=0 | никогда |
Чтобы попасть в ситуацию, когда мы видим B=5 до того, как увидим сохранение в A, либо чтение, либо запись должны происходить не по порядку. На последовательно-согласованной машине этого не может произойти.
Однопроцессорные процессоры, включая x86 и ARM, обычно являются последовательными. Потоки выполняются чередующимся образом, поскольку ядро ОС переключается между ними. Большинство систем SMP, включая x86 и ARM, не являются последовательными. Например, аппаратное обеспечение обычно буферизует хранилища на пути к памяти, чтобы они не сразу достигали памяти и не становились видимыми для других ядер.
Детали существенно различаются. Например, x86, хотя и не является последовательным, все же гарантирует, что reg0 = 5 и reg1 = 0 остаются невозможными. Хранилища буферизуются, но их порядок сохраняется. ARM, с другой стороны, этого не делает. Порядок буферизованных хранилищ не поддерживается, и хранилища могут не достигать всех остальных ядер одновременно. Эти различия важны для программистов на ассемблере. Однако, как мы увидим ниже, программисты C, C++ или Java могут и должны программировать таким образом, чтобы скрыть такие архитектурные различия.
До сих пор мы нереалистично предполагали, что порядок инструкций меняет только аппаратное обеспечение. На самом деле компилятор также меняет порядок инструкций для повышения производительности. В нашем примере компилятор может решить, что некоторому более позднему коду в потоке 2 требуется значение reg1 до того, как ему понадобится reg0, и поэтому сначала загрузите reg1. Или какой-то предыдущий код уже загрузил A, и компилятор может решить повторно использовать это значение вместо повторной загрузки A. В любом случае порядок загрузки в reg0 и reg1 может быть изменен.
Переупорядочение доступа к различным областям памяти как в аппаратном обеспечении, так и в компиляторе разрешено, поскольку оно не влияет на выполнение одного потока и может существенно повысить производительность. Как мы увидим, проявив немного осторожности, мы также можем предотвратить влияние этого на результаты многопоточных программ.
Поскольку компиляторы также могут изменять порядок доступа к памяти, эта проблема на самом деле не нова для SMP. Даже на однопроцессоре компилятор может переупорядочить загрузки на reg0 и reg1 в нашем примере, а поток 1 можно запланировать между переупорядоченными инструкциями. Но если бы наш компилятор не изменил порядок, мы могли бы никогда не столкнуться с этой проблемой. На большинстве ARM SMP, даже без изменения порядка компилятора, изменение порядка, вероятно, будет видно, возможно, после очень большого количества успешных выполнений. Если вы не программируете на языке ассемблера, SMP обычно просто увеличивает вероятность того, что вы увидите проблемы, которые существовали с самого начала.
Программирование без гонок данных
К счастью, обычно есть простой способ не думать об этих деталях. Если вы следуете некоторым простым правилам, обычно можно безопасно забыть весь предыдущий раздел, за исключением части «последовательной согласованности». К сожалению, другие осложнения могут стать заметными, если вы случайно нарушите эти правила.
Современные языки программирования поощряют так называемый стиль программирования «без гонок данных». Пока вы обещаете не вводить «гонки данных» и избегать нескольких конструкций, которые сообщают компилятору об обратном, компилятор и аппаратное обеспечение обещают обеспечить последовательно согласованные результаты. На самом деле это не означает, что они избегают переупорядочения доступа к памяти. Это означает, что если вы будете следовать правилам, вы не сможете определить, что порядок доступа к памяти изменяется. Это все равно, что сказать вам, что колбаса — вкусная и аппетитная еда, если вы пообещаете не посещать колбасный завод. Гонки данных — это то, что раскрывает ужасную правду о переупорядочении памяти.
Что такое «гонка данных»?
Гонка данных возникает, когда хотя бы два потока одновременно обращаются к одним и тем же обычным данным, и хотя бы один из них изменяет их. Под «обычными данными» мы подразумеваем что-то, что не является объектом синхронизации, предназначенным для взаимодействия потоков. Мьютексы, условные переменные, переменные Java или атомарные объекты C++ не являются обычными данными, и доступ к ним может осуществляться на гонках. Фактически они используются для предотвращения гонок данных на других объектах.
Чтобы определить, обращаются ли два потока одновременно к одной и той же ячейке памяти, мы можем игнорировать приведенное выше обсуждение переупорядочения памяти и предположить последовательную согласованность. В следующей программе нет гонки данных, если A
и B
— обычные логические переменные, которые изначально имеют значение false:
Тема 1 | Тема 2 |
---|---|
if (A) B = true | if (B) A = true |
Поскольку порядок операций не переупорядочивается, оба условия будут иметь значение false, и ни одна переменная никогда не будет обновляться. Таким образом, не может быть гонки данных. Нет необходимости думать о том, что может произойти, если каким-то образом переупорядочить загрузку из A
и сохранение в B
в потоке 1. Компилятору не разрешено переупорядочивать поток 1, переписывая его как « B = true; if (!A) B = false
». Это все равно, что готовить колбасу посреди города средь бела дня.
Гонки данных официально определены для базовых встроенных типов, таких как целые числа, ссылки или указатели. Присвоение int
с одновременным чтением его в другом потоке явно является гонкой данных. Но и стандартная библиотека C++, и библиотеки коллекций Java написаны так, чтобы вы также могли рассуждать о гонках данных на уровне библиотеки. Они обещают не вводить гонки данных, если к одному и тому же контейнеру не будет одновременного доступа, хотя бы один из которых его обновит. Обновление set<T>
в одном потоке при одновременном чтении его в другом позволяет библиотеке ввести гонку данных и, таким образом, может неофициально рассматриваться как «гонка данных на уровне библиотеки». И наоборот, обновление одного set<T>
в одном потоке при чтении другого в другом не приводит к гонке данных, поскольку библиотека обещает не вводить в этом случае (низкоуровневую) гонку данных.
Обычно одновременный доступ к различным полям структуры данных не может привести к гонке данных. Однако из этого правила есть одно важное исключение: смежные последовательности битовых полей в C или C++ рассматриваются как одна «ячейка памяти». Доступ к любому битовому полю в такой последовательности рассматривается как доступ ко всем из них с целью определения существования гонки данных. Это отражает неспособность обычного оборудования обновлять отдельные биты без чтения и перезаписи соседних битов. У Java-программистов подобных проблем нет.
Как избежать гонок за данными
Современные языки программирования предоставляют ряд механизмов синхронизации, позволяющих избежать гонок за данными. Самыми основными инструментами являются:
- Блокировки или мьютексы
- Мьютексы (C++11
std::mutex
илиpthread_mutex_t
) илиsynchronized
блоки в Java можно использовать, чтобы гарантировать, что определенный раздел кода не будет выполняться одновременно с другими разделами кода, обращающимися к тем же данным. Мы будем называть эти и другие подобные средства «замками». Последовательное получение определенной блокировки перед доступом к общей структуре данных и последующее ее освобождение предотвращает гонки данных при доступе к структуре данных. Это также гарантирует, что обновления и доступы являются атомарными, т. е. никакое другое обновление структуры данных не может выполняться посередине. Это заслуженно самый распространенный инструмент для предотвращения гонок за данными. Использованиеsynchronized
блоков Java или C++lock_guard
илиunique_lock
гарантирует правильное освобождение блокировок в случае исключения. - Летучие/атомарные переменные
- Java предоставляет
volatile
поля, которые поддерживают одновременный доступ без возникновения гонок за данными. С 2011 года C и C++ поддерживаютatomic
переменные и поля со схожей семантикой. Обычно их сложнее использовать, чем блокировки, поскольку они гарантируют, что индивидуальный доступ к одной переменной является атомарным. (В C++ это обычно распространяется на простые операции чтения-изменения-записи, такие как приращение. Java требует для этого специальных вызовов методов.) В отличие от блокировок,volatile
илиatomic
переменные не могут использоваться напрямую, чтобы предотвратить вмешательство других потоков в более длинные последовательности кода. .
Важно отметить, что volatile
имеет совершенно разные значения в C++ и Java. В C++ volatile
не предотвращает гонки данных, хотя в старом коде она часто используется в качестве обходного пути из-за отсутствия atomic
объектов. Это больше не рекомендуется; в C++ используйтеatomic atomic<T>
для переменных, к которым могут одновременно обращаться несколько потоков. volatile
C++ предназначена для регистров устройств и т.п.
atomic
переменные C/C++ или volatile
переменные Java можно использовать для предотвращения гонок данных по другим переменным. Если flag
объявлен как atomic<bool>
или atomic_bool
(C/C++) или volatile boolean
(Java) и изначально имеет значение false, то следующий фрагмент не содержит гонок данных:
Тема 1 | Тема 2 |
---|---|
A = ... | while (!flag) {} |
Поскольку поток 2 ожидает установки flag
, доступ к A
в потоке 2 должен происходить после, а не одновременно с назначением A
в потоке 1. Таким образом, в A
нет гонки данных. Гонка по flag
не считается гонкой за данными, поскольку энергозависимый/атомарный доступ не является «обычным доступом к памяти».
Реализация необходима для предотвращения или сокрытия переупорядочения памяти в достаточной степени, чтобы код, подобный предыдущему лакмусовому тесту, вел себя должным образом. Обычно это делает доступ к энергозависимой/атомарной памяти существенно более дорогим, чем обычный доступ.
Хотя предыдущий пример не содержит гонок данных, блокировки вместе с Object.wait()
в Java или условными переменными в C/C++ обычно обеспечивают лучшее решение, которое не требует ожидания в цикле при разрядке заряда батареи.
Когда переупорядочение памяти становится видимым
Программирование без гонок за данными обычно избавляет нас от необходимости явно решать проблемы переупорядочения доступа к памяти. Однако есть несколько случаев, когда изменение порядка становится видимым:- Если в вашей программе есть ошибка, приводящая к непреднамеренной гонке данных, преобразования компилятора и оборудования могут стать видимыми, и поведение вашей программы может оказаться неожиданным. Например, если мы забыли объявить
flag
изменчивым в предыдущем примере, поток 2 может увидеть неинициализированныйA
. Или компилятор может решить, что флаг не может измениться во время цикла потока 2, и преобразовать программу в
Во время отладки вы вполне можете увидеть, что цикл продолжается вечно, несмотря на то, чтоТема 1 Тема 2 A = ...
flag = truereg0 = флаг; пока (!reg0) {}
... = Аflag
имеет значение true. - C++ предоставляет средства для явного ослабления последовательной согласованности, даже если гонок нет. Атомарные операции могут принимать явные аргументы
memory_order_
.... Аналогичным образом, пакетjava.util.concurrent.atomic
предоставляет более ограниченный набор подобных возможностей, в частностиlazySet()
. И Java-программисты иногда используют преднамеренные гонки данных для аналогичного эффекта. Все это обеспечивает повышение производительности за счет большой сложности программирования. Мы лишь кратко обсудим их ниже . - Некоторый код C и C++ написан в старом стиле, не совсем согласующемся с текущими стандартами языка, в котором вместо
atomic
переменных используютсяvolatile
переменные, а упорядочение памяти явно запрещено путем вставки так называемых ограждений или барьеров . Это требует явных рассуждений о переупорядочении доступа и понимании моделей аппаратной памяти. Подобный стиль кодирования до сих пор используется в ядре Linux. Его не следует использовать в новых приложениях для Android, а также он здесь не обсуждается.
Упражняться
Отладка проблем с согласованностью памяти может быть очень сложной. Если отсутствие блокировки, atomic
или volatile
объявления приводит к тому, что какой-то код читает устаревшие данные, возможно, вы не сможете выяснить причину, исследуя дампы памяти с помощью отладчика. К тому времени, когда вы сможете выполнить запрос отладчика, все ядра ЦП могут обнаружить полный набор обращений, а содержимое памяти и регистров ЦП окажется в «невозможном» состоянии.
Чего не следует делать в C
Здесь мы представляем несколько примеров неправильного кода, а также простые способы их исправления. Прежде чем мы это сделаем, нам нужно обсудить использование базовой функции языка.
C/C++ и «летучий»
volatile
объявления C и C++ — это инструмент особого назначения. Они не позволяют компилятору переупорядочивать или удалять изменчивые доступы. Это может быть полезно для доступа кода к регистрам аппаратных устройств, памяти, сопоставленной более чем с одним местом, или в связи с setjmp
. Но C и C++ volatile
, в отличие volatile
Java, не предназначены для потокового взаимодействия.
В C и C++ доступ к volatile
данным может быть переупорядочен с доступом к энергонезависимым данным, и нет никаких гарантий атомарности. Таким образом, volatile
не может использоваться для обмена данными между потоками в переносимом коде, даже на однопроцессоре. C volatile
обычно не предотвращает переупорядочение доступа аппаратным обеспечением, поэтому сам по себе он еще менее полезен в многопоточных средах SMP. Именно по этой причине C11 и C++11 поддерживают atomic
объекты. Вместо этого вам следует использовать их.
Многие старые коды C и C++ по-прежнему злоупотребляют volatile
для взаимодействия между потоками. Это часто работает правильно для данных, которые помещаются в машинный регистр, при условии, что они используются либо с явными ограничениями, либо в случаях, когда порядок памяти не важен. Но не гарантируется правильная работа с будущими компиляторами.
Примеры
В большинстве случаев лучше использовать блокировку (например, pthread_mutex_t
или C++11 std::mutex
), а не атомарную операцию, но мы будем использовать последнюю, чтобы проиллюстрировать, как они будут использоваться в практической ситуации.
MyThing* gGlobalThing = NULL; // Wrong! See below. void initGlobalThing() // runs in Thread 1 { MyStruct* thing = malloc(sizeof(*thing)); memset(thing, 0, sizeof(*thing)); thing->x = 5; thing->y = 10; /* initialization complete, publish */ gGlobalThing = thing; } void useGlobalThing() // runs in Thread 2 { if (gGlobalThing != NULL) { int i = gGlobalThing->x; // could be 5, 0, or uninitialized data ... } }
Идея здесь в том, что мы выделяем структуру, инициализируем ее поля и в самом конце «публикуем» ее, сохраняя в глобальной переменной. В этот момент любой другой поток сможет его увидеть, но это нормально, поскольку он полностью инициализирован, верно?
Проблема в том, что сохранение в gGlobalThing
можно было наблюдать до инициализации полей, обычно потому, что либо компилятор, либо процессор переупорядочили хранилища в gGlobalThing
thing->x
. Другой поток, читающий thing->x
может увидеть 5, 0 или даже неинициализированные данные.
Основная проблема здесь — гонка данных на gGlobalThing
. Если поток 1 вызывает initGlobalThing()
а поток 2 вызывает useGlobalThing()
, gGlobalThing
можно читать во время записи.
Это можно исправить, объявив gGlobalThing
атомарным. В С++11:
atomic<MyThing*> gGlobalThing(NULL);
Это гарантирует, что записи станут видимыми для других потоков в правильном порядке. Это также гарантирует предотвращение некоторых других режимов сбоя, которые в противном случае разрешены, но вряд ли возникнут на реальном оборудовании Android. Например, это гарантирует, что мы не сможем увидеть указатель gGlobalThing
, который был записан лишь частично.
Чего не следует делать на Java
Мы не обсуждали некоторые важные функции языка Java, поэтому сначала кратко рассмотрим их.
Технически Java не требует, чтобы код был свободен от гонок данных. И есть небольшой объем очень тщательно написанного Java-кода, который корректно работает при наличии гонок данных. Однако написание такого кода чрезвычайно сложно, и ниже мы обсудим это лишь кратко. Что еще хуже, эксперты, определившие значение такого кода, больше не верят в его правильность. (Спецификация подходит для кода без гонок данных.)
На данный момент мы будем придерживаться модели без гонок данных, для которой Java предоставляет по существу те же гарантии, что и C и C++. Опять же, язык предоставляет некоторые примитивы, которые явно ослабляют последовательную согласованность, в частности вызовы lazySet()
и weakCompareAndSet()
в java.util.concurrent.atomic
. Как и в случае с C и C++, мы пока проигнорируем их.
Ключевые слова Java «синхронизированный» и «изменчивый»
Ключевое слово «synchronized» обеспечивает встроенный в язык Java механизм блокировки. С каждым объектом связан «монитор», который можно использовать для обеспечения взаимоисключающего доступа. Если два потока попытаются «синхронизироваться» на одном и том же объекте, один из них будет ждать завершения другого.
Как мы упоминали выше, volatile T
в Java является аналогом atomic<T>
в C++11. Параллельный доступ к volatile
полям разрешен и не приводит к гонкам данных. Игнорирование lazySet()
и др. и гонок данных, задача Java VM — гарантировать, что результат по-прежнему будет последовательным.
В частности, если поток 1 записывает в volatile
поле, а поток 2 впоследствии читает из этого же поля и видит вновь записанное значение, то поток 2 также гарантированно увидит все записи, ранее сделанные потоком 1. С точки зрения эффекта памяти, запись в энергозависимую память аналогична выпуску монитора, а чтение из энергозависимой памяти аналогично получению монитора.
Есть одно заметное отличие от atomic
C++: если мы напишем volatile int x;
в Java x++
— то же самое, что x = x + 1
; он выполняет атомарную загрузку, увеличивает результат, а затем выполняет атомарное сохранение. В отличие от C++, приращение в целом не является атомарным. Вместо этого операции атомарного приращения предоставляются java.util.concurrent.atomic
.
Примеры
Вот простая, неправильная реализация монотонного счетчика: ( Теория и практика Java: Управление волатильностью ) .
class Counter { private int mValue; public int get() { return mValue; } public void incr() { mValue++; } }
Предположим, get()
и incr()
вызываются из нескольких потоков, и мы хотим быть уверены, что каждый поток видит текущий счетчик при вызове get()
. Самая очевидная проблема заключается в том, что mValue++
на самом деле представляет собой три операции:
-
reg = mValue
-
reg = reg + 1
-
mValue = reg
Если два потока выполняются в incr()
одновременно, одно из обновлений может быть потеряно. Чтобы сделать приращение атомарным, нам нужно объявить incr()
«синхронизированным».
Однако он все еще не работает, особенно в SMP. По-прежнему существует гонка данных, поскольку get()
может обращаться к mValue
одновременно с incr()
. Согласно правилам Java, вызов get()
может выглядеть переупорядоченным относительно другого кода. Например, если мы читаем два счетчика подряд, результаты могут оказаться противоречивыми, поскольку мы изменили порядок вызовов get()
либо с помощью оборудования, либо с помощью компилятора. Мы можем исправить проблему, объявив get()
синхронизированным. С этим изменением код, очевидно, правильный.
К сожалению, мы ввели возможность конфликта блокировок, который может снизить производительность. Вместо того, чтобы объявлять get()
синхронизируемым, мы могли бы объявить mValue
как «изменчивый». (Обратите внимание, что incr()
по-прежнему должен использовать synchronize
поскольку в противном случае mValue++
не является отдельной атомарной операцией.) Это также позволяет избежать всех гонок данных, поэтому сохраняется последовательная согласованность. incr()
будет несколько медленнее, так как он несет как накладные расходы на вход/выход из монитора, так и накладные расходы, связанные с энергозависимым хранилищем, но get()
будет быстрее, поэтому даже при отсутствии конкуренции это победа, если количество операций чтения значительно превышает число пишет. (См. также AtomicInteger
чтобы узнать, как полностью удалить синхронизированный блок.)
Вот еще один пример, похожий по форме на предыдущие примеры C:
class MyGoodies { public int x, y; } class MyClass { static MyGoodies sGoodies; void initGoodies() { // runs in thread 1 MyGoodies goods = new MyGoodies(); goods.x = 5; goods.y = 10; sGoodies = goods; } void useGoodies() { // runs in thread 2 if (sGoodies != null) { int i = sGoodies.x; // could be 5 or 0 .... } } }
Здесь та же проблема, что и в коде C, а именно: на sGoodies
происходит гонка данных. Таким образом, присвоение sGoodies = goods
может наблюдаться до инициализации полей в goods
. Если вы объявите sGoodies
с ключевым словом volatile
, последовательная согласованность будет восстановлена, и все будет работать как положено.
Обратите внимание, что нестабильной является только сама ссылка sGoodies
. Доступа к полям внутри него нет. Если sGoodies
является volatile
и порядок памяти сохраняется должным образом, одновременный доступ к полям становится невозможен. Оператор z = sGoodies.x
выполнит энергозависимую загрузку MyClass.sGoodies
, за которой следует энергозависимая загрузка sGoodies.x
. Если вы создадите локальную ссылку MyGoodies localGoods = sGoodies
, то последующий z = localGoods.x
не будет выполнять никаких энергозависимых нагрузок.
Более распространенной идиомой в программировании на Java является печально известная «блокировка с двойной проверкой»:
class MyClass { private Helper helper = null; public Helper getHelper() { if (helper == null) { synchronized (this) { if (helper == null) { helper = new Helper(); } } } return helper; } }
Идея состоит в том, что мы хотим иметь один экземпляр объекта Helper
, связанный с экземпляром MyClass
. Нам нужно создать его только один раз, поэтому мы создаем и возвращаем его через специальную функцию getHelper()
. Чтобы избежать гонки, в которой два потока создают экземпляр, нам необходимо синхронизировать создание объекта. Однако мы не хотим платить накладные расходы за «синхронизированный» блок при каждом вызове, поэтому мы выполняем эту часть только в том случае, если helper
в данный момент имеет значение null.
Это гонка данных во helper
поле. Его можно установить одновременно с helper == null
в другом потоке.
Чтобы увидеть, почему это может потерпеть неудачу, рассмотрим тот же код, слегка переписанный, как если бы он был скомпилирован в C-подобный язык (я добавил пару целочисленных полей для представления активности конструктора Helper's
):
if (helper == null) { synchronized() { if (helper == null) { newHelper = malloc(sizeof(Helper)); newHelper->x = 5; newHelper->y = 10; helper = newHelper; } } return helper; }
Ничто не мешает ни аппаратному обеспечению, ни компилятору переупорядочить хранилище на helper
с теми, что указаны в полях x
/ y
. Другой поток может найти helper
, не равный нулю, но его поля еще не установлены и не готовы к использованию. Дополнительные сведения и дополнительные сведения о режимах сбоя см. в ссылке «Объявление двойной проверки блокировки нарушено» в приложении или в пункте 71 («Разумно используйте ленивую инициализацию») в книге Джоша Блоха «Эффективная Java», 2-е издание. .
Есть два способа это исправить:
- Сделайте простую вещь и удалите внешнюю проверку. Это гарантирует, что мы никогда не проверим значение
helper
за пределами синхронизированного блока. - Объявите
helper
изменчивым. С этим небольшим изменением код в примере J-3 будет корректно работать на Java 1.5 и более поздних версиях. (Возможно, вам понадобится минутка, чтобы убедить себя, что это правда.)
Вот еще одна иллюстрация volatile
поведения:
class MyClass { int data1, data2; volatile int vol1, vol2; void setValues() { // runs in Thread 1 data1 = 1; vol1 = 2; data2 = 3; } void useValues() { // runs in Thread 2 if (vol1 == 2) { int l1 = data1; // okay int l2 = data2; // wrong } } }
Глядя на useValues()
, если поток 2 еще не обнаружил обновление до vol1
, он не может знать, установлены ли data1
или data2
. Как только он увидит обновление vol1
, он узнает, что к data1
можно безопасно получить доступ и правильно прочитать, не вызывая гонки данных. Однако он не может делать никаких предположений относительно data2
, поскольку это сохранение было выполнено после энергозависимого сохранения.
Обратите внимание, что volatile
нельзя использовать для предотвращения переупорядочения других обращений к памяти, которые соперничают друг с другом. Не гарантируется создание инструкции по ограничению машинной памяти. Его можно использовать для предотвращения гонок за данными, выполняя код только тогда, когда другой поток удовлетворяет определенному условию.
Что делать
В C/C++ отдавайте предпочтение классам синхронизации C++11, например std::mutex
. Если нет, используйте соответствующие операции pthread
. К ним относятся правильные ограничения памяти, обеспечивающие правильное (последовательно согласованное, если не указано иное) и эффективное поведение на всех версиях платформы Android. Обязательно используйте их правильно. Например, помните, что ожидание условной переменной может ложно вернуться без сигнала и поэтому должно появляться в цикле.
Лучше избегать прямого использования атомарных функций, если только реализуемая вами структура данных не является чрезвычайно простой, например, счетчиком. Для блокировки и разблокировки мьютекса pthread требуется одна атомарная операция, и часто она стоит меньше, чем один промах в кэше, если нет конкуренции, поэтому вы не собираетесь много экономить, заменяя вызовы мьютекса атомарными операциями. Проекты без блокировок для нетривиальных структур данных требуют гораздо большей осторожности, чтобы гарантировать, что операции более высокого уровня над структурой данных выглядят атомарными (в целом, а не только их явно атомарными частями).
Если вы используете атомарные операции, смягчение порядка с помощью memory_order
... или lazySet()
может обеспечить преимущества в производительности, но требует более глубокого понимания, чем мы до сих пор говорили. Большая часть существующего кода, использующего их, обнаруживается постфактум. По возможности избегайте этого. Если ваши варианты использования не совсем соответствуют ни одному из описанных в следующем разделе, убедитесь, что вы либо являетесь экспертом, либо консультировались с ним.
Избегайте использования volatile
для взаимодействия потоков в C/C++.
В Java проблемы параллелизма часто лучше всего решать с помощью соответствующего служебного класса из пакета java.util.concurrent
. Код хорошо написан и хорошо протестирован на SMP.
Возможно, самое безопасное, что вы можете сделать, — это сделать ваши объекты неизменяемыми. Объекты из таких классов, как Java String и Integer, содержат данные, которые нельзя изменить после создания объекта, что позволяет избежать любой возможности возникновения гонок данных на этих объектах. Книга «Эффективная Java», 2-е изд. есть конкретные инструкции в «Пункте 15: Минимизация изменчивости». Обратите особое внимание на важность объявления полей Java «окончательными» ( Блох ) .
Даже если объект является неизменяемым, помните, что передача его другому потоку без какой-либо синхронизации — это гонка данных. Иногда это может быть приемлемо в Java (см. ниже), но требует большой осторожности и может привести к хрупкому коду. Если это не очень критично для производительности, добавьте volatile
объявление. В C++ передача указателя или ссылки на неизменяемый объект без надлежащей синхронизации, как и любая гонка данных, является ошибкой. В этом случае вполне вероятно, что это приведет к периодическим сбоям, поскольку, например, принимающий поток может увидеть неинициализированный указатель таблицы методов из-за переупорядочения хранилища.
Если ни существующий библиотечный класс, ни неизменяемый класс не подходят, для защиты доступа к любому полю, к которому может получить доступ более чем один поток, следует использовать оператор synchronized
Java или C++ lock_guard
/ unique_lock
Если мьютексы не подходят для вашей ситуации, вам следует объявить общие поля volatile
или atomic
, но вы должны очень внимательно понимать взаимодействие между потоками. Эти объявления не спасут вас от распространенных ошибок параллельного программирования, но помогут избежать загадочных сбоев, связанных с оптимизацией компиляторов и сбоями SMP.
Вам следует избегать «публикации» ссылки на объект, то есть делать ее доступной для других потоков, в его конструкторе. Это менее критично в C++ или если вы придерживаетесь нашего совета «нет гонок за данными» в Java. Но это всегда хороший совет, и он становится критически важным, если ваш Java-код запускается в других контекстах, в которых важна модель безопасности Java, а ненадежный код может вызвать гонку данных из-за доступа к этой «утечке» ссылки на объект. Это также важно, если вы решите игнорировать наши предупреждения и использовать некоторые методы, описанные в следующем разделе. Подробности см. в разделе « Техники безопасного строительства на Java ».
Еще немного о слабых порядках памяти
C++11 и более поздние версии предоставляют явные механизмы для ослабления гарантий последовательной согласованности для программ без гонок за данными. Явные аргументы memory_order_relaxed
, memory_order_acquire
(только загрузка) и memory_order_release
(только сохранение) для атомарных операций предоставляют строго более слабые гарантии, чем по умолчанию, обычно неявный, memory_order_seq_cst
. memory_order_acq_rel
предоставляет гарантии memory_order_acquire
и memory_order_release
для атомарных операций чтения-изменения записи. memory_order_consume
еще недостаточно четко определен или реализован, чтобы быть полезным, и на данный момент его следует игнорировать.
Методы lazySet
в Java.util.concurrent.atomic
аналогичны хранилищам memory_order_release
в C++. Обычные переменные Java иногда используются в качестве замены доступа к memory_order_relaxed
, хотя на самом деле они еще слабее. В отличие от C++, здесь не существует реального механизма неупорядоченного доступа к переменным, объявленным как volatile
.
Обычно вам следует избегать их, если только нет серьезных причин для их использования с точки зрения производительности. В слабоупорядоченных машинных архитектурах, таких как ARM, их использование обычно позволяет сэкономить порядка нескольких десятков машинных циклов для каждой атомарной операции. На x86 победа производительности ограничена магазинами и, вероятно, будет менее заметной. Несколько контр-гневно преимущество может уменьшаться с большим количеством ядра, так как система памяти становится более ограничивающим фактором.
Полная семантика слабо упорядоченной атомики сложна. В общем, они требуют точного понимания языковых правил, в которые мы не будем вдаваться здесь. Например:
- Компилятор или аппаратное обеспечение могут перемещать доступ
memory_order_relaxed
в (но не из) критического раздела, ограниченной сбором и выпуском блокировки. Это означает, что два хранилищаmemory_order_relaxed
могут стать видимыми вне порядка, даже если они разделены критическим разделом. - Обычная переменная Java, когда она злоупотребляет в качестве общего счетчика, может показаться, что другой поток уменьшается, даже если она увеличивается только одним другим потоком. Но это не так для C ++ Atomic
memory_order_relaxed
.
С учетом этого в качестве предупреждения мы даем небольшое количество идиомов, которые, кажется, охватывают многие варианты использования для слабо упорядоченной атомики. Многие из них применимы только к C ++.
Без доход
Довольно распространено, что переменная является атомной, потому что ее иногда читают одновременно с помощью записи, но не у всех доступа есть эта проблема. Например, переменная может быть атомной, поскольку она читается вне критического раздела, но все обновления защищены блокировкой. В этом случае чтение, которое, как оказалось, защищено одним и тем же замком, не может быть гонкой, поскольку не может быть одновременных записей. В таком случае, нестабильный доступ (загрузка в данном случае), может быть аннотирован с помощью memory_order_relaxed
, не изменяя правильность кода C ++. Реализация блокировки уже обеспечивает необходимое упорядочение памяти в отношении доступа к другим потокам, а memory_order_relaxed
указывает, что по сути, никакие дополнительные ограничения на заказ не должны быть обеспечены для атомного доступа.
В Java нет реального аналога этого.
Результат не полагается на правильность
Когда мы используем гоночную нагрузку только для генерации подсказки, обычно также можно не обеспечить применение памяти для нагрузки. Если значение не является надежным, мы также не можем надежно использовать результат, чтобы сделать вывод о других переменных. Таким образом, это нормально, если упорядочение памяти не гарантируется, а нагрузка поставляется с аргументом memory_order_relaxed
.
Общим примером этого является использование C ++ compare_exchange
для атомной замены x
на f(x)
. Начальная нагрузка x
для вычисления f(x)
не должна быть надежной. Если мы поймем это неправильно, compare_exchange
потерпит неудачу, и мы повторим. Для начальной нагрузки x
можно использовать аргумент memory_order_relaxed
; Только упорядочение памяти для фактического compare_exchange
имеет значение.
Атомно модифицированные, но не прочитанные данные
Иногда данные изменяются параллельно несколькими потоками, но не исследуются до тех пор, пока параллельные вычисления не будут завершены. Хорошим примером этого является счетчик, который атомно увеличивается (например, с использованием fetch_add()
в c ++ или atomic_fetch_add_explicit()
в c) несколькими потоками параллельно, но результат этих вызовов всегда игнорируется. Полученное значение читается только в конце, после того как все обновления завершены.
В этом случае невозможно определить, были ли доступ к этим данным переупорядочен, и, следовательно, код C ++ может использовать аргумент memory_order_relaxed
.
Простые счетчики событий являются распространенным примером этого. Поскольку это так часто, стоит сделать некоторые наблюдения по этому делу:
- Использование
memory_order_relaxed
повышает производительность, но может не решать наиболее важную проблему производительности: каждое обновление требует исключительного доступа к линии кэша, удерживающего счетчик. Это приводит к тому, что кеш пропускает каждый раз, когда новый поток обращается к счетчику. Если обновления часты и чередуются между потоками, гораздо быстрее избегать обновления общего счетчика каждый раз, например, с использованием потоковых столовых счетчиков и суммирования их в конце. - Этот метод объединяется с предыдущим разделом: можно одновременно прочитать приблизительные и ненадежные значения, пока они обновляются, со всеми операциями с использованием
memory_order_relaxed
. Но важно рассматривать полученные значения как совершенно ненадежные. То, что количество, по -видимому, было увеличено один раз, не означает, что другой поток может быть рассчитан на то, что приращение было выполнено. Вместо этого приращение может быть переупорядочено с более ранним кодом. (Что касается аналогичного случая, который мы упоминали ранее, C ++ гарантирует, что вторая нагрузка такого счетчика не вернет значение меньше, чем более ранняя нагрузка в том же потоке. Если, конечно, счетчик не переполнен.) - Обычно можно найти код, который пытается вычислить приблизительные значения счетчика, выполняя индивидуальные атомные (или нет) чтения и записи, но не делая приращение в целом атомной. Обычный аргумент заключается в том, что это «достаточно близко» для счетчиков производительности или тому подобного. Обычно это нет. Когда обновления достаточно часты (случай, о котором вы, вероятно, заботитесь), большая часть количества обычно теряется. На четырехъядерном устройстве более половины подсчетов обычно могут быть потеряны. (Легкое упражнение: постройте сценарий с двумя потоками, в котором счетчик обновляется миллион раз, но конечная стоимость счетчика - один.)
Простая связь флага
Хранилище memory_order_release
(или операция с чтением-модификацией-записью) гарантирует, что, если впоследствии нагрузка memory_order_acquire
(или операция по модификации чтения) считывает письменное значение, он также будет соблюдать любые хранилища (обычные или атомные), которые предшествуют memory_order_release
магазин. И наоборот, любые нагрузки, предшествующие memory_order_release
не будут соблюдать какие -либо хранилища, которые следовали за нагрузкой memory_order_acquire
. В отличие от memory_order_relaxed
, это позволяет использовать такие атомные операции для передачи прогресса одного потока в другую.
Например, мы можем переписать пример блокировки с двойной проверкой сверху в C ++ как
class MyClass { private: atomic<Helper*> helper {nullptr}; mutex mtx; public: Helper* getHelper() { Helper* myHelper = helper.load(memory_order_acquire); if (myHelper == nullptr) { lock_guard<mutex> lg(mtx); myHelper = helper.load(memory_order_relaxed); if (myHelper == nullptr) { myHelper = new Helper(); helper.store(myHelper, memory_order_release); } } return myHelper; } };
Магазин нагрузки и выпуска приобретенных гарантирует, что если мы увидим не ненулевого helper
, мы также увидим его поля правильно инициализированные. Мы также включили предварительное наблюдение, что нагрузки на нераскопию могут использовать memory_order_relaxed
.
Программист Java может представлять helper
как java.util.concurrent.atomic.AtomicReference<Helper>
и использовать lazySet()
в качестве магазина выпуска. Операции нагрузки будут продолжать использовать простые вызовы get()
.
В обоих случаях наша производительность сконцентрировалась на пути инициализации, что вряд ли будет иметь критическую производительность. Более читаемый компромисс может быть:
Helper* getHelper() { Helper* myHelper = helper.load(memory_order_acquire); if (myHelper != nullptr) { return myHelper; } lock_guard<mutex> lg(mtx); if (helper == nullptr) { helper = new Helper(); } return helper; }
Это обеспечивает тот же быстрый путь, но прибегает к по умолчанию, последовательно согласованным операциям на медленном пути, критически важном.
Даже здесь, helper.load(memory_order_acquire)
вероятно, будет генерировать тот же код на текущих архитектурах, поддерживаемых Android, как и простая (последовательно согласованная) ссылка на helper
. Действительно наиболее полезной оптимизацией здесь может быть введение myHelper
для устранения второй нагрузки, хотя будущий компилятор может сделать это автоматически.
Приобретение/выпуск заказа не предотвращает заметно задержку магазинов и не гарантирует, что магазины становятся видимыми для других потоков в последовательном порядке. В результате он не поддерживает сложный, но довольно распространенный шаблон кодирования, иллюстрированный алгоритмом взаимного исключения Деккера: все потоки сначала устанавливают флаг, указывающий, что они хотят что -то сделать; Если поток T , то замечает, что ни один другой поток не пытается что -то сделать, он может безопасно продолжить, зная, что помех не будет. Никакая другая поток не сможет продолжить, так как флаг T все еще установлен. Это терпит неудачу, если к флагу доступ к заказу приобретения/выпуска, поскольку это не мешает сделать флаг потока видимым для других поздно, после того как они ошибочно продолжили. По умолчанию memory_order_seq_cst
действительно это предотвращает.
Неизменные поля
Если поле объекта инициализируется при первом использовании, а затем никогда не изменяется, может быть возможно инициализировать и впоследствии прочитать его с использованием слабо упорядоченных доступа. В C ++ он может быть объявлен atomic
и доступен с использованием memory_order_relaxed
или в Java, его можно было объявить без volatile
и доступ без особых мер. Это требует, чтобы все следующее удержание:
- Должно быть возможно по значению самого поля, независимо от того, уже инициализировано оно. Чтобы получить доступ к полю, значение Fast Path Test и возврат должно прочитать поле только один раз. На Java последнее необходимо. Даже если полевые испытания в качестве инициализированного, вторая нагрузка может считать более раннее ненициализированное значение. В C ++ правило «читать один раз» является просто хорошей практикой.
- Как инициализация, так и последующие нагрузки должны быть атомными, в этих частичных обновлениях не должно быть видно. Для Java поле не должно быть
long
илиdouble
. Для C ++ требуется атомное назначение; Создание его на месте не будет работать, поскольку строительствоatomic
не является атомным. - Повторные инициализации должны быть безопасными, поскольку несколько потоков могут прочитать ненициализированное значение одновременно. В C ++ это обычно следует из «тривиально копируемого» требования, наложенного для всех атомных типов; Типы с вложенными указателями потребуют сделки в конструкторе копирования и не будут тривиально копируемыми. Для Java определенные типы ссылок приемлемы:
- Ссылки на Java ограничены неизменными типами, содержащими только окончательные поля. Конструктор неизменного типа не должен публиковать ссылку на объект. В этом случае правила окончательного поля Java гарантируют, что если читатель увидит ссылку, он также увидит инициализированные окончательные поля. C ++ не имеет аналогов с этими правилами, и по этой причине также недопустимо (в дополнение к нарушению «тривиально копируемых» требований).
Заключительные заметки
Хотя этот документ делает больше, чем просто царапает поверхность, он не управляет больше, чем неглубоким выпадением. Это очень широкая и глубокая тема. Некоторые области для дальнейшего исследования:
- Фактические модели памяти Java и C ++ выражаются с точки зрения отношения, прежде всего , которое указывает, когда два действия гарантированно произойдут в определенном порядке. Когда мы определили гонку данных, мы неофициально говорили о двух доступах к памяти, которые происходят «одновременно». Официально это определяется как ни один из которых не происходит до другого. Поучительно изучить фактические определения происхождения, и прежде всего и синхронизации с моделью памяти Java или C ++. Хотя интуитивное понятие «одновременно», как правило, достаточно хорошо, эти определения поучительны, особенно если вы обдумываете использование слабо упорядоченных атомных операций в C ++. (Текущая спецификация Java только определяет
lazySet()
очень неформально.) - Исследуйте, какие компиляторы и не разрешаются делать при переупорядочении кода. (Спецификация JSR-133 имеет несколько отличных примеров юридических преобразований, которые приводят к неожиданным результатам.)
- Узнайте, как написать неизменные классы в Java и C ++. (Это нечто большее, чем просто «не менять ничего после строительства».)
- Положите рекомендации в разделе параллелизма эффективной Java, 2 -е издание. (Например, вам следует избегать вызова методов, которые должны быть переопределены в синхронизированном блоке.)
- Прочитайте через
java.util.concurrent
иjava.util.concurrent.atomic
API, чтобы увидеть, что доступно. Рассмотрите возможность использования параллельных аннотаций, таких как@ThreadSafe
и@GuardedBy
(от net.jcip.annotations).
В разделе дальнейшего чтения в Приложении есть ссылки на документы и веб -сайты, которые будут лучше освещать эти темы.
Приложение
Реализация магазинов синхронизации
(Это не то, что большинство программистов окажутся в реализации, но обсуждение освещает.)
Для небольших встроенных типов, таких как int
, и аппаратное обеспечение, поддерживаемое Android, обычные инструкции нагрузки и хранилища гарантируют, что магазин будет видимым либо полностью, либо вообще не для другого процессора, загружающего одно и то же место. Таким образом, некоторое основное представление об «атомности» предоставляется бесплатно.
Как мы видели раньше, этого недостаточно. Чтобы обеспечить последовательную согласованность, мы также необходимы для предотвращения повторного порядка операций и обеспечения того, чтобы операции памяти стали видимыми для других процессов в последовательном порядке. Оказывается, что последнее автоматическое на оборудовании, поддерживаемом андроидом, при условии, что мы делаем разумный выбор для обеспечения соблюдения первого, поэтому мы в значительной степени игнорируем его здесь.
Порядок операций памяти сохраняется как путем предотвращения переупорядочения компилятором, так и предотвращения переупорядочения аппаратным обеспечением. Здесь мы сосредоточены на последнем.
Заказ памяти на ARMV7, x86 и MIPS применяется с инструкциями «забора», которые примерно предотвращают инструкции после того, как становятся видимыми, прежде чем инструкции, предшествующие забору. (Они также обычно называют инструкциями «барьер», но рискуют путаницей с барьерами в стиле pthread_barrier
, которые делают гораздо больше, чем это.) Точное значение инструкций по ограждению -довольно сложная тема, которая должна решить, как гарантия предоставляется По нескольким различным видам заборов взаимодействуют, и то, как они объединяются с другими гарантиями упорядочения, обычно предоставляемыми аппаратным обеспечением. Это обзор высокого уровня, поэтому мы будем скрывать эти детали.
Самым основным видом гарантии упорядочения является то, что предоставлена C ++ memory_order_acquire
и memory_order_release
Atomic Operations: операции памяти, предшествующие хранилищам выпуска, должны быть видны после нагрузки приобретения. На ARMV7 это применяется:
- Предшествует инструкции магазина с подходящей инструкцией забора. Это предотвращает переупорядочение всех предшествующих доступа к памяти с помощью инструкции по магазинам. (Это также излишне предотвращает переупорядочение с помощью более поздних инструкций.)
- Следуя инструкции по загрузке с подходящей инструкцией по ограждению, предотвращение переупорядочивания нагрузки с помощью последующих доступа. (И еще раз предоставление ненужного заказа по меньшей мере с более ранними нагрузками.)
Вместе этого достаточно для C ++ приобрести/выпустить заказ. Они необходимы, но недостаточно, для явов volatile
или C ++ последовательно последовательно atomic
.
Чтобы увидеть, что еще нам нужно, рассмотрим фрагмент алгоритма Деккера, который мы кратко упоминали ранее. flag1
и flag2
являются atomic
или volatile
переменными Java, оба изначально ложными.
Нить 1 | Нить 2 |
---|---|
flag1 = true | flag2 = true |
Последовательная согласованность подразумевает, что одно из назначений на flag
N должно быть выполнено первым, и быть замеченным тестом в другом потоке. Таким образом, мы никогда не увидим эти потоки, выполняющие «критическую штангу» одновременно.
Но ограждение, необходимое для заказа приобретения, добавляет только заборы в начале и конце каждого потока, что здесь не помогает. Кроме того, нам нужно убедиться, что если за сохранением volatile
/ atomic
хранилища соблюдается volatile
/ atomic
нагрузка, они не переупорядочены. Обычно это применяется, добавляя забор не только перед последовательно согласованным магазином, но и после него. (Это снова намного сильнее, чем требуется, так как этот забор обычно заказывает все более ранние добычи памяти по отношению ко всем более поздним.)
Вместо этого мы могли бы связать дополнительный забор с последовательно последовательными нагрузками. Поскольку магазины встречаются реже, конвенция, которую мы описали, более распространена и используется на Android.
Как мы видели в более раннем разделе, нам нужно вставить барьер магазина/загрузки между двумя операциями. Код, выполненный в виртуальной машине для нестабильного доступа, будет выглядеть примерно так:
нестабильная нагрузка | нестабильный магазин |
---|---|
reg = A | fence for "release" (2) |
Реальные машины архитектуры обычно предоставляют несколько типов заборов, которые заказывают различные типы доступа и могут иметь разные затраты. Выбор между ними тонкий и зависит от необходимости обеспечения того, чтобы магазины были видимыми для других ядер в последовательном порядке, и что упорядочение памяти, налагаемое комбинацией нескольких заборов, правильно сопоставляется. Для получения более подробной информации, пожалуйста, смотрите на странице Кембриджского университета с собранными сопоставлениями атомики с реальными процессорами .
На некоторых архитектурах, в частности x86, барьеры «приобретать» и «выпуск» ненужны, поскольку оборудование всегда неявно обеспечивает достаточное упорядочение. Таким образом, на x86 только последний забор (3) действительно генерируется. Точно так же на x86 операции атомного чтения-модификации-записи неявно включают сильный забор. Таким образом, они никогда не требуют никаких заборов. На ARMV7 все заборы, которые мы обсуждали выше, требуются.
ARMV8 предоставляет инструкции LDAR и STLR, которые непосредственно обеспечивают требования Java Elatile или C ++ последовательно согласованные нагрузки и хранилища. Они избегают ненужных ограничений переупорядочения, которые мы упомянули выше. 64-битный код Android на ARM использует их; Мы решили сосредоточиться на размещении забора ARMV7, потому что оно проливает больше света на фактические требования.
Дальнейшее чтение
Веб -страницы и документы, которые обеспечивают большую глубину или ширину. Более общепринятые статьи ближе к вершине списка.
- Модели согласованности общей памяти: учебник
- Написанная в 1995 году Adve & Gharachorloo, это хорошее место для начала, если вы хотите глубже погрузиться в модели последовательности памяти.
http://www.hpl.hp.com/techreports/compaq-dec/wrl-95-7.pdf - Барьеры памяти
- Хорошая маленькая статья, обобщающая проблемы.
https://en.wikipedia.org/wiki/memory_barrier - Основы тем
- Введение в многопоточное программирование в C ++ и Java, Hans Boehm. Обсуждение раст данных и основных методов синхронизации.
http://www.hboehm.info/c+mmmm/threadsintro.html - Параллелизм Java на практике
- Эта книга, опубликованная в 2006 году, охватывает широкий спектр темы. Настоятельно рекомендуется для любого, кто пишет многопоточный код в Java.
http://www.javaconcurrencyinpractice.com - JSR-133 (модель памяти Java) FAQ
- Нежелательное введение в модель памяти Java, включая объяснение синхронизации, летучих переменных и построения конечных полей. (Немного устаревшего, особенно когда он обсуждает другие языки.)
http://www.cs.umd.edu/~pugh/java/memorymodel/jsr-133-faq.html - Достоверность преобразования программы в модели памяти Java
- Довольно техническое объяснение оставшихся проблем с моделью памяти Java. Эти проблемы не распространяются на программы без данных.
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.112.1790&rep=rep1&type=pdf - Обзор пакета java.util.concurrent
- Документация для пакета
java.util.concurrent
. В нижней части страницы находится раздел под названием «Свойства согласованности памяти», который объясняет гарантии, сделанные различными классами.
java.util.concurrent
пакет Сводка - Теория и практика Java: безопасные методы строительства в Java
- В этой статье подробно рассматриваются опасности ссылок, выходящих во время построения объектов, и предоставляют руководящие принципы для защищенных потоков конструкторов.
http://www.ibm.com/developerworks/java/library/j-jtp0618.html - Теория и практика Java: управление волатильностью
- Хорошая статья, описывающая то, что вы можете и не можете достичь с помощью нестабильных полей в Java.
http://www.ibm.com/developerworks/java/library/j-jtp06197.html - Декларация «двойная проверка сломана».
- Детальное объяснение Билла Пью о различных способах, которыми блокировка двойной проверки нарушена без
volatile
илиatomic
. Включает C/C ++ и Java.
http://www.cs.umd.edu/~pugh/java/memorymodel/doublecheckedlocking.html - [ARM] Barrier Litmus тесты и кулинарная книга
- Обсуждение проблем SMP ARM, освещенное короткими фрагментами кода ARM. Если вы нашли примеры на этой странице слишком не специфичные или хотите прочитать формальное описание инструкции DMB, прочитайте это. Также описывается инструкции, используемые для барьеров памяти на исполняемом коде (возможно, полезно, если вы генерируете код на лету). Обратите внимание, что это предшествует ARMV8, который также поддерживает дополнительные инструкции по упорядочению памяти и перемещается в несколько более сильную модель памяти. (См. Руководство по справочнику архитектуры ARM® ARMV8, для деталей ARMV8-A Profile ».)
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/barrier_litmus_tests_and_cookbook_a08.pdf - Барьеры памяти ядра Linux
- Документация для барьеров памяти Linux. Включает несколько полезных примеров и ASCII Art.
http://www.kernel.org/doc/documentation/memory-barriers.txt - ISO/IEC JTC1 SC22 WG21 (стандарты C ++) 14882 (язык программирования C ++), раздел 1.10 и пункт 29 («Библиотека атомных операций»)
- Проект стандарта для атомных функций C ++. Эта версия близка к стандарту C ++ 14, который включает в себя незначительные изменения в этой области от C ++ 11.
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4527.pdf
(Intro: http://www.hpl.hp.com/techreports/2008/hpl-2008-56.pdf ) - ISO/IEC JTC1 SC22 WG14 (C Стандарты C) 9899 (C C программирование) Глава 7.16 («Atomics <stdatomic.h>»)
- Проект стандарта для ISO/IEC 9899-201x C Атомные функции работы. Для получения подробной информации, также проверьте более поздние отчеты о дефектах.
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf - C/C ++ 11 отображения с процессорами (Кембриджский университет)
- Коллекция «Ярослав Севсик» и Peter Sewell «Атомика C ++ на различные общие наборы инструкций процессора».
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html - Алгоритм Деккера
- «Первое известное правильное решение проблемы взаимного исключения при одновременном программировании». В статье в Википедии есть полный алгоритм с дискуссией о том, как ее нужно будет обновлять для работы с современными оптимизирующими компиляторами и оборудованием SMP.
https://en.wikipedia.org/wiki/dekker's_algorithm - Комментарии к ARM против Alpha и Address Adyerinders
- Электронная почта в списке рассылки Arm-Kernel от Catalin Marinas. Включает в себя хорошее резюме адресов адреса и контроля.
http://linux.derkeiler.com/mailing-lists/kernel/2009-05/msg11811.html - Что каждый программист должен знать о памяти
- Очень длинная и подробная статья о различных типах памяти, особенно кэшам процессора, Ульриха Дреппера.
http://www.akkadia.org/drepper/cpumemory.pdf - Рассуждения об руке слабо последовательной модели памяти
- Эта статья была написана Chong & Ishtiaq of Arm, Ltd. Он пытается описать модель памяти ARM SMP строго, но доступным образом. Определение «наблюдения», используемое здесь, происходит из этой статьи. Опять же, это предшествует ARMV8.
http://portal.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll=&dl=&cfid=96099715&cftoken=57505711 - Кулинарная книга JSR-133 для писателей компиляторов
- Даг Ли написал это как компаньон в документации JSR-133 (модель памяти Java). Он содержит первоначальный набор руководящих принципов реализации для модели памяти Java, которая использовалась многими авторами компиляторов, и все еще широко цитируется и, вероятно, дает представление. К сожалению, обсуждаемые здесь четыре сорта забора не являются хорошим совпадением для архитектур, поддерживаемых андроидом, а вышеупомянутые сопоставления C ++ 11 теперь являются лучшим источником точных рецептов, даже для Java.
http://g.oswego.edu/dl/jmm/cookbook.html - x86-tso: строгая и полезная модель программиста для мультипроцессоров x86
- Точное описание модели памяти x86. Точные описания модели памяти руки, к сожалению, значительно более сложны.
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf