Atomic c что это
Перейти к содержимому

Atomic c что это

<atomic>

Определяет классы и шаблоны классов для создания типов, поддерживающих атомарные операции.

Синтаксис

Remarks

В коде, скомпилированном с помощью, /clr:pure этот заголовок блокируется. /clr:safe Обе /clr:pure версии являются устаревшими в Visual Studio 2017 и более поздних версиях.

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

Поскольку атомарная операция неразличима, вторая атомарная операция с тем же объектом из другого потока может получить состояние объекта только до или после первой атомарной операции.

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

На некоторых платформах бывает невозможно эффективно реализовать атомарные операции для некоторых типов без использования блокировок mutex . Атомарный тип , lock-free если атомарные операции с этим типом не используют блокировки.

C++11 : в обработчиках сигналов можно выполнять атомарные операции с объектом obj , если obj.is_lock_free() или atomic_is_lock_free(x) нет true .

atomic_flag Класс предоставляет минимальный атомарный тип, содержащий bool флаг. Его операции всегда являются неблокирующими.

Шаблон atomic<T> класса хранит объект своего типа T аргумента и обеспечивает атомарный доступ к хранимым значениям. Его можно создать с помощью любого типа, который можно скопировать с помощью memcpy и проверить на равенство с помощью memcmp . В частности, его можно использовать с пользовательскими типами, которые соответствуют этим требованиям, и во многих случаях с типами с плавающей запятой.

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

Специализации для указателей

Частичные специализации atomic<T *> применяются для всех типов указателей. Они предоставляют методы для расчетов с указателями.

Специализации для целочисленных типов

Специализации atomic<integral> применяются для всех целочисленных типов. Они предоставляют дополнительные операции, недоступные через основной шаблон.

Каждый тип atomic<integral> имеет соответствующий макрос, который можно использовать в if directive для определения во время компиляции, являются ли операции этого типа неблокирующими. Если значение макроса равно нулю, операции с типом не блокируются. Если значение равно 1, операции могут быть неблокирующими, и требуется проверка времени выполнения. Если значение равно 2, операции являются неблокирующими. Вы можете использовать функцию atomic_is_lock_free для определения во время выполнения, являются ли операции с типом неблокирующими.

Для каждого целочисленного типа существует соответствующий именованный атомарный тип, который управляет объектом этого целочисленного типа. Каждый тип atomic_integral имеет тот же набор функций-членов, что и соответствующий экземпляр atomic<T> , и может быть передан в любую из атомарных функций, не являющихся членами.

Тип atomic_integral Целочисленный тип Макрос atomic_is_lock_free
atomic_char char ATOMIC_CHAR_LOCK_FREE
atomic_schar signed char ATOMIC_CHAR_LOCK_FREE
atomic_uchar unsigned char ATOMIC_CHAR_LOCK_FREE
atomic_char16_t char16_t ATOMIC_CHAR16_T_LOCK_FREE
atomic_char32_t char32_t ATOMIC_CHAR32_T_LOCK_FREE
atomic_wchar_t wchar_t ATOMIC_WCHAR_T_LOCK_FREE
atomic_short short ATOMIC_SHORT_LOCK_FREE
atomic_ushort unsigned short ATOMIC_SHORT_LOCK_FREE
atomic_int int ATOMIC_INT_LOCK_FREE
atomic_uint unsigned int ATOMIC_INT_LOCK_FREE
atomic_long long ATOMIC_LONG_LOCK_FREE
atomic_ulong unsigned long ATOMIC_LONG_LOCK_FREE
atomic_llong long long ATOMIC_LLONG_LOCK_FREE
atomic_ullong unsigned long long ATOMIC_LLONG_LOCK_FREE

Typedef имена существуют для специализаций атомарного шаблона для некоторых типов, определенных в заголовке <inttypes.h> .

