Подтвердить что ты не робот

Базовое внедрение реализации мьютексов

Существует популярная версия мьютекса спин-блокировки, которая распространяется по Интернету и которую можно встретить в книге Энтони Уильямса (С++ Concurrency в действии). Вот он:

class SpinLock
{
    std::atomic_flag locked;
public:
    SpinLock() :
        locked{ATOMIC_FLAG_INIT}
    {
    }
    void lock() 
    {
        while(locked.test_and_set(std::memory_order_acquire));
    }
    void unlock() 
    {
        locked.clear(std::memory_order_release);
    }
};

Я не понимаю, почему все используют std::memory_order_acquire для test_and_set, который является RMW-операцией. Почему это не std::memory_acq_rel? Предположим, что у нас есть 2 потока одновременно, пытаясь получить блокировку:

T1: test_and_set -> ret false
T2: test_and_set -> ret false

Ситуация должна быть возможной, так как мы имеем 2 acquire операции, которые не образуют никакого отношения sync with между собой. Да, после того, как мы разблокировали мьютекс, мы имеем операцию release, которая гласит следующий release sequence, и жизнь становится красочной, и все счастливы. Но почему это безопасно, прежде чем заголовок release sequence?

Так как многие люди упоминают именно эту реализацию, я полагаю, что она должна работать правильно. Так что мне не хватает?

ОБНОВЛЕНИЕ 1:

Я прекрасно понимаю, что операция атомарна, что операции между lock и unlock не могут выйти из критического раздела. Это не проблема. Проблема в том, что я не вижу, как вышеприведенный код предотвращает одновременное попадание двух мьютексов в критический раздел. Чтобы этого не произошло, должно произойти до отношения между 2 lock s. Может ли кто-нибудь показать мне, используя стандартные понятия С++, что код совершенно безопасен?

ОБНОВЛЕНИЕ 2:

Хорошо, мы близки к правильному ответу, я полагаю. Я нашел следующее в стандарте:

[atomics.order] статья 11

Операции Atomic read-modify-write всегда должны считывать последнее значение (в порядке модификации), написанном до записи, связанной с операция чтения-изменения-записи.

И по этой важной ноте я мог бы с радостью закрыть вопрос, но у меня все еще есть свои сомнения. Что относительно части in the modification order? Стандарт довольно ясен:

[intro.multithread] пункт 8

Все модификации конкретного атомного объекта M встречаются в некоторых конкретный общий порядок, называемый порядком модификации M. Если A и B - модификации атомного объекта M и A происходит до (как определено ниже) B, то A предшествует B в порядке модификации M, который определен ниже.

Таким образом, согласно этой статье, чтобы операция RMW имела самое последнее письменное значение, последняя операция записи должна произойти до чтения или операции RMW. Это не так в вопросе. Правильно?

ОБНОВЛЕНИЕ 3:

Я все больше думаю, что код для блокировки спина сломан. Вот мои рассуждения. С++ задает 3 типа операций:

  • Приобретение, выпуск, получение-релиз - это синхронизация.
  • Relaxed - это не sync ops
  • RMW - это операции со специальной особенностью

Давайте начнем с RMW и узнаем, что в них такого особенного. Во-первых, они представляют собой ценный актив в формировании release sequence, во-вторых, у них есть специальное предложение ([atomics.order], пункт 11), процитированное выше. Ничего особенного, что я нашел.

Приобретение/освобождение - это sync ops и release sync with acquire, образуя связь happens before. Расслабленные операции - это просто атомы и вообще не участвуют в порядке модификации.

Что мы имеем в нашем коде? У нас есть операция RMW, которая использует семантику памяти для хранения, поэтому всякий раз, когда достигается первая разблокировка (выпуск), она выполняет 2 роли:

  • Он формирует связь sync with с предыдущим release
  • Он участвует в release sequence. Но все верно только после завершения первого unlock.

До этого, если у нас есть 2+ потоки, которые одновременно запускают наш код lock, тогда мы можем ввести pass lock одновременно, так как операции 2 acquire не образуют никакого отношения. Они так же неупорядочены, как и расслабленные операции. Поскольку они неупорядочены, мы не можем использовать какие-либо специальные предложения о работе RMW, так как нет отношения happens before и, следовательно, никакого порядка модификации для флага locked.

Таким образом, либо моя логика ошибочна, либо код сломан. Пожалуйста, кто знает правду - прокомментируйте это.

4b9b3361

Ответ 1

Может ли кто-нибудь показать мне, используя стандартные понятия С++, что код совершенно безопасен?

У меня изначально были те же проблемы, что и вы. Я думаю, что ключевым является понимание того, что операции с переменной std::atomic_flag являются атомарными по отношению ко всем процессорам/ядрам. Две атомарные "тестовые и заданные" операции в отдельных потоках не могут одновременно преуспеть, независимо от заданного порядка памяти, поскольку они тогда не могут быть атомарными; операция должна применяться к фактической переменной, а не к кешированной локальной копии (что, я думаю, даже не концепция на С++).

Полная цепочка рассуждений:

29.7 p5 (речь идет о тестовом задании):

Эффекты: Атомно задает значение, на которое указывает объект, или на это значение true. Память зависит от значения порядка. Эти операции представляют собой операции атомарного чтения-изменения-записи (1.10). Возвращает: Атомно значение объекта непосредственно перед эффектами.

1.10 p6:

Все модификации конкретного атомного объекта M встречаются в определенном полном порядке, называемом порядком модификации M...

Итак, если в этом случае два потока пытаются заблокировать спин-блокировку одновременно, одна из них должна быть первой, а другая секундой. Теперь нам просто нужно показать, что вторая по необходимости вернет, что флаг уже установлен, тем самым предотвращая попадание потока в критический раздел.

