Sdílení objektů mezi vlákny jednoduše v C++20

11. 2. 2024 13:55 Ondřej Novák

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

Sdílet a zamykat

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.

shared_lockable_ptr<T>

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í

  • Konstruuji to stejně jako std::shared_ptr : shared_lockable_ptr<T> ptr(new T(…)), případně mám funkci make_shared_lockable<T>(…)
  • Pro přístup k objektu používám funkci lock: ptr.lock()
  • Tato funkce teprve vrací ukazatel, takže mohu zavolat ptr.lock()->do_something()
  • Vráceny ukazatel mohu uložit do proměnné (auto p = ptr.lock() ) a dokud tento ukazatel existuje, je objekt zamknutý
  • Mohu zamykat i sdíleně, pomocí ptr.lock_shared(), pak dostanu ukazatel na const T
  • Mohu také celý chytrý ukazatel označit jako const a tím zakážu volajícímu exkluzivní zámek
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

Implementace s využitím std::shared_ptr a std::unique_ptr

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

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?)

Návrh API

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

Přístup na sdílený objekt přes ukazatel

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

Implementace make_shared_lockable

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

Praktické postřehy

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)

Závěrem

Č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.

Sdílet