Глава 5. Модель памяти С++ и атомарные операции

В этой главе:

■ Детальные сведения о модели памяти С++.

■ Атомарные типы в стандартной библиотеке С++.

■ Операции над атомарными типами.

■ Как можно использовать эти операции для синхронизации потоков.

Одна из самых важных особенностей стандарта С++11 — та, которую большинство программистов даже не замечают. Это не новые синтаксические конструкции и не новые библиотечные средства, а новая модель памяти, учитывающая многопоточность. Без модели памяти, которая точно определяет, как должны работать основополагающие строительные блоки, ни на одно из описанных выше средств нельзя было бы полагаться. Понятно, почему большинство программистов этого не замечают: если вы пользуетесь для защиты данных мьютексами, а для сигнализации о событиях — условными переменными или будущими результатам, то вопрос о том, почему они работают, не так уж важен. И лишь когда вы подбираетесь «ближе к железу», становятся существенны точные детали модели памяти.

С++ используется для решения разных задач, но одна из основных — системное программирование. Поэтому комитет по стандартизации в числе прочих целей ставил и такую: сделать так, чтобы в языке более низкого уровня, чем С++, не возникало необходимости. С++ должен обладать достаточной гибкостью, чтобы программист мог сделать то, что хочет, без помех со стороны языка, в том числе и работать «на уровне железа». Атомарные типы и операции — шаг именно в этом направлении, поскольку они предоставляют низкоуровневые механизмы синхронизации, которые обычно транслируются в одну-две машинные команды.

В этой главе мы начнем с рассмотрения основ модели памяти, затем перейдем к атомарным типам и операциям и в конце обсудим различные виды синхронизации, реализуемые с помощью операций над атомарными типами. Это довольно сложная тема; если вы не планируете писать код, в котором атомарные операции используются для синхронизации (например, структуры данных без блокировок, рассматриваемые в главе 7), то все эти детали вам ни к чему. Но давайте потихоньку двинемся вперёд и начнем с модели памяти.

5.1. Основы модели памяти

У модели памяти есть две стороны: базовые структурные аспекты, относящиеся к размещению программы в памяти, и аспекты, связанные с параллелизмом. Структурные аспекты важны для параллелизма, особенно если опуститься на низкий уровень атомарных операций, поэтому с них я и начну. В С++ всё вращается вокруг объектов и ячеек памяти.

5.1.1. Объекты и ячейки памяти

Любые данные в программе на С++ состоят из объектов. Это не значит, что можно создать новый класс, производный от

int
, или что у фундаментальных типов есть функции-члены, или вообще нечто такое, что часто имеют в виду, когда говорят «нет ничего, кроме объектов» при обсуждении таких языков, как Smalltalk или Ruby. Это утверждение просто означает, что в С++ данные строятся из объектов. В стандарте С++ объект определяется как «область памяти», хотя далее речь идет о таких свойствах объектов, как тип и время жизни.

Некоторые объекты являются простыми значениями таких фундаментальных типов, как

int
или
float
, другие — экземплярами определенных пользователем классов. У некоторых объектов (например, массивов, экземпляров производных классов и экземпляров классов с нестатическими данными-членами) есть подобъекты, у других — нет.

Вне зависимости от типа объект хранится в одной или нескольких ячейках памяти. Каждая такая ячейка — это либо объект (или подобъект) скалярного типа, например

unsigned short
или
my_class*
, либо последовательность соседних битовых полей. Если вы пользуетесь битовыми полями, то имейте в виду один важный момент: хотя соседние битовые поля является различными объектами, они тем не менее считаются одной ячейкой памяти. На рис. 5.1 показано, как структура
struct
представлена в виде совокупности объектов и ячеек памяти.

Рис. 5.1. Разбиение

struct
на объекты и ячейки памяти

Во-первых, вся структура — это один объект, который состоит из нескольких подобъектом, по одному для каждого члена данных. Битовые поля

bf1
и
bf2
занимают одну ячейку памяти, объект
s
типа
std::string
занимает несколько ячеек памяти, а для каждого из остальных членов отведена своя ячейка. Обратите внимание, что битовое поле нулевой длины
bf3
заставляет отвести для
bf4
отдельную ячейку.

Отсюда можно сделать несколько важных выводов:

• каждая переменная — объект, в том числе и переменные, являющиеся членами других объектов;

• каждый объект занимает по меньшей мере одну ячейку памяти;

• переменные фундаментальных типов, например

int
или
char
, занимают в точности одну ячейку памяти вне зависимости от размера, даже если являются соседними или элементами массива;

• соседние битовые поля размещаются в одной ячейке памяти.

Уверен, что вы недоумеваете, какое отношение всё это имеет к параллелизму. Давайте разберемся.

5.1.2. Объекты, ячейки памяти и параллелизм

Для многопоточных приложений на С++ понятие ячейки памяти критически важно. Если два потока обращаются к разным ячейкам памяти, то никаких проблем не возникает и всё работает, как надо. Но если потоки обращаются к одной и той же ячейке, то необходима осторожность. Если ни один поток не обновляет ячейку памяти, то всё хорошо — доступ к данным для чтения не нуждается ни в защите, ни в синхронизации. Если же какой-то поток модифицирует данные, то возможно состояние гонки, описанное в главе 3.

Чтобы избежать гонки, необходимо принудительно упорядочить обращения из двух потоков. Один из возможных способов такого упорядочения дают мьютексы (см. главу 3) — если захватывать один и тот же мьютекс перед каждым обращением, то одновременно получить доступ к ячейке памяти сможет только один поток, так что упорядочение налицо. Другой способ упорядочить доступ из двух потоков — воспользоваться свойствами синхронизации, присущими атомарным операциям (о том, что это такое, см. раздел 5.2) над теми же или другими ячейками памяти. Такое использование атомарных операций описано в разделе 5.3. Если к одной и той же ячейке обращаются более двух потоков, то упорядочение должно быть определено для каждой пары.

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

Эта фраза критически важна: неопределенное поведение — один из самых грязных закоулков С++. Согласно стандарту языка, любое неопределенное поведение отменяет всякие гарантии — поведение всего приложения становится неопределённым, и оно может делать все, что угодно. Я знаю один пример неопределённого поведения, в результате которого загорелся монитор. Хотя маловероятно, что такое приключится с вами, гонка за данными безусловно является серьезной ошибкой, которой следует всеми силами избегать.

В этой фразе есть и еще один важный момент: избежать неопределенного поведения поможет использование атомарных операций для доступа к ячейке памяти, за которую возможна гонка. Саму гонку это не предотвращает — какая именно атомарная операция первой получит доступ к ячейке памяти, все равно не определено, — но программа тем не менее возвращается в область определённого поведения.

Прежде чем мы перейдем к атомарным операциям, нужно разобраться еще в одной важной концепции, касающейся объектов и ячеек памяти: порядке модификации.

5.1.3. Порядок модификации

Для каждого объекта в программе на С++ определён порядок модификации, состоящий из всех операций записи в объект из всех потоков программы, начиная с инициализации объекта. В большинстве случаев порядок меняется от запуска к запуску, но при любом выполнении программы все имеющиеся в системе потоки должны договориться о порядке модификации. Если объект не принадлежит одному из описанных в разделе 5.2 атомарных типов, то вы сами отвечаете за обеспечение синхронизации, достаточной для того, чтобы потоки могли договориться о порядке модификации каждой переменной. Если разные потоки видят разные последовательности значений одной и той же переменной, то имеет место гонка за данными и, как следствие, неопределённое поведение (см. раздел 5.1.2). Если вы используете атомарные операции, то за обеспечение необходимой синхронизации отвечает компилятор.

Это требование означает, что некоторые виды спекулятивного исполнения[11] не разрешены, потому что после того как некоторый поток увидел определённое значение объекта при данном порядке модификации, последующие операции чтения в том же потоке должны возвращать более поздние значения, а последующие операции записи в тот же объект в этом потоке должны происходить позже при данном порядке модификации. Кроме того, операция чтения объекта, следующая за операцией записи в этот объект, должна вернуть либо записанное значение, либо другое значение, которое было записано позже при данном порядке модификации этого объекта. Хотя все потоки обязаны договориться о порядке модификации каждого объекта в программе, не требуется, чтобы они договаривались об относительном порядке операций над разными объектами. Дополнительные сведения об упорядочении операций, выполняемых в разных потоках, см. в разделе 5.3.3.

Итак, что понимается под атомарной операцией и как ими можно воспользоваться для принудительного упорядочения?

5.2. Атомарные операции и типы в С++

Под атомарными понимаются неделимые операции. Ни из одного потока в системе невозможно увидеть, что такая операция выполнена наполовину, — она либо выполнена целиком, либо не выполнена вовсе. Если операция загрузки, которая читает значение объекта, атомарна, и все операции модификации этого объекта также атомарны, то в результате загрузки будет получено либо начальное значение объекта, либо значение, сохраненное в нем после одной из модификаций.

И наоборот, если операция не атомарная, то другой поток может видеть, что она выполнена частично. Если это операция сохранения, то значение, наблюдаемое другим потоком, может не совпадать ни со значением до начала сохранения, ни с сохраненным значением. С другой стороны, операция загрузки может извлечь часть объекта, после чего значение будет модифицировано другим потоком, а затем операция прочитает оставшуюся часть объекта. В результате будет извлечено значение, которое объект не имел ни до, ни после модификации. Это простая проблематичная гонка, описанная в главе 3, но на этом уровне она может представлять собой гонку за данными (см. раздел 5.1) и, стало быть, являться причиной неопределённого поведения.

В С++ для того чтобы операция была атомарной, обычно необходимы атомарные типы. Давайте познакомимся с ними.

5.2.1. Стандартные атомарные типы

Все стандартные атомарные типы определены в заголовке

. Любые операции над такими типами атомарны, и только операции над этими типами атомарны в смысле принятого в языке определения, хотя мьютексы позволяют реализовать кажущуюся атомарность других операций. На самом деле, и сами стандартные атомарные типы могут пользоваться такой эмуляцией: почти во всех имеется функция-член
is_lock_free()
, которая позволяет пользователю узнать, выполняются ли операции над данным типом с помощью действительно атомарных команд (
x.is_lock_free()
возвращает
true
) или с применением некоторой внутренней для компилятора и библиотеки блокировки (
x.is_lock_free()
возвращает
false
).

Единственный тип, в котором функция-член

is_lock_free()
отсутствует, — это
std::atomic_flag
. В действительности это по-настоящему простой булевский флаг, а операции над этим типом обязаны быть свободными от блокировок; если имеется простой свободный от блокировок булевский флаг, то на его основе можно реализовать простую блокировку и, значит, все остальные атомарные типы. Говоря по-настоящему простой, я именно это и имел в виду: после инициализации объект типа
std::atomic_flag
сброшен, и для него определены всего две операции: проверить и установить (функция-член
test_and_set()
) и очистить (функция-член
clear()
). Это всё — нет ни присваивания, ни копирующего конструктора, ни операции «проверить и очистить», вообще ничего больше.

Доступ ко всем остальным атомарным типам производится с помощью специализаций шаблона класса

std::atomic<>
; их функциональность несколько богаче, но они необязательно свободны от блокировок (как было объяснено выше). На самых распространенных платформах можно ожидать, что атомарные варианты всех встроенных типов (например,
std::atomic
и
std::atomic
) действительно будут свободны от блокировок, но такого требования не предъявляется. Как мы скоро увидим, интерфейс каждой специализации отражает свойства типа; например, поразрядные операции, например
&=
, не определены для простых указателей, поэтому они не определены и для атомарных указателей.

Помимо прямого использования шаблона класса

std::atomic<>
, разрешается использовать имена, приведённые в табл. 5.1, которые ссылаются на определенные в конкретной реализации атомарные типы. Из-за исторических особенностей добавления атомарных типов в стандарт С++ альтернативные имена типов могут ссылаться либо на соответствующую специализацию
std::atomic<>
, либо на базовый класс этой специализации. Поэтому смешение альтернативных имен и прямых имен специализаций
std::atomic<>
может сделать программу непереносимой.


Таблица 5.1. Альтернативные имена стандартных атомарных типов и соответствующие им специализации

std::atomic<>

Атомарный тип Соответствующая специализация
atomic_bool
std::atomic
atomic_char
std::atomic
atomic_schar
std::atomic
atomic_uhar
std::atomic
atomic_int
std::atomic
atomic_uint
std::atomic
atomic_short
std::atomic
atomic_ushort
std::atomic
atomic_long
std::atomic
atomic_ulong
std::atomic
atomic_llong
std::atomic
atomic_ullong
std::atomic
atomic_char16_t
std::atomic
atomic_char32_t
std::atomic
atomic_wchar_t
std::atomic

Помимо основных атомарных типов, в стандартной библиотеке С++ определены также псевдонимы

