Фото Dan Meyers на Unsplash
Фото Dan Meyers на Unsplash

Часто возникает путаница с тем, что же понимается в компьютерных науках под «атомарностью». Как правило, атомарность – это свойство процесса, означающее, что он совершается за один шаг или операцию. Но в языке C++  атомарность определяется гораздо более специфичным образом. На самом деле, при использовании std::atomic  с классами и типами еще не гарантируется, что весь код будет подлинно атомарным. Хотя, атомарные типы и входят в состав языка C++, сами атомарные операции должны поддерживаться на уровне того аппаратного обеспечения, на котором работает программа. Эта статья – простое руководство, помогающее понять, что же представляет собой атомарность в C++.

Типы

В C++ в шаблонный класс std::atomic<> можно обертывать и многие другие типы, что способствует атомарным операциям над соответствующим типом. Этот шаблон ни в коем случае не гарантирует, что все операции на самом деле получатся атомарными. Если какие-либо атомарные операции не поддерживаются задействованным CPU, то компилятор прибегнет к резервным вариантам на основе мьютексов. Хорошо, что есть полезная функция и гарантированный булев член атомарных типов – при помощи этих вещей можно проверить, поддерживает CPU атомарные операции над типами или нет.

#include <atomic>
#include <cstdio>

int main() {
    printf("is lock free? %s\n", std::atomic<int>::is_always_lock_free ? "true" : "false");
    std::atomic<int> a(3);
    printf("is this lock free? %s\n", a.is_lock_free() ? "true" : "false");
    return 0;
}

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

Загрузка и сохранение

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

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

Атомарность как момент времени

Возьмем переменную a, имеющую значение 3 в момент t1. Если атомарная загрузка применена в момент t1, то в результате этой операции загрузки будет получено значение 3. Однако, если загрузка произойдет на миг позже, в момент t2, то в результате может быть получено не 3, а другое значение, просто потому, что в момент t1 могла произойти другая операция.

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

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

#include <atomic>
#include <cstdio>
#include <thread>

static std::atomic<int> foobar(8);
static std::atomic<bool> start(false);

