Простое руководство по изучению многопоточности, конкурентности и параллелизма в C++
Вначале, когда ещё только состоялось моё знакомство с многопоточностью в C++, многое было мне непонятным и сбивало с толку. Сложность программы расцветала буйным цветом (именно так: подобно прекрасному цветку), конкурентность и параллелизм с их недетерминированным поведением меня просто убивали, и всё было как в тумане. Так что мне легко понять всех приступающих к изучению этих понятий. Спешу избавить вас от мучений и предлагаю вашему вниманию это простое руководство по изучению конкурентности, параллелизма и многопоточности в C++ (в конце данной статьи расписан план, в соответствии с которым мы будем двигаться дальше).
А пока освежим в памяти основные понятия и попробуем на вкус код, выполняемый в многопоточной среде.
В любом процессе создаётся уникальный поток выполнения, который называется основным потоком. Он может с помощью операционной системы запускать или порождать другие потоки, которые делят то же адресное пространство родительского процесса (сегмент кода, сегмент данных, а также другие ресурсы операционной системы, такие как открытые файлы и сигналы). С другой стороны, у каждого потока есть свой идентификатор потока, стек, набор регистров и счётчик команд. По сути, поток представляет собой легковесный процесс, в котором переключение между потоками происходит быстрее, а взаимодействие между процессами — легче.
Планировщик распределяет процессорное время между разными потоками. Это называется аппаратным параллелизмом или аппаратной конкурентностью (пока что считаем здесь параллелизм и конкурентность синонимами): когда несколько потоков выполняются на разных ядрах параллельно, причём каждый занимается конкретной задачей программы. → Примечание: чтобы определить количество задач, которые реально можно выполнять в многопоточном режиме на том или ином компьютере, используется функция std::thread::hardware_concurrency()
. Если число потоков будет превышать этот лимит, может начаться настоящая чехарда с переключением задач (когда слишком частые переключения между задачами — много раз в секунду — создают лишь иллюзию многопоточности).
#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
.Здесь вы можете найти пример кода, иллюстрирующий практически всё, что написано выше.
Из-за того, что несколько потоков делят одно адресное пространство и ресурсы, многие операции становятся критичными, и тогда многопоточности требуются примитивы синхронизации. И вот почему:
Поток должен объявить, что он использует. А затем, прежде чем трогать этот объект, проверить, не использует ли его кто-то ещё. Зелёный поток смотрит ТВ? Значит, никто не должен трогать ТВ (другие могут рядышком сесть и посмотреть, если что). Это можно сделать с помощью мьютекса.
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
, даже если нарушены ограничения.
Если два потока имеют доступ к одним и тем же данным (один к записи, другой — к чтению), нельзя сказать наверняка, какая операция будет выполняться первой.
Доступ должен быть синхронизирован.
Вы можете сказать: «Батюшки! Сколько всего намешано в этой статье!» Просто помните, что не надо пытаться понять всё и сразу, важно ухватить основные идеи.
Предлагаю пока что поиграть с примерами и посмотреть, как в них проявляется многопоточность. Можете подумать над другими примерами, где нужна синхронизация, и протестировать их (подсказка: потоки, удаляющие элементы из начала очереди. Не забывайте: прежде чем удалять, надо проверить, не пуста ли очередь).
В будущих статьях будут освящены следующие темы:
std::condition_variable
3. Атомарность→ Высокоуровневые подходы 3. Future и async 4. Промисы 5. std::packeged_task
valentina-codes/Cpp-ConcurrencyA guided tour to learn C++ Multithreading and Concurrency. Theory, code examples and challenges. This repository…github.com
Библиотека C++11 представляет стандартный механизм для синхронизации, независимый от базовой платформы, так что говорить о потоках, выполняемых в Linux и Windows, мы не будем. Тем более, что основные принципы похожи.
В следующей статье рассмотрим примитив синхронизации мьютекса и как его задействовать по максимуму.
Перевод статьи
Комментарии