Атомарный тип Typedef Name
atomic_int8_t atomic<int8_t>
atomic_uint8_t atomic<uint8_t>
atomic_int16_t atomic<int16_t>
atomic_uint16_t atomic<uint16_t>
atomic_int32_t atomic<int32_t>
atomic_uint32_t atomic<uint32_t>
atomic_int64_t atomic<int64_t>
atomic_uint64_t atomic<uint64_t>
atomic_int_least8_t atomic<int_least8_t>
atomic_uint_least8_t atomic<uint_least8_t>
atomic_int_least16_t atomic<int_least16_t>
atomic_uint_least16_t atomic<uint_least16_t>
atomic_int_least32_t atomic<int_least32_t>
atomic_uint_least32_t atomic<uint_least32_t>
atomic_int_least64_t atomic<int_least64_t>
atomic_uint_least64_t atomic<uint_least64_t>
atomic_int_fast8_t atomic<int_fast8_t>
atomic_uint_fast8_t atomic<uint_fast8_t>
atomic_int_fast16_t atomic<int_fast16_t>
atomic_uint_fast16_ atomic<uint_fast16_t>
atomic_int_fast32_t atomic<int_fast32_t>
atomic_uint_fast32_t atomic<uint_fast32_t>
atomic_int_fast64_t atomic<int_fast64_t>
atomic_uint_fast64_t atomic<uint_fast64_t>
atomic_intptr_t atomic<intptr_t>
atomic_uintptr_t atomic<uintptr_t>
atomic_size_t atomic<size_t>
atomic_ptrdiff_t atomic<ptrdiff_t>
atomic_intmax_t atomic<intmax_t>
atomic_uintmax_t atomic<uintmax_t>

Структуры

Имя Описание
atomic Структура Описывает объект, выполняющий atomic операции с сохраненным значением.
atomic_flag Структура Описывает объект, который автоматически устанавливает и очищает флаг bool .

Перечисления

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

Функции

В следующем списке функции, которые не заканчиваются _explicit семантикой соответствующего _explicit , за исключением того, что они имеют неявные memory_order аргументы memory_order_seq_cst .

Что такое std :: atomic?

Я понимаю, что std::atomic<> является атомарным объектом. Но в какой степени атомный? Насколько я понимаю, операция может быть атомарной. Что именно имеется в виду под атомарностью объекта? Например, если есть два потока, которые одновременно выполняют следующий код:

Тогда является ли вся операция (скажем, add_twelve_to(int) ) атомарной? Или внесены изменения в переменную atomic (так operator=() )?

3 ответа

Каждый экземпляр и полная специализация std :: atomic <> представляет тип, который отличается потоки могут одновременно работать (со своими экземплярами), не вызывая неопределенного поведения:

Объекты атомарных типов — единственные объекты C ++, которые свободны от гонок данных; то есть, если один поток записывает в атомарный объект, а другой поток читает из него, поведение четко определено.

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

std::atomic<> обертывает операции, которые до C ++ 11 раз приходилось выполнять с использованием (например) взаимосвязанные функции с MSVC или атомарные столбцы в случае GCC.

Кроме того, std::atomic<> дает вам больше контроля, разрешая различные порядок памяти, которые указать ограничения синхронизации и порядка. Если вы хотите узнать больше об атомике и модели памяти C ++ 11, эти ссылки могут быть полезны:

Обратите внимание, что для типичных случаев использования вы, вероятно, будете использовать перегруженные арифметические операторы или другой их набор:

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

Однако в некоторых случаях это может не требоваться (и ничего не дается бесплатно), поэтому вы можете использовать более явную форму:

Теперь ваш пример:

Не будет оценивать одну атомарную операцию: это приведет к a.load() (который сам по себе является атомарным), а затем добавлению между этим значением и 12 и a.store() (также атомарным) конечного результата. Как я отмечал ранее, здесь будет использоваться std::memory_order_seq_cst .

Однако, если вы напишете a += 12 , это будет атомарная операция (как я отмечал ранее) и примерно эквивалентна a.fetch_add(12, std::memory_order_seq_cst) .

Что касается вашего комментария:

Обычный int имеет атомарные загрузки и хранения. Какой смысл оборачивать его atomic<> ?

Ваше утверждение верно только для архитектур, которые обеспечивают такую ​​гарантию атомарности для хранилищ и / или загрузок. Есть архитектуры, которые этого не делают. Кроме того, обычно требуется, чтобы операции выполнялись с адресом, выровненным по слову / двойному слову, чтобы он был атомарным std::atomic<> — это то, что гарантированно будет атомарным на каждой платформе без дополнительных требований. Более того, он позволяет писать такой код:

