Программирование C++МногопоточностьРазработчик C++ системного программного обеспечения

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

Найдите ошибку в рассуждении: после разблокировки мьютекса другой поток обязан увидеть изменения, сделанные до этой разблокировки, даже если изменённые данные не являются атомарными?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Да, обязан — при условии, что оба потока используют тот же мьютекс, а доступ к данным выполняется внутри соответствующих критических секций. Разблокировка мьютекса синхронизируется с последующей успешной блокировкой этого мьютекса и создаёт отношение happens-before, поэтому для таких данных атомарность не требуется.

Исторический контекст

Модель памяти C++ и правила синхронизации были формализованы, чтобы программа могла переносимо описывать взаимодействие потоков независимо от оптимизаций компилятора и особенностей процессора. Одной из базовых задач было безопасно работать с обычными объектами, не превращая каждое поле в атомарное.

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

Постановка проблемы

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

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

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

Подробное решение

Операции, выполненные потоком до unlock, упорядочены перед последующей успешной операцией lock другого потока для того же мьютекса. Это даёт цепочку happens-before: запись данных происходит раньше разблокировки, разблокировка — раньше захвата, а захват — раньше чтения.

Поэтому защищённый обычный объект может быть согласованно прочитан без std::atomic. Мьютекс при этом должен быть один и тот же логически; совпадение защищаемых данных само по себе ничего не гарантирует.

#include <cassert> #include <mutex> #include <thread> std::mutex mutex; int value = 0; void writer() { std::lock_guard<std::mutex> lock(mutex); value = 42; } void reader() { std::lock_guard<std::mutex> lock(mutex); assert(value == 42); } int main() { std::thread w(writer); w.join(); std::thread r(reader); r.join(); }

value не атомарен, но программа корректна: reader захватывает тот же мьютекс после завершения writer. std::lock_guard гарантирует освобождение мьютекса при выходе из области видимости, в том числе при исключении.

Защита перестаёт быть достаточной, если чтение value вынести за пределы критической секции, использовать другой мьютекс или обращаться к объекту после его уничтожения. Также мьютекс не исправляет ошибки времени жизни: объект должен оставаться живым для всех потоков, которые его используют.

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

Ситуация из практики

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

Рассматривались два варианта:

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

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

Что кандидаты часто упускают

  1. Достаточно ли защищать запись мьютексом, если чтение выполняется без блокировки?

Нет. Для обычного объекта конфликтующая запись и чтение должны быть упорядочены отношением happens-before. Если читатель не захватывает тот же мьютекс и не использует другой корректный механизм синхронизации, возникает гонка данных, даже если запись сама выполняется под блокировкой.

  1. Создаёт ли любой захват мьютекса видимость всех изменений в программе?

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

  1. Можно ли после разблокировки безопасно передать указатель на защищённый объект, если сам указатель передаётся под мьютексом?

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