R0 CREW

Multithreading - everything they want you to know. Part 2 - Atomic operations

Вступление

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

Атомарная операция – это такая операция, которая не может быть прервана. Проще говоря, у такой операции есть два состояния:

  • ещё не выполнена;
  • уже выполнена.

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

Представим, что у нас есть массив char на size элементов. Нам нужно проинициализировать каждый элемент массива символом ‘a’. Мы пишем примерно такую функцию:

void initArray(char* begin,unsigned int size)
{
  for (unsigned int i = 0;i < size;i++)
  {
    begin[i] = ‘a’;
  }
}

В то же самое время другой поток конкурентно обращается к массиву. Возникает состояние гонки. Даже если функция initArray в первом потоке начала выполняться раньше, чем код во втором потоке стал обращаться к массиву, потребуется некоторое время, пока функция завершит работу. В это время могут возникать промежуточные состояния. То есть может так получиться, что проинициализирована только часть массива, и поток 2 обращается к непроинициализированной части. Это – ошибка синхронизации. Она не могла бы возникнуть, если бы initArray была атомарной.

Теперь мы примерно представляем, зачем нам атомарные операции. Чуть более обобщим понятие атомарности операции. Атомарная операция представляет собой выполнение каких-то действий над какими-то данными. Под действием здесь понимается всё, что угодно: копирование, чтение, инициализация, удаление и т.д. Под данными понимается любая структура данных – ячейка памяти, регистр, массив, контейнер. И пока выполняется атомарная операция, никакая другая операция доступ к этим самым данным получить не может. Т.е. атомарная операция эксклюзивно работает с некоторыми данными.

Средства обеспечения атомарности

Атомарность может достигаться двумя способами – программно или аппаратно. Если в первом случае мы имеем дело с эмуляцией атомарности, то истинная атомарность достигается благодаря аппаратному обеспечению.

Для того, чтобы понять, как это происходит, следует изучить мануалы вычислительного устройства, с которым работаете. В большинстве случаев – это центральный процессор (CPU), реже GPU. Если мы рассматриваем параллельное программирование для CPU, то, вероятнее всего, разговор идёт о процессорах семейства Intel.

Как обстоят дела у этих процессоров, можно прочитать в Intel Manuals

В этих талмудах объясняется природа атомарных операций, но для начала рассмотрим несколько понятий, которыми оперирует раздел MULTIPLE-PROCESSOR MANAGEMENT.

Термины и определения

Bus (шина) – в данном случае рассматривается Memory bus (шина памяти). Это устройство, используемое для обращения к физической памяти. Варианты её исполнения отличаются, и следует заметить, что она претерпела значительных изменений в ходе эволюции аппаратной части PC. Тем не менее, нас сейчас абсолютно не волнует, как она устроена физически. Самое главное – любое обращение к памяти не может идти в обход шины.

“Но как же кэш?” - спросит внимательный читатель. Да, при условии, что кэш не пуст (там уже что-то есть) вполне возможны обращения к памяти в кэше без доступа к шине. Но так или иначе, для загрузки данных в кэш нужно всё же вначале считать их через шину. Также необходимо, чтобы именно нужные нам данные лежали в кэше.

Кэш – некий промежуточный буфер, хранящий данные, обращение к которым происходит наиболее часто. В данном случае мы с вами говорим про кэш центрального процессора (CPU cache). Конечно же, он более быстрый, чем шина.

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

Когерентность (памяти либо кэша) – означает, что к одним и тем же данным могут получить доступ два и более процессоров. Причём сделать это одновременно. Такая своеобразная аппаратная конкуренция (гонка).

Очевидно, что атомарные операции должны как-то решать эту проблему.

Как достигается атомарность на Intel CPU

Атомарность на Intel CPU может достигаться аж трёмя способами:

  • с помощью гарантировано атомарных операций (Guaranteed atomic operations);
  • с помощью LOCK префикса и LOCK# сигналов;
  • с помощью протоколов когерентности кэша (Cache coherency protocols)

Рассмотрим их более детально.

Гарантированно атомарные операции

Из названия следует, что есть такие операции, которые по-любому будут атомарными. Нам не нужно как-то явно указывать на их атомарность. Они просто аппаратно атомарны и всё. Такими являются (начиная с 486 модели):

  • чтение и запись одного байта;
  • чтение и запись слова (двух байт), выровненного на 16 бит;
  • чтение и запись двойного слова (4-х байт), выровненного на 32 бита.

Начиная с Pentium 4, таких операций стало больше:

  • чтение и запись quadword (8 байт), выровненного на 64 бита;
  • доступ к 16 битам, адрес которых выровнен на 32 бита, но не попал в кэш.

Таким образом, это стало справедливо для архитектур x64_86. Тут пока всё просто. А вот следующий момент не так прост.

Начиная с семейства процессоров P6, доступ к невыровненным данным тоже может быть атомарным, если эти данные сейчас загружены в кэш. Но если данные находятся на границе страницы памяти (часть на одной, а часть – на другой), или на границе кэш линий, то атомарности на целом ряде процессоров не будет. Всё усложняется ещё и тем, что мы не управляем кэшем (пользовательский код ring-3 вообще ничего о нём не знает и не имеет прямого доступа).

SSE и x87 вообще не гарантируют атомраности.

LOCK# сигналы и префикс

На шину могут приходить LOCK сигналы, тогда одна операция выполняется, а остальные ждут освобождения шины. LOCK префикс перед инструкцией – один из способ посылки такого сигнала шине, но не единственный.

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

Но вернёмся к LOCK префиксу. Далеко не все инструкции допускают его наличие. Если префикс не поддерживается данной инструкцией, то генерируется исключение #UD. Такое же исключение генерируется, если инструкция не обращается к памяти, но содержит префикс LOCK. Поддерживаются следующие инструкции:

  • The bit test and modify instructions (BTS, BTR, and BTC).
  • The exchange instructions (XADD, CMPXCHG, and CMPXCHG8B).
  • The LOCK prefix is automatically assumed for XCHG instruction.
  • The following single-operand arithmetic and logical instructions: INC, DEC, NOT, and NEG.
  • The following two-operand arithmetic and logical instructions: ADD, ADC, SUB, SBB, AND, OR, and XOR.

Ещё один важный момент – инструкцияс префиксом LOCK гарантированно захватит указанные в инструкции адреса, но, возможно, захватит больше. Поэтому при реализации семафоров следует указывать одинаковые адреса и длины (byte, word, dword и т.д.) Но о семафорах мы будем говорить позже.

Cache Locking

Когда-то давно (для моделей 486 и Pentium) при выполнении LOCK операции на шину всегда приходил LOCK сигнал. Даже если эта операция работала с данными из кэша.

Начиная с семейства P6, если память полностью лежит в одной кэш-линии, то шина не лочится. Вместо этого данные правятся в кэшах процессоров согласно протоколам когерентности. Такая штука называется cache locking.

Другие архитектуры

Почему мы говорим за архитектуру Intel? Как же все остальные? У всех остальных есть свои механизмы, которые отвечают за поддержку атомарных операций. Для ознакомления с ними нужна документация по вашему процессору. Intel архитектуру здесь мы рассматриваем как наиболее быструю. Конечно, современные ARM процессоры уже могут потягаться с Intel, но пока мощные многопроцессорные системы (400 ядер и более) для арма всё ещё редкость. Конечно, времена меняются. Вполне может быть, что через несколько лет арм опередит Intel, но пока так. В любом случае, читайте документацию по железу, с которым вам приходится работать. Многие ответы на ваши вопросы находятся именно там.
Сравнение производительности arm и intel

Поднимаясь выше

Сейчас мы понимаем, как атомарные операции устроены на уровне ассемблерных инструкций. Но что, если мы не пишем на ассемблере, а, например, на С? Как нам быть в таком случае – неужели использовать ассемблерные вставки?

На самом деле – нет. В зависимости от операционной системы, есть несколько возможных решений.

В ОС Windows есть целый набор API с префиксом Interlocked. Смотрите раздел Interlocked Functions

В ОС семейства Linux таких API нет. Но есть встроенные функции компилятора gcc

Оба этих решения не так уж прекрасны, но до появления нового стандарта С++ нам было некуда деваться, и пришлось бы использовать эти способы.

