Платформы Android 3.0 и более поздних версий оптимизированы для поддержки многопроцессорных архитектур. В этом документе рассматриваются проблемы, которые могут возникнуть при написании многопоточного кода для симметричных многопроцессорных систем на языках программирования C, C++ и Java (далее для краткости именуемом просто «Java»). Это руководство предназначено для разработчиков приложений Android и не является исчерпывающим.
Введение
SMP – это аббревиатура от Symmetric Multi-Processor (симметричный мультипроцессор). Она описывает архитектуру, в которой два или более идентичных ядра ЦП имеют общий доступ к основной памяти. До недавнего времени все устройства Android были однопроцессорными.
Большинство устройств 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 выполняются на разных ядрах ЦП. Вы должны всегда делать это предположение, когда думаете о многопоточном коде.
Последовательная согласованность гарантирует, что после завершения выполнения обоих потоков регистры будут находиться в одном из следующих состояний:
| Регистрация | Штаты |
|---|---|
| reg0=5, reg1=3 | возможен (сначала выполнился поток 1) |
| reg0=0, reg1=0 | Возможно (поток 2 запущен первым) |
| reg0=0, reg1=3 | Возможно (параллельное выполнение) |
| reg0=5, reg1=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-системы, как правило, просто повышают вероятность того, что вы увидите проблемы, которые существовали всегда.
Программирование без гонок данных
К счастью, обычно есть простой способ избежать размышлений о любой из этих деталей. Если вы будете следовать нескольким простым правилам, то сможете забыть все, что было сказано в предыдущем разделе, кроме части о последовательной согласованности. К сожалению, другие сложности могут стать заметными, если вы случайно нарушите эти правила.
Современные языки программирования поощряют стиль программирования, известный как "свободный от гонки данных". Если вы пообещаете не вводить "гонки данных" и избегать нескольких конструкций, которые говорят компилятору об обратном, компилятор и аппаратное обеспечение обещают предоставлять последовательно согласованные результаты. Это не означает, что они избегают переупорядочивания доступа к памяти. Это означает, что если вы будете следовать правилам, то не сможете сказать, что обращения к памяти переупорядочиваются. Это как сказать, что колбаса – вкусная и аппетитная еда, если вы пообещаете не посещать колбасный завод. Гонки данных раскрывают неприглядную правду о переупорядочивании памяти.
Что такое "состояние гонки"?
Состояние гонки возникает, когда как минимум два потока одновременно обращаются к одним и тем же обычным данным и хотя бы один из них изменяет их. Под "обычными данными" мы подразумеваем то, что не является объектом синхронизации, предназначенным для взаимодействия потоков. Мьютексы, условные переменные, переменные volatile в Java или атомарные объекты в C++ не являются обычными данными, и их доступ может конкурировать. На самом деле они используются для предотвращения гонок данных на других объектах.
Чтобы определить, обращаются ли два потока одновременно к одной и той же ячейке памяти, мы можем проигнорировать обсуждение изменения порядка памяти выше и предположить последовательную согласованность. В следующей программе нет состояния гонки, если A и B – обычные логические переменные, которые изначально имеют значение false:
| Цепочка 1 | Цепочка 2 |
|---|---|
if (A) B = true |
if (B) A = true |
Поскольку операции не переупорядочиваются, оба условия будут ложными, и ни одна из переменных не будет обновлена. Таким образом, состояние гонки невозможно. Не нужно думать о том, что может произойти, если загрузка из A и сохранение в B в потоке 1 будут каким-то образом переупорядочены. Компилятору не разрешается изменять порядок потока 1, переписывая его как "B = true; if (!A) B = false". Это было бы похоже на приготовление колбасы посреди города средь бела дня.
Состояние гонки официально определено для базовых встроенных типов, таких как целые числа, ссылки и указатели. Присвоение значения переменной int и одновременное чтение ее значения в другом потоке – это явное состояние гонки. Но и стандартная библиотека C++, и библиотеки Java Collections написаны так, чтобы вы могли рассуждать о гонках данных на уровне библиотеки. Они обещают не вводить состояние гонки, если нет одновременных доступов к одному и тому же контейнеру, по крайней мере один из которых обновляет его. Обновление set<T> в одном потоке с одновременным чтением в другом позволяет библиотеке вводить состояние гонки данных, и поэтому неофициально может считаться "гонкой данных на уровне библиотеки".
И наоборот, обновление одного объекта set<T> в одном потоке при чтении другого объекта в другом потоке не приводит к состоянию гонки, поскольку библиотека гарантирует, что в этом случае не возникнет состояние гонки (низкого уровня).
Обычно одновременный доступ к разным полям в структуре данных не может привести к состоянию гонки. Однако есть одно важное исключение из этого правила: непрерывные последовательности битовых полей в C или C++ рассматриваются как единая "ячейка памяти". Доступ к любому битовому полю в такой последовательности рассматривается как доступ ко всем из них для целей определения существования гонки данных. Это связано с тем, что обычное оборудование не может обновлять отдельные биты, не считывая и не перезаписывая соседние. У программистов Java нет аналогичных проблем.
Как избежать состояния гонки
Современные языки программирования предоставляют ряд механизмов синхронизации, позволяющих избежать состояния гонки. Вот основные инструменты:
- Блокировки
- Блокировки (
std::mutexилиpthread_mutex_tв C++11) или блокиsynchronizedв Java можно использовать, чтобы определенные разделы кода не выполнялись одновременно с другими разделами кода, обращающимися к тем же данным. Все подобные средства мы будем называть блокировками. Последовательное получение определенной блокировки перед доступом к общей структуре данных и ее последующее освобождение предотвращает состояние гонки при доступе к структуре данных. Это также гарантирует, что обновления и доступы являются атомарными, то есть никакие другие обновления структуры данных не могут выполняться в середине. Это заслуженно самый распространенный инструмент для предотвращения гонок данных. Использование блоковsynchronizedв Java илиlock_guardлибоunique_lockв C++ гарантирует, что блокировки будут сняты надлежащим образом в случае исключения. - Переменные volatile/atomic
- В Java есть поля
volatile, которые поддерживают параллельный доступ без возникновения состояния гонки. С 2011 года в C и C++ поддерживаются переменные и поляatomicс похожей семантикой. Обычно их сложнее использовать, чем блокировки, поскольку они обеспечивают атомарность только отдельных обращений к одной переменной. (В C++ это обычно распространяется на простые операции чтения-модификации-записи, такие как инкременты. Java требует для этого специальных вызовов методов.) В отличие от блокировок, переменныеvolatileилиatomicнельзя использовать напрямую, чтобы другие потоки не мешали выполнению более длинных последовательностей кода.
Важно отметить, что volatile имеет совершенно разные значения в C++ и Java. В C++ volatile не предотвращает состояние гонки, хотя в старом коде оно часто используется в качестве обходного пути из-за отсутствия объектов atomic. Это больше не рекомендуется. В C++ используйте atomic<T> для переменных, к которым могут одновременно обращаться несколько потоков. C++ volatile предназначен для регистров устройств и тому подобного.
Переменные 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 не считается состоянием гонки данных, поскольку доступ к volatile/atomic не является обычным доступом к памяти.
Реализация должна предотвращать или скрывать изменение порядка памяти в достаточной степени, чтобы код, подобный предыдущему лакмусовому тесту, вел себя ожидаемым образом. Обычно это делает доступ к volatile- и atomic-памяти существенно более затратным, чем обычный доступ.
Хотя в предыдущем примере нет состояния гонки, блокировки вместе с Object.wait() в Java или условными переменными в C/C++ обычно обеспечивают лучшее решение, которое не предполагает ожидания в цикле при разрядке батареи.
Когда становится доступно изменение порядка воспоминаний
Программирование без состояния гонки обычно избавляет нас от необходимости явно решать проблемы с изменением порядка доступа к памяти. Однако в некоторых случаях изменение порядка становится заметным:- Если в вашей программе есть ошибка, приводящая к непреднамеренной гонке данных, преобразования компилятора и оборудования могут стать видимыми, а поведение вашей программы может быть неожиданным. Например, если бы мы забыли объявить
flagкак volatile в предыдущем примере, Thread 2 мог бы увидеть неинициализированныйA. Или компилятор может решить, что флаг не может измениться во время цикла потока 2, и преобразовать программу в
При отладке вы можете увидеть, что цикл продолжается бесконечно, несмотря на то, чтоЦепочка 1 Цепочка 2 A = ...
flag = truereg0 = flag; while (!reg0) {}
... = Aflagистинно. - C++ предоставляет средства для явного ослабления
последовательной согласованности, даже если нет гонок. Атомарные операции могут принимать явные аргументы
memory_order_… Пакетjava.util.concurrent.atomicпредоставляет более ограниченный набор аналогичных средств, в частностиlazySet(). А программисты на Java иногда намеренно используют гонки данных для аналогичного эффекта. Все они повышают производительность, но значительно усложняют программирование. Ниже мы кратко расскажем о них. - Некоторые коды C и C++ написаны в старом стиле, не полностью соответствующем текущим стандартам языка, в котором вместо переменных
atomicиспользуются переменныеvolatile, а упорядочение памяти явно запрещено путем вставки так называемых ограждений или барьеров. Это требует явного обоснования изменения порядка доступа и понимания моделей аппаратной памяти. Стиль кодирования, подобный этому, до сих пор используется в ядре Linux. Его не следует использовать в новых приложениях для Android, и здесь он не рассматривается.
Тренировка
Отладка проблем с согласованностью памяти может быть очень сложной. Если из-за отсутствия блокировки, atomic или volatile код считывает устаревшие данные, вы не сможете понять, почему это происходит, изучая дампы памяти с помощью отладчика. К тому времени, когда вы сможете выполнить запрос отладчика, все ядра ЦП могут наблюдать полный набор обращений, а содержимое памяти и регистров ЦП будет находиться в "невозможном" состоянии.
Чего не следует делать в C
Ниже приведены примеры неправильного кода и способы исправления ошибок. Но сначала нужно обсудить использование базовой функции языка.
C/C++ и volatile
Объявления volatile в C и C++ – это инструмент для очень узких целей.
Они не позволяют компилятору изменять порядок или удалять обращения к volatile. Это может быть полезно для кода, который обращается к регистрам аппаратных устройств, памяти, сопоставленной с несколькими местоположениями, или в связи с setjmp. Но C и C++ volatile, в отличие от Java
volatile, не предназначены для взаимодействия потоков.
В C и C++ доступ к данным volatile может быть переупорядочен с доступом к энергозависимым данным, и гарантии атомарности отсутствуют. Поэтому volatile нельзя использовать для обмена данными между потоками в переносимом коде даже на однопроцессорной системе. C volatile обычно не предотвращает изменение порядка доступа аппаратным обеспечением, поэтому само по себе оно ещё менее полезно в многопоточных средах SMP. Поэтому в C11 и C++11 поддерживаются объекты atomic. Рекомендуем использовать их.
Во многих старых кодах C и C++ volatile по-прежнему используется для связи между потоками. Обычно это работает правильно для данных, которые помещаются в регистр процессора, при условии, что они используются либо с явными барьерами, либо в случаях, когда порядок памяти не важен. но не гарантирует корректную работу с будущими компиляторами.
Примеры
В большинстве случаев лучше использовать блокировку (например, pthread_mutex_t или std::mutex в C++11), а не атомарную операцию, но мы применим последнюю, чтобы проиллюстрировать, как она будет использоваться в практической ситуации.
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 как атомарную переменную. В C++11:
atomic<MyThing*> gGlobalThing(NULL);
Это гарантирует, что записи будут видны другим потокам в правильном порядке. Это также гарантирует предотвращение некоторых других режимов отказа, которые в противном случае разрешены, но вряд ли возникнут на реальном оборудовании Android. Например, это гарантирует, что мы не увидим указатель gGlobalThing, который был записан только частично.
Чего не следует делать в Java
Мы не обсуждали некоторые важные функции языка Java, поэтому сначала кратко рассмотрим их.
Технически Java не требует, чтобы код был свободен от гонки данных. И есть небольшое количество очень тщательно написанного кода Java, который правильно работает при наличии гонок данных. Однако написать такой код очень сложно, поэтому мы лишь кратко рассмотрим его ниже. Хуже того, эксперты, которые определили значение этого кода, больше не считают спецификацию правильной. (Спецификация подходит для кода без состояния гонки.)
Пока мы будем придерживаться модели без гонки данных, для которой Java предоставляет по сути те же гарантии, что и C и C++. Опять же, язык предоставляет некоторые примитивы, которые явно ослабляют последовательную согласованность, в частности вызовы lazySet() и weakCompareAndSet() в java.util.concurrent.atomic.
Как и в случае с C и C++, мы пока проигнорируем их.
Ключевые слова synchronized и volatile в Java.
Ключевое слово synchronized предоставляет встроенный механизм блокировки языка Java. У каждого объекта есть связанный с ним монитор, который можно использовать для обеспечения взаимоисключающего доступа. Если два потока попытаются синхронизироваться на одном объекте, один из них будет ждать, пока другой не завершит работу.
Как упоминалось выше, volatile T в Java является аналогом atomic<T> в C++11. Параллельный доступ к полям volatile разрешен и не приводит к конфликтам данных.
Если не учитывать lazySet() и другие подобные методы, а также состояния гонки, то задача виртуальной машины Java – обеспечить последовательную согласованность результата.
В частности, если поток 1 записывает данные в поле volatile, а поток 2 затем считывает данные из этого же поля и видит новое значение, то поток 2 также гарантированно увидит все записи, ранее сделанные потоком 1. С точки зрения эффекта памяти запись в переменную volatile аналогична освобождению монитора, а чтение из переменной volatile аналогично получению монитора.
Есть одно заметное отличие от atomic в C++:
если мы пишем volatile int x;
в Java, то x++ – это то же самое, что и x = x + 1.
Выполняется атомарная загрузка, результат увеличивается, а затем выполняется атомарное
сохранение. В отличие от C++, инкремент в целом не является атомарным.
Атомарные операции инкремента предоставляются java.util.concurrent.atomic.
Примеры
Вот простой, но неправильный пример реализации монотонного счетчика (Java theory and practice: Managing volatility).
class Counter { private int mValue; public int get() { return mValue; } public void incr() { mValue++; } }
Предположим, что get() и incr() вызываются из нескольких потоков и мы хотим, чтобы каждый поток видел текущее количество при вызове get(). Самая очевидная проблема заключается в том, что mValue++ – это на самом деле три операции:
reg = mValuereg = reg + 1mValue = reg
Если два потока выполняются в incr() одновременно, одно из обновлений может быть потеряно. Чтобы сделать инкремент атомарным, нам нужно объявить incr() синхронизированным.
Однако он по-прежнему не работает, особенно на SMP. Состояние гонки по-прежнему возможно, поскольку get() может обращаться к mValue одновременно с incr(). Согласно правилам Java, вызов get() может быть переупорядочен относительно другого кода. Например, если мы считываем два счетчика подряд, результаты могут оказаться несогласованными, поскольку вызовы get() были переупорядочены аппаратным обеспечением или компилятором. Мы можем исправить проблему, объявив get() синхронизированным. Теперь код очевидно правильный.
К сожалению, мы ввели возможность конкуренции за блокировку, что может снизить производительность. Вместо того чтобы объявлять get() синхронизированным, мы могли бы объявить mValue с помощью volatile. (Обратите внимание, что 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 не будет выполнять загрузку volatile.
Более распространенным идиоматическим выражением в программировании на 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(). Чтобы избежать состояния гонки, когда два потока создают экземпляр, нам нужно синхронизировать создание объекта. Однако мы не хотим платить за блок synchronized при каждом вызове, поэтому мы делаем это только в том случае, если 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 не имеет значение null, но его поля ещё не заданы и не готовы к использованию.
Дополнительную информацию и другие режимы сбоя можно найти по ссылке "Double Checked Locking is Broken" (Блокировка с двойной проверкой не работает) в приложении или в статье 71 ("Use lazy initialization judiciously" (Используйте отложенную инициализацию разумно)) в книге Джоша Блоха (Josh Bloch) Effective Java, 2nd Edition (Эффективная Java, 2-е издание).
Есть два способа решить эту проблему:
- Просто удалите внешнюю проверку. Это гарантирует, что мы никогда не будем проверять значение
helperза пределами синхронизированного блока. - Объявите
helperкак volatile. После этого небольшого изменения код из примера 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.
Обратите внимание, что volatile нельзя использовать для предотвращения изменения порядка других обращений к памяти, которые конкурируют друг с другом. Это не гарантирует создание инструкции барьера памяти. Его можно использовать для предотвращения состояния гонки, выполняя код только тогда, когда другой поток удовлетворяет определенному условию.
Что вам нужно сделать
В C/C++ лучше использовать классы синхронизации C++11, например std::mutex. В противном случае используйте соответствующие операции pthread.
В частности, они содержат барьеры памяти, обеспечивающие правильное (последовательно согласованное, если не указано иное) и эффективное поведение на всех версиях платформы Android. Используйте их правильно. Например, помните, что ожидание условной переменной может ложно возвращаться без сигнала и поэтому должно появляться в цикле.
Лучше избегать прямого использования атомарных функций, за исключением случаев, когда вы реализуете очень простую структуру данных, например счетчик. Блокировка и разблокировка мьютекса pthread требуют по одной атомарной операции и часто обходятся дешевле, чем один промах кэша, если нет конкуренции, поэтому вы не сэкономите много, заменив вызовы мьютекса атомарными операциями. Для нетривиальных структур данных без блокировок требуется гораздо больше внимания, чтобы операции более высокого уровня со структурой данных выглядели атомарными (в целом, а не только их явно атомарные части).
Если вы используете атомарные операции, то можете повысить производительность, ослабив упорядочение с помощью memory_order… или lazySet(). Однако для этого требуется более глубокое понимание, чем мы изложили выше.
В большом количестве существующего кода, использующего эти методы, впоследствии обнаруживаются ошибки. По возможности избегайте таких ситуаций.
Если ваш случай не подходит ни под один из описанных ниже, убедитесь, что вы являетесь экспертом или проконсультировались с ним.
Не используйте volatile для связи между потоками в C/C++.
В Java проблемы с параллелизмом часто лучше всего решать с помощью соответствующего служебного класса из пакета java.util.concurrent. Код хорошо написан и протестирован на SMP.
Возможно, самое безопасное, что вы можете сделать, — это сделать свои объекты неизменяемыми. Объекты из классов, таких как String и Integer в Java, содержат данные, которые нельзя изменить после создания объекта, что исключает любую возможность конкуренции данных в этих объектах. В книге Effective Java, 2nd Ed. есть подробные инструкции в разделе "Item 15: Minimize Mutability". Обратите особое внимание на важность объявления полей Java как final (Bloch).
Даже если объект неизменяемый, помните, что его передача в другой поток без какой-либо синхронизации является состоянием гонки. В Java это иногда допустимо (см. ниже), но требует большой осторожности и может привести к созданию хрупкого кода. Если производительность не критична, добавьте объявление volatile. В C++ передача указателя или ссылки на неизменяемый объект без надлежащей синхронизации, как и любая гонка данных, является ошибкой.
В этом случае вполне вероятно, что это приведет к периодическим сбоям, поскольку, например, принимающий поток может увидеть неинициализированный указатель таблицы методов из-за изменения порядка хранения.
Если ни существующий класс библиотеки, ни неизменяемый класс не подходят, следует использовать оператор synchronized в Java или lock_guard/unique_lock в C++, чтобы защитить доступ к любому полю, к которому может обращаться более одного потока. Если мьютексы не подходят для вашей ситуации, вы должны объявить общие поля volatile или atomic, но вы должны быть очень осторожны, чтобы понять взаимодействие между потоками. Эти объявления не уберегут вас от распространенных ошибок параллельного программирования, но помогут избежать загадочных сбоев, связанных с оптимизацией компиляторов и неполадками SMP.
Не следует "публиковать" ссылку на объект, то есть делать ее доступной для других потоков, в его конструкторе. Это менее критично в C++ или если вы придерживаетесь нашего совета "никаких гонок данных" в Java. Но это всегда хороший совет, и он становится критически важным, если ваш код Java выполняется в других контекстах, в которых важна модель безопасности Java, и ненадежный код может вызвать гонку данных, обратившись к этой "утекшей" ссылке на объект. Это также важно, если вы решите проигнорировать наши предупреждения и использовать некоторые из методов, описанных в следующем разделе. Подробную информацию можно найти в статье Safe Construction Techniques in 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++, в Java нет механизма неупорядоченного доступа к переменным, объявленным как volatile.
Как правило, их следует избегать, если только нет веских причин для их использования. На слабо упорядоченных архитектурах, таких как ARM, их использование обычно экономит порядка нескольких десятков машинных циклов для каждой атомарной операции. На x86-64 прирост производительности ограничен хранилищами и, скорее всего, будет менее заметен. Как ни странно, преимущество может уменьшаться с увеличением количества ядер, поскольку система памяти становится более ограничивающим фактором.
Полная семантика слабо упорядоченных атомарных операций сложна. В целом они требуют точного понимания правил языка, в которые мы не будем здесь углубляться. Пример:
- Компилятор или аппаратное обеспечение могут перемещать обращения
memory_order_relaxedв критическую секцию, ограниченную получением и освобождением блокировки, но не из нее. Это означает, что двеmemory_order_relaxedоперации записи могут стать видимыми не по порядку, даже если они разделены критической секцией. - Обычная переменная Java, используемая в качестве общего счетчика, может уменьшаться в одном потоке, даже если она увеличивается только в другом потоке. Но это не так для атомарных операций C++.
memory_order_relaxed
С учетом этого предупреждения мы приводим здесь небольшое количество идиом, которые, по-видимому, охватывают многие варианты использования слабо упорядоченных атомарных операций. Многие из них применимы только к C++.
Доступ к контенту, не связанному с гонками
Переменная часто является атомарной, потому что иногда чтение происходит одновременно с записью, но не все обращения имеют эту проблему.
Например, переменная может быть атомарной, поскольку она считывается за пределами критической секции, но все обновления защищены блокировкой. В этом случае чтение, защищенное той же блокировкой, не может вызвать состояние гонки, поскольку параллельная запись невозможна. В этом случае доступ без состояния гонки (в данном случае загрузка) может быть аннотирован с помощью memory_order_relaxed без изменения правильности кода C++.
Реализация блокировки уже обеспечивает необходимый порядок доступа к памяти для других потоков, а memory_order_relaxed указывает, что для атомарного доступа не требуется применять дополнительные ограничения порядка.
В Java нет аналога этой функции.
Результат не считается правильным
Когда мы используем конкурирующую загрузку только для генерации подсказки, обычно также можно не применять упорядочивание памяти для загрузки. Если значение не является надежным, мы также не можем надежно использовать результат, чтобы сделать вывод о других переменных. Таким образом, если порядок памяти не гарантируется, а загрузка снабжена аргументом memory_order_relaxed, это нормально.
Распространенный пример – использование compare_exchange в C++ для атомарной замены 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 и обращаться к ней без специальных мер. Для этого должны выполняться все следующие условия:
- По значению поля должно быть понятно, было ли оно инициализировано. Чтобы получить доступ к полю, значение test-and-return быстрого пути должно считывать поле только один раз. В Java это обязательно. Даже если поле тестируется как инициализированное, вторая загрузка может прочитать более раннее неинициализированное значение. В C++ правило "прочитать один раз" – это просто хорошая практика.
- Инициализация и последующие загрузки должны быть атомарными, то есть частичные обновления не должны быть видны. В Java поле не должно быть
longилиdouble. Для C++ требуется атомарное присваивание. Создание на месте не сработает, поскольку созданиеatomicне является атомарным. - Повторные инициализации должны быть безопасными, поскольку несколько потоков могут одновременно считывать неинициализированное значение. В C++ это обычно следует из требования "тривиально копируемый", налагаемого на все атомарные типы. Типы с вложенными собственными указателями требуют освобождения памяти в конструкторе копирования и не являются тривиально копируемыми. Для Java допустимы определенные ссылочные типы:
- Ссылки Java ограничены неизменяемыми типами, содержащими только конечные поля. Конструктор неизменяемого типа не должен публиковать ссылку на объект. В этом случае правила для полей final в Java гарантируют, что если читатель увидит ссылку, он также увидит инициализированные поля final. В C++ нет аналогов этих правил, и указатели на принадлежащие объекты недопустимы по этой причине (в дополнение к нарушению требований "тривиально копируемый").
Заключительные замечания
Хотя этот документ не просто поверхностно описывает проблему, он не может дать больше, чем неглубокий анализ. Это очень широкая и глубокая тема. Вот некоторые области для дальнейшего изучения:
- Фактические модели памяти Java и C++ выражаются в терминах отношения happens-before, которое указывает, когда гарантируется, что два действия произойдут в определенном порядке. Когда мы определяли состояние гонки, мы неформально говорили о двух обращениях к памяти, происходящих "одновременно".
Официально это определяется как отсутствие одного события до другого.
Полезно изучить фактические определения happens-before и synchronizes-with в модели памяти Java или C++.
Хотя интуитивного понимания слова "одновременно" обычно достаточно, эти определения полезны, особенно если вы планируете использовать слабо упорядоченные атомарные операции в C++. Текущая спецификация Java определяет
lazySet()только в общих чертах. - Узнайте, что компиляторам разрешено и запрещено делать при изменении порядка кода. (В спецификации JSR-133 есть несколько отличных примеров допустимых преобразований, которые приводят к неожиданным результатам.)
- Узнайте, как писать неизменяемые классы на Java и C++. (Это не просто "не меняйте ничего после создания".)
- Изучите рекомендации в разделе Concurrency книги Effective Java, 2nd Edition. Например, не вызывайте методы, которые должны быть переопределены, внутри синхронизированного блока.
- Ознакомьтесь с документацией по API
java.util.concurrentиjava.util.concurrent.atomic, чтобы узнать, какие возможности они предоставляют. Рекомендуем использовать аннотации параллелизма, такие как@ThreadSafeи@GuardedBy(из net.jcip.annotations).
В разделе Дополнительная информация в приложении приведены ссылки на документы и сайты, которые помогут вам лучше разобраться в этих вопросах.
Приложение
Как реализовать хранилища синхронизации
(Большинству программистов не придется реализовывать это самостоятельно, но обсуждение будет полезным.)
Для небольших встроенных типов, таких как int, и оборудования, поддерживаемого Android, обычные инструкции по загрузке и хранению гарантируют, что хранилище будет видно либо полностью, либо вообще не будет видно другому процессору, загружающему то же местоположение. Таким образом, некоторое базовое понятие "атомарности" предоставляется бесплатно.
Как мы уже видели, этого недостаточно. Чтобы обеспечить последовательную согласованность, нам также необходимо предотвратить изменение порядка операций и обеспечить, чтобы операции с памятью становились видимыми для других процессов в согласованном порядке. Оказывается, что последнее происходит автоматически на оборудовании, поддерживаемом Android, при условии, что мы делаем разумный выбор для обеспечения первого, поэтому мы в значительной степени игнорируем его здесь.
Порядок операций с памятью сохраняется за счет предотвращения изменения порядка компилятором и аппаратным обеспечением. В этой статье мы рассмотрим второй вариант.
Упорядочение памяти на ARMv7, x86 и MIPS обеспечивается с помощью инструкций fence, которые не позволяют инструкциям, следующим за fence, становиться видимыми до инструкций, предшествующих fence. (Их также часто называют инструкциями барьера, но это может привести к путанице с барьерами в стиле pthread_barrier, которые делают гораздо больше.) Точное значение инструкций барьера – довольно сложная тема, которая должна учитывать то, как взаимодействуют гарантии, предоставляемые несколькими различными видами барьеров, и как они сочетаются с другими гарантиями упорядочения, обычно предоставляемыми аппаратным обеспечением. Это обзор высокого уровня, поэтому мы
опустим эти детали.
Самый базовый вид гарантии упорядочивания – это гарантия, предоставляемая атомарными операциями C++ memory_order_acquire и memory_order_release: операции с памятью, предшествующие сохранению с освобождением, должны быть видны после загрузки с получением. На ARMv7 это обеспечивается следующим образом:
- Предшествующая инструкция по хранению с подходящей инструкцией по ограждению. Это предотвращает изменение порядка всех предыдущих обращений к памяти с помощью инструкции сохранения. (Это также без необходимости предотвращает изменение порядка с помощью более поздней инструкции сохранения.)
- После инструкции загрузки следует подходящая инструкция барьера, которая предотвращает изменение порядка загрузки при последующих доступах. (И снова обеспечивая ненужный порядок по крайней мере с более ранними загрузками.)
Вместе они обеспечивают упорядочение acquire/release в C++.
Они необходимы, но недостаточны для последовательно согласованных volatile в Java или atomic в C++.
Чтобы понять, что ещё нам нужно, рассмотрим фрагмент алгоритма Деккера, который мы кратко упоминали ранее.
flag1 и flag2 – переменные C++ atomic или Java volatile, которые изначально имеют значение false.
| Цепочка 1 | Цепочка 2 |
|---|---|
flag1 = true |
flag2 = true |
Последовательная согласованность подразумевает, что одно из присвоений flagn должно быть выполнено первым и должно быть видно тесту в другом потоке. Таким образом, мы никогда не увидим,
чтобы эти потоки выполняли «критически важные вещи» одновременно.
Но барьеры, необходимые для упорядочивания acquire-release, добавляются только в начале и конце каждого потока, что в данном случае не помогает. Кроме того, необходимо убедиться, что если за сохранением volatile/atomic следует загрузка volatile/atomic, они не меняются местами.
Обычно это обеспечивается добавлением барьера не только перед последовательно согласованным хранилищем, но и после него.
(Это опять же намного строже, чем требуется, поскольку этот барьер обычно упорядочивает все более ранние обращения к памяти по отношению ко всем более поздним.)
Вместо этого мы могли бы связать дополнительный барьер с последовательно согласованными загрузками. Поскольку магазины встречаются реже, описанное нами соглашение более распространено и используется на Android.
Как мы видели в предыдущем разделе, нам нужно вставить барьер сохранения/загрузки между двумя операциями. Код, выполняемый в виртуальной машине для доступа к volatile-переменной, будет выглядеть примерно так:
| нестабильная нагрузка | энергозависимое хранилище |
|---|---|
reg = A |
fence for "release" (2) |
Реальные архитектуры машин обычно предоставляют несколько типов барьеров, которые упорядочивают различные типы доступов и могут иметь разную стоимость. Выбор между ними зависит от того, нужно ли обеспечить видимость хранилищ для других ядер в согласованном порядке и правильное составление порядка памяти, накладываемого комбинацией нескольких барьеров. Дополнительную информацию можно найти на странице Кембриджского университета, где собраны сопоставления атомарных операций с реальными процессорами.
В некоторых архитектурах, например x86, барьеры "получение" и "освобождение" не нужны, поскольку аппаратное обеспечение всегда неявно обеспечивает достаточный порядок. Таким образом, на x86 генерируется только последний барьер (3). Аналогично на x86 атомарные операции чтения-модификации-записи неявно включают строгий барьер. Поэтому им не нужны заборы. На ARMv7 все барьеры, которые мы обсуждали выше, обязательны.
ARMv8 предоставляет инструкции LDAR и STLR, которые напрямую обеспечивают выполнение требований к загрузке и хранению данных, последовательно согласованных с volatile в Java или C++. Это позволяет избежать ненужных ограничений на изменение порядка, о которых мы говорили выше. Они используются в 64-разрядном коде Android на ARM. Мы решили сосредоточиться на размещении барьеров в ARMv7, поскольку это позволяет лучше понять фактические требования.
Дополнительные ресурсы
Веб-страницы и документы, которые предоставляют более глубокую или широкую информацию. Чем полезнее статья, тем выше она в списке.
- Shared Memory Consistency Models: A Tutorial (Руководство по моделям согласованности общей памяти)
- Статья, написанная в 1995 г. Адве и Гарачорлу, – хороший источник информации для тех, кто хочет глубже разобраться в моделях согласованности памяти.
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf - Барьеры памяти
- Небольшая статья, в которой кратко описаны проблемы.
https://en.wikipedia.org/wiki/Memory_barrier - Основы Threads
- Введение в многопоточное программирование на C++ и Java, Ханс Бём. Обсуждение состояния гонки и основных методов синхронизации.
http://www.hboehm.info/c++mm/threadsintro.html - Java Concurrency In Practice
- В этой книге, опубликованной в 2006 году, подробно рассматривается широкий круг тем. Настоятельно рекомендуется всем, кто пишет многопоточный код на Java.
http://www.javaconcurrencyinpractice.com - Часто задаваемые вопросы о JSR-133 (модель памяти Java)
- Простое введение в модель памяти Java, включая объяснение синхронизации, переменных volatile и создания полей final.
(Немного устарела, особенно в части, касающейся других языков.)
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html - Validity of Program Transformations in the Java Memory Model
- Довольно техническое объяснение оставшихся проблем с моделью памяти Java. Эти проблемы не относятся к программам, в которых нет состояния гонки.
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.112.1790&rep=rep1&type=pdf - Обзор пакета java.util.concurrent
- Документация по пакету
java.util.concurrent. В нижней части страницы есть раздел Memory Consistency Properties (Свойства согласованности памяти), в котором объясняются гарантии, предоставляемые различными классами.java.util.concurrentСводка по пакету - Теория и практика Java: методы безопасного конструирования в Java
- В этой статье подробно рассматриваются опасности, связанные с утечкой ссылок во время создания объекта, и приводятся рекомендации по созданию потокобезопасных конструкторов.
http://www.ibm.com/developerworks/java/library/j-jtp0618.html - Теория и практика Java: управление волатильностью
- Хорошая статья о том, что можно и чего нельзя делать с полями volatile в Java.
http://www.ibm.com/developerworks/java/library/j-jtp06197.html - Декларация "Блокировка с двойной проверкой не работает"
- Подробное объяснение Билла Пью (Bill Pugh) о том, почему блокировка с двойной проверкой не работает без
volatileилиatomic. включая C/C++ и Java.
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html - [ARM] Barrier Litmus Tests and Cookbook
- Обсуждение проблем ARM SMP с короткими фрагментами кода ARM. Если примеры на этой странице показались вам недостаточно конкретными или вы хотите ознакомиться с формальным описанием инструкции DMB, прочитайте этот раздел. Также описываются инструкции, используемые для барьеров памяти в исполняемом коде (может быть полезно, если вы генерируете код на лету). Обратите внимание, что это предшествует ARMv8, который также поддерживает дополнительные инструкции по упорядочению памяти и перешел на несколько более строгую модель памяти. Подробную информацию можно найти в справочном руководстве по архитектуре ARMv8-A.
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf - Барьеры памяти в ядре Linux
- Документация по барьерам памяти ядра Linux. Включает полезные примеры и ASCII-графику.
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
(введение: http://www.hpl.hp.com/techreports/2008/HPL-2008-56.pdf) - ISO/IEC JTC1 SC22 WG14 (стандарты C) 9899 (язык программирования 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 с процессорами (Кембриджский университет)
- Коллекция переводов атомарных операций C++ в различные распространенные наборы команд процессора, созданная Ярославом Шевчиком (Jaroslav Sevcik) и Питером Сьюэллом (Peter Sewell).
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html - Алгоритм Деккера
- "Первое известное правильное решение проблемы взаимного исключения в параллельном программировании". В статье Википедии приведен полный алгоритм с обсуждением того, как его нужно будет обновить для работы с современными оптимизирующими компиляторами и оборудованием SMP.
https://en.wikipedia.org/wiki/Dekker's_algorithm - Комментарии о различиях между ARM и Alpha и зависимостях адресов
- Электронное письмо в списке рассылки arm-kernel от Каталина Маринаса. Включает краткое описание зависимостей адресов и управления.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html - What Every Programmer Should Know About Memory (Что каждый программист должен знать о памяти)
- Очень длинная и подробная статья Ульриха Дреппера о разных типах памяти, в частности о кеше ЦП.
http://www.akkadia.org/drepper/cpumemory.pdf - Рассуждения о слабо согласованной модели памяти ARM
- Эта статья написана Чонгом и Иштиаком из ARM, Ltd. В ней делается попытка описать модель памяти ARM SMP в строгой, но доступной форме. Определение наблюдаемости, которое используется в этой статье, взято из этой работы. Опять же, это было до ARMv8.
http://portal.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll=&dl=&CFID=96099715&CFTOKEN=57505711 - The JSR-133 Cookbook for Compiler Writers
- Дуг Ли написал это в качестве дополнения к документации JSR-133 (модель памяти Java). В нем содержатся первоначальные рекомендации по реализации модели памяти Java, которые использовались многими разработчиками компиляторов. На этот документ до сих пор часто ссылаются, и он может быть полезен.
К сожалению, четыре разновидности барьеров, обсуждаемые здесь, не подходят для архитектур, поддерживаемых Android, и приведенные выше сопоставления C++11 теперь являются лучшим источником точных рецептов, даже для Java.
http://g.oswego.edu/dl/jmm/cookbook.html - x86-TSO: A Rigorous and Usable Programmer’s Model for x86 Multiprocessors
- Точное описание модели памяти x86. Точные описания модели памяти ARM, к сожалению, значительно сложнее.
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf