- Простой многопоточный тип доступа к данным и атомарные переменные
- Введение
- Как работают атомарные переменные
- Ограничение размера атомарных переменных
- Варианты применения
- В реальности.
- Пора увидеть это в деле
- Предосторожности
- Заключение
- Значение слова «атомарный»
- атома́рный
- Делаем Карту слов лучше вместе
- Атомарные и неатомарные операции
- Неатомарные операции из нескольких инструкций
- Неатомарные инструкции процессора
- Все операции C/C++ считаются неатомарными
- Расслабленные (relaxed) атомарные операции
Простой многопоточный тип доступа к данным и атомарные переменные
Введение
В этой статье мне бы хотелось продолжить тему, начатую в моих предыдущих постах (см. ниже — прим. пер.). Вопрос, на который я попытаюсь ответить — что является наиболее эффективным и безопасным способом доступа к переменным простого типа данных из двух или более потоков. То есть, как изменить переменную из двух потоков одновременно, не нарушив ее значения.
В своем первом посте («Нужен ли мьютекс для защиты int») я показал, как легко превратить значение переменной в мусор, изменяя его из двух или более потоков. В своей второй статье («спин-блокировки pthread») я говорил о спин-блокировках (spinlocks — прим. пер.), последнем дополнении в библиотеку pthread. Спин-блокировки действительно могут помочь решить проблему. Однако, они больше подходят для защиты небольших структур данных, чем простых типы данных, таких как int и long. С другой стороны, атомарные переменные идеально подходят для этих задач.
Ключевой момент об атомарных переменных – это то, что как только кто-то начинает их считывать или записывать в них, ничто не может прервать процесс и произойти в середине. То есть, ничто не может разбить процесс доступа к атомарной переменной на две части. Поэтому они так и названы.
С практической стороны, атомарные переменные являются лучшим решением для проблемы одновременного доступа к простой переменной из двух или более потоков.
Как работают атомарные переменные
В действительности, очень просто. Архитектуры процессоров Intel x86 и x86_64 (как и подавляющее большинство других современных архитектур процессоров) имеют инструкции, позволящие заблокировать FSB при какой-либо операции доступа к памяти. FSB расшифровывается как Front Side Bus («передняя шина» — прим. пер.). Это шина, которую использует процессор для связи с RAM. То есть, блокировка FSB будет препятствовать любому другому процессору (ядру) и процессу работающему на этом процессоре в получении доступа к оперативной памяти. И это именно то, что нам нужно для реализации атомарных переменных.
Атомарные переменные широко используется в ядре Linux, но по какой-то причине никто не потрудился их реализовать для человеческого пользовательского режима. До GCC 4.1.2.
Ограничение размера атомарных переменных
Из практических соображений, гуру из Intel не реализовали FSB-блокировки при каждом возможном доступе к памяти. Например, для экономии времени, Intel-процессоры допускают реализации memcpy() и memcmp() одной инструкцией процессора. Но блокировка FSB при копировании большого буфера памяти может оказаться слишком дорогостоящей.
На практике, вы можете заблокировать FSB при доступе к целым числам длиной 1, 2, 4 и 8 байт. GCC позволяет делать атомарные операции почти прозрачно с int, long и long long (и их эквивалентам без знака).
Варианты применения
Увеличение переменной, зная, что никто другой не повредит ее значения — это хорошо, но недостаточно. Рассмотрим следующий фрагмент псевдокода.
Представим, что значение атомарной переменной — 1. Что произойдет, если два потока попытаются выполнить эту часть псевдо-C одновременно?
Вернемся к нашему моделированию. Вполне возможно, что поток 1 будет выполнять линию 1 и остановится, в то время как поток 2 будет выполнять линию 1 и продолжать выполнение линии 2. Позже поток 1 проснется и выполнит линию 2.
Когда это произойдет, ни один из потоков не запустит процедуру fire_a_gun() (линия 3). Очевидно, что это неправильное поведение, и если бы мы защитили эту часть кода с помощью мьютекса или спин-блокировки — этого бы не произошло.
В случае, если вам интересно, какова вероятность того, что случится что-то подобное, будьте уверены — это весьма вероятно. Когда я впервые начал работать с многопоточным программированием, я был поражен, узнав, что несмотря на то, что наша интуиция подсказывает нам, что сценарий, который я описал ранее маловероятен — это случается чрезвычайно часто.
Как я уже говорил, мы могли бы решить эту проблему путем отказа от атомарных переменных и используя вместо них спин-блокировку или мьютекс. К счастью, мы все еще можем использовать атомарные переменные. GCC разработчики думали о наших потребностях и данной конкретной проблеме и предложили решение. Давайте рассмотрим фактические процедуры, оперирующие атомарными переменными.
В реальности.
Есть несколько простых функций, делающие эту работу. Прежде всего, есть двенадцать (да-да, двенадцать — 12) функций, делающих атомарные добавление, замену и логические атомарные or, and, xor и nand. Есть две функции для каждой операции. Одна, которая возвращает значение переменной до ее изменения и другая, который возвращает значение переменной после изменения.
Вот фактические функции:
Это функции, которые возвращают значение переменной перед изменением. Следующие функции, с другой стороны, возвращают значение переменной после ее изменения.
тип в каждом из выражений может быть одним из следующих:
Это так называемые встроенные функции, что означает, что вам не нужно включать какие-либо заголовочные файлы для их использования.
Пора увидеть это в деле
Возвращаясь к примеру, начатому мной в первом посте, который упоминался ранее.
Напомню, что это небольшая программа открывающая несколько потоков. Количество потоков равно количеству процессоров в компьютере. Затем она привязывает каждый из потоков к одному из процессоров. Наконец, каждый поток запускает цикл и увеличивает глобальное целое число один миллион раз.
Обратите внимание на строки 36 и 37. Вместо того, чтобы просто увеличить переменную, я использую встроенную функцию __ sync_fetch_and_add(). Выполнение этого кода, очевидно, дает ожидаемые результаты — то есть значение global_int — это 4 000 000, как и ожидалось (число процессоров в машине, умноженных на один миллион — в моем случае это четырехъядерная машина). Помните, когда я запустил этот фрагмент кода, оставив строку 36 как есть, результатом было 1 908 090, а не 4 000 000, как мы ожидали.
Предосторожности
При использовании атомарных переменных должны быть приняты некоторые дополнительные меры предосторожности. Одной из серьезных проблем с реализацией атомарной переменной в GCC является то, что она позволяет делать атомарные операции на обычных переменных. То есть нет четкого различия между атомарными переменными и обычными. Ничто не мешает вам увеличить значение атомарной переменной с помощью __ sync_fetch_and_add(), как я только что продемонстрировал, а затем в коде сделать то же самое обычным оператором ++.
Очевидно, что это может стать серьезной проблемой. Вещи, как правило, имеют тенденцию к забыванию и это лишь вопрос времени, пока кто-то из вашего проекта или даже вы сами начнете изменять значение переменной с помощью обычных операторов, вместо атомарных функций, предоставляемых GCC.
Для решения этой проблемы, настоятельно рекомендую обернуть атомные функций и переменные либо с помощью ADT (Abstract Data Type — прим. пер.) в C, либо с помощью класса C++.
Заключение
Эта статья завершает серию статей и постов, где проводится исследование и изучение новейших технологий в мире многопоточного программирования для Linux. Надеюсь, вы найдете эти сообщения и посты полезными. Как обычно, в случае, если у вас есть дополнительные вопросы, пожалуйста, не стесняйтесь, напишите мне на e-mail (указан в оригинале — прим. пер.)
Данная и другие статьи автора не претендуют на полноту охвата материала по теме, но у них хороший вводный стиль изложения.
Источник
Значение слова «атомарный»
АТОМА́РНЫЙ, —ая, —ое. Спец. Прил. к атом. Есть дозиметры, меряющие дозу в веществе той же плотности, что и живая ткань, даже имеющем тот же атомарный состав. Лучник, Невидимый современник. || Существующий в виде отдельных, рассеянных атомов. Атомарный водород и кислород в атмосфере Марса.
Источник (печатная версия): Словарь русского языка: В 4-х т. / РАН, Ин-т лингвистич. исследований; Под ред. А. П. Евгеньевой. — 4-е изд., стер. — М.: Рус. яз.; Полиграфресурсы, 1999; (электронная версия): Фундаментальная электронная библиотека
атома́рный
1. хим. существующий в виде не соединенных в молекулы отдельных атомов ◆ атомарный кислород, атомарный хлор и т.д.
2. спец. единый, неделимый ◆ Атомарное действие.
Делаем Карту слов лучше вместе
Привет! Меня зовут Лампобот, я компьютерная программа, которая помогает делать Карту слов. Я отлично умею считать, но пока плохо понимаю, как устроен ваш мир. Помоги мне разобраться!
Спасибо! Я стал чуточку лучше понимать мир эмоций.
Вопрос: повлечься — это что-то нейтральное, положительное или отрицательное?
Источник
Атомарные и неатомарные операции
Перевод статьи Джефа Прешинга Atomic vs. Non-Atomic Operations. Оригинальная статья: http://preshing.com/20130618/atomic-vs-non-atomic-operations/
В Сети уже очень много написано об атомарных операциях, но в основном авторы рассматривают операции чтения-модификации-записи. Однако, существуют и другие атомарные операции, например, атомарные операции загрузки (load) и сохранения (store), которые не менее важны. В этой статье я сравню атомарные загрузки и сохранения с их неатомарными аналогами на уровне процессора и компилятора C/C++. По ходу статьи мы также разберемся с концепцией «состояния гонок» с точки зрения стандарта C++11.
Операция в общей области памяти называется атомарной, если она завершается в один шаг относительно других потоков, имеющих доступ к этой памяти. Во время выполнения такой операции над переменной, ни один поток не может наблюдать изменение наполовину завершенным. Атомарная загрузка гарантирует, что переменная будет загружена целиком в один момент времени. Неатомарные операции не дают такой гарантии.
Без подобных гарантии неблокирующее программирование было бы невозможно, поскольку было бы нельзя разрешить нескольким потокам оперировать одновременно одной переменной. Мы можем сформулировать правило:
В любой момент времени когда два потока одновременно оперируют общей переменной, и один из них производит запись, оба потока обязаны использовать атомарные операции.
Если вы нарушаете это правило, и каждый поток использует неатомарные операции, вы оказываетесь в ситауции, которую стандарт C++11 называет состояние гонок по данным (data race) (не путайте с похожей концепцией из Java, или более общим понятием состояния гонок (race condition)). Стандарт C++11 не объясняет, почему состояние гонок плохо, однако утверждает, что в таком состоянии вы получите неопределенное поведение (§1.10.21). Причина опасности таких состояний гонок, однако, очень проста: в них операции чтения и записи разорваны (torn read/write).
Операция с памятью может быть неатомарной даже на одноядерном процессоре только потому, что она использует несколько инструкций процессора. Однако и одна инструкция процессора на некоторых платформах также может быть неатомарной. Поэтому, если вы пишите переносимый код для другой платформы, вы никак не можете опираться на предположение об атомарности отдельной инструкции. Давайте рассмотрим несколько примеров.
Неатомарные операции из нескольких инструкций
Допустим, у нас есть 64-битная глобальная переменная, инициализированная нулем.
В какой-то момент времени мы присвоим ей значение:
Если мы скомпилируем этот код с помощью 32-битного компилятора GCC, мы получим такой машинный код:
Видно, что компилятор реализовал 64-битное присваивание с помощью двух процессорных инструкций. Первая инструкция присваивае нижним 32 битам значение 0x00000002, и вторая заносит в верхние биты значение 0x00000001. Очевидно, что такое присваивание неатомарно. Если к переменной sharedValue одновременно пытаются получить доступ различные потоки, можно получить несколько ошибочных ситуаций:
- Если поток, вызывающий storeValue, будет прерван между двумя инструкциями записи, то он оставит в памяти значение 0x0000000000000002 — это разорванная операция записи. Если в этот момент другой поток попытается прочитать sharedValue, он получит неправильное значение, которое никто и не собирался сохранять.
- Более того, если записывающий поток был остановлен между инструкциями записи, а другой поток поменяет значение sharedValue перед тем, как первый поток возобновит работу, мы получим постоянно разорванную запись: верхняя половина значения переменной будет установлена одним потоком, а нижняя — вторым.
- Чтобы получить разорванную запись на мультиядерных процессорах потоки даже не нужно прерывать: любой поток, выполняющийся на другом ядре, может прочитать значение переменной в момент, когда только половина нового значения записана в память.
Параллельное чтение из sharedVariable также имеет свои проблемы:
Здесь таким же образом компилятор реализует чтение двумя инструкциями: сначала нижние 32 бита считываются в регистр EAX, а потом верхние 32 бита считываются в EDX. В этом случае, если параллельная запись будет произведена между этими двумя инструкциями, мы получим разорванную операцию считывания, даже если запись была атомарной.
Эти проблемы отнюдь не теоретические. Тесты библиотеки Mintomic включает тест test_load_store_64_fail, в котором один поток сохраняет набор 64-битных значений в переменную используя обычный оператор присваивания, а другой поток производит обычную загрузку из той же самой переменной, проверяя результат каждой операции. В многопоточном режиме x86 этот тест ожидаемо падает.
Неатомарные инструкции процессора
Операция с памятью может быть неатомарной даже если она выполняется одной инструкцией процессора. Например, в наборе инструкций ARMv7 есть инструкция strd, которая сохраняет содержимое двух 32-битных регистров в 64-битной переменной в памяти.
На некоторых ARMv7 процессорах эта инструкция не является атомарной. Когда процессор видит такую инструкцию, он на самом деле выполняет две отдельные операции (§A3.5.3). Как и в предыдущем примере, другой поток, выполняющийся на другом ядре, может попасть в ситуацию разорванной записи. Интересно, что ситуация разорванной записи может возникнуть и на одном ядре: системное прерывание — скажем, для запланированной смены контекста потока — может возникнуть между внутренними операциями 32-битного сохранения! В этом случае, когда поток возобновит свою работу, он начнет выполнять инструкцию strd заново.
Другой пример, всем известная операция архитектуры x86, 32-битная операция mov атомарна в том случае, когда операнд в памяти выровнен, и не атомарна в противном случае. То есть, атомарность гарантируется только в случае, когда 32-битное целое число находится по адресу, который делится на 4. Mintimoc содержит тестовый пример test_load_store_32_fail, который проверяет это условие. Этот тест всегда выполняется успешно на x86, но если его модифицировать так, чтобы переменная sharedInt находилась по невыровненному адресу, тест упадет. На моем Core 2 Quad 6600 тест падает, когда sharedInt разделен между различными линиями кеша:
Думаю, мы рассмотрели достаточно нюансов процессорного выполнения. Давайте взглянем на атомарность на уровне C/C++.
Все операции C/C++ считаются неатомарными
В C/C++ каждая операция считается неатомарной до тех пор, пока другое не будет явно указано прозводителем компилятора или аппаратной платформы — даже обычное 32-битное присваивание.
Стандарты языка ничего не говорят по поводу атомарности в этом случае. Возможно, целочисленное присваивание атомарно, может быть нет. Поскольку неатомарные операции не дают никаких гарантий, обычное целочисленное присваивание в C является неатомарным по определению.
На практике мы обычно обладаем некоторой информацией о платформах, для которых создается код. Например, мы обычно знаем, что на всех современных процессорах x86, x64, Itanium, SPARC, ARM и PowerPC обычное 32-битное присваивание атомарно в том случае, если переменная назначения выровнена. В этом можно убедиться, перечитав соответствующий раздел документации процессора и/или компилятора. Я могу сказать, что в игровой индустрии атомарность очень многих 32-битных присваиваний гарантируется этим конкретным свойством.
Как бы там ни было, при написании действительно переносимого кода C и C++, мы следуем давно установившейся традиции считать, что мы не знаем ничего более того, что нам говорят стандарты языка. Переносимые C и C++ спроектированы так, чтобы выполнятся на любом возможном вычислительном устройстве прошлого, настоящего и будущего. Я, например, люблю представлять устройство, память которого можно менять только предварительно заполнив ее случайным мусором:
На таком устройстве вы уж точно не захотите произвести параллальное считывание, так же как и обычное присваивание, потому что слишком высок риск получить в результате случайное значение.
В C++11 наконец-то появился способ выполнять действительно переносимые атомарные сохранения и загрузки. Эти операции, произведенные с помощью атомарной библиотеки C++11 будут работать даже на условном устройстве, описанном ранее: даже если это будет означать, что библиотеке прийдется блокировать мьютекс для того, чтобы сделать каждую операцию атомарной. Моя библиотека Mintomic которую я выпустил недавно, не поддерживает такое количество различных платформ, но работает на некоторых старых компьютерах, оптимизирована вручную и гарантировано неблокирующая.
Расслабленные (relaxed) атомарные операции
Давайте вернемся к примеру с sharedValuem который мы рассматривали в начале. Давайте перепишем его с использованием Mintomic так, чтобы все операции выполнялись атомарно на каждой платформе, которую поддерживает Mintomic. Для начала мы объявим sharedValue как один из атомарных типов Mintomic:
Тип mint_atomic64_t гарантирует корректное выравнивание в памяти для атомарного доступа на каждой платформе. Это важно, поскольку, например, компилятор gcc 4.2 для ARM в среде разработки Xcode 3.2.5 не гарантирует, что тип uint64_t будет выровнен на 8 байтов.
В функции storeValue вместо выполнения обычного неатомарного присваивания, мы должны выполнить mint_store_64_relaxed.
Аналогично, в loadValue мы вызываем mint_load_64_relaxed.
Если использовать терминологию C++11, то эти функции сейчас свободны от состояний гонок по данным (data race free). Если они будут вызваны одновременно, абсолютно невозможно оказаться в ситуации разорванного чтения или записи, независимо от того, на какой платформе выполняется код: ARMv6/ARMv7(режимы Thumb или ARM), x86, x64 или PowerPC. Если вам интересно как работают mint_load_64_relaxed и mint_store_64_relaxed, то обе функции используют инструкцию cmpxchg8b на платформе x86. Подробности реализации для других платформ можно найти в реализации Mintomic.
Вот такой же код с использованием стандартной библиотеки C++11:
Вы должны были заметить, что оба примера используют расслабленные атомарные операции, что подтверждается суффиксом _relaxed в идентификаторах. Этот суффикс напоминает об определенных гарантиях относительно упорядочивания памяти (memory ordering).
В частности, для таких операций допукается переупорядочивание операций с памятью в соответствии с переупорядочиванием компилятором либо с переупорядочиванием памяти процессором. Компилятор даже может оптимизировать избыточные атомарные операции, так же как и неатомарные. Но во всех этих случаях атомарность оперций сохраняется.
Я думаю, что в случае выполнения параллельных операций с памятью, использование функций атомарных библиотек Mintomic или C++11 является хорошей практикой, даже если вы уверены, что обычные операции чтения либо записи будут атомарны на спользуемой вами платформе. Использование атомарных библиотек будет служить лишним напоминанием, что переменные могут быть использованы в конкурентной среде.
Надеюсь, теперь вам стало понятнее, почему Самая простая в мире неблокирующая хэш-таблица использует Mintomic для манипуляции общей памятью одновременно с другими потоками.
Об авторе. Джефф Прешинг работает архитектором ПО в игровой компании Ubisoft и специализируется на многопоточном программировании и неблокирующих алгоритмах. В этом году он делал доклад о многопоточной разработке игр в соответствии со стандартом С++11 на конференции CppCon, видео этого доклада было и на Хабре. Он ведет интересный блог Preshing on Programming, посвященный в том числе и тонкостям неблокирующего программирования и связанных с ним нюансов C++.
Я бы хотел много статей из его блога перевести для сообщества, но поскольку его записи часто ссылаются одна на другую, выбрать статью для первого перевода достаточно сложно. Я попытался выбрать такую статью, которая бы минимально базировалась на других. Хотя рассматриваемый вопрос достаточно прост, я надеюсь, он все же будет интересен многим, кто начинает знакомиться с многопоточным программированием в C++.
Источник