Новый стандарт С++

Какой он, и что он нам принёс в плане атомарных операций? Самым новым стандартом на сегодня является С++14. Его поддерживают компиляторы, он вышел официально, с его бесплатным черновым вариантом можно ознакомиться здесь.

Но нас интересует шаблон std::atomic<>, пришедший в С++ ещё с 11 стандарта.

Но а как же volatile или немного практических экспериментов.

Некоторые разработчики ошибочно применяют volatile там, где нужен atomic. Парадокс в том, что такое решение часто оказывается рабочим. Давайте разберёмся, почему.

Предположим, мы объявили int с квалификатором volatile. Этот квалификатор говорит компилятору о том, что память, выделенная под данную переменную, что переменная не контроллируется только нашим кодом. Что же – она может меняться каким-то магическим способом? Магии здесь никакой быть не может, а вот какой-то dma access вполне может быть. Поэтому компилятор не то, чтобы не оптимизирует работу с этой переменной. Просто при каждом “упоминании” её в коде компилятор генерирует явное обращение к ячейке памяти.
Есть информация в блоге Алёны.

Но при детальном прочтении видим некоторые жёсткие моменты:

Чисто теоретически указания volatile при работе с потоками достаточно, если тип данных, с которым идет работа, может быть записан на данной архитектуре атомарно, в один прием. Соответственно, надо точно знать к каким именно типам это относится. Казалось бы уж что-что, так bool должен писаться в один прием. Я вычитала, что на некоторых Windows’ах это вовсе даже и не так. И атомарность присутствует только при работе с char…

Это, конечно, ересь. Виндоусы тут ни при чём, как мы разобрались выше – за всё отвечает CPU. Более того, даже чисто теоретически при работе с потоками volatile недостаточно. Помните границы страниц и линий кэша при невыровненных данных? Давайте проведём опыт.

Для начала я на ОС Linux определяю размер страницы:

$ getconf PAGESIZE
4096

Теперь пишу такую программку на С++:

#include <vector>
#include <iostream>
#include <thread>
#include <stdint.h>

#define LOOP_NUMBER 10000000

struct unaligned
{
	uint8_t bytes[4092];
	volatile long long concurrent;
}__attribute__((packed));

unaligned test = {0};
std::vector<long long> bad_numbers;

void fill_with_zero()
{
	for(long long i = 0;i < LOOP_NUMBER;i++)
		test.concurrent = 0;
}

void fill_with_ff()
{
	for(long long i = 0;i < LOOP_NUMBER;i++)
		test.concurrent = 0xffffffffffffffff;
}

void check_value()
{
	for(long long i = 0;i < LOOP_NUMBER;i++)
	{
		long long temp = test.concurrent;
		if (temp != 0 && temp != 0xffffffffffffffff)
			bad_numbers.push_back(temp);
	}	
}

int main()
{
	std::thread zero_fill {fill_with_zero};
	std::thread full_fill {fill_with_ff};
	std::thread check {check_value};
	
	zero_fill.join();
	full_fill.join();
	check.join();

	for(auto x:bad_numbers)
		std::cout << std::hex << x << std::endl;
	return 0;
}

Скомпилируем её:

g++ -std=c++14 volatile.cpp -o volatile -lpthread

А вот фрагмент вывода, который после запуска появляется на экране:

ffffffff
ffffffff
ffffffff
ffffffff00000000
ffffffff
ffffffff00000000
ffffffff
ffffffff00000000
ffffffff00000000
ffffffff00000000
ffffffff00000000

CPU Info (частично)

$ lscpu
Architecture:          x86_64
CPU op-mode(s):        32-bit, 64-bit
Byte Order:            Little Endian
CPU(s):                8
On-line CPU(s) list:   0-7
Thread(s) per core:    2
Core(s) per socket:    4

Разберёмся, что произошло. lscpu показывает, что у моего процессора есть возможность выполнять параллельно 8 потоков. Значит три потока в тестовой программе вполне себе могут выполняться действительно параллельно. Далее я в одном из потоков в глобальную переменную пишу 0, в другом 0xffffffffffffffff (-1). Если операция была бы атомарной, никакое другое значение в той переменной появиться не могло бы. Но оно появляется. Вся суть в определении этой структуры:

