умные указатели — устройство и практикаsmart pointers — internals and practice
API уже разобран в stl/cpp11/shared-ptr.html — здесь про то, где физически хранится счётчик ссылок, почему он атомарный, и что происходит в памяти при каждом копировании shared_ptr. Ключевая структура — control block, отдельный от самого управляемого объекта блок данных.The API is already covered in stl/cpp11/shared-ptr.html — this page covers where the reference count physically lives, why it's atomic, and what happens in memory on every shared_ptr copy. The key structure is the control block, a data block separate from the managed object itself.
Каждый shared_ptr хранит не один, а два указателя: на управляемый объект и на control block — отдельно выделенную структуру, содержащую strong count (число владеющих shared_ptr), weak count (число наблюдающих weak_ptr) и, если объект создан через new напрямую, указатель на функцию-удалитель.Every shared_ptr stores not one but two pointers: one to the managed object, and one to the control block — a separately allocated structure holding the strong count (the number of owning shared_ptrs), the weak count (the number of observing weak_ptrs), and, if the object was created via a direct new, a pointer to the deleter function.
оба shared_ptr указывают на один объект и на один общий control blockboth shared_ptrs point at the same object and the same shared control block
Именно поэтому sizeof(shared_ptr<T>) == 2 * sizeof(void*) (16 байт на x86-64) — вдвое больше, чем у unique_ptr, который хранит только один указатель. Второй указатель — не опциональная деталь, а прямая причина того, почему shared_ptr вообще способен на разделяемое владение: control block переживает даже уничтожение самого объекта, пока жив хотя бы один weak_ptr (см. страницу про weak_ptr).This is exactly why sizeof(shared_ptr<T>) == 2 * sizeof(void*) (16 bytes on x86-64) — twice the size of a unique_ptr, which stores only one pointer. That second pointer isn't an optional detail — it's the direct reason shared_ptr can support shared ownership at all: the control block outlives even the object's own destruction, as long as at least one weak_ptr is still alive (see the weak_ptr page).
Копирование shared_ptr из разных потоков без синхронизации — обычная ситуация (передача объекта между потоками, замыкания в задачах пула потоков), и strong count изменяется при каждом копировании/уничтожении. Если бы инкремент/декремент не были атомарными, гонка данных на самом счётчике могла бы привести к недосчитанному числу владельцев и преждевременному удалению объекта, пока кто-то ещё им пользуется.Copying a shared_ptr from different threads with no external synchronization is a routine situation (passing an object between threads, closures in thread-pool tasks), and the strong count changes on every copy/destruction. If increment/decrement weren't atomic, a data race on the count itself could undercount the number of owners and destroy the object prematurely while someone was still using it.
Это единственная часть shared_ptr, которая потокобезопасна сама по себе — сам управляемый объект никак не защищён от гонок данных: два потока, одновременно вызывающие неconst методы через свои копии shared_ptr на один объект, всё равно гонятся за данные объекта, если не добавлена собственная синхронизация. Атомарность счётчика гарантирует только корректный подсчёт владельцев, не безопасность самого объекта.This is the only part of shared_ptr that's thread-safe on its own — the managed object itself has no protection from data races at all: two threads simultaneously calling non-const methods through their own copies of a shared_ptr to the same object still race on the object's data, unless synchronization is added separately. Atomicity of the count guarantees correct owner counting only, not the object's own safety.
std::shared_ptr<Config> cfg = std::make_shared<Config>();
// поток 1// thread 1
auto local1 = cfg; // atomic increment — безопасно из любого потока// atomic increment — safe from any thread
local1->setValue(42); // НЕ безопасно без своей синхронизации, если другой поток тоже пишет// NOT safe without your own synchronization, if another thread also writes
// поток 2// thread 2
auto local2 = cfg; // atomic increment — тоже безопасно// also safe
struct ControlBlock {
std::atomic<long> strong{1};
std::atomic<long> weak{0};
};
template<typename T>
class MySharedPtr {
T* ptr = nullptr;
ControlBlock* ctrl = nullptr;
public:
explicit MySharedPtr(T* p) : ptr(p), ctrl(new ControlBlock()) {}
MySharedPtr(const MySharedPtr& other) : ptr(other.ptr), ctrl(other.ctrl) {
if (ctrl) ctrl->strong.fetch_add(1, std::memory_order_relaxed); // atomic inc// atomic inc
}
MySharedPtr& operator=(const MySharedPtr& other) {
if (this == &other) return *this;
release();
ptr = other.ptr; ctrl = other.ctrl;
if (ctrl) ctrl->strong.fetch_add(1, std::memory_order_relaxed);
return *this;
}
~MySharedPtr() { release(); }
void release() {
if (!ctrl) return;
// acquire-release: последний, кто видит strong == 0, должен видеть все чужие записи в объект// acquire-release: the last one to see strong == 0 must see every other write to the object
if (ctrl->strong.fetch_sub(1, std::memory_order_acq_rel) == 1) {
delete ptr; // последний владелец — удаляем объект// the last owner — delete the object
if (ctrl->weak.load() == 0) delete ctrl; // control block живёт, пока есть weak_ptr// the control block survives while any weak_ptr exists
}
}
T* operator->() const { return ptr; }
long use_count() const { return ctrl ? ctrl->strong.load() : 0; }
};
Реальная libstdc++/libc++ реализация сложнее (учитывает make_shared, кастомные deleter-ы и allocator-ы через виртуальные функции в control block, отдельные пути для array-специализации), но эти ~30 строк — то, что от кандидата реально ожидают на собеседовании: атомарный счётчик, отдельный control block, корректный порядок memory order на декременте.The real libstdc++/libc++ implementation is more involved (it accounts for make_shared, custom deleters and allocators via virtual functions in the control block, separate paths for the array specialization), but these ~30 lines are what a candidate is genuinely expected to produce in an interview: an atomic count, a separate control block, the correct memory order on the decrement.
std::shared_ptr<T> a(rawPtr); std::shared_ptr<T> b(rawPtr); создаёt два независимых control block-а, каждый со своим strong count, начинающимся с 1 — оба искренне полагают, что владеют объектом единолично. Когда оба выйдут из скоупа, объект будет удалён дважды. Единственный безопасный способ получить второй shared_ptr на тот же объект — скопировать существующий shared_ptr, никогда не создавать новый из того же сырого указателя.std::shared_ptr<T> a(rawPtr); std::shared_ptr<T> b(rawPtr); creates two independent control blocks, each with its own strong count starting at 1 — both genuinely believe they solely own the object. When both go out of scope, the object gets deleted twice. The only safe way to get a second shared_ptr to the same object is to copy the existing shared_ptr — never construct a new one from the same raw pointer.
Метод, желающий вернуть shared_ptr на this, не может просто написать std::shared_ptr<T>(this) — это ровно та же ошибка «второй независимый control block». Класс обязан наследоваться от std::enable_shared_from_this<T>, которая хранит скрытый weak_ptr на самого себя, устанавливаемый в момент, когда объект впервые оборачивается в shared_ptr.A method that wants to return a shared_ptr to this can't just write std::shared_ptr<T>(this) — that's the exact same "second independent control block" mistake. The class must inherit from std::enable_shared_from_this<T>, which stores a hidden weak_ptr to itself, set the moment the object is first wrapped in a shared_ptr.
Atomic-операции компилируются в инструкции с барьером памяти (например, lock xadd на x86) вне зависимости от того, есть ли реальная конкуренция потоков — компилятор не может знать на этапе компиляции библиотечного кода, будет ли программа многопоточной. Это прямая, измеримая причина, почему shared_ptr медленнее unique_ptr даже там, где многопоточность вообще не участвует.Atomic operations compile down to instructions with a memory barrier (e.g., lock xadd on x86) regardless of whether real thread contention exists — the compiler can't know at the time it compiles library code whether the program will end up multi-threaded. This is a direct, measurable reason shared_ptr is slower than unique_ptr even where concurrency never enters the picture at all.