Под атомарными понимаются неделимые операции. Ни из одного потока в системе невозможно увидеть, что такая операция выполнена наполовину, — она либо выполнена целиком, либо не выполнена вовсе. Если операция загрузки, которая читает значение объекта, атомарна, и все операции модификации этого объекта также атомарны, то в результате загрузки будет получено либо начальное значение объекта, либо значение, сохраненное в нем после одной из модификаций.
И наоборот, если операция не атомарная, то другой поток может видеть, что она выполнена частично. Если это операция сохранения, то значение, наблюдаемое другим потоком, может не совпадать ни со значением до начала сохранения, ни с сохраненным значением. С другой стороны, операция загрузки может извлечь часть объекта, после чего значение будет модифицировано другим потоком, а затем операция прочитает оставшуюся часть объекта. В результате будет извлечено значение, которое объект не имел ни до, ни после модификации. Это простая проблематичная гонка, описанная в главе 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 к pstd::atomic p(some_array);│
и вернуть староеFoo* x = p.fetch_add(2); ←┘
значениеassert(x == some_array);
assert(p.load() == &some_array[2]);
x = (p -= 1); ←┐
Вычесть 1 из passert(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. Синхронизация операций и принудительное упорядочение