Во-первых, вы должны научиться мыслить как языковой адвокат.
Спецификация C ++ не содержит ссылки на какой-либо конкретный компилятор, операционную систему или процессор. Он ссылается на абстрактную машину, которая является обобщением реальных систем. В мире Language Lawyer работа программиста заключается в написании кода для абстрактной машины; Задача компилятора - реализовать этот код на конкретной машине. Жестко программируя спецификацию, вы можете быть уверены, что ваш код будет компилироваться и выполняться без изменений в любой системе с совместимым компилятором C ++, будь то сегодня или через 50 лет.
Абстрактная машина в спецификации C ++ 98 / C ++ 03 принципиально однопоточная. Поэтому невозможно написать многопоточный код C ++, который является «полностью переносимым» по отношению к спецификации. Спецификация даже не говорит ничего об атомарности загрузки и хранения памяти или о порядке, в котором могут происходить загрузки и хранения, не говоря уже о мьютексах.
Конечно, вы можете написать многопоточный код на практике для конкретных конкретных систем, таких как pthreads или Windows. Но не существует стандартного способа написания многопоточного кода для C ++ 98 / C ++ 03.
Абстрактная машина в C ++ 11 является многопоточной по своему дизайну. Он также имеет четко определенную модель памяти ; то есть он говорит, что компилятор может и не может делать, когда дело доходит до доступа к памяти.
Рассмотрим следующий пример, где пара глобальных переменных доступна одновременно двум потокам:
Global
int x, y;
Thread 1 Thread 2
x = 17; cout << y << " ";
y = 37; cout << x << endl;
Что может выводить тема 2?
В C ++ 98 / C ++ 03 это даже не неопределенное поведение; сам вопрос не имеет смысла, потому что стандарт не предусматривает ничего, что называется «нитью».
В C ++ 11 результатом является неопределенное поведение, потому что загрузки и хранилища не должны быть атомарными вообще. Что не может показаться большим улучшением ... И само по себе это не так.
Но с C ++ 11 вы можете написать это:
Global
atomic<int> x, y;
Thread 1 Thread 2
x.store(17); cout << y.load() << " ";
y.store(37); cout << x.load() << endl;
Теперь все становится намного интереснее. Прежде всего, поведение здесь определено . Теперь поток 2 может печатать 0 0
(если он выполняется до потока 1), 37 17
(если он выполняется после потока 1) или 0 17
(если он выполняется после того, как поток 1 назначает x, но до того, как он назначает y).
Он не может печатать 37 0
, потому что режим по умолчанию для атомарных загрузок / хранилищ в C ++ 11 состоит в обеспечении последовательной согласованности . Это просто означает, что все загрузки и хранилища должны быть «такими, как если бы» происходили в том порядке, в котором вы их записали в каждом потоке, а операции между потоками могут чередоваться, как нравится системе. Таким образом, стандартное поведение атома обеспечивает атомарность и порядок загрузки и хранения.
Теперь на современном процессоре обеспечение последовательной согласованности может быть дорогостоящим. В частности, компилятор, вероятно, будет создавать полноценные барьеры памяти между каждым доступом здесь. Но если ваш алгоритм может терпеть неупорядоченные загрузки и хранения; т.е. если это требует атомарности, но не упорядоченности; то есть, если он может терпеть 37 0
как вывод этой программы, то вы можете написать это:
Global
atomic<int> x, y;
Thread 1 Thread 2
x.store(17,memory_order_relaxed); cout << y.load(memory_order_relaxed) << " ";
y.store(37,memory_order_relaxed); cout << x.load(memory_order_relaxed) << endl;
Чем современнее процессор, тем больше вероятность, что он будет быстрее, чем в предыдущем примере.
И, наконец, если вам просто нужно поддерживать порядок в определенных загрузках и хранилищах, вы можете написать:
Global
atomic<int> x, y;
Thread 1 Thread 2
x.store(17,memory_order_release); cout << y.load(memory_order_acquire) << " ";
y.store(37,memory_order_release); cout << x.load(memory_order_acquire) << endl;
Это возвращает нас к заказанным нагрузкам и хранилищам - так что 37 0
это уже невозможно - но это происходит с минимальными издержками. (В этом тривиальном примере результат такой же, как у последовательной последовательной последовательности; в более крупной программе это не так).
Конечно, если вы хотите видеть только выходные данные 0 0
или 37 17
, вы можете просто обернуть мьютекс вокруг исходного кода. Но если вы прочитали это далеко, держу пари, вы уже знаете, как это работает, и этот ответ уже дольше, чем я предполагал :-).
Итак, суть. Мьютексы великолепны, и C ++ 11 их стандартизирует. Но иногда по соображениям производительности вам нужны низкоуровневые примитивы (например, классический шаблон блокировки с двойной проверкой ). Новый стандарт предоставляет высокоуровневые гаджеты, такие как мьютексы и условные переменные, а также низкоуровневые гаджеты, такие как атомарные типы и различные варианты барьера памяти. Так что теперь вы можете писать сложные, высокопроизводительные параллельные подпрограммы полностью на языке, указанном в стандарте, и вы можете быть уверены, что ваш код будет компилироваться и выполняться без изменений как в сегодняшних, так и в завтрашних системах.
Хотя, честно говоря, если вы не эксперт и не работаете над серьезным низкоуровневым кодом, вам, вероятно, следует придерживаться мьютексов и условных переменных. Это то, что я собираюсь сделать.
Подробнее об этом см. В этом блоге .