Далее в пункте 6 говорится:

... Если A и B являются модификациями атомного объекта M, а A происходит до (как определено ниже) B, то A предшествует B в порядке модификации M, который определен ниже. [Примечание. Это означает, что заказы на модификацию должны уважать отношения "произойдет до". - конечная нота]

Между двумя операциями test-and-set, происходящими в двух потоках, нет отношения "произойдет до", поэтому мы не можем определить, что на первом месте в порядке модификации; однако, из-за первого предложения в p6 (в котором указывается, что существует полный порядок), нужно обязательно перейти к другому. Теперь, с 29.3 p12:

Операции Atomic read-modify-write всегда должны считывать последнее значение (в порядке модификации), записанное перед записью, связанной с операцией read-modify-write.

Это показывает, что порядковый и заданный порядковые секунды должны сначала увидеть значение, записанное в тестовом наборе. Любые варианты получения/выпуска не влияют на это.

Следовательно, если два тестовых и заданных действия выполняются "одновременно", им будет задан произвольный порядок, а второй должен увидеть значение флага, которое было установлено первым. Таким образом, ограничения порядка памяти, заданные для тестовой и заданной операции, не имеют значения; они используются для управления порядком записи в другие переменные в течение периода, в течение которого происходит получение спин-блокировки.

Ответ на "Обновление 2" вопроса:

Таким образом, согласно этой статье, чтобы операция RMW имела самое последнее письменное значение, последняя операция записи должна произойти до чтения или операции RMW. Это не так в вопросе. Правильно?

Исправьте, что нет отношения "произойти до", но неправильно, чтобы операция RMW нуждалась в таких отношениях, чтобы гарантировать последнее письменное значение. Утверждение, которое вы указываете как "[atomics.order", пункт 11 ", не требует отношения" произойти до ", просто одна операция перед другой в" порядке модификации "для атомного флага. В пункте 8 говорится, что такой порядок будет, и это будет полный порядок:

Все модификации конкретного атомного объекта M встречаются в определенном полном порядке, называемом порядком модификации M...

... далее далее говорится, что полное упорядочение должно соответствовать любым отношениям "происходит до":

... Если A и B являются модификациями атомного объекта M, а A происходит до (как определено ниже) B, то A предшествует B в порядке модификации M, который определен ниже.

Однако, в отсутствие отношения "произойдет до", все еще существует полное упорядочение - просто, что это упорядочение имеет степень произвольности. То есть, если между "А" и "В" нет отношения "произойдет до", тогда не указывается, заказано ли А до или после Б. Но это должно быть одно или другое, поскольку существует конкретный общий порядок.

Почему требуется память_order_acquire?

Мьютекс, такой как спин-блокировка, часто используется для защиты других неатомных переменных и структур данных. Использование memory_order_acquire при блокировке спин-блокировки гарантирует, что прочитанные из таких переменных будут видеть правильные значения (т.е. Значения, записанные любым другим потоком, который ранее удерживал спин-блокировку). Для разблокировки требуется memory_order_release, чтобы другие потоки могли видеть письменные значения.

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

Дополнительные доказательства:

Во-первых, это примечание от 29.3:

Примечание. Атомные операции с указанием memory_order_relaxed ослаблены в отношении упорядочения памяти. Реализации должны по-прежнему гарантировать, что любой атомный доступ к конкретному атомному объекту будет неделимым по отношению ко всем другим атомарным доступам к этому объекту. - конец примечания

Это, по существу, говорит о том, что указанное упорядочение памяти не влияет на работу атома. Доступ должен быть "неделимым по отношению ко всем другим атомарным доступам", в том числе к другим потокам. Чтобы позволить двум тестовым операциям считывать одно и то же значение, эффективно было бы делить хотя бы один из них, чтобы он больше не был атомарным.

Кроме того, из пункта 1.10 пункта 5:

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

(Тест-набор входит в эту последнюю категорию), и особенно:

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

(акцент мой). Случай, когда два потока одновременно выполняли атомарный тест-набор (и оба выполняли часть "set" ), были бы такими гонорарами данных, поэтому этот текст снова указывает, что этого не может быть.

1.10 p8:

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

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

В частности, 1.10 p19:

[Примечание. Четыре предшествующих требования когерентности эффективно запрещают переупорядочение компиляторами атомных операций одному объекту, даже если обе операции являются ослабленными нагрузками. Это эффективно делает когерентность кеша гарантия, предоставляемая большинством аппаратных средств, доступных для атомных операций С++. - конечная нота]

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

Ответ 2

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

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

Рассмотрим:

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

Какая одна вещь, которая не должна произойти? Это то, что чтение и запись на шагах 6 и 7 каким-то образом перенаправляются до шага 5, наступая на другой поток, обращаясь к общим данным под защитой мьютекса.

Операция test_and_set уже является атомарной, поэтому шаги 4 и 5 по своей сути безопасны. И шаги 1 и 2 не могут изменять защищенные данные (потому что они происходят до того, как мы даже попытаемся заблокировать), поэтому нет никакого вреда в том, чтобы переупорядочивать их вокруг нашей операции блокировки.

Но шаги 6 и 7 - они не должны быть повторно заказаны до того, как мы заметим, что замок был разблокирован, чтобы мы могли его физически заблокировать. Это было бы катастрофой.

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

Именно то, что нам нужно.

Ответ 3

Как вы сказали, test_and_set - операция RMW. Однако для тестирования важно только, чтобы было прочитано правильное значение. Таким образом, memory_order_acquire представляется достаточным.

См. также таблицу Constants в http://en.cppreference.com/w/cpp/atomic/memory_order