friend bool operator==(const X& f, const X& s) { ... }
// оператор присваивания мы не переопределяем, используется
// присваивание по умолчанию - побайтовое копирование
};
...
X A;
...
X B(А); // потенциальная ошибка
...
B = A; // потенциальная ошибка
if (А == В) { ... } // потенциальная ошибка
Обратите внимание, что все объекты данных, для которых могут наблюдаться обсуждаемые эффекты, должны быть доступны вне потока, то есть быть глобальными с точки зрения видимости в потоке.
Именно для безопасного манипулирования данными в параллельной среде QNX API и вводятся атомарные операции. Десять атомарных функций делятся на две симметричные группы по виду своего именования и логике функционирования. Все атомарные операции осуществляются только над одним типом данных
unsigned int
unsigned int
volatile
Помимо атомарных операций над этой переменной могут выполняться любые другие действия, которые можно считать безопасными в многопоточной среде: инициализация, присваивание значений, сравнения. Более того, при выходе программы за область возможного многопоточного доступа к этой переменной она может далее использоваться любым традиционным и привычным образом.
Важно также отметить, что термин «атомарность» относится не к особым свойствам некоторого объекта данных, а к ограниченному ряду операций, которые можно безопасно выполнять над этим объектом в многопоточной среде.
Общий вид прототипов каждой из двух групп атомарных операций следующий:
void atomic_*(volatile unsigned *D, unsigned S);
unsigned atomic_*_value(volatile unsigned *D, unsigned S);
где вместо
*
add
sub
clr
*D) &= ~S
set
*D) |= S
toggle
*D) ^= S
D
S
Две формы атомарных функций для каждой операции отличаются тем, что первая из них выполняет операцию без возврата значения, а вторая возвращает значение, которое операнд
D
++D
--D
D++
D--
Зачем нужны две формы для операции? Техническая документация QNX утверждает, что вторая форма может выполняться дольше. Справедливость этого утверждения и насколько дольше выполняется вторая форма, мы скоро увидим на примерах.
Итак, у нас есть 10 функций для выполнения пяти атомарных операций:
atomic_add() atomic_add_value()
atomic_sub() atomic_sub_value()
atomic_clr() atomic_clr_value()
atomic_set() atomic_set_value()
atomic_toggle() atomic_toggle_value()
Как используются атомарные операции? Обычно для предотвращения одновременного изменения некоторого счетчика индекса мы вынуждены создавать критическую секцию, обозначая ее, скажем, операциями над мьютексом. В частности, в следующем примере нам необходимо из различных потоков последовательно дописывать некоторые байтовые результаты в единый буфер:
// глобальные описания, доступные всем потокам
const unsigned int N = ...
uint8_t buf[N];
// индекс текущей позиции записи
unsigned int ind = 0;
// общий мьютекс, доступный каждому из потоков
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
...
// выполняется в каждом из потоков:
uint8_t res[M]; // результат некоторой операции
unsigned int how = ... // реальная длина этого результата
pthread_mutex_lock(&mutex);
memcpy((void*)buf + ind, (void*)res, how);
ind += how;
pthread_mutex_unlock(&mutex);
Используя атомарные операции, мы можем этот процесс записать так (все глобальные описания остаются неизменными):
// глобальные описания, доступные всем потокам
...
// индекс текущей позиции записи
volatile unsigned int ind = 0;
...
// выполняется в каждом из потоков:
uint8_t res[M]; // результат некоторой операции
unsigned int how = ... // реальная длина этого результата
memcpy((void*)buf + atomic_add_value(ind, how), (void*)res, how);
Или даже так:
// глобальные описания, доступные всем потокам
...
// <b>указатель</b>текущей позиции записи:
volatile unsigned int ind = (unsigned int)buf;
...
// выполняется в каждом из потоков: