[C++] часть 1: многопоточность, конкурентность и параллелизм: ОСНОВЫ

Простое руководство по изучению многопоточности, конкурентности и параллелизма в C++

Андрей Шагин
Nov 5 · 6 min read

Вначале, когда ещё только состоялось моё знакомство с многопоточностью в C++, многое было мне непонятным и сбивало с толку. Сложность программы расцветала буйным цветом (именно так: подобно прекрасному цветку), конкурентность и параллелизм с их недетерминированным поведением меня просто убивали, и всё было как в тумане. Так что мне легко понять всех приступающих к изучению этих понятий. Спешу избавить вас от мучений и предлагаю вашему вниманию это простое руководство по изучению конкурентности, параллелизма и многопоточности в C++ (в конце данной статьи расписан план, в соответствии с которым мы будем двигаться дальше).

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

1. Что такое поток?

В любом процессе создаётся уникальный поток выполнения, который называется основным потоком. Он может с помощью операционной системы запускать или порождать другие потоки, которые делят то же адресное пространство родительского процесса (сегмент кода, сегмент данных, а также другие ресурсы операционной системы, такие как открытые файлы и сигналы). С другой стороны, у каждого потока есть свой идентификатор потока, стек, набор регистров и счётчик команд. По сути, поток представляет собой легковесный процесс, в котором переключение между потоками происходит быстрее, а взаимодействие между процессами — легче.

2. Что такое конкурентность/параллелизм

Планировщик распределяет процессорное время между разными потоками. Это называется аппаратным параллелизмом или аппаратной конкурентностью (пока что считаем здесь параллелизм и конкурентность синонимами): когда несколько потоков выполняются на разных ядрах параллельно, причём каждый занимается конкретной задачей программы.
Примечание: чтобы определить количество задач, которые реально можно выполнять в многопоточном режиме на том или ином компьютере, используется функция std::thread::hardware_concurrency(). Если число потоков будет превышать этот лимит, может начаться настоящая чехарда с переключением задач (когда слишком частые переключения между задачами — много раз в секунду — создают лишь иллюзию многопоточности).

3. Основные операции с потоками с помощью std::thread

  • Заголовочный файл| #include <thread>
  • Запуск потока| std::thread t(callable_object, arg1, arg2, ..)
    Создаёт новый поток выполнения, ассоциируемый с t, который вызывает callable_object(arg1, arg2). Вызываемый объект (т.е. указатель функции, лямбда-выражение, экземпляр класса с вызовом функции operator) немедленно выполняется новым потоком с (выборочно) передаваемыми аргументами. Они копируются по умолчанию. Если хотите передать по ссылке, придётся использовать метод warp к аргументу с помощью std::ref(arg). Не забывайте: если хотите передать unique_ptr, то должны переместить его (std::move(my_pointer)), так как его нельзя копировать.
  • Жизненный цикл потока| t.join() и t.detach()
    Если основной поток завершает выполнение, все второстепенные сразу останавливаются без возможности восстановления. Чтобы этого не допустить, у родительского потока имеются два варианта для каждого порождённого:
    → Блокирует и ждёт завершения порождённого потока, вызывая на нём метод join.
    → Прямо объявляет, что порождённый поток может продолжить выполнение даже после завершения родительского, используя метод detach.
  • Запомните: объект потока можно перенести, но нельзя копировать.

вы можете найти пример кода, иллюстрирующий практически всё, что написано выше.

4. Зачем нужна синхронизация?

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

  • Память — дом с привидениями
    Память никогда больше не будет обычным хранилищем данных — теперь это обитель привидений. Представьте: поток смотрит Netflix, уютно устроившись перед Smart TV, и тут вдруг экран мигает и выключается. В панике поток набирает 112, а в ответ… «Доставка пиццы, спасибо, что позвонили». Что происходит? А то, что в доме полно привидений (где в роли привидений другие потоки): они все в одной комнате и взаимодействуют с одними и теми же объектами (это называется гонка данных), но друг для друга они привидения.

Поток должен объявить, что он использует. А затем, прежде чем трогать этот объект, проверить, не использует ли его кто-то ещё. Зелёный поток смотрит ТВ? Значит, никто не должен трогать ТВ (другие могут рядышком сесть и посмотреть, если что). Это можно сделать с помощью мьютекса.

  • Нужны атомарные операции!
    Большинство операций неатомарные. Если операция неатомарная, можно увидеть её промежуточное состояние, так как она не является неделимой. Например: запись 64 битов, 32 бита за один раз. Во время этой операции другой поток может увидеть 32 старых бита и 32 новых, получая совершенно неверный результат. По этой причине результаты таких операций должны казаться атомарными, даже если они такими не являются.
    Примечание: даже инкремент не является атомарной операцией: int tmp = a; a = tmp + 1;
    Самое простое решение здесь — использовать шаблон std::atomic, который разрешает атомарные операции разных типов.
  • Когерентность кеша и выполнение с изменением очерёдности
    Каждое ядро пытается сохранить результаты какой-то работы, помещая недавние значения в локальный кеш. Но несколько потоков выполняются на разных ядрах, и значения, хранящиеся в кеше, больше не могут быть валидными, так что рано или поздно кеш должен обновляться. В то же время изменения не видны другим, пока кеш не очищен. Чтобы распространить изменения и обеспечить корректную видимость памяти, нужны определённые механизмы.
    Кроме того, для повышения эффективности процессор и/или компилятор может поменять очерёдность выполнения команд. Это может привести к непредсказуемому поведению в параллельно выполняемой программе, в связи с чем необходимо гарантировать исполнение критически важных команд в первоначальном порядке.
    Эта работа выполняется примитивами синхронизации, предполагающими использование барьеров доступа к памяти (строки кода, которые не вычеркнуть какими-то операциями) для обеспечения согласованности и предотвращения изменения очерёдности выполнения (инструкции внутри барьеров памяти нельзя вытащить оттуда).

Пример кода

Обратимся к коду. Теперь вы сами можете проверить это недетерминированное поведение многопоточности.

#include <thread>
#include <iostream>
#include <string>

void run(std::string threadName) {
for (int i = 0; i < 10; i++) {
std::string out = threadName + std::to_string(i) + "\n";
std::cout << out;
}
}

int main() {
std::thread tA(run, "A");
std::thread tB(run, "\tB");
tA.join();
tB.join();
}

Возможный вывод:

B0
A0
A1
A2
B1
A3
B2
B3

..

В отличие от однопоточной реализации, каждое выполнение даёт разный и непредсказуемый результат (единственное, что можно сказать определённо: строки А и B упорядочены по возрастанию). Это может вызвать проблемы, когда очерёдность команд имеет значение.

#include <thread>
#include <iostream>
#include <string>

void runA(bool& value, int i) {
if(value) {
//значение всегда должно быть равным 1
std::string out = "[ " + std::to_string(i) + " ] value " + std::to_string(value) + "\n";
std::cout << out;
}
}

void runB(bool& value) {
value = false;
}
int main() {
for(int i = 0; i < 20; i++) {
bool value = true; //1
std::thread tA(runA, std::ref(value), i);
std::thread tB(runB, std::ref(value));
tA.join();
tB.join();
}
}

Возможный вывод:

..
[ 12 ] value 0
[ 13 ] value 1
[ 14 ] value 0
[ 15 ] value 0
[ 16 ] value 0
[ 17 ] value 0
[ 18 ] value 1
[ 19 ] value 0
..

Но что здесь происходит? После того как поток А оценивает «значение» как истинное, поток B меняет его. Теперь мы внутри блока if, даже если нарушены ограничения.

Если два потока имеют доступ к одним и тем же данным (один к записи, другой — к чтению), нельзя сказать наверняка, какая операция будет выполняться первой.

Доступ должен быть синхронизирован.

Заключение

Вы можете сказать: «Батюшки! Сколько всего намешано в этой статье!» Просто помните, что не надо пытаться понять всё и сразу, важно ухватить основные идеи.

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

План статей

В будущих статьях будут освящены следующие темы:

  • Теория + Простые примеры
    → Низкоуровневые подходы
    1. Мьютекс
    2. std::condition_variable
    3. Атомарность
    → Высокоуровневые подходы
    3. Future и async
    4. Промисы
    5. std::packeged_task
  • Практика + самостоятельная работа и упражнения

Библиотека C++11 представляет стандартный механизм для синхронизации, независимый от базовой платформы, так что говорить о потоках, выполняемых в Linux и Windows, мы не будем. Тем более, что основные принципы похожи.

В следующей статье рассмотрим примитив синхронизации мьютекса и как его задействовать по максимуму.

Читайте также:


NOP::Nuances of programming

Перевод и адаптация статей в сфере IT

Андрей Шагин

Written by

NOP::Nuances of programming

Перевод и адаптация статей в сфере IT

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade