Skip to content

Latest commit

 

History

History
339 lines (258 loc) · 23.5 KB

README.md

File metadata and controls

339 lines (258 loc) · 23.5 KB

Нити (threads)

Атомарность работы с памятью

На всех современных процессорах (x86, x64, Itanium, SPARC, ARM, PowerPC) операции чтения из памяти и записи в память натурально-выравненных значений размера не более машинного слова атомарны. Точную информацию об этом можно получить в руководстве по соответствующей архитектуре. Таким образом, можно предполагать, что на этих архитектурах чтение/запись 32-битных значений, адреса которых выравнены по границе 4 байт атомарны.

Атомарность чтения и записи означает, что в случае одновременного чтения одним ядром и записи другим ядром значения по одному и тому же адресу, 32-битное значение не "рассыпется на куски" частично старого, а частично нового значения. Операция чтения вернет либо старое значение ячейки памяти, либо новое значение ячейки памяти.

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

Но, строго говоря, с точки зрения стандартов Си и Си++ такое предположение некорректно! С точки зрения стандарта по умолчанию никакие операции работы с памятью не являются атомарными. Мы должны использовать специальные типы данных (например std::atomic) и операции с ними.

И, конечно же, "наивное" предположение об атомарности будет неверным для операций, которые требуют чтения из ячейки памяти и записи в нее же, и для типов, больших чем машинное слово. То есть "наивная" операция a += 2 или ++a для целого типа или операция присваивания для типа long long на 32-битной платформе не будет атомарна никогда.

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

Data race

Если несколько нитей одновременно обращаются к одной и той же ячейке памяти, причем хотя бы одна из нитей выполняет запись в эту ячейку, такая ситуация называется data race. Если при выполнении программы возникает data race, дальнейшее поведение программы не определено (undefined behavior). Программы, при выполнении которых возможны data race, являются некорректными программами на C или C++.

Например:

int balance = 1000;

void thread1() {
    balance += 10;
}

void thread2() {
    std::cout << balance << std::endl;
}

Эта программа содержит data race, так как thread1 обновляет переменную balance одновременно с тем, как её читает thread2.

Следующая программа не содержит data race:

int deposit = 1000;
int tax = 0;
int interest = 0;

void thread1() {
     tax = 0.13 * deposit;
}

void thread2() {
     interest = 0.08 * deposit;
}

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

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

Следующий пример содержит data race, так как оба потока вызывают метод push_back(), а метод push_back() модифицирует вектор, который сам по себе не потокобезопасный.

std::vector<int> squares;

void thread1() {
    for (int i = 0; i < 10; i++) {
        squares.push_back(i * i);
    }
}

void thread2() {
    for (int i = 10; i < 20; i++) {
        squares.push_back(i * i);
    }
}

Мьютексы

man 3 pthread_mutex_lock

Если одни и те же данные используются одновременно более, чем в одном потоке, и хотя бы в одном из этих потоков модифицируются, может возникнуть "состояние гонки" (race condition), когда появляется возможность для какого-либо потока обнаружить данные частично модифицированными, т.е. в неконсистентном виде. Операция с данными, при которой невозможно состояние гонки, называется атомарной.

Самый простой (но часто не самый практичный) способ обеспечить атомарность операции - при помощи мьютекса (mutual exclusion, взаимного исключения) гарантировать, что к данным в одно и то же время имеет доступ не больше одного потока.

// хотим гарантировать, что эти два счётчика модифицируются одновременно, 
// то есть никакое чтение не должно увидит эту пару в состоянии, 
// когда один из счётчиков модифицирован, а другой - ещё нет
int counter0 = 0;
int counter1 = 0;
pthread_mutex_t counter_mutex = PTHREAD_MUTEX_INITIALIZER;

// оба счётчика увеличиваются одновременно
void add_counters(int add0, int add1) {
    pthread_mutex_lock(&counter_mutex);
    counter0 += add0;
    counter1 += add1;
    pthread_mutex_unlock(&counter_mutex);
}

// возвращаем консистентные значения счётчиков
void read_counters(int* cnt0, int* cnt1) {
    pthread_mutex_lock(&counter_mutex);
    *cnt0 = counter0;
    *cnt1 = counter1;
    pthread_mutex_unlock(&counter_mutex);
}

Условные переменные

man 3 pthread_cond_wait

Мьютексы предназначены для организации критической секции в нитях, то есть фрагмента кода, в котором может находиться одновременно не более одной нити. У заблокированного мьютекса есть нить-владелец, которая его заблокировала, и только эта нить может разблокировать мьютекс.

По этой причине мьютексы нельзя использовать для организации контролируемого ожидания нитей. Мы хотим иметь возможность поместить нить в состояние сна (блокировки), а когда наступит какое-то нужное нам событие, разбудить одну или все нити, которые спят в ожидании этого события. Такое поведение можно реализовать с помощью условных переменных (conditional variables). На самом деле, этот механизм ближе по семантике к сигналам, нежели чем к переменным.

Одна нить может послать сигнал какой-то одной ожидающей нити с помощью функции pthread_cond_signal либо всем ожидающим нитям с помощью pthread_cond_broadcast. Нить или нити в этот момент должны ожидать прихода сигнала, вызвав функцию pthread_cond_wait. Если сразу несколько нитей ждут в функции pthread_cond_wait на одной и той же условной переменной, то pthread_cond_signal разбудит какую-то одну нить, а pthread_cond_broadcast разбудит все нити.

Упрощенный фрагмент программы может быть таким:

pthread_cond_t c = PTHREAD_COND_INITIALIZER;

// первая нить
pthread_cond_signal(&c);

// вторая нить
pthread_cond_wait(&c);

Однако при параллельном выполнении нитей практически невозможно гарантировать, что ожидающая нить войдет в выполнение функции pthread_cond_wait раньше, чем другая нить выполнит pthread_cond_signal. Но если pthread_cond_signal отправляет сигнал "в никуда", то этот сигнал просто потеряется! Поэтому при выполнении pthread_cond_signal и pthread_cond_wait есть race condition.

Чтобы отловить ситуацию, когда pthread_cond_signal выполнится раньше pthread_cond_wait, нам потребуется дополнительная обычная переменная, например, типа bool. Мы ее установим в true при отправке сигнала и будем проверять при получении сигнала. Получится примерно такой фрагмент:

pthread_cond_t c = PTHREAD_COND_INITIALIZER;
volatile bool f = false;

// первая нить
f = true; // устанавливаем флаг на случай, если signal и wait разомнутся по времени
pthread_cond_signal(&c);

// вторая нить
if (!f) pthread_cond_wait(&c); // Некорректная программа!

Однако в этом примере по-прежнему есть race condition, а именно, если во второй нити будет выполнена проверка !f, а сразу же после нее управление будет передано на первую нить, которая выполнит и установку переменной f, и посылку сигнала, после чего управление вернется на вторую нить, и то, что f изменилась, второй нитью не будет замечено, и сигнал будет потерян. После чего вторая нить будет заблокирована на неопределенное время в pthread_cond_wait.

Поэтому для корректной проверки значения флага f нам потребуется еще и мьютекс. Корректный фрагмент программы выглядит следующим образом:

pthread_cond_t c = PTHREAD_COND_INITIALIZER;
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
bool f = false;

// первая нить
pthread_mutex_lock(&m);
f = true; // устанавливаем флаг на случай, если signal и wait разомнутся по времени
pthread_cond_signal(&c);
pthread_mutex_unlock(&m);

// вторая нить
pthread_mutex_lock(&m);
while (!f) {
  pthread_cond_wait(&c, &m);
}
f = false;
pthread_mutex_unlock(&m);

