Программирование C++Современный C++C++ разработчик системного программного обеспечения

Разберите ситуацию: несколько потоков читают и заменяют общий std::shared ptr. Какой механизм C++20 нужен, ...

Разберите ситуацию: несколько потоков читают и заменяют общий std::shared_ptr. Какой механизм C++20 нужен, чтобы синхронизировать доступ к самой переменной-указателю без внешнего мьютекса?

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

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

Используйте специализацию std::atomic<std::shared_ptr<T>>. Она делает атомарными операции чтения, записи и обмена значением самой переменной shared_ptr, включая корректное управление её контрольным блоком.

Это не делает объект T потокобезопасным: если потоки изменяют один и тот же объект, для его состояния всё равно нужна отдельная синхронизация либо неизменяемые снимки.

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

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

В C++11 для таких операций появились специализированные свободные функции атомарной работы с shared_ptr. В C++20 их более идиоматичной заменой стала специализация std::atomic<std::shared_ptr<T>>, предоставляющая привычный атомарный интерфейс.

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

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

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

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

Атомарный shared_ptr хранит владение так, чтобы операции load, store, exchange и сравнение с обменом выполнялись как единая синхронизированная операция над переменной.

#include <atomic> #include <memory> struct Config { int limit; }; int read_limit(const std::atomic<std::shared_ptr<const Config>>& current) { auto snapshot = current.load(); return snapshot->limit; } void replace(std::atomic<std::shared_ptr<const Config>>& current) { current.store(std::make_shared<const Config>(Config{42})); }

Читатель получает собственную копию shared_ptr через load. Поэтому выбранный объект и его контрольный блок остаются живы до окончания работы читателя, даже если другой поток уже заменил значение в атомарной переменной.

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

Атомарность не означает обязательную аппаратную lock-free реализацию. Проверить это можно через is_lock_free; конкретная реализация вправе использовать внутреннюю блокировку. Если требуется совместимость со стандартами до C++20, применяют специализированные свободные функции std::atomic_load и std::atomic_store для shared_ptr.

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

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

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

Можно защищать изменяемую конфигурацию мьютексом, но это увеличивает стоимость чтения и усложняет настройку времени блокировок. Более подходящий вариант — std::atomic<std::shared_ptr<const Config>>: обновляющий поток создаёт новый неизменяемый снимок и публикует его, а читатели атомарно получают стабильные снимки без общей блокировки.

В результате читатели не видят частично обновлённую конфигурацию, старые снимки автоматически освобождаются после завершения их использования, а синхронизация ограничена заменой указателя. Цена решения — дополнительные выделения памяти при создании снимков и отсутствие гарантии lock-free.

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

  1. Достаточно ли атомарного shared_ptr, если два потока изменяют поля объекта T?

Нет. Атомарной является переменная, содержащая shared_ptr, а не объект T. Для совместного изменения объекта нужны мьютекс, атомарные поля или другой механизм синхронизации; часто проще публиковать новые неизменяемые экземпляры.

  1. Почему копирование разных экземпляров shared_ptr обычно безопасно при общем объекте?

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

  1. Гарантирует ли std::atomic<std::shared_ptr<T>> отсутствие блокировок?

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