int main() {
    std::thread t1 = std::thread([]{
        int records[10];
        while (!start.load());
        for (size_t i = 0; i < 10; ++i) {
            foobar.store(3);
            records[i] = foobar.load();
        }
        for (size_t j = 0; j < 10; ++j) {
            printf("t1 %zu - %d\n", j, records[j]);
        }
    });

    std::thread t2 = std::thread([]{
        int records[10];
        while (!start.load());
        for (size_t i = 0; i < 10; ++i) {
            foobar.store(6);
            records[i] = foobar.load();
        }
        for (size_t j = 0; j < 10; ++j) {
            printf("t2 %zu - %d\n", j, records[j]);
        }
    });
    start.store(true);

    t1.join();
    t2.join();
    return 0;

Если вы скомпилируете и выполните эту программу, то можете получить примерно следующий результат:

t1 0 - 3
t1 1 - 3
t1 2 - 3
t1 3 - 3
t1 4 - 3
t1 5 - 3
t1 6 - 3
t1 7 - 3
t1 8 - 3
t1 9 - 3
t2 0 - 6
t2 1 - 6
t2 2 - 6
t2 3 - 6
t2 4 - 6
t2 5 - 6
t2 6 - 6
t2 7 - 6
t2 8 - 6
t2 9 - 6

Он может показаться странным – ведь, судя по выводу, каждый из потоков был в состоянии загрузить именно то значение, которое сохранил. Возможно, операции в этой программе совершались в таком порядке: поток t1 загружал начальную переменную, затем завершал все свои операции загрузки и сохранения, а потом поток t2 загружал начальную переменную и завершал все свои операции сохранения и загрузки. В результате потоку приходилось выполнять дополнительную работу, происходящую вне разделяемой памяти, например, for (int k = 0; k < 1000; ++k); . Если сделать так и запустить программу, то начнет проявляться разбежка между значениями, которые загружал каждый из потоков.

Операции обмена

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

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

#include <atomic>
#include <cstdio>
#include <thread>

static std::atomic<int> foobar(8);

int main() {
    std::thread t1 = std::thread([]{
        int value = 4;
        for (int i = 0; i < 100000; ++i)
        {
            value = foobar.exchange(value);
        }
    });
    printf("%d\n", foobar.load());
    foobar.exchange(14);
    printf("%d\n", foobar.load());
    printf("%d\n", foobar.load());
    printf("%d\n", foobar.load());
    printf("%d\n", foobar.load());
    t1.join();
    return 0;
}

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

Операции выборки данных 

Операции выборки данных (fetch), например, «выбрать и сложить» или «выбрать и вычесть» применяют к атомарной переменной некоторую операцию и выбирают то значение, которое находилось в переменной до совершения операции. Операции выборки работают примерно так же, как операции обмена, в том смысле, что атомарный обмен заключается лишь в записи значения и «выборке» предыдущего значения. Есть несколько типов операций выборки, и в C++ поддерживаются следующие из них:

  • fetch_add

  • fetch_sub

  • fetch_and

  • fetch_or

  • fetch_xor

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

#include <atomic>
#include <cstdio>

struct Counter {

    Counter(): count(0) {}

    std::atomic<unsigned> count;

    unsigned operator++(int) {
        return count.fetch_add(1);
    }

    unsigned operator--(int) {
        return count.fetch_sub(1);
    }
};


int main() {
    Counter a;
    printf("%u\n", a++);
    printf("%u\n", a++);
    printf("%u\n", a--);
    printf("%u\n", a--);
    return 0;
}

Если этот код скомпилировать и выполнить, он выведет на экран

0
1
2
1

Причина, по которой fetch_sub сначала возвращает 2, заключается в том, что fetch_add возвращает значение до того, как прирастить его. Следующий вызов fetch_sub возвращает 1, указывая, что предыдущий вызов вычел 1 после того, как было выбрано предыдущее значение.

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

std::atomic<int> a(3);
int b = a.load() * 3;
a.store(b);

Но при таком подходе возникает проблема. Возможно, что между второй и третьей строкой из предыдущего примера другой поток изменит значение a, поэтому цель «умножить на 3» не будет достигнута, ведь b – это произведение предыдущего значения a и 3. Если просто сохранить b в a, это может привести к несогласованности данных. В таком сценарии нет ничего опасного, отсутствует вероятность утечки в памяти или ошибки сегментации. Да, в многопоточной программе у всех потоков должно быть общее согласованное представление о данных с атомарными составляющими, чтобы задачи успешно решались. Поэтому в атомарной операции нужно предусмотреть концепцию «отказа».

Сравнение с обменом

Сравнение с обменом, также именуемое сравнением с перестановкой (CAS) – это самая мощная операция, доступная в C++. В большинстве случаев так обеспечивается атомарное сравнение актуального значения атомарной переменной и другого значения. Если результат сравнения будет true, то программа попытается сохранить желаемое значение. Несмотря на то, что сравнение с обменом является атомарной операцией, эта операция, конечно же, может закончиться неудачно – если в работу вмешается другой поток и изменит значение переменной между актом ее считывания и актом ее записи. Операция сравнения с обменом иначе называется «чтение-изменение-запись» (RMW).

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

В C++ есть две функции, выполняющие сравнение с обменом, compare_exchange_weak и compare_exchange_strong. Слабая версия больше подходит для ситуаций, в которых операции вызываются циклически. Циклический вызов сравнения с обменом – распространенная ситуация, когда требуется реализовать неблокирующие структуры данных. Например, рассмотрим простейшую неблокирующую структуру данных такого рода – стек.

#include <iostream>
#include <atomic>

template<class T>
class LFS {
public:
	struct node
	{
	    T data;
	    node<T>* next;
	    node(const T& data) : data(data), next(nullptr) {}
	};
	void push(const T& val)
	{
		node<T>* new_node = new node<T>(val);
		new_node->next = _head.load();
		while(!_head.compare_exchange_weak(new_node->next, new_node));
	}

	bool pop()
	{
		node<T>* got = _head.load();
		node<T>* nextn = nullptr;
		do {
			if(got == nullptr) {
				return false;
			}
			nextn = got->next;
		} while(!_head.compare_exchange_weak(got, nextn));
		delete got;
		return true;
	}
private:
	std::atomic<node<T>*> _head;
};

У вышеприведенного стека два метода - push и pop. Каждый из них удовлетворяет главному критерию неблокируемости: один поток постоянно прогрессирует и завершает свою задачу выталкивания из стека или записи в стек. Если будет много потоков вызовут compare_exchange_weak, то только у одного из них эта попытка будет успешной. Все остальные потоки потерпят с compare_exchange_weak неудачу – то есть, загрузят то значение, которое прямо сейчас сохранено в переменной. Такой цикл, фактически, обеспечивает, чтобы операция сравнения с обменом «повторялась с последним известным значением» атомарной переменной.

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

Комментарии (7)


  1. starik-2005
    09.06.2022 10:15

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

    Если вы скомпилируете и выполните эту программу, то можете получить примерно следующий результат:

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

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


  1. ReadOnlySadUser
    09.06.2022 10:54
    +4

    Самая сложная часть в атомарных операциях - это std::memory_order . Вот про него бы хоть пару слов сказали. К сожалению, то что написано в статье можно за 5 минут объяснить первому встречному, но это не приблизит его к реальному пониманию проблем, связанных с атомарностью.


    1. svr_91
      09.06.2022 11:39
      +3

      Мне больше всего понравилось вот это объяснение
      https://youtu.be/DccZE5PYhZM?list=PL3BR09unfgcgJPQZKaacwzGmcXMtEA-19&t=4498

      Только из него я наконец-то понял, как все работает


    1. Tuxman
      10.06.2022 06:50
      +1

      А ещё про барьеры, например, что можно сделать ring buffer без блокировки, как io_uring.


  1. Kelbon
    09.06.2022 17:39
    -1

    Хуже перевода сложно найти.

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

    ::IS_ALWAYS_LOCK_FREE, как вы это перевели "могут быть не атомарными" неизвестно


    1. ph_piter Автор
      10.06.2022 12:36

      Поясните, что вы имеете в виду. Где в оригинале или переводе утверждается, что атомарные операции не являются атомарными"?


      1. Kelbon
        10.06.2022 13:12

        Типы

        В C++ в шаблонный класс std::atomic<> можно обертывать и многие другие типы, что способствует атомарным операциям над соответствующим типом. Этот шаблон ни в коем случае не гарантирует, что все операции на самом деле получатся атомарными

        В первой строке