typedef
для атомарных типов, соответствующих различным неатомарным библиотечным
typedef
, например
std::size_t
. Они перечислены в табл. 5.2.


Таблица 5.2. Соответствие между стандартными атомарными и встроенными

typedef

Атомарный
typedef
Соответствующий
typedef
из стандартной библиотеки
atomic_int_least8_t
int_least8_t
atomic_uint_least8_t
uint_least8_t
atomic_int_least16_t
int_least16_t
atomic_uint_least16_t
uint_least16_t
atomic_int_least32_t
int_least32_t
atomic_uint_least32_t
uint_least32_t
atomic_int_least64_t
int_least64_t
atomic_uint_least64_t
uint_least64_t
atomic_int_fast8_t
int_fast8_t
atomic_uint_fast8_t
uint_fast8_t
atomic_int_fast16_t
int_fast16_t
atomic_uint_fast16_t
uint_fast16_t
atomic_int_fast32_t
int_fast32_t
atomic_uint_fast32_t
uint_fast32_t
atomic_int_fast64_t
int_fast64_t
atomic_uint_fast64_t
uint_fast64_t
atomic_intptr_t
intptr_t
atomic_uintptr_t
uintptr_t
atomic_size_t
size_t
atomic_ptrdiff_t
ptrdiff_t
atomic_intmax_t
intmax_t
atomic_uintmax_t
uintmax_t

Да уж, типов немало! Но есть простая закономерность — атомарный тип, соответствующий стандартному

typedef T
, имеет такое же имя с префиксом
atomic_
:
atomic_T
. То же самое относится и к встроенным типам с тем исключением, что
signed
сокращается до
s
,
unsigned
— до
u
, a
long long
— до
llong
. Вообще говоря, проще написать
std::atomic
для нужного вам типа
T
, чем пользоваться альтернативными именами.

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

load()
,
store()
,
exchange()
,
compare_exchange_weak()
и
compare_exchange_strong()
. Поддерживаются также составные операторы присваивания (там, где это имеет смысл)
+=
,
-=
,
*=
,
|=
и т.д., а для целочисленных типов и специализаций
std::atomic<>
для указателей — еще и операторы
++
и
--
. Этим операторам соответствуют также именованные функции-члены с идентичной функциональностью:
fetch_add()
,
fetch_or()
и т.д. Операторы присваивания возвращают сохраненное значение, а именованные функции-члены — значение, которое объект имел до начала операции. Это позволяет избежать потенциальных проблем, связанных с тем, что обычно операторы присваивания возвращают ссылку на объект в левой части. Чтобы получить из такой ссылки сохраненное значение, программа должна была бы выполнить еще одну операцию чтения, но тогда между присваиванием и чтением другой поток мог бы модифицировать значение, открывая дорогу гонке.

Но шаблон класса

std::atomic<>
— не просто набор специализаций. В нем есть основной шаблон, который можно использовать для создания атомарного варианта пользовательского типа. Поскольку это обобщенный шаблон класса, определены только операции
load()
,
store()
(а также присваивание значения пользовательского типа и преобразования в пользовательский тип),
exchange()
,
compare_exchange_weak()
и
compare_exchange_strong()
.

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

• Операции сохранения, для которых можно задавать упорядочение

memory_order_relaxed
,
memory_order_release
и
memory_оrder_sеq_cst
.

• Операции загрузки, для которых можно задавать упорядочение

memory_order_relaxed
,
memory_order_consume
,
memory_order_acquire
и
memory_order_seq_cst
.

• Операции чтения-модификации-записи, для которых можно задавать упорядочение

memory_order_relaxed
,
memory_order_consume
,
memory_order_acquire
,
memory_order_release
,
memory_order_acq_rel
и
memory_order_seq_cst
.

По умолчанию для всех операций подразумевается упорядочение

memory_оrder_sеq_cst
.

Теперь рассмотрим, какие операции можно производить над каждым из стандартных атомарных типов, начиная с

std::atomic_flag
.

5.2.2. Операции над
std::atomic_flag

Простейший стандартный атомарный тип

std::atomic_flag
представляет булевский флаг. Объекты этого типа могут находиться в одном из двух состояний: установлен или сброшен. Этот тип намеренно сделан максимально простым, рассчитанным только на применение в качестве строительного блока. Поэтому увидеть его в реальной программе можно лишь в очень специфических обстоятельствах. Тем не менее, он послужит нам отправной точкой для обсуждения других атомарных типов, потому что на его примере отчетливо видны общие относящиеся к ним стратегии.

Объект типа

std::atomic_flag
должен быть инициализирован значением
ATOMIC_FLAG_INIT
. При этом флаг оказывается в состоянии сброшен. Никакого выбора тут не предоставляется — флаг всегда должен начинать существование в сброшенном состоянии:

std::atomic_flag f = ATOMIC_FLAG_INIT;

Требование применяется вне зависимости от того, где и в какой области видимости объект объявляется. Это единственный атомарный тип, к инициализации которого предъявляется столь специфическое требование, зато при этом он является также единственным типом, гарантированно свободным от блокировок. Если у объекта

std::atomic_flag
статический класс памяти, то он гарантированно инициализируется статически, и, значит, никаких проблем с порядком инициализации не будет — объект всегда оказывается инициализированным к моменту первой операции над флагом.

После инициализации с флагом можно проделать только три вещи: уничтожить, очистить или установить, одновременно получив предыдущее значение. Им соответствуют деструктор, функция-член

clear()
и функция-член
test_and_set()
. Для обеих функций
clear()
и
test_and_set()
можно задать упорядочение памяти.
clear()
— операция сохранения, поэтому варианты упорядочения
memory_order_acquire
и
memory_order_acq_rel
к ней неприменимы, a
test_and_set()
— операция чтения-модификации-записи, так что к ней применимы любые варианты упорядочения. Как и для любой атомарной операции, по умолчанию подразумевается упорядочение
memory_order_seq_cst
. Например:

f.clear(std::memory_order_release);←
(1)

bool x = f.test_and_set();     ←
(2)

Здесь при вызове

clear()
(1) явно запрашивается сброс флага с семантикой освобождения, а при вызове
test_and_set()
(2) подразумевается стандартное упорядочение для операции установки флага и получения прежнего значения.

Объект

std::atomic_flag
нельзя сконструировать копированием из другого объекта, не разрешается также присваивать один
std::atomic_flag
другому. Это не особенность типа
std::atomic_flag
, а свойство, общее для всех атомарных типов. Любые операции над атомарным типом должны быть атомарными, а для присваивания и конструирования копированием нужны два объекта. Никакая операция над двумя разными объектами не может быть атомарной. В случае копирования и присваивания необходимо сначала прочитать значение первого объекта, а потом записать его во второй. Это две отдельные операции над двумя различными объектами, и их комбинация не может быть атомарной. Поэтому такие операции запрещены.

Такая ограниченность функциональности делает тип

std::atomic_flag
идеальным средством для реализации мьютексов-спинлоков. Первоначально флаг сброшен и мьютекс свободен. Чтобы захватить мьютекс, нужно в цикле вызывать функцию
test_and_set()
, пока она не вернет прежнее значение
false
, означающее, что теперь в этом потоке установлено значение флага
true
. Для освобождения мьютекса нужно просто сбросить флаг. Реализация приведена в листинге ниже.


Листинг 5.1. Реализация мьютекса-спинлока с использованием

std::atomic_flag

class spinlock_mutex {

 std::atomic_flag flag;

public:

 spinlock_mutex():

  flag(ATOMIC_FLAG_INIT) {}


 void lock() {

  while (flag.test_and_set(std::memory_order_acquire));

 }


 void unlock() {

  flag.clear(std::memory_order_release);

 }

};

Это очень примитивный мьютекс, но даже его достаточно для использования в сочетании с шаблоном

std::lock_guard<>
(см. главу 3). По своей природе, он активно ожидает в функции-члене
lock()
, поэтому не стоит использовать его, если предполагается хоть какая-то конкуренция, однако задачу взаимного исключения он решает. Когда дело дойдет до семантики упорядочения доступа к памяти, мы увидим, как гарантируется принудительное упорядочение, необходимое для захвата мьютекса. Пример будет приведён в разделе 5.3.6.

Тип

std::atomic_flag
настолько ограничен, что его даже нельзя использовать в качестве обычного булевского флага, так как он не допускает проверки без изменения значения. На эту роль больше подходит тип
std::atomic
, который я рассмотрю ниже.

5.2.3. Операции над
std::atomic

Из атомарных целочисленных типов простейшим является

std::atomic
. Как и следовало ожидать, его функциональность в качестве булевского флага богаче, чем у
std::atomic_flag
. Хотя копирующий конструктор и оператор присваивания по-прежнему не определены, но можно сконструировать объект из неатомарного
bool
, поэтому в начальном состоянии он может быть равен как
true
, так и
false
. Разрешено также присваивать объектам типа
std::atomic
значения неатомарного типа
bool
:

std::atomic b(true);

b = false;

Что касается оператора присваивания с неатомарным

bool
в правой части, нужно еще отметить отход от общепринятого соглашения о возврате ссылки на объект в левой части — этот оператор возвращает присвоенное значение типа
bool
. Такая практика обычна для атомарных типов: все поддерживаемые ими операторы присваивания возвращают значения (соответствующего неатомарного типа), а не ссылки. Если бы возвращалась ссылка на атомарную переменную, то программа, которой нужен результат присваивания, должна была бы явно загрузить значение, открывая возможность для модификации результата другим потоком в промежутке между присваиванием и чтением. Получая же результат присваивания в виде неатомарного значения, мы обходимся без дополнительной операции загрузки и можем быть уверены, что получено именно то значение, которое было сохранено.

Запись (любого значения:

true
или
false
) производится не чрезмерно ограничительной функцией
clear()
из класса
std::atomic_flag
, а путём вызова функции-члена
store()
, хотя семантику упорядочения доступа к памяти по-прежнему можно задать. Аналогично вместо
test_and_set()
используется более общая функция-член
exchange()
, которая позволяет атомарно заменить ранее сохраненное значение новым и вернуть прежнее значение. Тип
std::atomic
поддерживает также проверку значения без модификации посредством неявного преобразования к типу
bool
или явного обращения к функции
load()
. Как нетрудно догадаться,
store()
— это операция сохранения,
load()
— операция загрузки, a
exchange()
— операция чтения-модификации-записи:

std::atomic b;

bool x = b.load(std::memory_order_acquire);

b.store(true);

x = b.exchange(false, std::memory_order_acq_rel);

Функция

exchange()
— не единственная операция чтения-модификации-записи, которую поддерживает тип
std::atomic
; в нем также определена операция сохранения нового значения, если текущее совпадает с ожидаемым.

Сохранение (или несохранение) нового значения в зависимости от текущего

Новая операция называется «сравнить и обменять» и реализована в виде функций-членов

compare_exchange_weak()
и
compare_exchange_strong()
. Эта операция — краеугольный камень программирования с использованием атомарных типов; она сравнивает значение атомарной переменной с указанным ожидаемым значением и, если они совпадают, то сохраняет указанное новое значение. Если же значения не совпадают, то ожидаемое значение заменяется фактическим значением атомарной переменной. Функции сравнения и обмена возвращают значение типа
bool
, равное
true
, если сохранение было произведено, и
false
— в противном случае.

В случае

compare_exchange_weak()
сохранение может не произойти, даже если текущее значение совпадает с ожидаемым. В таком случае значение переменной не изменится, а функция вернет
false
. Такое возможно на машинах, не имеющих аппаратной команды сравнить-и-обменять, если процессор не может гарантировать атомарности операции — например, потому что поток, в котором операция выполнялась, был переключён в середине требуемой последовательности команд и замещен другим потоком (когда потоков больше, чем процессоров). Эта ситуация называется ложным отказом, потому что причиной отказа являются не значения переменных, а хронометраж выполнения функции.

Поскольку

compare_exchange_weak()
может стать жертвой ложного отказа, обычно ее вызывают в цикле:

bool expected = false;

extern atomic b; // установлена где-то в другом месте

while (!b.compare_exchange_weak(expected, true) && !expected);

Этот цикл продолжается, пока

expected
равно
false
, что указывает на ложный отказ
compare_exchange_weak()
.

С другой стороны,

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

Если мы хотим изменить переменную, каким бы ни было ее текущее значение (при этом новое значение может зависеть от текущего), то обновление

expected
оказывается полезной штукой; на каждой итерации цикла
expected
перезагружается, так что если другой поток не модифицирует значение в промежутке, то вызов
compare_exchange_weak()
или
compare_exchange_strong()
должен оказаться успешным на следующей итерации. Если новое сохраняемое значение вычисляется просто, то выгоднее использовать
compare_exchange_weak()
, чтобы избежать двойного цикла на платформах, где
compare_exchange_weak()
может давать ложный отказ (и, следовательно,
compare_exchange_strong()
содержит цикл). С другой стороны, если вычисление нового значения занимает длительное время, то имеет смысл использовать
compare_exchange_strong()
, чтобы не вычислять значение заново, когда
expected
не изменилась. Для типа
std::atomic
это не столь существенно — в конце концов, есть всего два возможных значения — но для более широких атомарных типов различие может оказаться заметным.

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