Если в первой нити не использовать мьютекс, то сохраняется возможность race condition, описанная выше. Обратите внимание, что во второй нити проверка значения флага и переход к ожиданию приема сигнала выполняются с заблокированным мьютексом m. Поэтому на время собственно ожидания мьютекс m необходимо разблокировать, чтобы первая нить вообще имела возможность войти в критическую секцию и отправить сигнал второй нити. Поэтому функция pthread_cond_wait принимает два параметра: условную переменную, на которой нужно ждать и мьютекс, который нужно разблокировать на время ожидания. Операция разблокировки мьютекса и перехода к ожиданию выполняется внутри pthread_cond_wait атормарно.

Модель памяти

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

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

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

Например, если в одной нити выполняется фрагмент:

y = 0;
x = 0;
// ...
x = 1;
y = 2;
// ...
x = 3;

а в другой нити фрагмент:

if (y == 2) {
  z = x;
}

Переменная z вполне может получить значение 0, если компилятор или процессор поменял местами присваивания x = 1 и y = 2.

Модель памяти определяет, как может и как не может изменятся порядок выполнения операций доступа к памяти в разных случаях выполнения программы. Современные стандарты языков Си и Си++ (C11 и C++14) имеют общую модель памяти.

atomic-типы

Для обеспечения атомарности операций с памятью и спецификации разрешенных оптимизаций при работе с памятью Си++ предлагает шаблонный класс std::atomic<T>. Методы этого типа гарантируют атомарность доступа к переменной и определенный порядок выполнения операций с памятью.

В качестве типа T можно использовать любой тип данных, но не для любого типа данных компилятор сможет сгенерировать машинный код, который не будет использовать мьютексы. Например, не стоит ожидать, что работа с типом std::atomic<long long> на 32-битной платформе не потребует операций с мьютексом. Но разумно ожидать, что работа с типами std::atomic<bool>, std::atomic<int>, std::atomic<char *> (или любой указатель, на самом деле) будет выполняться с помощью специализированных инструкций процессора.

Чтобы определить, требуется ли блокировка для работы с atomic<T> можно использовать метод is_lock_free, например:

std::atomic<long long> llv{0};

if (!llv.is_lock_free()) {
    std::cerr << "so sad" << std::endl;
}

memory_order

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

Некоторые значения констант перечислены ниже:

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

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

memory_order_release - запрещает перестановку операций работы с памятью после данной операции. То есть, если в тексте программы операция обращения к памяти находится перед атомарной операции с этим флагом, то и при выполнении программы она будет выполнена ранее. Это гарантирует, что все записи в память будут видны другим нитям, которые выполняют операцию с флагом memory_order_acquire.

Флаги memory_order_acquire и memory_order_release являются дополняющими друг друга. Операция lock обычно выполняется с флагом memory_order_acquire, а операция unlock - c флагом memory_order_release.

memory_order_acq_rel - и memory_order_acquire, и memory_order_release одновременно. То есть нельзя переставлять неатомарные операции относительно такой атомарной операции. Тем не менее, если мы рассмотрим сами атомарные операции, то их наблюдаемый в разных нитях относительный порядок может различаться.

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

Операции с atomic-типами

void std::atomic<T>::store(T value, std::memory_order order = std::memory_order_seq_cst); Эта операция сохраняет в памяти значение value, параметр order задает модель записи. Например,

std::atomic<bool> lock{true};

// ...
lock.store(true, std::memory_order_release);

T std::atomic<T>::load(std::memory_order order = std::memory_order_seq_cst); Загружает значение из памяти и возвращает результат. Пример:

std::atomic<Data *> instance = nullptr;

auto local = instance.load(std::memory_order_relaxed);

T std::atomic<T>::exchange(T newval, std::memory_order order = std::memory_order_seq_cst); Записывает в память значение newval и одновременно считывает старое значение, которое возвращается в качестве результата. Например,

std::atomic<bool> lock{true};

auto oldval = lock.exchange(false, std::memory_order_acquire);

Про другие операции можно прочитать в документации.