Обратите внимание, что условие утверждения всегда будет истинным (и, следовательно, никогда не сработает), поэтому вы всегда можете быть уверены, что данные готовы после выхода из цикла while . Это потому:

  • store() для флага выполняется после установки sharedData (мы предполагаем, что generateData() всегда возвращает что-то полезное, в частности, никогда не возвращает NULL ) и использует std::memory_order_release порядок:

memory_order_release

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

  • sharedData используется после выхода из цикла while , и поэтому после выхода из флага load() будет возвращено ненулевое значение. load() использует порядок std::memory_order_acquire :

std::memory_order_acquire

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

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

std::atomic существует, потому что многие ISA имеют для него прямую аппаратную поддержку

То, что стандарт C ++ говорит о std::atomic , было проанализировано в других ответах.

Итак, теперь давайте посмотрим, что компилируется std::atomic , чтобы получить иную информацию.

Главный вывод из этого эксперимента заключается в том, что современные процессоры имеют прямую поддержку атомарных целочисленных операций, например префикса LOCK в x86, а std::atomic в основном существует как переносимый интерфейс для этих вторжений: Что означает инструкция «lock» в сборке x86? В aarch64 , LDADD будет используемый.

Эта поддержка позволяет использовать более быстрые альтернативы более общим методам, таким как std::mutex , которые могут сделать более сложные разделы с несколькими инструкциями атомарными за счет более медленной работы, чем std::atomic , поскольку std::mutex делает < Системные вызовы > в Linux, что намного медленнее, чем инструкции пользовательской среды, выдаваемые std::atomic , см. Также: Создает ли std :: mutex забор?

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

Крайне вероятный «неправильный» вывод состояния гонки для main_fail.out :

И детерминированный «правильный» вывод остальных:

неатомарная версия сохраняет глобальное значение в регистре и увеличивает регистр на единицу.

Следовательно, в конце очень вероятно, что четыре записи снова произойдут в global с тем же «неправильным» значением 100000 .

std::atomic компилируется в lock addq . Префикс LOCK позволяет следующему inc извлекать, изменять и обновлять память атомарно.

наш явный префикс LOCK встроенной сборки компилируется почти так же, как std::atomic , за исключением того, что наш inc используется вместо add . Не знаю, почему GCC выбрал add , учитывая, что наш INC сгенерировал декодирование на 1 байт меньше.

ARMv8 может использовать LDAXR + STLXR или LDADD в новых процессорах: Как запустить потоки на простом C?

Протестировано в Ubuntu 19.10 AMD64, GCC 9.2.1, Lenovo ThinkPad P51.

Я понимаю, что std::atomic<> делает объект атомарным.

Это вопрос перспективы . вы не можете применить его к произвольным объектам и сделать их операции атомарными, но можно использовать предоставленные специализации для (большинства) интегральных типов и указателей.

std::atomic<> не (с помощью шаблонных выражений) упрощает это до единственной атомарной операции, вместо этого член operator T() const volatile noexcept выполняет атомарную load() из a , затем добавляется двенадцать и operator=(T t) noexcept делает store(t) .

std::atomic. Модель памяти C++ в примерах

Для написания эффективных и корректных многопоточных приложений очень важно знать какие существуют механизмы синхронизации памяти между потоками исполнения, какие гарантии предоставляют элементы многопоточного программирования, такие как мьютекс, join потока и другие. Особенно это касается модели памяти C++, которая была создана сложной таковой, чтобы обеспечивать оптимальный многопоточный код под множество архитектур процессоров. Кстати, язык программирования Rust, будучи построенным на LLVM, использует модель памяти такую же, как в C++. Поэтому материал в этой статье будет полезен программистам на обоих языках. Но все примеры будут на языке C++. Я буду рассказывать про std::atomic , std::memory_order и на каких трех слонах стоят атомики.