memory_order_acq_rel
, а при неудачном —
memory_order_relaxed
. В случае отказа функция сохранить-и-обменять не производит сохранение, поэтому семантика
memory_order_release
или
memory_order_acq_rel
неприменима. Поэтому задавать эти варианты упорядочения для отказа не разрешается. Кроме того, нельзя задавать для отказа более строгое упорядочение, чем для успеха; если вы требуете семантику
memory_order_acquire
или
memory_order_seq_cst
в случае отказа, то должны потребовать такую же и в случае успеха.

Если упорядочение для отказа не задано, то предполагается, что оно такое же, как для успеха, с тем отличием, что часть release заменяется:

memory_order_release
становится
memory_order_relaxed
, a
memory_order_acq_rel
memory_order_acquire
. Если не задано ни одно упорядочение, то как обычно предполагается
memory_order_seq_cst
, то есть полное последовательное упорядочение доступа как в случае успеха, так и в случае отказа. Следующие два вызова
compare_exchange_weak()
эквивалентны:

std::atomic b;

bool expected;

b.compare_exchange_weak(expected, true,

memory_order_acq_rel, memory_order_acquire);

b.compare_exchange_weak(expected, true, memory_order_acq_rel);

К чему приводит задание того или иного упорядочения, я расскажу в разделе 5.3.

Еще одно отличие

std::atomic
от
std::atomic_flag
заключается в том, что тип
std::atomic
не обязательно свободен от блокировок; для обеспечения атомарности реализация библиотеки может захватывать внутренний мьютекс. В тех редких случаях, когда это важно, можно с помощью функции-члена
is_lock_free()
узнать, являются ли операции над
std::atomic
свободными от блокировок. Это еще одна особенность, присущая всем атомарным типам, кроме
std::atomic_flag
.

Следующими по простоте являются атомарные специализации указателей

std::atomic
.

5.2.4. Операции над
std::atomic
: арифметика указателей

Атомарная форма указателя на тип

T
std::atomic
— выглядит так же, как атомарная форма
bool
(
std::atomic
). Интерфейс по существу такой же, только операции применяются к указателям на значения соответствующего типа, а не к значениям типа
bool
. Как и в случае
std::atomic
, копирующие конструктор и оператор присваивания не определены, но разрешено конструирование и присваивание на основе подходящих указателей. Помимо обязательной функции
is_lock_free()
, тип
std::atomic
располагает также функциями
load()
,
store(
),
exchange()
,
compare_exchange_weak()
и
compare_exchange_strong()
с такой же семантикой, как
std::atomic
, но принимаются и возвращаются значения типа
T*
, а не
bool
.

Новыми в типе

std::atomic
являются арифметические операции над указателями. Базовые операции предоставляются функциями-членами
fetch_add()
и
fetch_sub()
, которые прибавляют и вычитают целое число из сохраненного адреса, а также операторы
+=
,
-=
,
++
и
--
(последние в обеих формах — пред и пост), представляющие собой удобные обертки вокруг этих функций. Операторы работают так же, как для встроенных типов: если
x
— указатель
std::atomic
на первый элемент массива объектов типа
Foo
, то после выполнения оператора
x+=3
x
будет указывать на четвертый элемент и при этом возвращается простой указатель
Foo*
, который также указывает на четвертый элемент. Функции
fetch_add()
и
fetch_sub()
отличаются от операторов тем, что возвращают старое значение (то есть
x.fetch_add(3)
изменит
x
, так что оно будет указывать на четвертый элемент, но вернет указатель на первый элемент массива). Эту операцию еще называют обменять-и-прибавить, она относится к категории атомарных операций чтения-модификации-записи, наряду с
exchange()
,
compare_exchange_weak()
и
compare_exchange_strong()
. Как и другие операции такого рода,
fetch_add()
возвращает простой указатель
T*
, а не ссылку на объект
std::atomic
, поэтому вызывающая программа может выполнять действия над прежним значением:

class Foo{};

Foo some_array[5];        │
Прибавить 2 к p

std::atomic p(some_array);│
и вернуть старое

Foo* x = p.fetch_add(2);    ←┘
значение

assert(x == some_array);

assert(p.load() == &some_array[2]);

x = (p -= 1);           ←┐ 
Вычесть 1 из p

assert(x == &some_array[1]);    │
и вернуть новое

assert(p.load() == &some_array[1]);│
значение

Функциям можно также передать в дополнительном аргументе семантику упорядочения доступа к памяти:

p.fetch_add(3, std::memory_order_release);

Поскольку

fetch_add()
и
fetch_sub()
— операции чтения-модификации-записи, то они принимают любую семантику упорядочения и могут участвовать в последовательности освобождений. Для операторных форм задать семантику невозможно, поэтому предполагается семантика
memory_order_sеq_cst
.

Все прочие атомарные типы по существу одинаковы: это атомарные целочисленные типы с общим интерфейсом, различаются они только ассоциированными встроенными типами. Поэтому я опишу их все сразу.

5.2.5. Операции над стандартными атомарными целочисленными типами

Помимо обычного набора операций (

load()
,
store()
,
exchange()
,
compare_exchange_weak()
и
compare_exchange_strong()
), атомарные целочисленные типы такие, как
std::atomic
или
std::atomic
) обладают целым рядом дополнительных операций:
fetch_add()
,
fetch_sub()
,
fetch_and()
,
fetch_or()
,
fetch_xor()
, их вариантами в виде составных операторов присваивания (
+=
,
-=
,
&=
,
|=
,
^=
) и операторами пред- и постинкремента и декремента (
++x
,
x++
,
--x
,
x--
). Это не весь набор составных операторов присваивания, имеющихся у обычного целочисленного типа, но близко к тому — отсутствуют лишь операторы умножения, деления и сдвига. Поскольку атомарные целочисленные значения обычно используются в качестве счетчиков или битовых масок, потеря не слишком велика, а в случае необходимости недостающие операции можно реализовать с помощью вызова функции
compare_exchange_weak()
в цикле.

Семантика операций близка к семантике функций

fetch_add()
и
fetch_sub()
в типе
std::atomic
; именованные функции выполняют свои операции атомарно и возвращают старое значение, а составные операторы присваивания возвращают новое значение. Операторы пред- и постинкремента и декремента работают как обычно:
++x
увеличивает значение переменной на единицу и возвращает новое значение, а
x++
увеличивает значение переменной на единицу и возвращает старое значение. Как вы теперь уже понимаете, результатом в обоих случаях является значение ассоциированного целочисленного типа.

Мы рассмотрели все простые атомарные типы; остался только основной обобщенный шаблон класса

std::atomic<>
без специализации.

5.2.6. Основной шаблон класса
std::atomic<>

Наличие основного шаблона позволяет создавать атомарные варианты пользовательских типов, в дополнение к стандартным атомарным типам. Однако в качестве параметра шаблона

std::atomic<>
может выступать только тип, удовлетворяющий определенным условиям. Точнее, чтобы тип
UDT
мог использоваться в конструкции
std::atomic
, в нем должен присутствовать тривиальный оператор присваивания. Это означает, что в типе не должно быть виртуальных функций или виртуальных базовых классов, а оператор присваивания должен генерироваться компилятором. Более того, в каждом базовом классе и нестатическом члене данных также должен быть тривиальный оператор присваивания. Это позволяет компилятору использовать для присваивания функцию
memcpy()
или эквивалентную ей, поскольку исполнять написанный пользователем код не требуется.

Наконец, тип должен допускать побитовое сравнение на равенство. Это требование из того же разряда, что требования к присваиванию — должна быть не только возможность колировать объекты с помощью

memcpy()
, но и сравнивать их с помощью
memcmp()
. Это необходимо для правильной работы операции сравнить-и-обменять.

Чтобы понять, чем вызваны такие ограничения, вспомните рекомендацию из главы 3: не передавать ссылки и указатели на защищенные данные за пределы области видимости в виде аргументов предоставленной пользователем функции. В общем случае компилятор не в состоянии сгенерировать свободный от блокировок код для типа s

td::atomic
, поэтому он вынужден применять внутренние блокировки. Если бы пользовательские операторы присваивания и сравнения были разрешены, то пришлось бы передавать ссылку на защищенные данные в пользовательскую функцию, нарушая тем самым приведённую выше рекомендацию. Кроме того, библиотека вправе использовать единую блокировку для всех нуждающихся в ней атомарных операций, поэтому, разрешив вызывать пользовательские функции в момент, когда эта блокировка удерживается, мы могли бы получить взаимоблокировку или надолго задержать другие потоки, если сравнение занимает много времени. Наконец, эти ограничения повышают шансы на то, что компилятор сумеет сгенерировать для
std::atomic
код, содержащий истинно атомарные команды (и тем самым обойтись в данной конкретизации вообще без блокировок), поскольку в этой ситуации он вправе рассматривать определенный пользователем тип как неструктурированную последовательность байтов.

Отметим, что несмотря на то, что типы

std::atomic
и
std::atomic
формально разрешены, так как встроенные типы с плавающей точкой удовлетворяют сформулированным выше критериям на использование
memcpy
и
memcmp
, их поведение в части функции
compare_exchange_strong
может оказаться неожиданным. Операция может завершиться отказом, даже если ранее сохраненное значение численно равно ожидаемому, но имеет другое внутреннее представление. Отметим также, что над числами с плавающей точкой не определены атомарные арифметические операции. Аналогичное поведение
compare_exchange_strong
вы получите, если конкретизируете
std::atomic<>
пользовательским типом, в котором оператор сравнения на равенство определён, но отличается от сравнения с помощью
memcmp
— операция может завершиться отказом, потому что равные значения имеют различное представление.

Если размер пользовательского типа

UDT
равен (или меньше) размеру
int
или
void*
, то на большинстве платформ для типа
std::atomic
можно сгенерировать код, содержащий только атомарные команды. На некоторых платформах подобный код можно сгенерировать и в случае, когда размер пользовательского типа в два раза превышает размер
int
или
void*
. Обычно это платформы, на которых имеется команда сравнения и обмена двойных слов double-word-compare-and-swap (DWCAS), соответствующая функциям
compare_exchange_xxx
.

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

std::atomic>
, но можете использовать для параметризации классы, содержащие счетчики, флаги, указатели и даже массивы простых элементов. Обычно это не проблема; чем сложнее структура данных, тем больше вероятность, что в ней нужно будет определить какие-то другие операции, помимо простейшего присваивания и сравнения. Но в таком случае лучше воспользоваться классом
std::mutex
, который гарантирует надлежащую защиту данных при выполнении этих операций (см. главу 3).

Интерфейс шаблона

std::atomic
, конкретизированного пользовательским типом
T
, ограничен набором операций, доступных классу
std::atomic
:
load()
,
store()
,
exchange()
,
compare_exchange_weak()
,
compare_exchange_strong()
, присваивание значения типа
T
и преобразование в значение типа
T
.

В табл. 5.3 перечислены операции, доступные для всех атомарных типов.


Таблица 5.3. Операции над атомарными типами

Операция
atomic_ flag
atomic 
atomic 
atomic 
atomic 
test_and_set
clear
is_lock_free
load
store
exchange
compare_exchange_weak, compare_exchange_strong
fetch_add, +=
fetch_sub, -=
fetch_or, |=
fetch_and, &=
fetch_xor, ^=
++, --

5.2.7. Свободные функции для атомарных операций

До сих пор я описывал только те операции над атомарными типами, которые реализованы функциями-членами. Однако для всех этих операций существуют также эквивалентные функции, не являющиеся членами классов. Как правило, имена свободных функций строятся по единому образцу: имя соответствующей функции-члена с префиксом