struct unaligned
{
	uint8_t bytes[4092];
	volatile long long concurrent;
}__attribute__((packed));

Мы отключаем выравнивание на 64 битную границу и располагаем переменную concurrent на стыке страниц – 4 байта на одной странице, а 4 – на другой. Операции записи теряют свою атомарность. Исправить эту проблему можно, убрав attribute((packed)). Каждое поле структуры будет принудительно выровнено, и приложение выдаст пустой output.

Но что, если мы по каким-то причинам обязаны работать именно с такой структурой без выравнивания? На помощь нам придёт std::atomic? Не совсем, точнее – не придёт. Но давайте по порядку.

Выше мы уже вспомнили о нововведениях С++ 11 – std::atomic. Этот шаблон может сделать работу с нашими типами атомарной. Но подойдёт ли он в нашей конкретной ситуации? Напомню – у нас есть невыровненная структура, с одним из её полей мы бы хотели работать атомарно. Как быть?

Просто вставить внутрь структуры объект std::atomic нельзя. Если всё же мы так сделаем, то получим warning (часть сообщения приведена ниже):

warning: ignoring packed attribute because of unpacked non-POD field ‘std::atomic<long long int>

Мы включили в невыровненную структуру non-POD элемент. Поэтому атрибут, отключающий выравнивание, был проигнорирован, и выравнивание принудительно включилось.

Про POD из стандарта (Параграф 3.9, пункт 9)
Arithmetic types (3.9.1), enumeration types, pointer types, pointer to member types (3.9.2), std::nullptr_t, and cv-qualified versions of these types (3.9.3) are collectively called scalar types. Scalar types, POD classes (Clause 9), arrays of such types and cv-qualified versions of these types (3.9.3) are collectively called POD types.

Получается, что, хотя sizeof(T) == sizeof(std::atomic), использовать std::atomic в такой ситуации не выйдет.

Но выше мы выяснили, что атомарными являются операции с данными. Сами данные в памяти не имеют свойств атомарности. Значит нужно использовать не атомарные типы, а атомарные операции. Как проблема решается в С, в котором нет классов? С помощью атомарных функций.

Ознакомившись с этим материалом, становиться понятно, что нам нужны пара функций:

  • atomic_store
  • atomic_load

Для этого нам понадобиться создать файл с расширением .c и реализовать там пару функций для чтения/записи переменной из нашей невыровненной структуры. То есть, у нас будет два объектных файла, которые потом мы слинкуем в один исполняемый.

Наш .с файл будет выглядеть так:

#include <stdatomic.h>

void threadsafe_write(volatile long long* address,long long value)
{
	atomic_store(address , value);	
}

long long threadsafe_read(volatile long long* address)
{
	return atomic_load(address);	
}

Сохранил я его под именем safe.c. А компилироваться он будет так:

gcc -c safe.c

Наш volatile.cpp теперь будет выглядеть так:

#include <vector>
#include <iostream>
#include <thread>
#include <stdint.h>

#define LOOP_NUMBER 10000000

extern "C" void threadsafe_write(volatile long long* address,long long value);
extern "C" long long threadsafe_read(volatile long long* address);

struct unaligned
{
	uint8_t bytes[4092];
	volatile long long concurrent;	
}__attribute__((packed));

unaligned test = {0};
std::vector<long long> bad_numbers;

void fill_with_zero()
{
	for(long long i = 0;i < LOOP_NUMBER;i++)
		threadsafe_write(&test.concurrent , 0);
}

void fill_with_ff()
{
	for(long long i = 0;i < LOOP_NUMBER;i++)
		threadsafe_write(&test.concurrent , 0xffffffffffffffff);
}

void check_value()
{
	for(long long i = 0;i < LOOP_NUMBER;i++)
	{
		long long temp = threadsafe_read(&test.concurrent);
		if (temp != 0 && temp != 0xffffffffffffffff)
			bad_numbers.push_back(temp);
	}	
}

