A konkurens C++ egyik leggyakoribb félreértése, hogy a
std::shared_ptr „thread-safe okos pointer”. Ez a mondat két
különböző dolgot mos össze: az ownership könyvelésének szinkronizációját
és a mutatott objektum állapotának szinkronizációját. Ebben az
alfejezetben egy connection registry példáján választjuk szét a kettőt,
és megadjuk mindkettőre a szerződést. Az előfeltétel a semaphore, latch, barrier és szinkron stream
részben rögzített szinkronizációs eszköztár, a shared_ptr, control block és weak_ptr részben
bevezetett tulajdonlási modell, valamint a Konkurens
élettartam és nehéz hibák részben tárgyalt happens-before és
adatverseny definíció. Ezeket itt nem írjuk újra, csak felidézzük a
szükséges mértékben.
A fejezet állításai a C++20 memóriamodell ismert követelményein alapulnak; a konkrét szabványparagrafusok és a C++26-os javaslatok státuszának ellenőrzése a Feature-test, standard library és build capability, illetve a C++26 és későbbi irányok ellenőrzött státusszal részek eszköztárával, a tanuló laborjában történik. A fejezet nem tartalmaz mért eredményt.
Az ownership (tulajdonlás) szerződése megmondja, ki
felel a mögöttes objektum megszüntetéséért, és mikor. A
shared_ptr ezt osztott tulajdonlássá teszi: a control block
referenciaszámlálója dönti el, mikor fut le a deleter. A pointee
synchronization szerződése ezzel szemben azt mondja meg, hogy a
mutatott objektum belső állapotához hogyan férünk hozzá párhuzamosan:
melyik művelet olvas, melyik ír, milyen zár vagy atomi művelet védi, és
mi a láthatósági viszony a szálak között.
A két szerződés ortogonális. A shared_ptr<P> azt
garantálja, hogy a *p objektum élettartama alatt nem szűnik
meg, amíg legalább egy shared_ptr példány rá mutat. Azt
viszont nem garantálja, hogy p->mezo olvasása és írása
két szálon nem versenyez. Ha a Connection objektumnak van
egy std::uint64_t bytes_ tagja, és az egyik szál
add_bytes-ot hív, a másik snapshot-ot, akkor a
shared_ptr semmit nem tesz hozzá a helyességhez: a
bytes_-re adatverseny van, ami nem definiált viselkedés,
függetlenül attól, hogy a tulajdonlás rendben van.
A szerződés megfogalmazásához három feltételt rögzítünk. Egyrészt
minden mutálható pointee-állapothoz külön védelmet rendelünk (mutex,
atomikus változó vagy dokumentáltan egyszálú használat). Másrészt a
shared_ptr példányokra vonatkozó műveletek köre pontosan
az, amit a control block kezel, semmi több. Harmadrészt a két védelmi
réteg sorrendje (lock ordering) rögzített, különben a két szerződés
együtt deadlockot termel.
A C++20 követelménye szerint a shared_ptr tagfüggvényei
— beleértve a másoló konstruktort és a másoló értékadást — különböző
shared_ptr példányokon külön szálakról is hívhatók további
szinkronizáció nélkül, még akkor is, ha ezek a példányok ugyanannak az
objektumnak a tulajdonjogát osztják. Ennek mechanizmusa a control
blockban tartott referenciaszámláló atomikus növelése és csökkentése; az
utolsóként csökkentő szál futtatja a deletet, és ez a szál a számláló
csökkentésével szinkronizál minden korábbi tulajdonossal.
Két fontos korlátot kell ehhez hozzátenni. Az egyik, hogy a
„thread-safe” itt kizárólag a könyvelésre vonatkozik: arra, hogy a
számláló nem veszik el, és a destruktor pontosan egyszer fut le. A
másik, hogy ez nem jelent lock-free implementációt. A gyakorlatban a
legtöbb standard library implementáció atomikus műveletekkel kezeli a
számlálót, de a shared_ptr mérete és a control block
allokációja miatt a másolás költsége nem nulla; teljesítményérzékeny
útvonalon ezt a Profilozás, benchmark és cost
model rész módszertanával kell megmérni, nem feltételezni.
A gyakorlati következmény a registry esetében: a
std::unordered_map<std::string, std::shared_ptr<Connection>>
tartalmához való hozzáférést saját mutex védi, mert a map belső
szerkezete nem thread-safe. A benne tárolt shared_ptr érték
kimásolása viszont a zár alatt biztonságosan megtehető, és a kimásolt
példány a zár elengedése után is érvényes tulajdonjogot ad.
A garantált biztonság kizárólag különböző
shared_ptr példányokra vonatkozik. Ha két szál ugyanazon
shared_ptr objektumon hív reset-et, értékadást
vagy destruktort, az adatverseny a C memóriamodell értelmében, tehát nem
definált viselkedés — nem „csak” logikai hiba, és nem javítja semmilyen
retry. Ilyenkor a példányt magát kell szinkronizálni, vagy atomikus
wrapperre kell váltani.
A C++20 bevezette a
std::atomic<std::shared_ptr<T>> specializációt,
amely egy shared_ptr objektumot tesz atomikusan olvashatóvá
és írhatóvá. Mellette a shared_ptr-re specializált
std::atomic_load, std::atomic_store szabad
függvények C++11 óta léteznek, de a C++20ban deprecálttá váltak; új
kódban a specializációt használjuk. Fontos korlát: az
atomic<shared_ptr> jellemzően nem
lock-free, belső zárat használ, tehát nem tekinthető a mutex
kiváltásának nagy forgalmú esetben. Reális használati mintája a ritkán
írt, gyakran olvasott konfiguráció publikálása:
// config_publish.cpp — ISO C++20
#include <atomic>
#include <memory>
#include <string>
struct Config {
std::string endpoint;
int timeout_ms;
};
std::atomic<std::shared_ptr<const Config>> g_config{nullptr};
void publish(std::shared_ptr<const Config> c) {
g_config.store(std::move(c)); // release szemantika
}
std::shared_ptr<const Config> current() {
return g_config.load(); // acquire szemantika
}A store/load alapértelmezett
memory_order_seq_cst, ami erősebb a szükségesnél; a
release/acquire pár elegendő, és ezt a C++ atomics, atomic_ref és publication rész
indokolja. A const Config választás szándékos: a publikált
objektumot a kiadás után nem módosítjuk, így a pointee synchronization
kérdése eltűnik. Ha módosítani kellene, az újabb publikálás új
objektummal történik, nem a régi írásával.
A példa három zárolási szintet mutat be: a registry mapjének zárját,
a Connection belső állapotának zárját és a worker queue
zárját. A cél, hogy a shared_ptr ownership műveletei soha
ne fussanak felhasználói destruktor alatt, és hogy a taszk futtatása
soha ne történjen queue-zár alatt.
// registry.cpp — ISO C++20, x86-64 System V, GCC 13 vagy Clang 17
#include <condition_variable>
#include <cstdint>
#include <deque>
#include <functional>
#include <memory>
#include <mutex>
#include <stdexcept>
#include <string>
#include <thread>
#include <unordered_map>
#include <utility>
class Connection {
public:
explicit Connection(std::string id) : id_(std::move(id)) {}
const std::string& id() const noexcept { return id_; }
// pointee state: saját szerződés, saját zár
void add_bytes(std::uint64_t n) {
std::lock_guard lk(state_mu_);
bytes_ += n;
++ops_;
}
struct Snapshot {
std::uint64_t bytes;
std::uint64_t ops;
};
Snapshot snapshot() const {
std::lock_guard lk(state_mu_);
return Snapshot{bytes_, ops_}; // konzisztens másolat
}
private:
const std::string id_;
mutable std::mutex state_mu_;
std::uint64_t bytes_ = 0;
std::uint64_t ops_ = 0;
};
class Registry {
public:
// Szerződés: c nem üres; a kulcs c->id(). Azonos id esetén csere.
void add(std::shared_ptr<Connection> c) {
if (!c) throw std::invalid_argument("Registry::add: null connection");
std::shared_ptr<Connection> victim; // a zárblokkon KÍVÜL
{
std::lock_guard lk(mu_);
// Ha a try_emplace kivételt dob (allokáció), c a hívó oldali
// paraméterben marad, és a zár nélkül szabadul fel.
auto [it, inserted] = map_.try_emplace(c->id());
victim = std::exchange(it->second, std::move(c)); // noexcept
}
// A régi owner (ha volt) itt, a zár feloldása után szabadul fel:
// a destruktora vagy deletere visszahívhat a regiszterbe.
}
std::shared_ptr<Connection> find(const std::string& id) const {
std::lock_guard lk(mu_);
auto it = map_.find(id);
if (it == map_.end()) return nullptr;
return it->second; // refcount növelés a zár alatt
}
void remove(const std::string& id) {
std::shared_ptr<Connection> victim;
{
std::lock_guard lk(mu_);
auto it = map_.find(id);
if (it == map_.end()) return;
victim = std::move(it->second);
map_.erase(it);
}
// victim itt szabadul fel: a registry zárja nélkül
}
private:
mutable std::mutex mu_;
std::unordered_map<std::string, std::shared_ptr<Connection>> map_;
};
class Worker {
public:
using Task = std::function<void()>;
void start() { th_ = std::thread([this] { run(); }); }
void submit(Task t) {
{
std::lock_guard lk(mu_);
if (stop_) return; // leállítás után elutasítunk
q_.push_back(std::move(t));
}
cv_.notify_one();
}
void stop() {
{
std::lock_guard lk(mu_);
stop_ = true;
}
cv_.notify_all();
if (th_.joinable()) th_.join();
std::deque<Task> leftover;
{
std::lock_guard lk(mu_);
leftover.swap(q_);
}
leftover.clear(); // shared_ptr-ek zár nélkül szabadulnak
}
// A destruktor a hibaúton is szabályozottan leállít: a stop() idempotens.
// Korlát: a Worker-t a tulajdonos szála destruálja, nem a saját munkaszála
// (self-join esetén a join resource_deadlock_would_occur hibát dobna).
~Worker() { stop(); }
private:
void run() {
for (;;) {
Task t;
{
std::unique_lock lk(mu_);
cv_.wait(lk, [this] { return stop_ || !q_.empty(); });
if (stop_) return; // drop politika
t = std::move(q_.front());
q_.pop_front();
}
t(); // taszk futtatása zár nélkül
}
}
std::mutex mu_;
std::condition_variable cv_;
std::deque<Task> q_;
bool stop_ = false;
std::thread th_;
};
int main() {
Registry reg;
Worker w;
w.start();
auto c = std::make_shared<Connection>("db-1");
reg.add(c);
w.submit([c] { c->add_bytes(1024); });
if (auto p = reg.find("db-1")) {
(void)p->snapshot();
}
reg.remove("db-1");
w.stop();
}Az add és a remove ugyanazt az elvet
követi: a map a zár alatt változik, de egy Connection
utolsó tulajdonosának elengedése, vagyis a destruktor vagy egy egyéni
deleter futása, mindig a zár feloldása után történik.
Ha az add a régi elemet a zár alatt cserélné le (például
insert_or_assign-nal), és a map volt a régi
Connection utolsó tulajdonosa, a deleter a mu_
zár alatt futna. Egy olyan deleter, amely visszahív a regiszterbe
(find), ugyanazt a nem rekurzív mutexet próbálná
megszerezni: önmagával kerülne holtpontba, második szál nélkül. Az
add ezért a régi ownert a zárblokkon kívül deklarált
victim változóba mozgatja. A beszúrást
try_emplace végzi: ha ez allokációs kivételt dob, az új
owner még a hívó paraméterében van, nem a félkész map-bejegyzésben. A
szerződés szerint a c nem lehet üres; erre az
add std::invalid_argument-et dob.
Buildjegyzőkönyv (lab-gcc profil). A
program figyelmeztetés nélkül fordult és 0 kóddal futott, TSan
(setarch -R) alatt is. Egy azonos id-t lecserélő próba,
amelynek deletere find-ot hív, a régi objektumot egyszer
szabadította fel (freed=1), az eltávolítás után a másodikat
(freed=2), ASan/UBSan alatt is. Az
insert_or_assign-os eredeti változat ugyanebben a próbában
holtpontra jutott (5 másodperces timeout, 124-es kód). Az üres pointert
az add Registry::add: null connection
üzenettel utasította el.
A buildparancs a tanuló laborjában futtatandó; a fejezet nem állítja, hogy a program lefutott vagy hogy a TSan tiszta eredményt adott:
g++ -std=c++20 -O1 -g -fsanitize=thread -pthread registry.cpp -o registry_tsan
./registry_tsanA példa három tanulsága. Először: a find a
shared_ptr másolatát a registry zárja alatt készíti el, így
a visszaadott példány tulajdonjoga akkor is érvényes, ha közben egy
másik szál remove-ot hajt végre. Másodszor: a
remove a victim-et a zár elengedése után dobja
el, így a Connection destruktora nem a registry zárja alatt
fut — ez a lock ordering védelme, mert a destruktor elvileg más zárat is
foghatna. Harmadszor: a Worker::run a taszkot a queue
zárján kívül hívja meg, különben a taszk submit-hívása
önmagával szemben deadlockolna.
A shared_ptr referenciaszámlálója egy reclamation
stratégia: az objektum akkor szűnik meg, amikor az utolsó tulajdonos
elengedi. Ez kényelmes, de két ismert gyenge pontja van. Az egyik a
ciklikus hivatkozás, amelyet a weak_ptr tör meg — erről a
shared_ptr, control block és weak_ptr rész
szólt. A másik az, hogy a reclamation nem védi meg a nyers pointert vagy
referenciát, amelyet a shared_ptr mellől adunk át: ha egy
másik szál közben elengedi az utolsó tulajdonost, a nyers hozzáférés
danglinggá válik.
Az ABA jelenség akkor lép fel, ha egy atomikusan
tárolt címre CAS-t hajtunk végre, de a cím időközben felszabadult és
ugyanarra a címre új allokáció került. A címérték megegyezik, a jelentés
nem. shared_ptr esetén ez akkor kerülhető el, ha a
tulajdonjogot is atomikusan visszük magunkkal — például
weak_ptr::lock()-kal vagy
atomic<shared_ptr>-rel —, nem pedig nyers pointerrel.
A lock-free struktúrák ehhez epoch- vagy hazard pointer alapú
reclamationt használnak; ezek szabványosítása a C++26 felé irányuló
javaslatok témája, amelynek elfogadási és implementálási státuszát itt
nem állítjuk, hanem a C++26 és későbbi irányok
ellenőrzött státusszal részre utalunk.
A ThreadSanitizer (-fsanitize=thread) a happens-before
gráfot építi fel a memóriahozzáférésekből és a szinkronizációs
eseményekből, és jelzi a versenyző párokat. Nem keverhető egy binárison
belül AddressSanitizerrel, és a -O1 -g kombináció ajánlott
hozzá; a futásidejű költség jelentős. A TSan megtalálja az adatversenyt,
de nem találja meg a logikai race-t (például azt, hogy a
stop_ beállítása és a taszk futtatása között elveszik egy
értesítés), és nem bizonyítja a deadlock-mentességet sem — legfeljebb a
lock-set analízis jelezhet ciklust.
A stresszpróba sok szálat és rövid kritikus szakaszokat használ; jó
indikátor, de nem bizonyíték: a hiányzó hiba nem bizonyítja a
helyességet. Ami bizonyítható, az az invariáns. A példában a
snapshot() egyetlen zár alatt adja vissza a
bytes_ és ops_ értékét. Ha két külön gettert
írnánk, a két érték két különböző időállapotból származna, és az
ops_ == 0 && bytes_ > 0 kombináció hamisan
jelenhetne meg. A konzisztens pillanatkép nem teljesítményoptimalizálás,
hanem szerződés: a fogyasztó egyetlen állapotra következtethet.
A leggyakoribb shutdown deadlock nem a shared_ptr-ben,
hanem a leállítás sorrendjében keletkezik. Ha a stop() a
queue zárját tartva vár a join-ra, a worker nem tud belépni
a run zárblokkjába, és a két szál egymásra vár. Ha a worker
a queue zárja alatt futtatja a taszkot, a taszk submit-ja
ugyanarra a zárra vár — önmagával szemben. Ha a stop_ írása
nem a queue zárja alatt történik, a wait predikátuma
adatversenyes, és az értesítés elveszhet.
A resource lifetime ugyanilyen fontos. A queue-ban maradó taszkok
shared_ptr kópiái meghosszabbítják a
Connection élettartamát a registry elpusztítása után is. A
helyes sorrend tehát: stop flag beállítása zár alatt,
notify_all, join, majd a maradék taszkok
elengedése zár nélkül — ahogy a példa Worker::stop-ja
teszi. A fogalmak pontosan: egy std::thread objektum
joinable, amíg nem hívtunk rá join-t vagy
detach-et — függetlenül attól, hogy a szál már befejezte-e
a futását. A join futó szálra is hívható: addig blokkol,
amíg a szál befejeződik. Egy joinable std::thread
destruktora std::terminate-et hív. Ezért a
~Worker is meghívja a stop()-ot: ha a
tulajdonos a start() után, de a stop() előtt
kivétel miatt hagyja el a hatókört, a worker így is leáll és összeolvad,
mielőtt a q_, a cv_ és a mu_
megszűnne (a th_ az utolsó tag, és a destruktor törzse a
tagok előtt fut). A detach nem megoldás, mert a munkaszál a
this-t kölcsönzi, és túlélné az objektumot. A stop
idempotens, tehát a normál úton hívott explicit stop() után
a destruktor ismételt hívása ártalmatlan.
Buildjegyzőkönyv (lab-gcc profil).
Negatív próba: a main a w.start() után, a
w.stop() előtt kivételt dob, amelyet egy külső
try/catch kap el (a
std::set_terminate a 42-es kóddal jelezné a terminate-et).
A javítás előtti, üres destruktorú változat terminate-tel,
42-es kóddal állt le; a javított változat elkapva: ...
üzenettel, 1-es kóddal lépett ki, TSan és ASan/UBSan alatt is jelzés
nélkül.
Az első hiba, hogy a shared_ptr-t automatikus
pointee-védelemnek tekintjük. A javítás: minden mutálható taghoz külön
védelmet rendelünk, és ezt a fejlécben dokumentáljuk. A második, hogy
ugyanazon shared_ptr példányt két szálon
reset-eljük; itt a példányt kell védeni vagy
atomic<shared_ptr>-re váltani. A harmadik, hogy a
find nyers referenciát ad vissza a map elemére a zár
elengedése után; helyette shared_ptr kópiát kell
visszaadni. A negyedik, hogy a destruktor zár alatt fut, és lock
ordering ciklust okoz; a javítás a victim kiemelése a záron
kívülre. Az ötödik, hogy a wait predikátum nélküli, és
hamis ébresztésre is továbblép; a wait(lk, pred) alak
kötelező. A hatodik, hogy a stressz- vagy TSan-futás hiányát helyességi
bizonyítéknak tekintjük.
1. feladat (magyarázó). Fogalmazd meg pontosan,
miért nem adatverseny, ha két szál két különböző
shared_ptr<Connection> példányon hajt végre másolást
ugyanarra a control blockra, és miért adatverseny, ha ugyanazon a
példányon hajt végre reset-et. Nevezd meg a happens-before
éleket.
2. feladat (kódolási). Egészítsd ki a
Registry-t clear_all() művelettel, amely az
összes connectiont eltávolítja, és garantálja, hogy a
Connection destruktorok nem a registry zárja alatt
futnak.
3. feladat (diagnosztikai). A
Worker::run alábbi változata deadlockot okozhat. Azonosítsd
a ciklust, add meg a javítást, és mondd meg, melyik TSan-eredmény nem
jelezné a hibát.
void run() {
for (;;) {
std::unique_lock lk(mu_);
cv_.wait(lk, [this] { return stop_ || !q_.empty(); });
if (stop_) return;
auto t = std::move(q_.front());
q_.pop_front();
t(); // a zár még mindig tartva
}
}4. feladat (kódolási, nehezebb). A
Worker::stop jelenleg drop politikát követ. Írj olyan
stop(bool drain) változatot, amely
drain == true esetén a maradék taszkokat lefuttatja, de
biztosítja, hogy a leállítás alatt érkező új submit
elutasításra kerüljön, és a ciklus véges maradjon.
1. megoldás. Két különböző shared_ptr
példány esetén a másolás a control block referenciaszámlálóján atomikus
növelést végez. Az atomikus read-modify-write művelet a szekvenciális
konzisztencia miatt teljes sorrendet ad a két szál műveletei között, így
a számláló értéke nem vész el, és az utolsó csökkentő szál pontosan
egyszer futtatja a deletet. A két szál között a
synchronizes-with él az atomikus változón keresztül jön
létre, tehát a két példány objektumára vonatkozó műveletek nem
versenyeznek. Ugyanazon shared_ptr példányon végzett
reset ezzel szemben a példány belső pointerére és a
kontrollblokk-hivatkozására is ír, és ezt a szabvány nem teszi
atomikussá; két szál egyidejű írása a C memóriamodell szerint
adatverseny, tehát nem definiált viselkedés. Az ellenőrzési feltétel: a
válasz nevezze meg a referenciaszámláló atomikus műveletét, a
synchronizes-with élt és az adatverseny definícióját.
2. megoldás. A kulcs, hogy a zárolt szakasz csak a
map módosítását és a shared_ptr-ek kiemelését végezze; a
felszabadítás a zár elengedése után történjen.
void clear_all() {
std::unordered_map<std::string, std::shared_ptr<Connection>> doomed;
{
std::lock_guard lk(mu_);
doomed.swap(map_);
}
// doomed itt szabadul fel: a registry zárja nélkül
}A swap után a map_ üres, a
doomed pedig a régi tartalmat birtokolja. A
doomed destruktora a mu_ elengedése után fut,
így a Connection destruktorok nem a registry zárja alatt
futnak. Az ellenőrzési feltétel: a clear_all után a
find minden kulcsra nullptr-t adjon, és a
destruktorok hívási sorrendje ne tartalmazzon registry-zárat (TSan +
naplózott destruktor mellett ellenőrizhető).
3. megoldás. A ciklus ciklusa: a worker a
mu_ zár alatt futtatja a taszkot; ha a taszk
submit-ot hív, az ugyanarra a mu_-re vár,
amelyet a worker már tart, és soha nem enged el — önmagával szembeni
deadlock. Ugyanez a stop oldaláról is jelentkezhet: ha a
taszk stop-ot hív, a stop a
mu_-re vár, miközben a join a worker szálra
vár, amely a taszkban van. A javítás a taszk futtatásának kiemelése a
zár alól: a kritikus szakasz csak a queue-ból való kiemelés, a
t() hívás a unique_lock megszűnése után
történik. A TSan ezt a hibát nem feltétlenül jelzi, mert nincs benne
adatverseny: a queue minden hozzáférése zár alatt van, a holtpont
logikai, nem memóriamodell-sértés. Az ellenőrzési feltétel: a válasz
nevezze meg a zár birtoklását a t() hívás alatt, és mondja
ki, hogy a deadlock nem adatverseny.
4. megoldás. A drain politikánál a worker a
stop_ beállítása után is feldolgozza a sorban lévő
elemeket, de új elemet nem fogad el, így a ciklus véges. A
submit a zár alatt ellenőrzi a stop_-ot; a
run a zár alatt csak akkor lép ki, ha
stop_ && q_.empty().
void submit(Task t) {
{
std::lock_guard lk(mu_);
if (stop_) return;
q_.push_back(std::move(t));
}
cv_.notify_one();
}
void stop_drain() {
{
std::lock_guard lk(mu_);
stop_ = true;
}
cv_.notify_all();
if (th_.joinable()) th_.join();
}
void run() {
for (;;) {
Task t;
{
std::unique_lock lk(mu_);
cv_.wait(lk, [this] { return stop_ || !q_.empty(); });
if (q_.empty()) { // stop_ && üres: kilépés
if (stop_) return;
continue;
}
t = std::move(q_.front());
q_.pop_front();
}
t();
}
}A végesség indoklása: a stop_ beállítása után minden
submit a zár alatt elutasításra kerül, tehát a queue hossza
monoton csökken, és a ciklus legfeljebb a stop_
pillanatában a sorban lévő elemek számáig fut. Az ellenőrzési feltétel:
stop_drain után a submit ne módosítsa a
queue-t, a join pedig előbb térjen vissza, mint a
stop_drain visszatérése; stressztesztben a taszkok száma
legyen nagyobb a szálakénál.
A shared_ptr két külön szerződést fed: a control block
szintjén az ownership könyvelése különböző példányokon szálbiztos, a
pointee állapotához viszont semmit nem ad. Ugyanazon
shared_ptr példány egyidejű módosítása adatverseny; erre a
C++20 atomic<shared_ptr> specializációja kínál
megoldást, jellemzően nem lock-free áron. A registry példa megmutatta,
hogy a rétegek szétválasztása — registry-zár, pointee-zár, queue-zár —
és a felhasználói destruktorok záron kívüli futtatása adja a helyes
sorrendet. A reclamation shared_ptr-nél
referenciaszámlálás, nyers pointerrel kombinálva nem véd; az ABA és a
lock-free reclamation a C++26-os kitekintés témája. A helyesség eszköze
a TSan és a stressz mellett az invariáns pillanatkép és a leállítási
sorrend explicit szerződése, nem a „thread-safe okos pointer”
feltételezése.