атомарность и модели памяти c++
Читаю "C++ практика многопоточного программирования" Уильямса Э и не могу понять почему в предложенном коде, в компонентной функции test_and_set достаточно указать memory_order_aqcuire для корректной работы мьютекса.На мой взгляд мы можем так же зайти в защищенную мьютексом область кода(в которой уже работает некий поток) из другого потока, ведь после записи в флаг значения тру(захвате блокировки) нет гарантии, что другой поток их моментально увидит. И за одно хотелось, чтобы посоветовали хорошую литературу или лекции по данной подтеме, потому что по ощущениям в читаемой мною книге не очень хорошо объяснена конкретная тема
class spinlock_mutex
{
std::atomic_flag flag;
public:
spinlock_mutex() : flag(ATOMIC_FLAG_INIT) {}
void lock()
{
while(flag.test_and_set(std::memory_order_acquire));
}
void unlock()
{
flag.clear(std::memory_order_release);
}
};
Ответы (2 шт):
У каждой атомарной переменной есть свой так называемый modification order (порядок изменения?). Это последовательность значений, которые та принимает во время работы программы.
Все потоки всегда наблюдают один и тот же порядок (для каждой отдельной атомарной переменной), при любых std::memory_order-ах.
(Понятно, что этот список значений нигде не хранится, это только абстракция из стандарта.)
нет гарантии, что другой поток их моментально увидит
Тут спасает порядок модификации.
Я так понимаю, вы представляете себе пример в духе:
- Поток 1 делает
flag.test_and_set(...)и получает ноль. - Поток 1 меняет какую-то не-атомарную переменную (допустим
int x;x = 10), но еще не успевает сбросить флаг. - Поток 2 делает
flag.test_and_set(...)и получает ноль. - Поток 2 меняет ту же самую не-атомарную переменную (
x = 20). Есть конфликт с шагом (2) или нет?
Так вот, у flag есть порядок модификации. Он не может быть такой:
Поток 1 читает 0 и пишет 1
Поток 2 читает 0 и пишет 1
(и дальше оба потока пишут ноль, не важно в каком порядке)
Потому что это противоречие. Как второй шаг может прочитать 0, если первый шаг уже записал 1?
Поэтому порядок обязан быть такой:
Поток 1 читает 0 и пишет 1 (A)
Поток 1 пишет 0 (B)
Поток 2 читает 0 и пишет 1 (C)
Поток 2 пишет 0 (D)
(Или второй поток перед первым, неважно.)
Но это все равно неполное доказательство. Какое нам дело до порядка модификации флага? Как он мешает компилятору выдвинуть работу с x из под спинлок и все равно получить гонку?
Для этого нужны memory_order_acquire и release (seq_cst тоже подходит, потому что он еще строже, а relaxed не подходит).
Когда второй поток читает флаг (в режиме acquire) и видит 0 (шаг (C)), этот ноль к нему пришел из .clear() в первом потоке (шаг (B), в режиме release).
Тогда говорят, что release-запись "синхронизируется" c acquire-чтениями той же самой переменной, идущими прямо за записью (т.е. если между ними в порядке модификации нет других записей).
Синхронизация гарантирует, что все, что происходило до (B) (т.е. наш x = 10), произойдет также и до (C), а x = 20 происходит после (C). Поэтому x = 10 гарантированно идет перед x = 20, и гонки нет.
посоветовали хорошую литературу или лекции по данной подтеме
Я слышал хорошие вещи про Concurrency in Action.
Еще, я когда несколько лет назал сел разбираться с атомиками, описал свое понимание тут: What do each memory_order mean?. Может будет полезно.
почему в предложенном коде, в компонентной функции test_and_set достаточно указать memory_order_aqcuire для корректной работы мьютекса.
Грубо говоря, потому-что в функции flag.clear используется memory_order_release (к примеру, если бы там было - memory_order_relaxed, то была бы некорректная работа, данные иногда бы "не записывались" или "не счтывались").
Как обычно определяется мьютекс:
- Эксклюзивное исполнение, только один поток владеет мьютексом;
- Поток, который захватил мьютекс, имеет доступ к той информации, которую создал предыдущий владелец (или она ему была доступна);
- Информация, которую создал текущий владелец до освобождения мьютекса, будет доступна следующему владельцу.
В модели памяти C/C++ это выражено отношениями:
Между видимой записью некоей переменной в потоке X и атомарной переменной flag - std::memory_order_release;
Семантикой атомарных операций
flag.clear()иflag.test_and_set();Между атомарной переменной flag и видимым чтением этой же переменной в потоке Y - std::memory_order_acquire
На мой взгляд мы можем так же зайти в защищенную мьютексом область кода(в которой уже работает некий поток) из другого потока, ведь после записи в флаг значения тру(захвате блокировки) нет гарантии
Не очень понятно, с одной стороны, да, если другой поток не захватывает тот же самый экземпляр переменной flag, то получается состояние гонки, ввиду того, что неизвестно, ни когда "запись" в "завершится", ни когда "чтение" реально "произойдёт".
другой поток их моментально увидит.
С другой стороны, если один поток освобождает flag, а другой захватывает flag, то все отношения выполняются. И для выполнения этих отношений совершенно не требуется никакой "моментальности", но только по результату процесса освобождения/захвата (бывает, достаточно ресурсоёмкого, зависит от архитектуры и прочего, прочего).
И за одно хотелось, чтобы посоветовали хорошую литературу или лекции по данной подтеме, потому что по ощущениям в читаемой мною книге не очень хорошо объяснена конкретная тема
В деле модели памяти, если в этих книгах написано то, что в стандарте, то зачем они, когда у нас есть стандарт? Если же в этих книгах написано не то, что в стандарте, то тем более их ... (по крайней мере, в этом месте, читать не стоит).
На мой непросвещённый взгляд, в стандартах C/C++ модель памяти изложена максимально просто. А всякие упрощённые изложения во всяких "мурзилках", нарушают стандарт.
Если цель разработка алгоритмов синхронизации, то [intro.multithread] и [atomics.order]. Но, если просто для некоторого понимания, при использовании готовых шаблонов, то и Уильямс неплох, достаточно близко к стандарту.