int main()
{
	std::thread zero_fill {fill_with_zero};
	std::thread full_fill {fill_with_ff};
	std::thread check {check_value};
	
	zero_fill.join();
	full_fill.join();
	check.join();	

	for(auto x:bad_numbers)
		std::cout << std::hex << x << std::endl;
	return 0;
}

Добавились объявления:

extern "C" void threadsafe_write(volatile long long* address,long long value);
extern "C" long long threadsafe_read(volatile long long* address);

И именно они используются для атомарного чтения/записи поля структуры unaligned test. Теперь скомпилируем второй объектный файл:

g++ -c volatile.cpp -std=c++14

И слинкуем всё вместе:

g++ safe.o volatile.o -o volatile -lpthread -std=c++14

Запускаем… и что мы видим? Опять десятки значений вида:

ffffffff00000000
ffffffff
ffffffff00000000
ffffffff00000000
ffffffff00000000

Не работает такая атомарность – не атомарна она совсем. Тогда я попробовал немного другой вариант со встроенными в gcc функциями.
Код нашего safe.c изменился вот так:

#include <stdatomic.h>

void threadsafe_write(volatile long long* address,long long value)
{	
	__atomic_store (address,&value, __ATOMIC_SEQ_CST);
}

long long threadsafe_read(volatile long long* address)
{	
	return __atomic_load_n (address, __ATOMIC_SEQ_CST);
}

Пересобираем эту конструкцию:

gcc -c safe.c
g++ -c volatile.cpp -std=c++14
g++ safe.o volatile.o -o volatile -lpthread -std=c++14

Запускаем… а результат всё тот же. Да что ж такое? Подизассемблируем немного – посмотрим, в какой код компилятор превратил наши старания:

public threadsafe_write
threadsafe_write proc near

var_10= qword ptr -10h
var_8= qword ptr -8

push    rbp
mov     rbp, rsp
mov     [rbp+var_8], rdi
mov     [rbp+var_10], rsi
mov     rax, [rbp+var_10]
mov     rdx, rax
mov     rax, [rbp+var_8]
mov     [rax], rdx
mfence
nop
pop     rbp
retn
threadsafe_write endp

И ещё:

public threadsafe_read
threadsafe_read proc near

var_8= qword ptr -8

push    rbp
mov     rbp, rsp
mov     [rbp+var_8], rdi
mov     rax, [rbp+var_8]
mov     rax, [rax]
pop     rbp
retn
threadsafe_read endp

Как видим, нет в листингах дизассемблера ни lock префиксов, ни xchg инструкций. Из синхронизационных механизмов есть только инструкция mfence, и только в threadsafe_write. threadsafe_read вообще просто читает значение по указателю.

Неужели синхонизация невозможна? Давайте ручками реализуем эти две функций на ассемблере – посмотрим, получится ли у нас. Берем GNU assembler, и компилируем такой файл:

.globl  threadsafe_write
.globl	threadsafe_read
        
        .text
threadsafe_write:
	xchg (%rdi), %rsi
        	ret
threadsafe_read:
	mov (%rdi), %rax
again:
	lock cmpxchg %rax, (%rdi)
	jnz again
	ret

Здесь мы используем AT&T синтаксис, который GNU assembler хочет видеть по умолчанию. Теперь немного о конвенции вызовов можно почитать здесь.

Если кратко, то первый параметр передаётся в регистре rdi, второй – в rsi. Объвляем два символа (функции) в глобальной области видимости:

.globl  threadsafe_write
.globl	threadsafe_read

Дальше рассмотрим реализацию threadsafe_write. По сути, всё сводится к одной инструкции XCHG, которая без явного указания LOCK префикса генерирует LOCK сигнал. XCHG запишет по указанному адресу требуемое значение. Тут всё просто.

threadsafe_read тоже должна быть атомарной. Иначе можно начать читать одно значение, а закончить чтение, когда значение будет другим. Т.е. начали читать -1, а закончили читать 0. В результате половина всех бит равны 1, а другая –0.