atomic_
(например,
std::atomic_load()
). Затем эти функции перегружаются для каждого атомарного типа. Если имеется возможность задать признак упорядочения доступа к памяти, то предлагаются две разновидности функции: одна без признака, другая — ее имя заканчивается суффиксом
_explicit
— с одним или несколькими дополнительными параметрами для задания признаков (например,
std::atomic_store(&atomic_var, new_value)
и
std::atomic_store_explicit(&atomic_var, new_value, std::memory_order_release)
. Если в случае функций-членов объект атомарного типа задается неявно, то все свободные функции принимают в первом параметре указатель на такой объект.

Например, для функции

std::atomic_is_lock_free()
есть только одна разновидность (хотя и перегруженная для всех типов), причём
std::atomic_is_lock_free(&a)
возвращает то же значение, что
a.is_lock_free()
для объекта
а
атомарного типа. Аналогично
std::atomic_load(&a)
— то же самое, что
a.load()
, а эквивалентом
a.load(std::memory_order_acquire)
является
std::atomic_load_explicit(&a, std::memory_order_acquire)
.

Свободные функции совместимы с языком С, то есть во всех случаях принимают указатели, а не ссылки. Например, первый параметр функций-членов

compare_exchange_weak()
и
compare_exchange_strong()
(ожидаемое значение) — ссылка, но вторым параметром
std::atomic_compare_exchange_weak()
(первый — это указатель на объект) является указатель. Функция
std::atomic_compare_exchange_weak_explicit()
также требует задания двух параметров, определяющих упорядочение доступа к памяти в случае успеха и отказа, тогда как функции-члены для сравнения с обменом имеют варианты как с одним параметром (второй по умолчанию равен
std::memory_order_seq_cst
), так и с двумя.

Операции над типом

std::atomic_flag
нарушают традицию, поскольку в именах функций присутствует дополнительное слово «flag»:
std::atomic_flag_test_and_set()
,
std::atomic_flag_clear()
, но у вариантов с параметрами, задающими упорядочение доступа, суффикс
_explicit
по-прежнему имеется:
std::atomic_flag_test_and_set_explicit()
и
std::atomic_flag_clear_explicit()
.

В стандартной библиотеке С++ имеются также свободные функции для атомарного доступа к экземплярам типа

std::shared_ptr<>
. Это отход от принципа, согласно которому атомарные операции поддерживаются только для атомарных типов, поскольку тип
std::shared_ptr<>
заведомо не атомарный. Однако комитет по стандартизации С++ счел этот случай достаточно важным, чтобы предоставить дополнительные функции. К числу определенных для него атомарных операций относятся загрузка, сохранение, обмен и сравнение с обменом, и реализованы они в виде перегрузок тех же операций над стандартными атомарными типами, в которых первым аргументом является указатель
std::shared_ptr<>*
:

std::shared_ptr p;


void process_global_data() {

 std::shared_ptr local = std::atomic_load(&p);

 process_data(local);

}


void update_global_data() {

 std::shared_ptr local(new my_data);

 std::atomic_store(&p, local);

}

Как и для атомарных операций над другими типами, предоставляются

_explicit
-варианты, позволяющие задать необходимое упорядочение, а для проверки того, используется ли в реализации внутренняя блокировка, имеется функция
std::atomic_is_lock_free()
.

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

std::mutex
и
std::future<>
. Помня об этом, перейдём к материалу, составляющему главное содержание этой главы: аспектам модели памяти, относящимся к параллелизму, и тому, как с помощью атомарных операций можно синхронизировать данные и навязать порядок доступа к памяти.

5.3. Синхронизация операций и принудительное упорядочение

Пусть имеются два потока, один из которых заполняет структуру данных, а другой читает ее. Чтобы избежать проблематичного состояния гонки, первый поток устанавливает флаг, означающий, что данные готовы, а второй не приступает к чтению данных, пока этот флаг не установлен. Описанный сценарий демонстрируется в листинге ниже.


Листинг 5.2. Запись и чтение переменной в разных потоках

#include 

#include 

#include 


std::vector data;

std::atomic data_ready(false);


void reader_thread() {

 while (!data_ready.load()) {       ←
(1)

  std::this_thread::sleep(std::milliseconds(1));

 }

 std::cout << "Ответ=" << data[0] << "\n";←
(2)

}


void writer_thread() {

 data.push_back(42); ←
(3)

 data_ready = true;  ←
(4)

}

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

Требуемое упорядочение обеспечивают операции с переменной

data_ready
типа
std::atomic
и делается это благодаря отношениям происходит-раньше и синхронизируется-с, заложенным в модель памяти. Запись данных (3) происходит-раньше записи флага
data_ready
(4), а чтение флага (1) происходит-раньше чтения данных (2). Когда прочитанное значение
data_ready
(1) равно
true
, операция записи синхронизируется-с этой операцией чтения, что приводит к порождению отношения происходит-раньше. Поскольку отношение происходит-раньше транзитивно, то запись данных (3) происходит-раньше записи флага (4), которая происходит-раньше чтения значения
true
из этого флага (1), которое в свою очередь происходит-раньше чтения данных (2). И таким образом мы получаем принудительное упорядочение: запись данных происходит-раньше чтения данных, и программа работает правильно. На рис. 5.2 изображены важные отношения происходит-раньше в обоих потоках. Я включил две итерации цикла
while
в потоке-читателе.

Рис. 5.2. Принудительное задание упорядочения неатомарных операций с помощью атомарных

Все это может показаться интуитивно очевидным — разумеется, операция записи значения происходит раньше операции его чтения! В случае атомарных операций по умолчанию это действительно так (на то и умолчания), однако подчеркну: у атомарных операций есть и другие возможности для задания требований к упорядочению, и скоро я о них расскажу.

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

5.3.1. Отношение синхронизируется-с

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

Идея такова: подходящим образом помеченная атомарная операция записи

W
над переменной
x
синхронизируется-с подходящим образом помеченной атомарной операцией чтения над переменной
x
, которая читает значение, сохраненное либо данной операцией записи (
W
), либо следующей за ней атомарной операцией записи над
x
в том же потоке, который выполнил первоначальную операцию
W,
либо последовательностью атомарных операций чтения-модификации-записи над
x
(например,
fetch_add()
или
compare_exchange_weak()
) в любом потоке, при условии, что значение, прочитанное первым потоком в этой последовательности, является значением, записанным операцией
W
(см. раздел 5.3.4).

Пока оставим в стороне слова «подходящим образом помеченная», потому что по умолчанию все операции над атомарными типами помечены подходящим образом. По существу сказанное выше означает ровно то, что вы ожидаете: если поток А сохраняет значение, а поток В читает это значение, то существует отношение синхронизируется-с между сохранением в потоке А и загрузкой в потоке В — как в листинге 5.2.

Уверен, вы догадались, что нюансы как раз и скрываются за словами «подходящим образом помеченная». Модель памяти в С++ допускает применение различных ограничений на упорядочение к операциям над атомарными типами, и именно это и называется пометкой. Варианты упорядочения доступа к памяти и их связь с отношением синхронизируется-с рассматриваются в разделе 5.3.3. А пока отступим на один шаг и поговорим об отношении происходит-раньше.

5.3.2. Отношение происходит-раньше

Отношение происходит-раньше — основной строительный блок механизма упорядочения операций в программе. Оно определяет, какие операции видят последствия других операций и каких именно. В однопоточной программе всё просто: если в последовательности выполняемых операций одна стоит раньше другой, то она и происходит-раньше. Иначе говоря, если операция А в исходном коде предшествует операции В, то А происходит-раньше В. Это мы видели в листинге 5.2: запись в переменную

data
(3) происходит-раньше записи в переменную
data_ready
(4). В общем случае между операциями, которые входят в состав одного предложения языка, нет отношения происходит-раньше, поскольку они не упорядочены. По-другому то же самое можно выразить, сказав, что порядок не определён. Мы знаем, что программа, приведённая в следующем листинге, напечатает "
1,2
" или "
2,1
", но что именно, неизвестно, потому что порядок двух обращений к
get_num()
не определён.


Листинг 5.3. Порядок определения аргументов функции не определён

#include 


void foo(int a, int b) {

 std::cout << a << "," << b << std::endl;

}


int get_num() {

 static int i = 0;

 return ++i;

}


int main() {

 foo(get_num(), get_num());←┐
Порядок обращений

}              │
к get_num() не определен

Существуют случаи, когда порядок операций внутри одного предложения точно известен, например, если используется встроенный оператор «занятая» или результат одного выражения является аргументом другого выражения. Но в общем случае никакого отношения расположено-перед (а, значит, и отношения происходит-раньше) между ними не существует. Разумеется, все операции в одном предложении происходят раньше всех операций в следующем за ним предложении.

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

На понятийном уровне отношение межпоточно происходит-раньше довольно простое, оно опирается на отношение синхронизируется-с, введенное в разделе 5.3.1: если операция А в одном потоке синхронизируется-с операцией В в другом потоке, то А межпоточно происходит-раньше В. Это отношение также транзитивно: если А межпоточно происходит-раньше В, а В межпоточно происходит-раньше С, то А межпоточно происходит-раньше С. Это мы тоже видели в листинге 5.2.

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

Именно эти критически важные правила и обеспечивают упорядоченность операций между потоками, благодаря чему программа в листинге 5.2 работает правильно. Как мы скоро увидим, существуют дополнительные нюансы, связанные с зависимостями между данными. Чтобы разобраться в них, мне нужно будет рассмотреть признаки упорядочения доступа к памяти, используемые в атомарных операциях, и рассказать, как они связаны с отношением синхронизируется-с.

5.3.3. Упорядочение доступа к памяти для атомарных операций

Существует шесть вариантов упорядочения доступа к памяти, которые можно задавать в операциях над атомарными типами:

memory_order_relaxed
,
memory_order_consume
,
memory_order_acquire
,
memory_order_release
,
memory_order_acq_rel
и
memory_order_seq_cst
. Если не указано противное, то для любой операции над атомарными типами подразумевается упорядочение
memory_order_seq_cst
— самое ограничительное из всех. Хотя вариантов шесть, представляют они всего три модели: последовательно согласованное упорядочение (
memory_order_seq_cst
), упорядочение захват-освобождение (
memory_order_consume
,
memory_order_acquire
,
memory_order_release
и
memory_order_acq_rel
) и ослабленное упорядочение (
memory_order_relaxed
).

Эти три модели упорядочения доступа к памяти влекут за собой различные издержки для процессоров с разной архитектурой. Например, в системах с точным контролем над видимостью операций процессорами, отличными от произведшего изменения, могут потребоваться дополнительные команды синхронизации для обеспечения последовательно согласованного упорядочения по сравнению с ослабленным или упорядочением захват-освобождение, а также для обеспечения упорядочения захват-освобождение по сравнению с ослабленным. Если в такой системе много процессоров, то на выполнение дополнительных команд синхронизации может уходить заметное время, что приведет к снижению общей производительности системы. С другой стороны, процессоры с архитектурой x86 или x86-64 (в частности, Intel и AMD, столь распространенные в настольных ПК) не требуют никаких дополнительных команд для обеспечения упорядочения захват-освобождение, помимо необходимых для гарантий атомарности, и даже последовательно согласованное упорядочение не нуждается в каких-то специальных действиях на операциях загрузки, хотя операции сохранения все же требуют некоторых добавочных затрат.

Наличие различных моделей упорядочения доступа к памяти позволяет эксперту добиться повышения производительности за счет более точного управления отношениями упорядочения там, где это имеет смысл, и в то же время использовать последовательно согласованное упорядочение (которое гораздо проще для понимания) в случаях, когда такой выигрыш не критичен.

Чтобы выбрать подходящую модель, нужно понимать, каковы последствия того или иного решения для поведения программы. Поэтому рассмотрим, какое влияние оказывают различные модели на упорядочение операций и отношение синхронизируется-с.

Последовательно согласованное упорядочение

Упорядочение по умолчанию называется последовательно согласованным, потому что оно предполагает, что поведение программы согласовано с простым последовательным взглядом на мир. Если все операции над экземплярами атомарных типов последовательно согласованы, то поведение многопоточной программы такое же, как если бы эти операции выполнялись в какой-то определенной последовательности в одном потоке. Это самое простое для понимания упорядочение доступа к памяти, потому оно и подразумевается по умолчанию: все потоки должны видеть один и тот же порядок операций. Таким образом, становится достаточно легко рассуждать о поведении программы, написанной с использованием атомарных переменных. Можно выписать все возможные последовательности операций, выполняемых разными потоками, отбросить несогласованные и проверить, что в остальных случаях программа ведет себя, как и ожидалось. Это также означает, что порядок операций нельзя изменять; если в каком-то потоке одна операция предшествует другой, то этот порядок должен быть виден всем остальным потокам.

С точки зрения синхронизации, последовательно согласованное сохранение синхронизируется-с последовательно согласованной операцией загрузки той же переменной, в которой читается сохраненное значение. Тем самым мы получаем одно ограничение на упорядочение операций в двух или более потоках. Однако этим последовательная согласованность не исчерпывается. Любая последовательно согласованная операция, выполненная после этой загрузки, должна быть видна всякому другому потоку в системе с последовательно согласованными атомарными операциями именно как следующая за загрузкой. Пример в листинге 5.4 демонстрирует это ограничение на упорядочение в действии. Однако это ограничение не распространяется на потоки, в которых для атомарных операций задано ослабленное упорядочение — они по-прежнему могут видеть операции в другом порядке. Поэтому, чтобы получить пользу от последовательного согласования операций, его надо использовать во всех потоках.

Но за простоту понимания приходится платить. На машине со слабым упорядочением и большим количеством процессоров может наблюдаться заметное снижение производительности, потому что для поддержания согласованной последовательности операций, возможно, придётся часто выполнять дорогостоящие операции синхронизации процессоров. Вместе с тем следует отметить, что некоторые архитектуры процессоров (в частности, такие распространенные, как x86 и x86-64) обеспечивают последовательную согласованность с относительно низкими издержками, так что если вас волнует влияние последовательно согласованного упорядочения на производительность, ознакомьтесь с документацией но конкретному процессору.

В следующем листинге последовательная согласованность демонстрируется на примере. Операции загрузки и сохранения переменных

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


Листинг 5.4. Из последовательной согласованности вытекает полная упорядоченность

#include 

#include 

#include 


std::atomic x, y;

std::atomic z;


void write_x() {

 x.store(true, std::memory_order_seq_cst); ←
(1)

}


void write_y() {

 y.store(true, std::memory_order_seq_cst); ←
(2)

}


void read_x_then_y() {

 while (!x.load(std::memory_order_seq_cst));←
(3)

 if (y.load(std::memory_order_seq_cst))

  ++z;

}


void read_y_then_x() {

 while (!y.load(std::memory_order_seq_cst));←
(4)

 if (x.load(std::memory_order_seq_cst))

  ++z;

}


int main() {

 x = false;

 y = false;

 z = 0;

 std::thread a(write_x);

 std::thread b(write_y);

 std::thread с(read_x_then_y);

 std::thread d(read_y_then_x);

 a.join();

 b.join();

 c.join();

 d.join();

 assert(z.load() != 0); ←
(5)

}

Утверждение

assert
(5) не может сработать, потому что первым должно произойти сохранение
x
(1) или сохранение
y
(2), пусть даже точно не сказано, какое именно. Если загрузка
y
в функции
read_x_then_y
(3) возвращает
false
, то сохранение
x
должно было произойти раньше сохранения
y
, и в таком случае загрузка
x
в
read_y_then_x
(4) должна вернуть
true
, потому что наличие цикла
while
гарантирует, что в этой точке
у
равно
true
. Поскольку семантика
memory_order_seq_cst
требует полного упорядочения всех операций, помеченных признаком
memory_order_seq_cst
, то существует подразумеваемое отношение порядка между операцией загрузки
y
, которая возвращает
false
(3), и операцией сохранения
y
(1). Чтобы имело место единственное полное упорядочение в случае, когда некоторый поток сначала видит
x==true
, затем
y==false
, необходимо, чтобы при таком упорядочении сохранение
x
происходило раньше сохранения
y
.

Разумеется, поскольку всё симметрично, могло бы произойти и ровно наоборот: загрузка

x
(4) возвращает
false
, и тогда загрузка
y
(3) обязана вернуть
true
. В обоих случаях
z
равно 1. Может быть и так, что обе операции вернут
true
, и тогда
z
будет равно 2. Но ни в каком случае
z
не может оказаться равным нулю.

Операции и отношения происходит-раньше для случая, когда

read_x_then_y
видит, что
x
равно
true
, а
y
равно
false
, изображены на рис. 5.3. Пунктирная линия от операции загрузки
y
в
read_x_then_y
к операции сохранения
y
в
write_y
показывает наличие неявного отношения порядка, необходимого для поддержания последовательной согласованности: загрузка должна произойти раньше сохранения в глобальном порядке операций, помеченных признаком
memory_order_seq_cst
, — только тогда получится показанный на рисунке результат.

Рис. 5.3. Последовательная согласованность и отношения происходит-раньше

Последовательная согласованность — самое простое и интуитивно понятное упорядочение, но оно же является и самым накладным из- за необходимости глобальной синхронизации между всеми потоками. В многопроцессорной системе это потребовало бы многочисленных и затратных по времени взаимодействий между процессорами. Чтобы избежать затрат на синхронизацию, необходимо выйти за пределы мира последовательной согласованности и рассмотреть другие модели упорядочения доступа к памяти.

Не последовательно согласованное упорядочение доступа к памяти

За пределами уютного последовательно согласованного мирка нас встречает более сложная реальность. И, пожалуй, самое трудное — смириться с тем фактом, что единого глобального порядка событий больше не существует. Это означает, что разные потоки могут по-разному видеть одни и те же операции, и с любой умозрительной моделью, предполагающей, что операции, выполняемые в разных потоках, строго перемежаются, следует распрощаться. Вы должны учитывать не только то, что события могут происходить по-настоящему одновременно, но и то, что потоки не обязаны согласовывать порядок событий между собой. Чтобы написать (или хотя бы понять) код, в котором используется упорядочение, отличное от

memory_order_seq_cst
, абсолютно необходимо уложить этот факт в мозгу. Мало того что компилятор вправе изменять порядок команд. Даже если потоки исполняют один и тот же код, они могут видеть события в разном порядке, потому что в отсутствие явных ограничений на упорядочение кэши различных процессоров и внутренние буферы могут содержать различные значения для одной и той же ячейки памяти. Это настолько важно, что я еще раз повторю: потоки не обязаны согласовывать порядок событий между собой.

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

Проще всего это продемонстрировать, перейдя от последовательной согласованности к ее полной противоположности — упорядочению

memory_order_relaxed
для всех операций. Освоив этот случай, мы сможем вернуться к упорядочению захват-освобождение, которое позволяет избирательно вводить некоторые отношения порядка между операциями. Это хоть как-то поможет собрать разлетевшиеся мозги в кучку.

Ослабленное упорядочение

Операции над атомарными типами, выполняемые в режиме ослабленного упорядочения, не участвуют в отношениях синхронизируется-с. Операции над одной и той же переменной в одном потоке по-прежнему связаны отношением происходит-раньше, но на относительный порядок операций в разных потоках не накладывается почти никаких ограничений. Есть лишь одно требование: операции доступа к одной атомарной переменной в одном и том же потоке нельзя переупорядочивать — если данный поток видел определенное значение атомарной переменной, то последующая операция чтения не может извлечь предыдущее значение этой переменной. В отсутствие дополнительной синхронизации порядок модификации отдельных переменных — это единственное, что объединяет потоки, использующие модель

memory_order_relaxed
.

Чтобы продемонстрировать, до какой степени могут быть «ослаблены» операции в этой модели, достаточно всего двух потоков (см. листинг 5.5).


Листинг 5.5. К ослабленным операциям предъявляются очень слабые требования

#include 

#include 

#include 


std::atomic x,y;

std::atomic z;


void write_x_then_y() {

 x.store(true, std::memory_order_relaxed); ←
(1)

 y.store(true, std::memory_order_relaxed); ←
(2)

}


void read_y_then_x() {

 while (!y.load(std::memory_order_relaxed));←
(3)

 if (x.load(std::memory_order_relaxed))   ←
(4)

  ++z;

}


int main() {

 x = false;

 y = false;

 z = 0;

 std::thread а(write_x_then_y);

 std::thread b(read_y_then_x);

 a.join();

 b.join();

 assert (z.load() != 0); ←
(5)

}

На этот раз утверждение (5) может сработать, потому что операция загрузки

x
(4) может прочитать
false
, даже если загрузка
y
(3) прочитает
true
, а сохранение
x
(1) происходит-раньше сохранения
y
(2).
x
и
y
— разные переменные, поэтому нет никаких гарантий относительно порядка видимости результатов операций над каждой из них.

Ослабленные операции над разными переменными можно как угодно переупорядочивать при условии, что они подчиняются ограничивающим отношениям происходит-раньше (например, действующим внутри одного потока). Никаких отношений синхронизируется-с не возникает. Отношения происходит-раньше, имеющиеся в листинге 5.5, изображены на рис. 5.4, вместе с возможным результатом. Несмотря на то, что существует отношение происходит-раньше отдельно между операциями сохранения и операциями загрузки, не существует ни одного такого отношения между любым сохранением и любой загрузкой, поэтому операция загрузки может увидеть операции сохранения не в том порядке, в котором они происходили.

Рис. 5.4. Ослабленные атомарные операции и отношения происходит-раньше

Рассмотрим чуть более сложный пример с тремя переменными и пятью потоками.


Листинг 5.6. Ослабленные операции в нескольких потоках

#include 

#include 

#include 


std::atomic x(0), y(0), z(0);←
(1)

std::atomic go(false);    ←
(2)


unsigned const loop_count = 10;


struct read_values {

 int x, y, z;

};


read_values values1[loop_count];

read_values values2[loop_count];

read_values values3[loop_count];

read_values values4[loop_count];

read_values values5[loop_count];


void increment(

 std::atomic* var_to_inc, read_values* values) {

 while (!go) ←
(3) В цикле ждем сигнала

  std::this_thread::yield();

 for (unsigned i = 0; i < loop_count; ++i) {

  values[i].x = x.load(std::memory_order_relaxed);

  values[i].y = y.load(std::memory_order_relaxed);

  values[i].z = z.load(std::memory_order_relaxed);

  var_to_inc->store(i + 1, std::memory_order_relaxed);←
(4)

  std::this_thread::yield();

 }

}


void read_vals(read_values* values) {

 while (!go) ←
(5) В цикле ждем сигнала

 std::this_thread::yield();

 for (unsigned i = 0; i < loop_count; ++i) {

  values[i].x = x.load(std::memory_order_relaxed);

  values[i].y = y.load(std::memory_order_relaxed);

  values[i].z = z.load(std::memory_order_relaxed);

  std::this_thread::yield();

 }

}


void print(read_values* v) {

 for (unsigned i = 0; i < loop_count; ++i) {

  if (i)

  std::cout << ",";

  std::cout <<

   "(" << v [i] .x << "," << v[i].y << "," << v[i].z << ")";

 }

 std::cout << std::endl;

}


int main() {

 std::thread t1(increment, &x, values1);

 std::thread t2(increment, &y, values2);

 std::thread t3(increment, &z, values3);

 std::thread t4(read_vals, values4);

 std::thread t5(read_vals, values5);


 go = true; ←┐
Сигнал к началу выполнения


              
            
(6) главного цикла


 t5.join();

 t4.join();

 t3.join();

 t2.join();

 t1.join();


 print(values1);←┐

 print(values2); │
Печатаем получившиеся

 print(values3);
(7) значения

 print(values4);

 print(values5);

}

По существу, это очень простая программа. У нас есть три разделяемых глобальных атомарных переменных (1) и пять потоков. Каждый поток выполняет 10 итераций цикла, читая значения трех атомарных переменных в режиме

memory_order_relaxed
и сохраняя их в массиве. Три из пяти потоков обновляют одну из атомарных переменных при каждом проходе по циклу (4), а остальные два только читают ее. После присоединения всех потоков мы распечатываем массивы, заполненные каждым из них (7).

Атомарная переменная

go
(2) служит для того, чтобы все потоки начали работу по возможности одновременно. Запуск потока — накладная операция и, не будь явной задержки, первый поток мог бы завершиться еще до того, как последний зачал работать. Каждый поток ждет, пока переменная
go
станет равна
true
, и только потом входит в главный цикл (3), (5), а переменная
go
устанавливается в
true
только после запуска всех потоков (6).

Ниже показан один из возможных результатов прогона этой прогона:

(0,0,0),(1,0,0),(2,0,0),(3,0,0),(4,0,0),(5,7,0),(6,7,8),(7,9,8),(8,9,8),(9,9,10)

(0,0,0),(0,1,0),(0,2,0),(1,3,5),(8,4,5),(8,5,5),(8,6,6),(8,7,9),(10,8,9),(10,9,10)

(0,0,0),(0,0,1),(0,0,2),(0,0,3),(0,0,4),(0,0,5),(0,0,6),(0,0,7),(0,0,8),(0,0,9)

(1,3,0),(2,3,0),(2,4,1),(3,6,4),(3,9,5),(5,10,6),(5,10,8),(5,10,10),(9,10,10),(10,10,10)

(0,0,0),(0,0,0),(0,0,0),(6,3,7),(6,5,7),(7,7,7),(7,8,7),(8,8,7),(8,8,9),(8,8,9)

Первые три строки относятся к потокам, выполнявшим обновление, последние две — к потокам, которые занимались только чтением. Каждая тройка — это значения переменных

x
,
y
,
z
в порядке итераций цикла. Следует отметить несколько моментов.

• В первом наборе значения

x
увеличиваются на 1 в каждой тройке, во втором наборе на 1 увеличиваются значения
y
, а в третьем — значения
z
.

• Значения

x
(а равно
y
и
z
) увеличиваются только в пределах данного набора, но приращения неравномерны и относительный порядок в разных наборах различен.

• Поток 3 не видит обновлений

x
и
y
, ему видны только обновления
z
. Но это не мешает другим потокам видеть обновления
z
наряду с обновлениями
x
и
y
.

Это всего лишь один из возможных результатов выполнения ослабленных операций. Вообще говоря, возможен любой результат, в котором каждая из трех переменных принимает значения от 0 до 10, и в каждом потоке, обновляющем некоторую переменную, ее значения монотонно изменяются от 0 до 9.

Механизм ослабленного упорядочения

Чтобы попять, как всё это работает, представьте, что каждая переменная — человек с блокнотом, сидящий в отдельном боксе. В блокноте записана последовательность значений. Вы можете позвонить сидельцу и попросить либо прочитать вслух какое-нибудь значение, либо записать новое. Новое значение он записывает в конец последовательности.

При первой просьбе дать значение человек может прочитать любое значение из списка, имеющегося в данный момент. В ответ на следующую просьбу он может прочитать либо то же самое значение, либо значение, расположенное позже него в списке, но никогда — значение, расположенное раньше уже прочитанного. Если вы просили записать значение, а потом прочитать, то он может сообщить либо значение, записанное в ответ на вашу просьбу, либо расположенное позже него в списке.

Теперь представьте, что в начале списка находятся значения 5, 10, 23, 3, 1, 2. Человек может прочитать любое из них. Если он скажет 10, то в следующий раз он может прочитать также 10 или любое последующее число, но не 5. Если вы позвоните пять раз, то может услышать, например, последовательность «10, 10, 1, 2, 2». Если вы попросите записать 42, он добавит это число в конец списка. Если вы затем будете просить прочитать число, то он будет повторять «42», пока в списке не появится новое число и он не захочет назвать его.

Предположим далее, что у Карла тоже есть телефон этого человека. Карл тоже может позволить ему с просьбой либо прочитать, либо записать число. При этом к Карлу применяются те же правила, что и к вам. Телефон только один, поэтому в каждый момент времени человек общается только с одним из вас, так что список в его блокноте растет строго последовательно. Но из того, что вы попросили записать его новое число, вовсе не следует, что он должен сообщить его Карлу. и наоборот. Если Карл попросил назвать число и услышал в ответ «23», то из того, что вы попросили записать число 42, не вытекает, что в следующий раз Карл услышит его. Человек может назвать Карлу любое из чисел 23, 3, 1, 2, 42 или даже 67, если после вас позвонил Фред и попросил записать это число. Он даже может назвать Карлу последовательность «23, 3, 3, 1, 67», и это не будет противоречить тому, что услышали вы. Можно представить себе, что человек запоминает, какое число кому назвал, сдвигая указатели, на которых написано имя спрашивающего, как показано на рис. 5.5.

Рис. 5.5. Блокнот человека, сидящего в боксе

Теперь представьте, что имеется целый ряд боксов, в каждом из которых сидит по человеку с блокнотом и телефоном. Это всё наши атомарные переменные. У каждой переменной свой порядок модификации (список значений в блокноте), по между ними нет никакой связи. Если каждый звонящий (вы, Карл, Анна, Дэйв и Фред) представляет поток, то именно такая картина наблюдается, когда все операции работают в режиме

memory_order_relaxed
. К человеку, сидящему в боксе, можно обращаться и с другими просьбами, например: «запиши это число и скажи мне, что находится в конце списка» (
exchange
) или «запиши это число, если число в конце списка равно тому, в противном случае скажи мне, что я должен был бы предположить» (
compare_exchange_strong
), но общий принцип при этом не изменяется.

Применив эту метафору к программе в листинге 5.5, можно сказать, что

write_x_then_y
означает, что некто позвонил человеку в боксе
x
, попросил его записать
true
, а потом позвонил человеку в боксе
y
и попросил его записать
true
. Поток, выполняющий функцию
read_y_then_x
, раз за разом звонит человеку в боксе
y
и спрашивает значение, пока не услышит
true
, после чего звонит человеку в боксе
x
и спрашивает значение у него. Человек в боксе
x
не обязан сообщать вам какое-то конкретное значение из своего списка и с полным правом может назвать
false
.

Из-за этого с ослабленными атомарными операциями трудно иметь дело. Чтобы они были полезны для межпоточной синхронизации, их нужно сочетать с атомарными операциями, работающими в режиме с более строгой семантикой упорядочения. Я настоятельно рекомендую вообще избегать ослабленных атомарных операций, если без них можно обойтись, а, если никак нельзя, то использовать крайне осторожно. Учитывая, насколько интуитивно неочевидные результаты получились в листинге 5.5 при наличии всего двух потоков и двух переменных, нетрудно представить себе сложности, с которыми придется столкнуться, когда потоков и переменных станет больше.

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

Упорядочение захват-освобождение

Упорядочение захват-освобождение — шаг от ослабленного упорядочения в сторону большего порядка; полной упорядоченности операций еще нет, но какая-то синхронизация уже возможна. При такой модели атомарные операции загрузки являются операциями захвата (

memory_order_acquire
), атомарные операции сохранения — операциями освобождения (
memory_order_release
), а атомарные операции чтения-модификации-записи (например,
fetch_add()
или
exchange()
) — операциями захвата, освобождения или того и другого (
memory_order_acq_rel
). Синхронизация попарная — между потоком, выполнившим захват, и потоком, выполнившим освобождение. Операция освобождения синхронизируется-с операцией захвата, которая читает записанное значение. Это означает, что различные потоки могут видеть операции в разном порядке, но возможны все-таки не любые порядки. В следующем листинге показала программа из листинга 5.4, переработанная под семантику захвата-освобождения вместо семантики последовательной согласованности.


Листинг 5.7. Из семантики захвата-освобождения не вытекает полная упорядоченность

#include 

#include 

#include 


std::atomic x, y;

std::atomic z;


void write_x() {

 x.store(true, std::memory_order_release);

}


void write_y() {

 y.store(true, std::memory_order_release);

}


void read_x_then_y() {

 while (!x.load(std::memory_order_acquire));

 if (y.load(std::memory_order_acquire)) ←
(1)

  ++z;

}


void read_y_then_x() {

 while (!y.load(std::memory_order_acquire));

 if (x.load(std::memory_order_acquire)) ←
(2)

  ++z;

}


int main() {

 x = false;

 y = false;

 z = 0;

 std::thread a(write_x);

 std::thread b(write_y);

 std::thread с(read_x_then_y);

 std::thread d(read_y_then_x);

 a.join();

 b.join();

 c.join();

 d.join();

 assert(z.load() != 0); ←
(3)

}

В данном случае утверждение (3) может сработать (как и в случае ослабленного упорядочения), потому что обе операции загрузки —

x
(2) и
y
(1) могут прочитать значение
false
. Запись в переменные
x
и
y
производится из разных потоков, но упорядоченность между освобождением и захватом в одном потоке никак не отражается на операциях в других потоках.

На рис. 5.6 показаны отношения происходит-раньше, имеющие место в программе из листинга 5.7, а также возможный исход, когда два потока-читателя имеют разное представление о мире. Это возможно, потому что, как уже было сказано, не существует отношения происходит-раньше, которое вводило бы упорядочение.

Рис. 5.6. Захват-освобождение и отношения происходит-раньше

Чтобы осознать преимущества упорядочения захват-освобождение, нужно рассмотреть две операции сохранения в одном потоке, как в листинге 5.5. Если при сохранении

y
задать семантику
memory_order_release
, а при загрузке
y
— семантику
memory_order_acquire
, как в листинге ниже, то операции над
x
станут упорядоченными.


Листинг 5.8. Операции с семантикой захвата-освобождения могут упорядочить ослабленные операции

#include 

#include 

#include 


std::atomic x, y;

std::atomic z;


void write_x_then_y() {

 x.store(true,std::memory_order_relaxed);  ←
(1)

 y.store(true,std::memory_order_release);  ←
(2)

}


void read_y_then_x() {

 while (!y.load(std::memory_order_acquire));←
(3)

 if (x.load(std::memory_order_relaxed))   ←
(4)

  ++z;

}


int main() {

 x = false;

 y = false;

 z = 0;

 std::thread a(write_x_then_y);

 std::thread b(read_y_then_x);

 a.join();

 b.join();

 assert(z.load() != 0); ←
(5)

}

В конечном итоге операция загрузки

y
(3) увидит значение
true
, записанное операцией сохранения (2). Поскольку сохранение производится в режиме
memory_order_release
, а загрузка — в режиме
memory_order_acquire
, то сохранение синхронизируется-с загрузкой. Сохранение
x
(1) происходит-раньше сохранения
y
(2), потому что обе операции выполняются в одном потоке. Поскольку сохранение
y
синхронизируется-с загрузкой
y
, то сохранение
x
также происходит-раньше загрузки
y
, и, следовательно, происходит-раньше загрузки
x
(4). Таким образом, операция загрузки
x
должна прочитать
true
, и, значит, утверждение (5) не может сработать. Если бы загрузка
y
не повторялась в цикле
while
, то высказанное утверждение могло бы оказаться неверным; операция загрузки
y
могла бы прочитать
false
, и тогда не было бы никаких ограничений на значение, прочитанное из
x
. Для обеспечения синхронизации операции захвата и освобождения должны употребляться парами. Значение, сохраненное операций восстановления, должно быть видно операции захвата, иначе ни та, ни другая не возымеют эффекта. Если бы сохранение в предложении (2) или загрузка в предложении (3) выполнялись в ослабленной операции, то обращения к
x
не были бы упорядочены, и, значит, нельзя было бы гарантировать, что операция загрузки в предложении (4) прочитает значение
true
, поэтому утверждение
assert
могло бы сработать.

К упорядочению захват-освобождение можно применить метафору человека с блокнотом в боксе, если ее немного дополнить. Во-первых, допустим, что каждое сохранение является частью некоторого пакета обновлений, поэтому, обращаясь к человеку с просьбой записать число, вы заодно сообщается ему идентификатор пакета, например: «Запиши 99 как часть пакета 423». Если речь идет о последнем сохранении в пакете, то мы сообщаем об этом: «Запиши 147, отметив, что это последнее сохранение в пакете 423». Человек в боксе честно записывает эту информацию вместе с указанным вами значением. Так моделируется операция сохранения с освобождением. Когда вы в следующий раз попросите записать значение, помер пакета нужно будет увеличить: «Запиши 41 как часть пакета 424».

Теперь, когда вы просите сообщить значение, у вас есть выбор: узнать только значение (это аналог ослабленной загрузки) или значение и сведения о том, является ли оно последним в пакете (это аналог загрузки с захватом). Если информация о пакете запрашивается, по значение не последнее в пакете, то человек ответит: «Число равно 987, и это 'обычное' значение»; если же значение последнее, то ответ прозвучит так: «Число 987, последнее в пакете 956 от Анны». Тут-то и проявляется семантика захвата-освобождения: если, запрашивая значение, вы сообщите человеку номера всех пакетов, о которых знаете, то он найдёт в своем списке последнее значение из всех известных вам пакетов и назовёт либо его, либо какое-нибудь следующее за ним в списке.

Как эта метафора моделирует семантику захвата-освобождения? Взгляните на наш пример — и поймете. В самом начале поток

а
вызывает функцию
write_x_then_y
и говорит человеку в боксе
x
: «Запиши
true
, как часть пакета 1 от потока
а
». Затем поток
а
говорит человеку в боксе
y
: «Запиши
true
, как последнюю операцию записи в пакете 1 от потока
а
». Тем временем поток
b
выполняет функцию
read_y_then_x
. Он раз за разом просит человека в боксе
y
сообщить значение вместе с информацией о пакете, пока не услышит в ответ «
true
». Возможно, спросить придется много раз, но в конце концов человек обязательно ответит «
true
». Однако человек в боксе
y
говорит не просто «
true
», а еще добавляет: «Это последняя операция записи в пакете 1 от потока
а
».

Далее поток

b
просит человека в боксе
x
назвать значение, но на это раз говорит: «Сообщи мне значение и, кстати, я знаю о пакете 1 от потока
а
». Человек в боксе x ищет в своем списке последнее упоминание о пакете 1 от потока
а
. Он находит единственное значение
true
, которое стоит последним в списке, поэтому он обязан сообщить именно это значение, иначе нарушит правила игры.

Вспомнив определение отношения межпоточно происходит раньше в разделе 5.3.2, вы обнаружите, что одно из его существенных свойств — транзитивность: если А межпоточно происходит-раньше В и В межпоточно происходит-раньше С, то А межпоточно происходит-раньше С. Это означает, что упорядочение захват-освобождение можно использовать для синхронизации данных между несколькими потоками, даже если «промежуточные» потоки на самом деле не обращались к данным.

Транзитивная синхронизация с помощью упорядочения захват-освобождение

Для рассуждений о транзитивном упорядочении нужны по меньшей мере три потока. Первый модифицирует какие-то разделяемые переменные и выполняет операцию сохранения с освобождением в одну из них. Второй читает переменную, записанную операцией сохранения с освобождением, с помощью операции загрузки с захватом и выполняет сохранение с освобождением во вторую разделяемую переменную. Наконец, третий поток выполняет операцию загрузки с захватом для второй разделяемой переменной. При условии, что операции загрузки с захватом видят значения, записанные операциями сохранения с освобождением, и тем самым поддерживают отношения синхронизируется-с, третий поток может прочитать значения других переменных, сохраненные первым потоком, даже если промежуточный поток к ним не обращался. Этот сценарий иллюстрируется в следующем листинге.


Листинг 5.9. Транзитивная синхронизация с помощью упорядочения захват-освобождение

std::atomic data[5];

std::atomic sync1(false), sync2(false);


void thread_1() {

 data[0].store(42, std::memory_order_relaxed);

 data[1].store(97, std::memory_order_relaxed);

 data[2].store(17, std::memory_order_relaxed);

 data[3].store(-141, std::memory_order_relaxed);

 data[4].store(2003, std::memory_order_relaxed);←┐
Установить

 sync1.store(true, std::memory_order_release); 
(1)sync1

}


void thread_2()                 
(2)Цикл до

{                         │
установки

 while (!sync1.load(std::memory_order_acquire));←┘
sync1

 sync2.store(true, std::memory_order_release); ←┐
Установить

}                       
(3) sync2


void thread_3()                 
(4)Цикл до

{                         │
установки

 while (!sync2.load(std::memory_order_acquire));←┘
sync2

 assert(data[0].load(std::memory_order_relaxed) == 42);

 assert(data[1].load(std::memory_order_relaxed) == 97);

 assert(data[2].load(std::memory_order_relaxed) == 17);

 assert(data[3].load(std::memory_order_relaxed) == -141);

 assert(data[4].load(std::memory_order_relaxed) == 2003);

}

Хотя поток

thread_2
обращается только к переменным
sync1
(2) и
sync2
(3), этого достаточно для синхронизации между
thread_1
и
thread_3
и, стало быть, гарантии несрабатывания утверждений
assert
. Прежде всего, операции сохранения в элементы массива
data
в потоке
thread_1
происходят-раньше сохранения
sync1
(1), потому что они связаны отношением расположено-перед в одном потоке. Поскольку операция загрузки
sync1
(2) находится внутри цикла
while
, она в конце концов увидит значение, сохраненное в
thread_1
и, значит, образует вторую половину пары освобождение-захват. Поэтому сохранение
sync1
происходит-раньше последней загрузки
sync1
в цикле
while
. Эта операция загрузки расположена-перед (и, значит, происходит-раньше) операцией сохранения
sync2
(3), которая образует пару освобождение-захват вместе с последней операцией загрузки в цикле
while
в потоке
thread_3
(4). Таким образом, сохранение
sync2
(3) происходит-раньше загрузки (4), которая происходит-раньше загрузок
data
. В силу транзитивности отношения происходит-раньше всю эту цепочку можно соединить: операции сохранения
data
происходят-раньше операций сохранения
sync1
(1), которые происходят-раньше загрузки
sync1
(2), которая происходит-раньше сохранения
sync2
(3), которая происходит-раньше загрузки
sync2
(4), которая происходит-раньше загрузок
data
. Следовательно, операции сохранения
data
в потоке
thread_1
происходят-раньше операций загрузки
data
в потоке
thread_3
, и утверждения
assert
сработать не могут.

В этом случае можно было бы объединить

sync1
и
sync2
в одну переменную, воспользовавшись операцией чтения-модификации-записи с семантикой
memory_order_acq_rel
в потоке
thread_2
. Один из вариантов — использовать функцию
compare_exchange_strong()
, гарантирующую, что значение будет обновлено только после того, как поток
thread_2
увидит результат сохранения в потоке
thread_1
:

std::atomic sync(0);


void thread_1() {

 // ...

 sync.store(1, std::memory_order_release);

}


void thread_2() {

 int expected = 1;

 while (!sync.compare_exchange_strong(expected, 2,

     std::memory_order_acq_rel))

  expected = 1;

}


void thread_3() {

 while(sync.load(std::memory_order_acquire) < 2);

 // ...

}

При использовании операций чтения-модификации-записи важно выбрать нужную семантику. В данном случае нам нужна одновременно семантика захвата и освобождения, поэтому подойдет

memory_order_acq_rel
, но можно было бы применить другие виды упорядочения. Операция
fetch_sub
с семантикой
memory_order_acquire
не синхронизируется ни с чем, хотя и сохраняет значение, потому что это не операция освобождения. Аналогично сохранение не может синхронизироваться-с операцией
fetch_or
с семантикой
memory_order_release
, потому что часть «чтение»
fetch_or
не является операцией захвата. Операции чтения-модификации-записи с семантикой
memory_order_acq_rel
ведут себя как операции захвата и освобождения одновременно, поэтому предшествующее сохранение может синхронизироваться-с такой операцией и с последующей загрузкой, как и обстоит дело в примере выше.

Если вы сочетаете операции захвата-освобождения с последовательно согласованными операциями, то последовательно согласованные операции загрузки ведут себя, как загрузки с семантикой захвата, а последовательно согласованные операции сохранения — как сохранения с семантикой освобождения. Последовательно согласованные операции чтения-модификации-записи ведут себя как операции, наделенные одновременно семантикой захвата и освобождения. Ослабленные операции так и остаются ослабленными, но связаны дополнительными отношениями синхронизируется-с и последующими отношениями происходит-раньше, наличие которых обусловлено семантикой захвата-освобождения.

Несмотря на интуитивно неочевидные результаты, всякий, кто использовал блокировки, вынужденно имел дело с вопросами упорядочения: блокировка мьютекса — это операция захвата, а его разблокировка — операция освобождения. Работая с мьютексами, вы на опыте узнаете, что при чтении значения необходимо захватывать тот же мьютекс, который захватывался при его записи. Точно так же обстоит дело и здесь — для обеспечения упорядочения операции захвата и освобождения должны применяться к одной и той же переменной. Если данные защищены мьютексом, то взаимно исключающая природа блокировки означает, что результат неотличим от того, который получился бы, если бы операции блокировки и разблокировки были последовательно согласованы. Аналогично, если для построения простой блокировки к атомарным переменным применяется упорядочение захват-освобождение, то с точки зрения программы, использующей такую блокировку, поведение кажется последовательно согласованным, хотя внутренние операции таковыми не являются.

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

Зависимости по данным, упорядочение захват-освобождение и семантика
memory_order_consume

Во введении к этому разделу я говорил, что семантика

memory_order_consume
является частью модели упорядочения захват-освобождение, но из предшествующего описания она полностью выпала. Дело в том, что семантика
memory_order_consume
особая: она связана с зависимостями по данным и позволяет учесть соответствующие нюансы в отношении межпоточно происходит-раньше, о котором шла речь в разделе 5.3.2.

С зависимостями по данным связаны два новых отношения: предшествует-по-зависимости (dependency-ordered-before) и переносит-зависимость-в (carries-a-dependency-to). Как и отношение расположено-перед, отношение переносит-зависимость-в применяется строго внутри одного потока и моделирует зависимость по данным между операциями — если результат операции А используется в качестве операнда операции В, то А переносит-зависимость-в В. Если результатом операции А является значение скалярного типа, например

int
, то отношение применяется и тогда, когда результат А сохраняется в переменной, которая затем используется в качестве операнда В. Эта операция также транзитивна, то есть если А переносит-зависимость-в В и В переносит-зависимость-в С, то А переносит-зависимость-в С.

С другой стороны, отношение предшествует-по-зависимости может применяться к разным потокам. Оно вводится с помощью атомарных операций загрузки, помеченных признаком

memory_order_consume
. Это частный случай семантики
memory_order_acquire
, в котором синхронизированные данные ограничиваются прямыми зависимостями; операция сохранения А, помеченная признаком
memory_order_release
,
memory_order_acq_rel
или
memory_order_seq_cst
, предшествует-по-зависимости операции загрузки В, помеченной признаком
memory_order_consume
, если потребитель читает сохраненное значение. Это противоположность отношению синхронизируется-с, которое образуется, если операция загрузки помечена признаком
memory_order_acquire
. Если такая операция В затем переносит-зависимость-в некоторую операцию С, то А также предшествует-по-зависимости С.

Это не дало бы ничего полезного для целей синхронизации, если бы не было связано с отношением межпоточно происходит-раньше. Однако же справедливо следующее утверждение: если А предшествует-по-зависимости В, то А межпоточно происходит-раньше В.

Одно из важных применений такого упорядочения доступа к памяти связано с атомарной операцией загрузки указателя на данные. Пометив операцию загрузки признаком

memory_order_consume
, а предшествующую ей операцию сохранения — признаком
memory_order_release
, можно гарантировать, что данные, адресуемые указателем, правильно синхронизированы, даже не накладывая никаких требований к синхронизации с другими независимыми данными. Этот сценарий иллюстрируется в следующем листинге.


Листинг 5.10. Использование

std::memory_order_consume
для синхронизации данных

struct X {

 int i;

 std::string s;

};


std::atomic p;

std::atomic a;


void create_x() {

 X* x = new X;

 x->i = 42;

 x->s = "hello";

 a.store(99, std::memory_order_relaxed);←
(1)

 p.store(x, std::memory_order_release); ←
(2)

}


void use_x() {

 X* x;

 while (!(x = p.load(std::memory_order_consume)))←
(3)

 std::this_thread::sleep(std::chrono::microseconds(1));

 assert(x->i == 42);               ←
(4)

 assert(x->s =="hello");             ←
(5)

 assert(a.load(std::memory_order_relaxed) == 99);←
(6)

}


int main() {

 std::thread t1(create_x);

 std::thread t2(use_x);

 t1.join();

 t2.join();

}

Хотя сохранение

а
(1) расположено перед сохранением
p
(2) и сохранение
p
помечено признаком
memory_order_release
, но загрузка
p
(3) помечена признаком
memory_order_consume
. Это означает, что сохранение
p
происходит-раньше только тех выражений, которые зависят от значения, загруженного из
p
. Поэтому утверждения о членах-данных структуры
x
(4), (5) гарантированно не сработают, так как загрузка
p
переносит-зависимость-в эти выражения посредством переменной
x
. С другой стороны, утверждение о значении
а
(6) может как сработать, так и не сработать; эта операция не зависит от значения, загруженного из
p
, поэтому нет никаких гарантий о прочитанном значении. Это ясно следует из того, что она помечена признаком
memory_order_relaxed
.

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

std::kill_dependency()
для явного разрыва цепочки зависимостей. Эта функция просто копирует переданный ей аргумент в возвращаемое значение, но попутно разрывает цепочку зависимостей. Например, если имеется глобальный массив с доступом только для чтения, и вы используете семантику
std::memory_order_consum
e при чтении какого-то элемента этого массива из другого потока, то с помощью
std::kill_dependency()
можно сообщить компилятору, что ему необязательно заново считывать содержимое элемента массива (см. пример ниже).

int global_data[] = { ... };

std::atomic index;


void f() {

 int i = index.load(std::memory_order_consume);

 do_something_with(global_data[std::kill_dependency(i)]);

}

Разумеется, в таком простом случае вы вряд ли вообще будете пользоваться семантикой

std::memory_order_consume
, но в аналогичной ситуации функцией
std::kill_dependency()
можно воспользоваться и в более сложной программе. Только не забывайте, что это оптимизация, поэтому прибегать к ней следует с осторожностью и только тогда, когда профилирование ясно продемонстрировало необходимость.

Теперь, рассмотрев основы упорядочения доступа к памяти, мы можем перейти к более сложным аспектам отношения синхронизируется-с, которые проявляются в форме последовательностей освобождений (release sequences).

5.3.4. Последовательности освобождений и отношение синхронизируется-с

В разделе 5.3.1 я упоминал, что можно получить отношение синхронизируется-с между операцией сохранения атомарной переменной и операцией загрузки той же атомарной переменной в другом потоке, даже если между ними выполняется последовательность операций чтения-модификации-записи, — при условии, что все операции помечены надлежащим признаками. Теперь, когда мы знаем обо всех возможных «признаках» упорядочения, я могу подробнее осветить этот вопрос. Если операция сохранения помечена одним из признаков

memory_order_release
,
memory_order_acq_rel
или
memory_order_seq_cst
, а операция загрузки — одним из признаков
memory_order_consume
,
memory_order_acquire
или
memory_order_seq_cst
, и каждая операция в цепочке загружает значение, записанное предыдущей операцией, то такая цепочка операций составляет последовательность освобождений, и первая в ней операция сохранения синхронизируется-с (в случае
memory_order_acquire
или
memory_order_seq_cst
) или предшествует-по-зависимости (в случае
memory_order_consume
) последней операции загрузки. Любая атомарная операция чтения-модификации-записи в цепочке может быть помечена произвольным признаком упорядочения (даже
memory_order_relaxed
).

Чтобы попять, что это означает и почему так важно, рассмотрим значение типа

atomic
, которое используется как счетчик
count
элементов в разделяемой очереди (см. листинг ниже).


Листинг 5.11. Чтение из очереди с применением атомарных операций

#include 

#include 


std::vector queue_data; std::atomic count;


void populate_queue() {

 unsigned const number_of_items = 20;

 queue_data.clear();

 for (unsigned i = 0; i < number_of_items; ++i) {

  queue_data.push_back(i);

 } ←
(1) Начальное сохранение

 count.store(number_of_items, std::memory_order_release);

}


void consume_queue_items() {

 while (true) { ←
(2) Операция ЧМЗ

 int item_index;

 if (

  (item_index =

  count.fetch_sub(1, std::memory_order_acquire)) <= 0) {

  wait_for_more_items();←┐
Ждем дополнительных

  continue;       
(3) элементов

 }

 process(queue_data[item_index-1]);←┐
Чтение из queue_data

}                 
(4) безопасно


int main() {

 std::thread a(populate_queue);

 std::thread b(consume_queue_items);

 std::thread с(consume_queue_items);

 a.join();

 b.join();

 c.join();

}

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

count.store(numbеr_of_items, memory_order_release)
(1), чтобы другие потоки узнали о готовности данных. Потоки- потребители, читающие данные из очереди, могли бы затем вызвать
count.fetch_sub(1, memory_order_acquire)
(2), чтобы проверить, есть ли элементы в очереди перед тем, как фактически читать из разделяемого буфера (4). Если счетчик
count
стал равен 0, то больше элементов нет, и поток должен ждать (3).

Если поток-потребитель всего один, то всё хорошо;

fetch_sub()
— это операция чтения с семантикой
memory_order_acquire
, а операция сохранения была помечена признаком
memory_order_release
, поэтому сохранение синхронизируется-с загрузкой, и поток может читать данные из буфера. Но если читают два потока, то второй вызов
fetch_sub()
увидит значение, записанное при первом вызове, а не то, которое было записано операцией
store
. Без правила о последовательности освобождений между вторым и первым потоком не было бы отношения происходит-раньше, поэтому было бы небезопасно читать из разделяемого буфера, если только и для первого вызова
fetch_sub()
тоже не задана семантика
memory_order_release
; однако, задав ее, мы ввели бы излишнюю синхронизацию между двумя потоками-потребителями. Без правила о последовательности освобождений или задания семантики
memory_order_release
для всех операций
fetch_sub
не было бы никакого механизма, гарантирующего, что операции сохранения в queue_data видны второму потребителю, следовательно, мы имели бы гонку за данными. К счастью, первый вызов
fetch_sub(
) на самом деле участвует в последовательности освобождений, и вызов
store()
синхронизируется-с вторым вызовом
fetch_sub()
. Однако отношения синхронизируется-с между двумя потоками-потребителями все еще не существует. Это изображено на рис. 5.7, где пунктирные линии показывают последовательность освобождений, а сплошные — отношения происходит-раньше.

Рис. 5.7. Последовательность освобождений для операций с очередью из листинга 5.11

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

fetch_sub()
, операция
store()
синхронизируется-с каждым звеном, помеченным признаком
memory_order_acquire
. В данном примере все звенья одинаковы и являются операциями захвата, но это вполне могли бы быть разные операции с разной семантикой упорядочения доступа к памяти.

Хотя большая часть отношений синхронизации проистекает из семантики упорядочения доступа к памяти, применённой к операциям над атомарными переменными, существует возможность задать дополнительные ограничения на упорядочение с помощью барьеров (fence).

5.3.5. Барьеры

Библиотека атомарных операций была бы неполна без набора барьеров. Это операции, которые налагают ограничения на порядок доступа к памяти без модификации данных. Обычно они используются в сочетании с атомарными операциями, помеченными признаком

memory_order_relaxed
. Барьеры — это глобальные операции, они влияют на упорядочение других атомарных операций в том потоке, где устанавливается барьер. Своим названием барьеры обязаны тому, что устанавливают в коде границу, которую некоторые операции не могут пересечь. В разделе 5.3.3 мы говорили, что компилятор или сам процессор вправе изменять порядок ослабленных операций над различными переменными. Барьеры ограничивают эту свободу и вводят отношения происходит-раньше и синхронизируется-с, которых до этого не было.

В следующем листинге демонстрируется добавление барьера между двумя атомарными операциями в каждом потоке из листинга 5.5.


Листинг 5.12. Ослабленные операции можно упорядочить с помощью барьеров

#include 

#include 

#include 


std::atomic x, y;

std::atomic z;


void write_x_then_y() {

 x.store(true, std::memory_order_relaxed);      ←
(1)

 std::atomic_thread_fence(std::memory_order_release);←
(2)

 y.store(true, std::memory_order_relaxed);      ←
(3)

}


void read_y_then_x() {

 while (!y.load(std::memory_order_relaxed));     ←
(4)

 std::atomic_thread_fence(std::memory_order_acquire);←
(5)

 if (x.load(std::memory_order_relaxed))        ←
(6)

  ++z;

}


int main() {

 x = false;

 y = false;

 z = 0;

 std::thread a(write_x_then_y);

 std::thread b(read_y_then_x);

 a.join();

 b.join();

 assert(z.load() != 0); ←
(7)

}

Барьер освобождения (2) синхронизируется-с барьером захвата (5), потому что операция загрузки

y
в точке (4) читает значение, сохраненное в точке (3). Это означает, что сохранение
x
(1) происходит-раньше загрузки
x
(6), поэтому прочитанное значение должно быть равно
true
, и утверждение (7) не сработает. Здесь мы наблюдаем разительное отличие от исходного случая без барьеров, когда сохранение и загрузка
x
не были упорядочены, и утверждение могло сработать. Отметим, что оба барьера обязательны: чтобы получить отношение синхронизируется-с необходимо освобождение в одном потоке и захват в другом.

В данном случае барьер освобождения (2) оказывает такой же эффект, как если бы операция сохранения

y
(3) была помечена признаком
memory_order_release
, а не
memory_order_relaxed
. Аналогично эффект от барьера захвата (5) такой же, как если бы операция загрузки
y
(4) была помечена признаком
memory_order_acquire
. Это общее свойство всех барьеров: если операция захвата видит результат сохранения, имевшего место после барьера освобождения, то барьер синхронизируется-с этой операцией захвата. Если же операция загрузки, имевшая место до барьера захвата, видит результат операции освобождения, то операция освобождения синхронизируется-с барьером захвата. Разумеется, можно поставить барьеры по обе стороны, как в примере выше, и в таком случае если загрузка, которая имела место до барьера захвата, видит значение, записанное операцией сохранения, имевшей место после барьера освобождения, то барьер освобождения синхронизируется-с барьером захвата.

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

write_x_then_y
из листинга 5.12 и перенести запись в
x
после барьера, как показано ниже, то уже не гарантируется, что условие в утверждение будет истинным, несмотря на то что запись в x предшествует записи в
y
:

void write_x_then_y() {

 std::atomic_thread_fence(std::memory_order_release);

 x.store(true, std::memory_order_relaxed);

 y.store(true, std::memory_order_relaxed);

}

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

x
и сохранением
y
. Конечно, наличие или отсутствие барьера не влияет на упорядочения, обусловленные отношениями происходит-раньше, которые существуют благодаря другим атомарным операциям.

Данный пример, как и почти все остальные в этой главе, целиком построен на переменных атомарных типов. Однако реальная польза от применения атомарных операций для навязывания упорядочения проистекает из того, что они могут упорядочивать неатомарные операции и тем самым предотвращать неопределенное поведение из-за гонок за данными, как мы видели в листинге 5.2.

5.3.6. Упорядочение неатомарных операций с помощью атомарных

Если заменить тип переменной

x
в листинге 5.12 обычным неатомарным типом
bool
(как в листинге ниже), то гарантируется точно такое же поведение, как и раньше.


Листинг 5.13. Принудительное упорядочение неатомарных операций

#include 

#include 

#include 


bool x = false;   ←┐
Теперь x — простая

std::atomic y;│
неатомарная

std::atomic z; │
переменная

void write_x_then_y() {
(1) Сохранение x

 x = true;      ←┘
перед барьером

 std::atomic_thread_fence(std::memory_order_release);

 y.store(true, std::memory_order_relaxed);←┐
Сохранение y

}                     
(2) после барьера


void read_y_then_x()             
(3) Ждем, пока не

{                       │
увидим значение,

 while (!y.load(std::memory_order_relaxed));←┘
записанное в 2

 std::atomic_thread_fence(std::memory_order_acquire);

 if (x) ←┐
Здесь будет прочитано

  ++z;  
(4) значение, записанное в 1

}


int main() {

 x = false;

 y = false;

 z = 0;

 std::thread a(write_x_then_y);

 std::thread b(read_y_then_x);

 a.join();

 b.join();       
(5) Это утверждение

 assert(z.load() != 0);←┘
не сработает

}

Барьеры по-прежнему обеспечивают упорядочение сохранения

x
(1) и
y
(2) и загрузки
y
(3) и
x
(4), и, как и раньше, существует отношение происходит-раньше между сохранением
x
и загрузкой
x
, поэтому утверждение (5) не сработает. Сохранение
y
(2) и загрузка
y
(3) тем не менее должны быть атомарными, иначе возникла бы гонка за
y
, но барьеры упорядочивают операции над
x
после того, как поток-читатель увидел сохраненное значение
y
. Такое принудительное упорядочение означает, что гонки за
x
нет, хотя ее значение модифицируется в одном потоке, а читается в другом.

Но не только с помощью барьеров можно упорядочить неатомарные операции. Эффект упорядочения мы наблюдали также в листинге 5.10, где пара

memory_order_release
/
memory_order_consume
упорядочивала неатомарные операции доступа к динамически выделенному объекту. Многие примеры из этой главы можно было бы переписать, заменив некоторые операции с семантикой
memory_order_relaxed
простыми неатомарными операциями.

Упорядочение неатомарных операций с помощью атомарных — это та область, где особую важность приобретает аспект расположено-перед отношения происходит-раньше. Если неатомарная операция расположено-перед атомарной, и эта атомарная операция происходит-раньше какой-либо операции в другом потоке, то и неатомарная операция также происходит-раньше этой операции в другом потоке. Именно из этого вытекает упорядочение операций над

x
в листинге 5.13, и именно поэтому работает пример из листинга 5.2. Этот факт также лежит в основе таких высокоуровневых средств синхронизации в стандартной библиотеке С++, как мьютексы и условные переменные. Чтобы понять, как это работает, рассмотрим простой мьютекс-спинлок из листинга 5.1.

В функции

lock()
выполняется цикл по
flag.test_and_set()
с упорядочением
std::memory_order_acquire
, а функция
unlock()
вызывает операцию
flag.clear()
с признаком упорядочения
std::memory_order_release
. В момент, когда первый поток вызывает
lock()
, флаг еще сброшен, поэтому первое обращение к
test_and_set()
установит его и вернет
false
. Это означает, что поток завладел блокировкой, и цикл завершается. Теперь этот поток вправе модифицировать любые данные, защищенные мьютексом. Всякий другой поток, который вызовет
lock()
в этот момент, обнаружит, что флаг уже поднят, и потому будет заблокирован в цикле
test_and_set()
. Когда поток, владеющий блокировкой, закончит модифицировать защищенные данные, он вызовет функцию
unlock()
, которая вызовет
flag.clear()
с семантикой
std::memory_order_release
.

Это приводит к синхронизации-с (см. раздел 5.3.1) последующим обращением к

flag.test_and_set()
из функции
lock()
в другом потоке, потому что в этом обращении задана семантика
std::memory_order_acquire
. Так как модификация защищенных данных обязательно расположена-перед вызовом
unlock()
, то эта модификация происходит-раньше вызова
unlock()
и, следовательно, происходит-раньше последующего обращения к
lock()
из другого потока (благодаря наличию отношения синхронизируется-с между
unlock()
и
lock()
) и происходит-раньше любой операции доступа к данным из второго потока после того, как он захватит блокировку.

В других реализациях мьютексов используются иные внутренние операции, но принцип остается неизменным:

lock()
— это операция захвата над некоторой внутренней ячейкой памяти, a
unlock()
— операция освобождения над той же ячейкой памяти.

5.4. Резюме

В этой главе мы рассмотрели низкоуровневые детали модели памяти в C++11 и атомарные операции, лежащие в основе синхронизации потоков. Были также рассмотрены простые атомарные типы, предоставляемые специализациями шаблона класса

std::atomic<>
, и обобщенный интерфейс в виде основного шаблона
std::atomic<>
, операции над этими типами и непростые детали, связанные с различными вариантами упорядочения доступа к памяти.

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

В следующей главе мы увидим, как высокоуровневые средства синхронизации вкупе с атомарными операциями применяются для проектирования эффективных контейнеров, допускающих параллельный доступ, а также напишем алгоритмы для параллельной обработки данных.

Загрузка...