Programování vícevláknových aplikací může být pro některé programátory skutečnou výzvou. Problémové situace nastávají kdykoliv vlákna přistupují na sdílené objekty. Zapomenutá synchronizace je často zdrojem nepředvídatelného chování a náhodných pádů. Ale vhodným nástrojem si lze práci zjednodušit a kód dobře zabezpečit
V jazyce C++ už drahnou dobu není problém něco sdílet mezi více místy. K takovému účelu se používá std::shared_ptr
. Ten je navržen tak, aby sdílení nemuselo být řešeno v objektu, který se sdílí. Jedná se o tzv neintrusivní sdílení. Sdílení se v tomto případě řeší pomocí počítání referencí a samotný čítač referencí je realizován mimo sdílený objekt. Opakem je pak intrusivní sdílení, kdy čítač referencí je součástí sdíleného objektu. Takovým příkladem je například rozhraní COM+ od Microsoftu. Znalci určitě poznají funkce AddRef a Release()
Ze své zkušenosti s různými programátorskými týmy vím, že zamykání sdílených objektů programátoři řeší v rámci návrhu těch objektů. Předpokládá se, že je dopředu známo z hlediska návrhu struktury programu, které objekty se budou sdílet a které nikoliv a jakým způsobem bude probíhat přístup na sdílené objekty. A tady je často kámen úrazu, který vede na mnoho chyb, které se navíc špatně hledají, protože bývají často výsledkem špatně zvolené synchronizace, nedomyšlení některých vztahů a nebo prosté opomenutí pokrýt synchronizací všechny přístupová místa. Chyba se pak projeví ve zcela náhodných a nepředvídatelných situacích.
Z myšlenky neintrusivního čítání přišla inspirace na implementaci neintrusivního zamykání. Primárně totiž při návrhu objektů nepředpokládáme jeho sdílení mezi vlákny, vyjma případů k tomu určených. Proto ani v běžných třídách jako std::vector
, std::map
se nenachází zámky chránící objekt při každém přístupu. Bylo by možné naprogramovat varianty std::lockable_vector
, std::lockable_map
a pokrýt všechny přístupy do objektu zámkem. Vzhledem k tomu, jak košatá mají tyto třídy API, tak mnoho štěstí. Mnohem lepší by bylo implementovat zámek podobně jako samotný sdílený ukazatel.
Nejprve když jsem hledal řešení, tak jsem prolezl celou knihovnu STL a nenašel jsem nic, co by připomínalo řešení mého nápadu. A přitom si myslím, že takový nástroj by byl užitečný. A dodám, že tento příspěvek nepíšu půl hodiny po tom, co jsem si naprogramoval koncept. Tento nástroj jsem v praxi vyzkoušel v jednom projektu, který aktivně používám od roku 2019 a ukázalo se to velice užitečné. Krátká ochutnávka co to umí
shared_lockable_ptr<Foo> ptr(new Foo);
ptr.lock()->do_something();
ptr.lock_shared()->query_something();
{
auto p = ptr.lock_shared();
if (p->is_ready()) p->get_result();
} //unlock here
Samotný chytrý ukazatel se chová jako shared_ptr, tedy jeho kopírováním se zvyšuje počty referencí a jakmile jsou všechny reference zničeny, objekt samotný se zničí. Objekt, který je takto sdílen přitom nemusí implementovat zámky. Jediný přístup k objektu je přes lock() nebo lock_shared() a obě funkce vrací chytrý ukazatel, který sám zajistí odemčení, jakmile tento ukazatel pozbude platnost
Je zřejmé, že neintrusivní zamykání bude stejně jako neintrusivní čítání implementován pomocí řídícího bloku, který je držen mimo vlastní sdílený objekt. Takto to implementuje std::shared_ptr
s tím, že toto uspořádání umožňuje implementaci i slabých ukazatelů ( std::weak_ptr
).
Přiznám se, že má první implementace tohoto nástroje byla na zelené louce, takže jsem si „střihl“ implementaci celé té struktury od A až do Z, tedy jak řídícího bloku, tak vlastního počítání referencí, prostě všeho co k tomu patří, kompletní kopie shared_ptr ale i se zamykáním. Tento příspěvek ale píšu proto, abych ukázal jak chytře se dá využít existující nástroje standardní knihovny a celkem snadno a bezpečně implementovat takový ukazatel pomocí těchto nástrojů.
Začneme s tím, kde zámek instanciovat. Zde nám pomůže …
Deleter je primárně chápán jako funkce, která se stará o ukončení sdílení daného objektu, přitom ve výchozím stavu jej zničí. Protože to ale ne vždy potřebujeme, tak nám std::shared_ptr
nabízí možnost definovat vlastní činnost při ukončení sdílení pomocí lambda funkce.
Deleter není omezen na funkci, obecně to je objekt, protože … lambda funkce jsou vlastně objekty, které mají definovaný operátor volání. Tady využijeme i to, že STL knihovna nabízí funkci std::get_deleter()
, která vrací instanci deleteru pro daný chytrý ukazatel. Takže máme možnost si do deleteru uložit cokoliv, co můžeme později potřebovat. Samotný deleter je pak instanciován v řídícím bloku chytrého ukazatele, tedy hned vedle čítače referencí
object ┌──►┌───────────────────┐◄───┐ │ │ │ │ │ │ │ │ │ │ │ │ shared_lockable_ptr │ └───────────────────┘ │ ┌─────────────────┐ │ │ │ object_ptr ●─┼───┘ │
├─────────────────┤ control_block │ │ control_ptr ●─┼──────►┌────────────────────┐ │
└─────────────────┘ │ counter │ │ ├────────────────────┤ │ │ deleter │ │ ┼ │ ┌──────────────┐ │ │ │ │shared_mutex │ │ │ │ │ │ │ │ │ │custom_deleter│ │ │ │ └──────────────┘ │ │ │ │ │ ├────────────────────┤ │ │ allocator │ │ ├────────────────────┤ │ │ object_ptr ●─┼───┘
└────────────────────┘
Deleter, který drží zámek by mohl vypadat takto
template<typename T>
class SharedPtrDeleter {
public:
std::shared_mutex mx;
void operator()(T *ptr) {
delete ptr;
}
};
Asi by bylo vhodné, aby naše úprava neblokovala možnost definovat vlastní deleter i na náš shared_lockable_ptr
. Takže přidáme možnost definovat funkci
template<typename T, typenname DeleterFn> class SharedPtrDeleter { public: std::shared_mutex mx; DeleterFn _deleter;
void operator()(T *ptr) { _deleter(ptr); } };
Tohle je hezký, ale narazíme na jeden dost zásadní problém. Funkce std::get_deleter předpokládá, že známe typ našeho deleteru, jinak není ochotná nám k němu dát přístup. A musíme znát přesně tento typ, není možné požadovat typ předka
Ve skutečnosti funkce std::get_deleter
je šablonou, která vyžaduje tento zápis
Deleter *del_ptr = std::get_deleter<Deleter>(shared_ptr);
V případě našeho deleteru by to znamenalo znát T
a DeleterFn
v kterémkoliv místě programu a to není možné. On je už problém s tím T
protože shared_ptr umí aliasovat ukazatel – to se hodí například pří sdílení přes ukazatel předka – a už je naprosto nemožné získat původní DeleterFn
. Takže přes šablonu třídy to nepůjde. Je třeba to navrhnout tak, aby náš deleter nebyla šablona
class SharedPtrDeleter { public: std::shared_mutex mx; std::function<void(void *)> deleterFn template<typename T> void operator()(T *ptr) { deleterFn(ptr); } };
Tohle je lepší, ale vadou na kráse je, že deleter funkce
musí očekávat ukazatel na void
, dochází tedy ke ztrátě typu. To se dá vyřešit tím, že funkci wrapnem do další funkce, kde typ necháme převést zpět na T, který známe v místě, kde se deleter zakládá. Na konci článku bude odkaz na implementaci, kde je zvoleno ještě jiné řešení, a kde nepoužívám std::function
ale pro jednoduché deletery je vyhrazena paměť přímo uvnitř tohoto objektu ( small object optimalization
).
Problém: Deleter musí být movable - v tomto případě std::shared_mutex
není movable. To lze vyřešit tím, že definuje vlastní kopírovací konstruktor, který nebude kopírovat std::shared_mutex
. Nebudeme kopírovat ani funkci, protože ta bude nastavena až dodatečně na již zkonstruovaným sdíleným ukazateli. Takže kopírovací konstruktor bude prázdný
class SharedPtrDeleter { public: std::shared_mutex mx; std::function<void(void *)> deleterFn SharedPtrDeleter() = default; SharedPtrDeleter(const SharedPtrDeleter &) {} template<typename T> void operator()(T *ptr) { deleterFn(ptr); } };
Finální řešení, které najdete na konci, je ještě trochu jiné a cílem bylo vyhnout se jedné zbytečné konstrukci mutexu (schválně jestli přijdete na to kde?)
template<typename T> class shared_lockable_ptr: public shared_lockable_ptr_common { public: shared_lockable_ptr() = default; shared_lockable_ptr(T *ptr); template<typename Fn> shared_lockable_ptr(T *ptr, Fn &&custom_deleter) auto lock(); auto lock() const ; auto lock_shared() ; auto lock_shared() const; void reset(); void reset(T *ptr); protected: std::shared_ptr<T> _subject; };
Schválně nechávám auto u zamykacích funkcí, protože o tom bude následující kapitola
Naivní přístup by byl, kdyby funkce lock a lock_shared vracely nativní ukazatel, ale to by znamenalo, že programátor by musel myslet na odemykání. Mnohem lepší by bylo, kdyby byl vrácen objekt, který pomocí mechanismu RAII bude vědět okamžik, kdy je vhodné původní objekt odemknout.
Mohl bych navrhnout třídu, která by instanciovala takový objekt, ale protože se zde jedná o ukazatel – a to si pamatujte, že správu unikátního ukazatele je výhodné provádět přes std::unique_ptr
a jeho deleter
std::unique_ptr<T, ExclusiveUniquePtrDeleter> lock(); std::unique_ptr<T, SharedUniquePtrDeleter> lock_shared();
A teď si pojďme ukázat, jak vypadají oba deleteři.
template<bool shared> struct UniquePtrDeleter { std::shared_ptr<std::nullptr_t> _subject; template<typename T> void operator()(T *) { SharedPtrDeleter *del = std::get_deleter<SharedPtrDeleter>(this->_subject); if constexpr(shared) { del->mx.unlock_shared(); } else { del->mx.unlock(); } } }; using SharedUniquePtrDeleter = UniquePtrDeleter<true>; using ExclusiveUniquePtrDeleter = UniquePtrDeleter<false>;
Principiálně jde o to, že deleter toho unikátního ukazatele drží jednu instanci sdíleného ukazatele a tím zároveň umožňuje přístup na deleter toho ukazatele, kde máme onen zámek, který je třeba odemknout. Při ukončení platnosti unikátního ukazatele se tedy zavolá deleter a ten odemkne zámek a zároveň zničí jednu referenci.
Nezvyklé tu je std::shared_ptr<std::nullptr_t>. Ve skutečnosti tento objekt nedrží vlastní ukazatel, pouze odkazuje na řídící blok sdíleného ukazatele. Při inicializaci se používá schopnost sdílenému ukazatele převzít jakýkoliv ukazatel při zachování reference na řídící blok. V tomto případě se ukazatel inicializuje na nullptr
, protože se nepoužívá. Zároveň se tím vyhnu použití šablony u deletera a to umožňuje přetypovat unique_ptr
na libovolný jiný ukazatel bez ztráty schopnosti odemknutí původního objektu na konci platnosti
Napíšeme si ještě dvě funkce, které provedou zamčení samotného sdíleného ukazatele a vrácení unikátního ukazatel
template<typename T> static std::unique_ptr<std::add_const_t<T>, SharedUniquePtrDeleter> lock_ptr_shared(const std::shared_ptr<T> &ptr) { SharedPtrDeleter *dl = std::get_deleter<SharedPtrDeleter>(ptr); dl->mx.lock_shared(); return {ptr.get(), {{ptr, nullptr}}}; } template<typename T> static std::unique_ptr<T, ExclusiveUniquePtrDeleter> lock_ptr_exclusive(const std::shared_ptr<T> &ptr) { SharedPtrDeleter *dl = std::get_deleter<SharedPtrDeleter>(ptr); dl->mx.lock(); return {ptr.get(), {{ptr, nullptr}}}; }
K tomu jen poznámku. Sdílený ukazatel musí být zkonstruován s našim SharedPtrDeleter, jinak funkce get_deleter
vrátí nullptr
a program spadne na null pointer reference
. Bylo by asi vhodné tam dát nějakou podmínku (ve výsledku tam je). Ale obě funkce nikdo nebude volat přímo, budou součástí implementace třídy shared_lockable_ptr
auto lock() { return lock_ptr_exclusive(_subject); } auto lock() const { return lock_ptr_shared(_subject); } auto lock_shared() { return lock_ptr_shared(_subject); } auto lock_shared() const { return lock_ptr_shared(_subject); }
Exkluzivní přístup je možné získat jen když zavolám lock()
na no-const objektu. Jinak se vrátí ukazatel se sdíleným přístupem a ten se vrací jako const ukazatel (jak je vidět výše). U dobře navrženého objektu by nám to mělo znemožnit měnit tento objekt ve sdíleném režimu
Po vzoru make_shared
jsem do kódu přidal i tuto funkci, a to ze dvou důvodů. Ten první je čistě z hlediska code review, kdy při čtení kódu nechceme vidět používání nahého new a delete. Funkce make_shared_lockable
tak přesněji vyjadřuje, co se na místě děje a jaké z toho plynou důsledky.
auto foo_ptr = make_shared_lockable<Foo>(args...); foo_ptr.lock()->do_something();
Tím druhým důvodem je optimalizace alokace paměti, kdy dochází k alokaci jednoho souvislého bloku pro řídící blok a objekt samotný. Díky tomu jsou čítač referencí a sdílený objekt blízko u sebe a ideálně ve stejné paměťové stránce, takže je velká šance, že při manipulaci s čítačem bude do lokální cache načten i obsah objektu, se kterým se záhy bude nejspíš pracovat.
Naštěstí std::shared_ptr
nabízí dost nástrojů, jak ovlivnit alokaci řídícího bloku a to pomocí alokátoru. Alokátor provede alokaci nejen řídícího bloku ale alokuje prostor navíc, kde je konstruován vlastní objekt. S využitím deleteru lze pak zajistit destrukci sdíleného objektu ve správný čas bez dealokace prostoru, protože ten musí být ponechán pro řídící blok v případě že by existovaly slabé (weak) reference na objekt.
template<typename T, typename ... Args> shared_lockable_ptr<T> make_shared_lockable(Args && ... args) { T *space; std::shared_ptr<std::nullptr_t> control(nullptr, shared_lockable_ptr_common::SharedPtrDeleter(), shared_lockable_ptr_common::Allocator<T>(&space)); shared_lockable_ptr_common::SharedPtrDeleter *deleter = std::get_deleter<shared_lockable_ptr_common::SharedPtrDeleter>(control); deleter->init_custom_deleter<void>([space](auto){ std::destroy_at(space); }); std::construct_at(space, std::forward<Args>(args)...); return shared_lockable_ptr<T>(std::shared_ptr<T>(control, space)); }
Alokátor může vypadat třeba takto:
template<typename T> struct Allocator { Allocator(T ** space_ptr):_space_ptr(space_ptr) {} T ** _space_ptr; using value_type = std::nullptr_t; template<typename U> struct ControlAlloc { using value_type = U; ControlAlloc(const Allocator &a):_owner(a) {} const Allocator &_owner; U *allocate(std::size_t n) { std::size_t control = sizeof(U)*n; std::size_t need = control+sizeof(T); void *ptr =::operator new(need); T *private_space = reinterpret_cast<T *>( reinterpret_cast<char *>(ptr)+control); *_owner._space_ptr = private_space; return reinterpret_cast<U *>(ptr); } void deallocate(U *ptr, std::size_t ) { ::operator delete(ptr); } }; template<typename X> struct rebind { using other = std::conditional_t< std::is_same_v<X,std::nullptr_t>, Allocator<T>,ControlAlloc<X> >; }; };
Úplně se mi teď nechce probírat, proč je alokátor navržen zrovna takto, případně zájmu to mohu popsat v diskuzi. Obecně psaní alokátorů v C++ podle STL knihovny je trochu bolest, ale lze to překonat. Primárně je zde problém s předáním ukazatele na část bloku rezervovaného pro umístění sdíleného objektu. Ten se předává přes dvojitý ukazatel (T **) – ukazatel je vyplněn během alokace a na to funkce make_shared_lockable
čeká. Jako value_type
je uveden nullptr, což způsobí, že řídící blok sdíleného pointeru si nebude ukládat ukazatel na sdílený objekt. V době konstrukce je neplatný a jeho skutečnou hodnotu si nakonec drží deleter ( init_custom_deleter
).
Výše uvedenou strukturu jsem úspěšně přeložil v GCC, Clang i v MSVC
Uvedený nástroj používám v projektu, kde mám mnoho objektů, které provádí výpočty a komunikaci s rozhrannímy třetích stran. Konkrétně jde o automatické obchodování na kryptoburzách a forexu. Nejde tady přímo o HFT ani o arbitráže, takže nepotřebuju nějakou exterémní rychlost, ale spíš častěji potřebuji, číst statistiky z těchto výpočtů z webového rozhraní. Webový server tak často přistupuje na tyto objekty mezi jednotlivými výpočty. A tady se právě používá výše zmíněný nástroj.
Osobně se domnívám, že tento typ sdílení a zamykání se hodí na středně velké až velké objekty. Zamykat s tím objekty, které se vejdou do registru procesoru je mrhání místem a výkonem. Například samotný std::shared_mutex
zabírá v paměti něco pod 100 bajtů, prakticky tak řídící blok s čítačem má kolem 110 bajtů (na x64). Ani rychlost zamykání nebude oslnivá.
Pro rychlejší přístup a v případě menších objektů doporučuji jít na to cestou imutabilních objektů, kdy změna stavu takového objektu je provedena pomocí jeho kopie a pak nahrazení sdíleného objektu novou verzí. Lze to napsat skoro atomicky a možná se k tomu v budoucnu vrátím. Mimochodem do C++26 se chystá nástroj v podobě RCU knihovny a hazardous_pointer, kde se autoři snaží řešit přesně stejný problém (viz zde)
Článek byl nejen o představení jednoduchého nástroje na synchronizaci ale i popis nápadu jako využít standardní sdílený ukazatel na implementaci zamykání. Hlavním benefitem přitom má být to, že std::shared_ptr
má spoustu hotového kódu, který lze použít i zde, například std::enable_shared_from_this
nebo std::weak_ptr
.
Kód na vyzkoušení najdete na Compiler Exploreru.
Určitě si něco podobného dejte do své knihovničky užitečných šablon a při sdílení objektů mezi vlákny se to bude velice hodit. Osobně si myslím, že by to mělo být součástí STL knihovny.
No C++20 má (by mělo mít?) std::atomic_shared_ptr, ale není to to samé, našli bychom podstatné rozdíly, jde o použití v projektu.
Minimálně ve standardu je
https://en.cppreference.com/w/cpp/memory/shared_ptr/atomic2
Pokud jedeš změnu stavu ve sdíleném objektu jako kopie immutable objektu a následně jeho nahrazení, pak se k tomu hodí, mnohem rychlejší má být právě RCU, kdy se reclam starých verzí provádí asynchroně a tedy by to mělo být ještě efektivnější z hlediska update sdíleného objektu.
Tohle se hodí spíš na klasické stavové věci, kdy máš nejen víc čtenářů ale víc zapisovatelů, typicky když chci do nějakého asynchronního výpočtu zasáhnout zvnější.
Není to moc komplikované na to, že chci v podstatě mít jen mutex + nějakou instanci?
Nestačilo by něco jako toto?
https://godbolt.org/z/vTv8Yeznb
Jako přiznám se, že právě komplikovaný kód v C++ je jedna z věci, proč jsem začal některé věci dělat v Rustu a Go. Moc nechápu, proč bych si měl psát custom alokátor jen kvůli tomu, že chci k něčemu přidat mutex.
U toho Lockable se dá zapomenout ten AccessScope udělat a jde tam lézt napřímo. Ale to by se dalo celkem jednoduše vyřešit přes private + friend.
Akorát to moc neumožňuje převzít existující objekt (včetně podpory weak_ptr a custom deleteru pro tento případ), a pokud bys to řešil přes další pointer, další level indirekce.
Ale ano, tak nějak vypadala ta první verze, co jsem psal "na zelené louce". Kdybych to měl rozpracovat do kompletního API, nebude to o moc kratší. Jde jen o to, jestli uložím mutex spolu s objektem, který chráním, nebo ho stčím do control blocku. Tvé řešení je maličko jednodušší v případě, že bych používal make_shared_lockable. V případě ale kdy potřebuju adaptovat existující objekt, tak je to v koncích.
Objevil sem tenhle blog dneska, ten tvuj spusob vynucuje pointery, coz muze byt zbytecne neco podobneho jsem resil pred nejakou dobou cca takto: https://godbolt.org/z/Tco6azKj5
(toto je na rychlo sepsana verze, melo by se to ucesat, ale nevynucuje to pointery, pristup je private, dal by se parametrizovat typ mutexu, ....).
Postupem casu jsem si spise oblibil predat proste lambdu s tim co se ma provest za akce a tu predat strukture, ktera nejdriv zamkne a pak provola lambdu.
Co zarovnání na 16 bytů (nebo cokoliv relevantního pro danou architekturu) ?
Jestli tam kompilátor někam hodí aligned read do XMM registru (nebo ZMM), tak to celé padne, pokud nebude náhodou control struktura správné velikosti.
Jinak s C++ už dlouho nepracuju, ale mám matné vzpomínky, že podporovala atomické struktury (postavené na compare-and-exchange nebo mutexech dle velikosti), ty by šly možná částečně využít a nejspíš by vyřešili i problém výše.
Intenzivně se zabývám programováním zejména v jazyce C++. Vyvíjím vlastní knihovny, vzory, techniky, používám šablony, to vše proto, aby se mi usnadnil život při návrhu aplikací. Pracoval jsem jako programátor ve společnosti Seznam.cz. Nyní jsem se usadil v jednom startupu, kde vyvíjím serverové komponenty a informační systémy v C++
Přečteno 51 061×
Přečteno 23 937×
Přečteno 22 869×
Přečteno 20 949×
Přečteno 17 757×