Как она работает? Вначале копируем (неатомарно) в rax значение, которое в этот момент вполне может модифицироваться другим потоком. Тогда то, что мы скопируем, может отличаться от того, что лежит по адресу в rsi. А может и не отличаться. Чтобы это узнать, делается cmpxchg. Делается с префиксом lock, чтобы быть атомарной инструкцией. Эта инструкция сравнивает значение в ячейке памяти со значением в регистре rax. Если они равны, то из второго параметра (у нас это тоже rax), в ячейку памяти пишется значение. В противном случае из ячейки оно читается в rax.

В общем, компилируем и запускаем:

gcc -c safe.s
g++ -c volatile.cpp -std=c++14
g++ safe.o volatile.o -o volatile -lpthread -std=c++14

И работает! Всё атомарно и никаких значений, кроме 0 и -1, в вектор не попадают.

Но решение – так себе. Во-первых, оно не кроссплатформенное. Во-вторых, только для одного конкретного типа long long int (или для другого 64-битного). Но как же работает std::atomic? Он-то хоть работает?

Напишем такую простенькую программку:

#include <atomic>
#include <iostream>

int main()
{
	std::atomic<long long> i(0);
	for (int j = 0;j < 100000;j++)
		i++;

	return i.load();
}

И соберём исполняемый файл:

g++ -std=c++14 atom.cpp -o atom

Теперь загрузим в дизассемблер и посмотрим реализацию атомарного инкремента:

public std::__atomic_base<long long>::operator++(int) ; weak
std::__atomic_base<long long>::operator++(int) proc near

var_2C= dword ptr -2Ch
var_28= qword ptr -28h
var_14= dword ptr -14h
var_10= qword ptr -10h
var_8= qword ptr -8

push    rbp
mov     rbp, rsp
mov     [rbp+var_28], rdi
mov     [rbp+var_2C], esi
mov     rax, [rbp+var_28]
mov     [rbp+var_10], rax
mov     [rbp+var_8], 1
mov     [rbp+var_14], 5
mov     rdx, [rbp+var_8]
mov     rax, [rbp+var_10]
lock xadd [rax], rdx
mov     rax, rdx
nop
pop     rbp
retn
std::__atomic_base<long long>::operator++(int) endp

В данном случае атомарность достигается за счёт ключевой инструкции lock xadd [rax], rdx. Ну, что же – отличная новость – std::atomic работает как нужно.

Классно, но какого же сивого мерина не работают сишные API? Чтобы это выяснить, нам понадобится стандарт С11.

В разделе, посвящённом atomic, можем найти такую строку:

Implementations that define the macro STDC_NO_THREADS need not provide this header nor support any of its facilities.

Модифицируем наш safe.c следующим образом:

#include <stdatomic.h>

#ifndef __STDC_NO_THREADS__

void threadsafe_write(volatile long long* address,long long value)
{
	atomic_store(address , value);	
}

long long threadsafe_read(volatile long long* address)
{
	return atomic_load(address);	
}

#endif

Пересоберём объектные файлы и начнём линковку, и вдруг видим такие сообщения:

volatile.o: In function `fill_with_zero()':
volatile.cpp:(.text+0x27): undefined reference to `threadsafe_write'
volatile.o: In function `fill_with_ff()':
volatile.cpp:(.text+0x5e): undefined reference to `threadsafe_write'
volatile.o: In function `check_value()':
volatile.cpp:(.text+0x9d): undefined reference to `threadsafe_read'
collect2: error: ld returned 1 exit status

Из-за того, что макрос STDC_NO_THREADS был задефайнен, атомарные функции не работают как надо. А во время последнего опыта исполняемый файл из-за этого даже не собрался. Видите, какие коварные ошибки могут поджидать нас.

Lock Free

LockFree – это атомарность без блокировок. Но без каких именно? Мы только что видели, что существуют чисто атомарные операции (инструкции), существуют те, которые генерируют LOCK сигнал, и существует LOCK префикс. Какие из них можно назвать lockfree? Если есть lock префикс – это тоже lockfree или уже нет?

Для этого обратимся к методу std::atomic::is_lock_free.

Он вернёт true или false, и мы сможем узнать, является ли какой-то конкретный atomic lockfree или нет. Модернизируем наш atom.cpp:

#include <atomic>
#include <iostream>

int main()
{
	std::atomic<long long> i(0);
	for (int j = 0;j < 100000;j++)
		i++;

	std::cout << i.is_lock_free() << std::endl;

	return i.load();
}

