Вступление
В предыдущей части мы говорили о примитиве синхронизации под названием критическая секция. Как оказалось, этот примитив не так уж элементарен. Поэтому начнём разбирать его составные части. И сегодня мы поговорим о такой важной вещи, как атомарные операции.
Атомарная операция – это такая операция, которая не может быть прервана. Проще говоря, у такой операции есть два состояния: - ещё не выполнена;
- уже выполнена.
И не может быть каких-либо промежуточных состояний типа всё ещё выполняется, выполнилась наполовину и так далее. Чтобы лучше понять сказанное, давайте обратимся к примеру.
Представим, что у нас есть массив char на size элементов. Нам нужно проинициализировать каждый элемент массива символом ‘a’. Мы пишем примерно такую функцию:
Code:
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 определяю размер страницы:
Code:
$ getconf PAGESIZE
4096
Теперь пишу такую программку на С++:
Code:
#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;
}
Скомпилируем её:
Code:
g++ -std=c++14 volatile.cpp -o volatile -lpthread
А вот фрагмент вывода, который после запуска появляется на экране:
Code:
ffffffff
ffffffff
ffffffff
ffffffff00000000
ffffffff
ffffffff00000000
ffffffff
ffffffff00000000
ffffffff00000000
ffffffff00000000
ffffffff00000000
CPU Info (частично)
Code:
$ 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). Если операция была бы атомарной, никакое другое значение в той переменной появиться не могло бы. Но оно появляется. Вся суть в определении этой структуры:
Code:
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 (часть сообщения приведена ниже):
Code:
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<T>), использовать std::atomic в такой ситуации не выйдет.
Но выше мы выяснили, что атомарными являются операции с данными. Сами данные в памяти не имеют свойств атомарности. Значит нужно использовать не атомарные типы, а атомарные операции. Как проблема решается в С, в котором нет классов? С помощью атомарных функций.
Ознакомившись с этим материалом, становиться понятно, что нам нужны пара функций:
Для этого нам понадобиться создать файл с расширением .c и реализовать там пару функций для чтения/записи переменной из нашей невыровненной структуры. То есть, у нас будет два объектных файла, которые потом мы слинкуем в один исполняемый.
Наш .с файл будет выглядеть так:
Code:
#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. А компилироваться он будет так:
Наш volatile.cpp теперь будет выглядеть так:
Code:
#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;
}
Добавились объявления:
Code:
extern "C" void threadsafe_write(volatile long long* address,long long value);
extern "C" long long threadsafe_read(volatile long long* address);
И именно они используются для атомарного чтения/записи поля структуры unaligned test. Теперь скомпилируем второй объектный файл:
Code:
g++ -c volatile.cpp -std=c++14
И слинкуем всё вместе:
Code:
g++ safe.o volatile.o -o volatile -lpthread -std=c++14
Запускаем… и что мы видим? Опять десятки значений вида:
Code:
ffffffff00000000
ffffffff
ffffffff00000000
ffffffff00000000
ffffffff00000000
Не работает такая атомарность – не атомарна она совсем. Тогда я попробовал немного другой вариант со встроенными в gcc функциями.
Код нашего safe.c изменился вот так:
Code:
#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);
}
Пересобираем эту конструкцию:
Code:
gcc -c safe.c
g++ -c volatile.cpp -std=c++14
g++ safe.o volatile.o -o volatile -lpthread -std=c++14
Запускаем… а результат всё тот же. Да что ж такое? Подизассемблируем немного – посмотрим, в какой код компилятор превратил наши старания:
Code:
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
И ещё:
Code:
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, и компилируем такой файл:
Code:
.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. Объвляем два символа (функции) в глобальной области видимости:
Code:
.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.
В общем, компилируем и запускаем:
Code:
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<T>? Он-то хоть работает?
Напишем такую простенькую программку:
Code:
#include <atomic>
#include <iostream>
int main()
{
std::atomic<long long> i(0);
for (int j = 0;j < 100000;j++)
i++;
return i.load();
}
И соберём исполняемый файл:
Code:
g++ -std=c++14 atom.cpp -o atom
Теперь загрузим в дизассемблер и посмотрим реализацию атомарного инкремента:
Code:
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<T> работает как нужно.
Классно, но какого же сивого мерина не работают сишные API? Чтобы это выяснить, нам понадобится стандарт С11.
В разделе, посвящённом atomic, можем найти такую строку:
Implementations that define the macro __STDC_NO_THREADS__ need not provide this header nor support any of its facilities.
Модифицируем наш safe.c следующим образом:
Code:
#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
Пересоберём объектные файлы и начнём линковку, и вдруг видим такие сообщения:
Code:
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:
Code:
#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();
}
Сборка:
Code:
g++ -std=c++14 atom.cpp -o atom
Запуск, и он печатает 1. Т.е. префикс lock не добавляет блокировку в алгоритм. А что же тогда добавляет? По ссылке выше есть пример кода:
Code:
#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';
}
Собираем пример:
Code:
g++ -std=c++14 example.cpp -o example -latomic
Имеем такой же вывод, как и в примере:
Code:
std::atomic<A> is lock free? false
std::atomic<B> is lock free? True
Но как будет происходить работа с std::atomic<A>? Нет такой инструкции, чтобы атомарно его модифицировать весь. Теперь давайте модифицируем пример:
Code:
#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;
}
И соберём:
Code:
g++ -std=c++14 example.cpp -o example -latomic -lpthread -static
На самом деле, вся блокировка сводится к вызову вот такой функции:
Code:
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 называются те алгоритмы, которые не используют блокировок, переключающих контекст потока.
Заключение
Вот и подошла к конца вторая часть цикла. Буду рад вашим комментариям, пожеланиям и замечаниям.