В стандарте C++11 появилась возможность писать многопоточные программы на C++, используя только стандартные средства языка. В то время многоядерные процессоры уже завоевали рынок. Особенность выполнения программы на многоядерном процессоре в том, что инструкции программы из разных потоков физически могут исполняться одновременно. Ранее многопоточность на одном ядре эмулировалась частым переключением контекста исполнения с одного потока на последующие. Для оптимизации работы с памятью у каждого ядра имеется его личный кэш памяти, над ним стоит общий кэш памяти процессора, далее оперативная память. Задача синхронизации памяти между ядрами — поддержка консистентного представления данных на каждом ядре (читай в каждом потоке). Очевидно, что если применить строгую упорядоченность изменений памяти, то операции на разных ядрах уже не будут выполнятся параллельно: остальные ядра будут ожидать, когда одно ядро выполнит инструкции изменения данных. Поэтому процессоры поддерживают работу с памятью с менее строгими гарантиями консистентности памяти. Более того, разработчику программы предоставляется выбор, какие гарантии по доступу к памяти из разных потоков требуются для достижения максимальной корректности и производительности многопоточной программы. Задача предоставить разные гарантии по памяти решалась по-разному для разных архитектур процессоров. Наиболее популярные архитектуры x86-64 и ARM имеют разные представления о том, как синхронизировать память.

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

Отсюда следует несколько важных выводов: модель синхронизации памяти C++ — это «искусственные» правила, которые учитывают особенности различных архитектур процессоров. В модели C++ некоторые конструкции, описанные стандартом как undefined behavior (UB), могут корректно работать на одной архитектуре, но приводить к ошибкам работы с памятью на других архитектурах.

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

Код каждого потока компилируется и выполняется так, как будто он один в программе. Вся синхронизация данных между потоками возложена на плечи атомиков ( std::atomic ), т.к. именно они предоставляют возможность форсировать «передачу» изменений данных в другой поток. Далее я покажу, что мьютексы ( std::mutex ) и другие многопоточные примитивы либо реализованы на атомиках, либо предоставляют гарантии, семантически похожие на атомарные операции. Поэтому ключом к написанию корректных многопоточных программ является понимание того, как конкретно работают атомики.

Три слона

На мой взгляд, основная проблема с атомиками в C++ состоит в том, что они несут сразу три функции. Так на каких же трех слонах держатся атомики?

Атомики позволяют реализовать… атомарные операции.

Атомики накладывают ограничения на порядок выполнения операций с памятью в одном потоке.

Синхронизируют память в двух и более потоках выполнения.

Атомарная операция — это операция, которую невозможно наблюдать в промежуточном состоянии, она либо выполнена либо нет. Атомарные операции могут состоять из нескольких операций. Если говорить про тип std::atomic, то он предоставляет ряд примитивных операций: load , store , fetch_add , compare_exchange_* и другие. Последние две операции — это read-modify-write операции, атомарность которых обеспечивается специальными инструкциями процессора.

Рассмотрим простой пример read-modify-write операции, а именно прибавление к числу единицы. Пример 0, link:

В случае с обычной переменной v1 типа int имеем три отдельных операций: read-modify-write. Нет гарантий, что другое ядро процессора не выполняет другой операции над v1 . Операция над v2 в машинных кодах представлена как одна операция с lock сигналом на уровне процессора, гарантирующим, что к кэш линии, в которой лежит v2 , эксклюзивно имеет доступ только ядро, выполняющее эту инструкцию.

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

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

Случаи, когда синхронизация памяти не требуется:

Если все потоки, работающие с одним участком памяти, используют ее только на чтение

Если разные потоки используют эксклюзивно разные участки памяти

Далее будет рассмотрены более сложные случаи, когда требуется чтение и запись одного участка памяти из разных потоков. Язык C++ предоставляет три способа синхронизации памяти. По мере возрастания строгости: relaxed , release/acquire и sequential consistency . Рассмотрим их.

Неделимый, но расслабленный

Самый простой для понимания флаг синхронизации памяти — relaxed . Он гарантирует только свойство атомарности операций, при этом не может участвовать в процессе синхронизации данных между потоками. Свойства:

модификация переменной «появится» в другом потоке не сразу

поток thread2 «увидит» значения одной и той же переменной в том же порядке, в котором происходили её модификации в потоке thread1

порядок модификаций разных переменных в потоке thread1 не сохранится в потоке thread2

Можно использовать relaxed модификатор в качестве счетчика. Пример 1, link:

