Две формы атомарных функций для каждой операции отличаются тем, что первая из них выполняет операцию без возврата значения, а вторая возвращает значение, которое операнд
D
имел до выполнения операции (т.e. прежнее значение, как это делают, например, префиксные операции инкремента
++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);
Или даже так:
// глобальные описания, доступные всем потокам
...
//
указательтекущей позиции записи:
volatile unsigned int ind = (unsigned int)buf;
...
// выполняется в каждом из потоков:
memcpy((void*)atomic_add_value(ind, how), (void*)res, how);
В последнем случае это, конечно, трюкачество, построенное на том, что в 32-разрядной архитектуре представления указателя и переменной
unsigned int
совпадают, но это «работающее трюкачество» и работающее иногда весьма эффективно.
Техника применения атомарных операций оказывается крайне удобной, например, при осуществлении вывода, часто диагностического, из различных потоков. Положим, нам нужно в каждом из многих потоков выводить диагностическую строку при достижении ими определенной точки исполнения:
cout << "Это вывод потока " << pthread_self() << endl;
Но так делать нельзя: при таком решении у нас появляются 2 проблемы, одна из которых очевидна, а другая — не очень:
1. Выводы разных потоков могут «смешиваться»; более того, за счет буферизации вывода некоторые «рваные» фрагменты мы будем наблюдать дважды. Одним словом, наш вывод окажется полностью «нечитабельным».
2. При осуществлении вывода в контексте потока почти наверняка в процессе вывода будут выполняться системные вызовы, которые потребуют новой диспетчеризации и приведут к вытеснению исходного потока. При этом порядок передачи управления от потока к потоку при наличии отладочного вывода будет отличаться от порядка при его отсутствии. А это, наверное, не совсем то, что мы ожидали. В результате при наличии отладочного вывода мы можем наблюдать совсем не ту картину, для изучения которой этот вывод, собственно, и предназначен.