Действительно, наиболее часто примитивы синхронизации применяются для создания критической секции кода с целью предотвращения возможности одновременного воздействия на объекты данных со стороны нескольких параллельно развивающихся ветвей программы.
При одновременной работе с данными из различных потоков состояние данных после такого воздействия должно считаться «неопределенным», при этом последствия могут быть более тяжкими, чем просто некорректное состояние данных - структура сложных объектов может быть просто разрушена.
В многопоточной среде элементарные и привычные операции могут таить в себе опасности. Действительно, простейший оператор вида:
i = i + 1;
содержит в себе опасность, если этот оператор записан в функции потока, выполняемой несколькими экземплярами потоков (совершенно типичный случай). Не менее опасен, но менее очевиден по внешнему виду и оператор:
i += 1;
Даже операторы инкремента и декремента (
++i
и
--i
), которые в системе команд практически всех типов процессоров выполняются как атомарные и которые являются основой для реализации семафорных операций, в симметричной мультипроцессорной архитектуре перестают быть безопасными. Хуже того, привычные программисту операции стандартной библиотеки и просто синтаксические конструкции языка становятся небезопасными в многопоточной среде. Вот еще два примера:
1. Оператор копирования нетипизированного блока памяти, безбоязненно используемый десятилетиями:
void* memcpy(void* dst, const void* src, size_t length);
2. Операторы присваивания, инициализации или сравнения структурированных объектов данных:
struct X {
X(const X& y) { ... }
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);
где вместо
*
должно стоять имя одной из пяти операций (таким алгоритмом и обеспечивается 10 различных атомарных функций):
add
— добавить численное значение к операнду;
sub
— вычесть численное значение из операнда;
clr
— очистить
битыв значении операнда (выполняется побитовая операция (
*D) &= ~S
);
set
— установить
битыв значении операнда (выполняется побитовая операция (
*D) |= S
);
toggle
— инвертировать
битыв значении операнда (выполняется побитовая операция (
*D) ^= S
);
D
— именно тот объект, над которым осуществляется атомарная операция;
S
— второй операнд осуществляемой операции.