Использование в качестве флага остановки. Пример 2, link:

В данном примере не важен порядок в котором thread1 увидит изменения из потока, вызывающего stop_thread1 . Также не важно то, чтобы thread1 мгновенно (синхронно) увидел выставление флага stopped в true .

Пример неверного использования relaxed в качестве флага готовности данных. Пример 3, link:

Тут нет гарантий, что поток thread2 увидит изменения data ранее, чем изменение флага ready , т.к. синхронизацию памяти флаг relaxed не обеспечивает.

Полный порядок

Флаг синхронизации памяти «единая последовательность» (sequential consistency, seq_cst ) дает самые строгие. Его свойства:

порядок модификаций разных атомарных переменных в потоке thread1 сохранится в потоке thread2

все потоки будут видеть один и тот же порядок модификации всех атомарных переменных. Сами модификации могут происходить в разных потоках

все модификации памяти (не только модификации над атомиками) в потоке thread1 , выполняющей store на атомарной переменной, будут видны после выполнения load этой же переменной в потоке thread2

Таким образом можно представить seq_cst операции, как барьеры памяти, в которых состояние памяти синхронизируется между всеми потоками программы.

Этот флаг синхронизации памяти в C++ используется по умолчанию, т.к. с ним меньше всего проблем с точки зрения корректности выполнения программы. Но seq_cst является дорогой операцией для процессоров, в которых вычислительные ядра слабо связаны между собой в плане механизмов обеспечения консистентности памяти. Например, для x86-64 seq_cst дешевле, чем для ARM архитектур.

Продемонстрируем второе свойство. Пример 4, из книги [1], link:

После того, как все четыре потока отработают, значение переменной z будет равно 1 или 2 , потому что потоки thread_read_x_then_y и thread_read_y_then_x «увидят» изменения x и y в одном и том же порядке. От запуска к запуску это могут быть: сначала x = true , потом y = true , или сначала y = true , потом x = true .

Модификатор seq_cst всегда может быть использован вместо relaxed и acquire/release , еще и поэтому он является модификатором по умолчанию. Удобно использовать seq_cst для отладки проблем, связанных с гонкой данных в многопоточной программе: добиваемся корректной работы программы и далее заменяем seq_cst на менее строгие флаги синхронизации памяти. Примеры 1 и 2 также будут корректно работать, если заменить relaxed на seq_cst , а пример 3 начнет работать корректно после такой замены.

Синхронизация пары. Acquire/Release

Флаг синхронизации памяти acquire/release является более тонким способом синхронизировать данные между парой потоков. Два ключевых слова: memory_order_acquire и memory_order_release работают только в паре над одним атомарным объектом. Рассмотрим их свойства:

модификация атомарной переменной с release будет видна видна в другом потоке, выполняющем чтение этой же атомарной переменной с acquire

все модификации памяти в потоке thread1 , выполняющей запись атомарной переменной с release , будут видны после выполнения чтения той же переменной с acquire в потоке thread2

процессор и компилятор не могут перенести операции записи в память раньше release операции в потоке thread1 , и нельзя перемещать выше операции чтения из памяти позже acquire операции в потоке thread2

Важно понимать, что нет полного порядка между операциями над разными атомиками, происходящих в разных потоках. Например, в примере 4 если все операции store заменить на memory_order_release , а операции load заменить на memory_order_acquire , то значение z после выполнения программы может быть равно 0, 1 или 2. Это связано с тем, что, независимо от того в каком порядке по времени выполнения выполнены store для x и y , потоки thread_read_x_then_y и thread_read_y_then_x могут увидеть эти изменения в разных порядках. Кстати, такими же изменениями для load и store можно исправить пример 3. Такое изменение будет корректным и производительными, т.к. тут нам не требуется единый порядок изменений между всеми потоками (как в случае с seq_cst ), а требуется синхронизировать память между двумя потоками.

Используя release , мы даем инструкцию, что данные в этом потоке готовы для чтения из другого потока. Используя acquire , мы даем инструкцию «подгрузить» все данные, которые подготовил для нас первый поток. Но если мы делаем release и acquire на разных атомарных переменных, то получим UB вместо синхронизации памяти.