Сборка:

g++ -std=c++14 atom.cpp -o atom

Запуск, и он печатает 1. Т.е. префикс lock не добавляет блокировку в алгоритм. А что же тогда добавляет? По ссылке выше есть пример кода:

#include <iostream>
#include <utility>
#include <atomic>
 
struct A { int a[100]; };
struct B { int x, y; };
int main()
{
    std::cout << std::boolalpha
              << "std::atomic<A> is lock free? "
              << std::atomic<A>{}.is_lock_free() << '\n'
              << "std::atomic<B> is lock free? "
              << std::atomic<B>{}.is_lock_free() << '\n';
}

Собираем пример:

g++ -std=c++14 example.cpp -o example -latomic

Имеем такой же вывод, как и в примере:

std::atomic<A> is lock free? false
std::atomic<B> is lock free? True

Но как будет происходить работа с std::atomic? Нет такой инструкции, чтобы атомарно его модифицировать весь. Теперь давайте модифицируем пример:

#include <iostream>
#include <utility>
#include <atomic>
 
struct A { int a[100]; };
int main()
{
	std::atomic<A> test;
	A for_test;
	for (int i = 0;i < 100;i++)
		for_test.a[i] = i;
	test.exchange(for_test);
	auto testb = test.exchange(for_test);
	std::cout << testb.a[10] << std::endl;
}

И соберём:

g++ -std=c++14 example.cpp -o example -latomic -lpthread -static

На самом деле, вся блокировка сводится к вызову вот такой функции:

public libat_lock_n
.text:0000000000401770 libat_lock_n    proc near               ; CODE XREF: libat_exchange+11A#p
.text:0000000000401770                 shr     rdi, 6
.text:0000000000401774                 push    r14
.text:0000000000401776                 push    r13
.text:0000000000401778                 push    r12
.text:000000000040177A                 push    rbp
.text:000000000040177B                 lea     r13, locks
.text:0000000000401782                 push    rbx
.text:0000000000401783                 mov     rbx, rdi
.text:0000000000401786                 mov     ebp, 1000h
.text:000000000040178B                 and     ebx, 3Fh
.text:000000000040178E                 cmp     rsi, 1000h
.text:0000000000401795                 cmovbe  rbp, rsi
.text:0000000000401799                 xor     r14d, r14d
.text:000000000040179C                 xor     r12d, r12d
.text:000000000040179F                 nop
.text:00000000004017A0
.text:00000000004017A0 loc_4017A0:                             ; CODE XREF: libat_lock_n+52#j
.text:00000000004017A0                 mov     rdi, rbx
.text:00000000004017A3                 add     rbx, 1
.text:00000000004017A7                 shl     rdi, 6
.text:00000000004017AB                 add     rdi, r13        ; mutex
.text:00000000004017AE                 call    __pthread_mutex_lock
.text:00000000004017B3                 cmp     rbx, 40h
.text:00000000004017B7                 cmovz   rbx, r12
.text:00000000004017BB                 add     r14, 40h
.text:00000000004017BF                 cmp     r14, rbp
.text:00000000004017C2                 jb      short loc_4017A0
.text:00000000004017C4                 pop     rbx
.text:00000000004017C5                 pop     rbp
.text:00000000004017C6                 pop     r12
.text:00000000004017C8                 pop     r13
.text:00000000004017CA                 pop     r14
.text:00000000004017CC                 retn
.text:00000000004017CC libat_lock_n    endp

И ключевым здесь является вызов функции __pthread_mutex_lock. Таким образом, lockfree называются те алгоритмы, которые не используют блокировок, переключающих контекст потока.

Заключение

Вот и подошла к конца вторая часть цикла. Буду рад вашим комментариям, пожеланиям и замечаниям.

Спасибо за статью.
Хочу уточнить не совсем понятный момент, а почему макрос был задефайнен?

Добрый вечер. Вопрос действительно интересный. Я не дефайнил его специально, видимо, при использовании заголовочного файла stdatomic.h так, как это делал я, этот макрос дефайнится автоматически. Пока я не нашёл дополнительную информацию по этому вопросу.