Рассмотрим реализацию простейшего мьютекса, который ожидает в цикле сброса флага для того, чтобы получить lock . Такой мьютекс называют spinlock . Это не самый эффективный способ реализации мьютекса, но он обладает всеми нужными свойствами, на которые я хочу обратить внимание. Пример 5, link:

Функция lock() непрерывно пробует сменить значение с false на true с модификатором синхронизации памяти acquire . Разница между compare_exchage_weak и strong незначительна, про нее можно почитать на cppreference. Функция unlock() выставляет значение в false с синхронизацией release . Обратите внимание, что мьютекс не только обеспечивает эксклюзивным доступ к блоку кода, который он защищает. Он также делает доступным те изменения памяти, которые были сделаны до вызова unlock() в коде, который будет работать после вызова lock() . Это важное свойство. Иногда может сложиться ошибочное мнение, что мьютекс в конкретном месте не нужен.

Рассмотрим такой пример, называемый Double Checked Locking Anti-Pattern из [2]. Пример 6, link:

Идея проста: хотим единожды в рантайме инициализировать объект Singleton . Это нужно сделать потокобезопасно, поэтому имеем мьютекс и флаг инициализации. Т.к. создается объект единожды, а используется singleton указатель в read-only режиме всю оставшуюся жизнь программы, то кажется разумным добавить предварительную проверку if (initialized) return . Данный код будет корректно работать на архитектурах процессора с более строгими гарантиями консистентности памяти, например в x86-64. Но данный код неверный с точки зрения стандарта C++. Давайте рассмотрим такой сценарий использования:

Рассмотрим следующую последовательность действий во времени:

1. сначала отрабатывает thread1 -> выполняет инициализацию под мьютексом:

lock мьютекса ( acquire )

unlock мьютекса ( release )

2. далее в игру вступает thread2 :

if(initalized) возвращает true (память, где содержится initialized могла быть неявно синхронизирована между ядрами процессора)

singleton->do_job() приводит к segmentation fault (указатель singleton не обязан был быть синхронизирован с потоком thread1 )

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

Семантика acquire/release классов стандартной библиотеки

Механизм acquire/release поможет понять гарантии синхронизации памяти, которые предоставляют классы стандартной библиотеки для работы с потоками. Ниже приведу список наиболее часто используемых операций.

std::thread::(constructor) vs функция потока

Вызов конструктора объекта std::thread ( release ) синхронизирован со стартом работы функции нового потока ( acquire ). Таким образом функция потока будет видеть все изменения памяти, которые произошли до вызова конструктора в исходном потоке.

std::thread::join vs владеющий поток

После успешного вызова join поток, в котором был вызван join, «увидит» все изменения памяти, которые были выполнены завершившимся потоком.

std::mutex::lock vs std::mutex::unlock

успешный lock синхронизирует память, которая была изменена до вызова предыдущего unlock.

std::promise::set_value vs std::future::wait

set_value синхронизирует память с успешным wait .

И так далее. Полный список можно найти в книге [1].

Что это все значит? Повторю эту важную мысль еще раз: это значит, на примере std::promise::set_value и std::future::wait , что тут мы не только получили данные, которые содержатся в примитиве синхронизации, но и нам доступны все изменения памяти, которые были в потоке до того, как он выполнил set_value . Это маленькое чудо нам кажется само собой разумеющееся с нашим бытовым, последовательным причинно-следственным, взглядом на мир. Но в мире многоядерного процессора, законы которого больше похожи на квантовую физику, которую никто до конца не понимает, нет единого последовательно порядка изменения памяти в разных ядрах процессора, если это не затребовано разработчиком явно, или неявно через многопоточные примитивы.

Заключение

Сложно представить современную C++ программу, которая была бы однопоточной. Опасно писать многопоточные программы, не имея представления о правилах синхронизации памяти. Я считаю, что нужно знать, как работают атомики в C++. Чтобы не совершать ошибок типа volatile bool , чтобы понимать, какие изменения в каких потоках будут видны после использования того или иного многопоточного примитива, чтобы использовать read-modify-write атомарные операции вместо мьютекса, там где это возможно. Данная статья помогла мне систематизировать материал, который я находил в разных источниках и освежить знания в памяти. Надеюсь, она поможет и вам!

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *