shared_ptr i weak_ptr- ostatni gasi światło.

Po omówieniu unique_ptr przyszła pora na omówienie shared_ptr i weak_ptr. O ile poprzedni wskaźnik posiadał zasoby na własność i jako jedyny miał prawo do ich modyfikacji, o tyle dzisiejsi dwaj bohaterowie dzielą się prawem własności i pracują razem nad jednym obiektem. Jeżeli zastanawiasz się w jaki sposób jest to zrealizowane, to wpis powinien dać Ci solidne podstawy by to zrozumieć. 

Jeżeli nie czytałeś poprzedniego artykułu na temat unique_ptr to gorąco zachęcam Cię byś się z nim zapoznał/a- jeżeli zastanawiasz się czy lepiej najpierw poznać unieuq_ptr to odpowiedź brzmi tak- jest on prostrzy i dzięki niemu lepiej zrozumiesz ten temat. Dzisiaj omówimy shared_ptr poświęcając mu wiele czasu, oraz na koniec porozmawiamy o jego dopełnieniu- weak_ptr. Jeżeli jesteś osobą, która zawsze pyta "jak to działa?!" to na końcu porozmawiamy troche o możliwej implementacji mechanizmów związanych ze wskaźnikami.

W celu zachowania spójności z poprzednim tekste i łatwego porównania wskaźników, dzisiaj omowimy shared_ptr według tematów zgodnych z tym jak omawialiśmy unique_ptr:

 

shared_ptr zagadnienia:

  1. Wstęp teoretyczny
  2. Tworzenie, użycie, waga
  3. Kopiowanie i przenoszenie
  4. Użycie w kontenerze
  5. Custom deleter (niestandardowe niszczenie obiektu)
  6. Shared_ptr dla tablic
  7. Weak_ptr
  8. Control block, reference counter
  9. make_shared
  10. Wielowątkowość
  11. Podsumowanie

Wstęp teoretyczny

Shared_ptr możemy podsumować w kilku charakterystycznych dla niego cechach:

  1. Jest współwłaścicielem wskazywanego obiektu- nie posiada go na własność
  2. Wskazywany obiekt jest niszczony wraz z ostatnim shared_ptr, który na niego wskazuje.
  3. Na podstawie unique_ptr możemy stworzyć shared_ptr
  4. Wielowątkowy dostęp do zasobów wymaga synchronizacji

Pierwszy punkt zdradza nam naturę shared_ptr- żaden ze stworzonych wskaźników nie będzie posiadał na własność obiektu, na który wskazuje. Wszystkie stworzone shared_ptr współdzielą jeden obiekt, po to by na nim pracować, oraz zniszczyć go, w momencie gdy ostatni z nich przestanie istnieć (a obiekt tym samym stanie się bezużyteczny). Dzięki takiemu podejściu, możliwa jest praca nad obiektem w wielu miejscach kodu, jednocześnie z pewnością, że zasób nie zostanie usunięty do póki ktoś z niego korzysta. Jeżeli zaświeciła Ci się lampka i pracę w wielu miejscach kodu skojarzyłeś z wielowątkowością, to dobrze! Temat jest istotny, ponieważ trzeba już na wstępie powiedzieć, ze shared_ptr nie jest mutexem. Co to oznacza? Tyle, że nie synchronizuje on dostępu do obiektu na który wskazuje. Co prawda jego wewnętrzne mechanizmy (o czym dowiemy się w dalszej części artykułu) kontrolujące zliczanie istniejących wskaźników są w pełni odporne na data race o tyle sam zasób, który współdzielą wymaga synchronizacji.

Tworzenie, użycie, waga

Widget * raw_ptr = new Widget{10};
shared_ptr<Widget> sh_ptr{raw_ptr};     // antipattern!
auto sh_ptr2 = make_shared<Widget>();   // that's fine.
cout << endl;

cout << "sh_ptr value: \t" << *sh_ptr << endl;
cout << "raw_ptr value:\t" << *raw_ptr << endl;
cout << endl ;

cout << "sh_ptr size: \t"  << sizeof(sh_ptr) << endl;
cout << "raw_ptr size:\t" << sizeof(raw_ptr) << endl;
cout << endl;

cout << "sh_ptr address: \t" << sh_ptr.get() << endl;
cout << "raw_ptr address:\t" << raw_ptr << endl ;
cout << endl ;

Outout:

Widget ctor, number= 10
Widget ctor

sh_ptr value:   10
raw_ptr value:  10

sh_ptr size:    16
raw_ptr size:   8

sh_ptr address:         0x112dc20
raw_ptr address:        0x112dc20

~Widget dtor
~Widget dtor

Zaczynamy od postaw, czyli dereferowania naszego wskaźnika, jego wagi, oraz uzyskiwania adresu obiektu, na który wskazuje. Czy zmieniło się coś w porównaniu do unique_ptr? Niewiele, waga wzrosła z 8 bajtów do 16. Dereferowanie do obiektu wciąż odbywa się tak samo jak przy zwykłym wskaźniku, a uzyskanie adresu wskazywanego obiektu daje nam surowy wskaźnik na obiekt. Waga shared_ptr jest dwa razy większa niż zwykłego wskaźnika i jest to pierwszy koszt jaki ponosimy. Wynika to z implementacji shared_ptr, w której posiada on dwa wskaźniki- pierwszy na zasób, na który wskazuje, oraz drugi na blok kontrolny. Czym jest blok kontrolny? O tym powiemy sobie później. Na ten moment załóżmy, że jest to licznik referencji, czyli licznik, który informuje nasz shared_ptr ile instancji innych shared_ptr wskazuje na dany obiekt. Jeżeli licznik spadnie do zera, nasz shared_ptr usunie obiekt podczas własnej destrukcji.

komentarz antipattern! odnosi się do tworzenia shared_ptr. W C++11 mamy dostępną funkcję make_shared, która zwraca nam shared_ptr do obiektu jaki chcemy stworzyć. Jej użycie eliminuje potrzebnę używania operatora new, co w przypadku shared_ptr jest dość istotne. O zagrożeniach jakie niesie za sobą używanie raw ptr do tworzenia shared_ptr jeszcze sobie powiemy. Na ten moment warto byś zapamiętał/a, że make_shared() jest domyślną formą tworzenia shared_ptr- działa szybciej i bezpieczniej niż tworzenie shared_ptr na podstawie wskaźnika surowego.

Kopiowanie i przenoszenie

auto sh_ptr1 = make_shared<Widget>(15);
shared_ptr<Widget> sh_ptr2{sh_ptr1};
auto sh_ptr3 = sh_ptr1;
shared_ptr<Widget> sh_ptr4{move(sh_ptr1)};

cout << "ptr1 address:\t" << sh_ptr1.get() << endl;
cout << "ptr2 address:\t" << sh_ptr2.get() << endl;
cout << "ptr3 address:\t" << sh_ptr3.get() << endl;
cout << "ptr3 address:\t" << sh_ptr4.get() << endl;

output:

Widget ctor, number= 15
ptr1 address:   0
ptr2 address:   0x2128c38
ptr3 address:   0x2128c38
ptr3 address:   0x2128c38
~Widget dtor

Dla shared_ptr kopiowanie oraz przenoszenie działa intuicyjnie. W przypadku przenoszenia, przeniesiony shared_ptr wskazuje na nullptr, natomiast podczas kopiowania inkrementowany jest licznik referencji i możemy pracować na obu wskaźnikach. Należy pamiętać, że inkrementacja licznika referencji jest operacją atmową, co oznacza, że jest odporna na data race, lecz za to stosunkowo kosztowna.

Użycie w kontenerze

auto sh_ptr1 = make_shared<Widget>(1);
auto sh_ptr2 = make_shared<Widget>(2);  

auto w_vector = vector<shared_ptr<Widget>>{sh_ptr1, sh_ptr2}; 


w_vector.push_back(move(sh_ptr1)); 
w_vector.push_back(sh_ptr2);       

w_vector.push_back(shared_ptr<Widget> {new Widget{3}}); 
w_vector.push_back(make_shared<Widget>(4));             

for(auto & elem : w_vector)
{
	cout << *elem << endl;
}

cout << "Moved ptr address: " << sh_ptr1.get() << endl;

output:

Widget ctor, number= 1
Widget ctor, number= 2
Widget ctor, number= 3
Widget ctor, number= 4
1
2
1
2
3
4
Moved ptr address: 0
~Widget dtor
~Widget dtor
~Widget dtor
~Widget dtor

Operacje na kontenerach standardowych przebiegają bez problemu. Możemy kopiować, przesuwać- wszystko zgodnie z oczekiwaniami. Dobrze zwrócić uwagę na 4 obiekty, które stworzyliśmy, oraz poprawne ich usunięcie. Wskaźniki ulegały kopiowaniu, przesunięciu, lecz końcowo ilość zniszczonych obiektów odpowiadała ilości stworzonych. Wskaźnik inteligentny zwolnił nas z odpowiedzialności za zaliczanie ilości pointerów, które wskazują na ten sam zasób.

Custom deleter

auto custom_deleter_lambda = [](Widget * pWidget)
{
	delete pWidget;
};

std::function<void(Widget * pWidget)> custom_deleter_function = custom_deleter_lambda;

struct custom_deleter_object
{
	void operator()(Widget * pWidget) const { delete pWidget;}
};

shared_ptr<Widget> sh_del {nullptr};
shared_ptr<Widget> sh_del1(nullptr, custom_deleter_lambda);
shared_ptr<Widget> sh_del2(nullptr, custom_deleter_function); 
shared_ptr<Widget> sh_del3(nullptr, custom_deleter_object());
shared_ptr<Widget> sh_del4(nullptr, custom_deleter_function_p);

cout << "smart without deleter size:\t\t" << sizeof(sh_del) << endl;
cout << "smart with lambda deleter size:\t\t"  << sizeof(sh_del1) << endl;
cout << "smart with function deleter size:\t"  << sizeof(sh_del2) << endl;
cout << "smart with func obj deleter size:\t"  << sizeof(sh_del3) << endl;
cout << "smart with func ptr deleter size:\t"  << sizeof(sh_del4) << endl;

output:

smart without deleter size:             16
smart with lambda deleter size:         16
smart with function deleter size:       16
smart with func obj deleter size:       16
smart with func ptr deleter size:       16

Jeżeli czytałeś poprzedni artykuł o unique_ptr to zapewne pamiętasz, że w jego przypadku, zdefiniowanie custom deletera wymagało zmiany typu samego wskaźnika (który był parametryzowany typem owego deletera). W przypadku shared_ptr nie ponosimy tego kosztu. Możemy dodać custom deleter, natomist typ shared_ptr nie ulegnie zmienie. Zawdzięczamy to wcześniej wspomnianemu blokowi kontrolnemu. To w nim przechowywana jest informacja o tym, w jaki sposób usunąć wskazywany obiekt. Tym samym rozmiar shared_ptr także nie ulegnie zmianie- w końcu wskaźnik do deletera pozostaje w bloku kontronym, więc to na niego przerzucane są koszty z tym związane.

shared_ptr dla tablic

shared_ptr<Widget> sh_ptr_table{ new Widget[10], [](Widget * ptr_w) {delete[] ptr_w;}};

Shared_ptr nie posiada interface przeznaczonego specjalnie do obsługi tablic (w przeciwieństwie do unique_ptr). Jeżeli jednak chcesz stworzyć taki pointer, możesz zrobić to, pamiętając, aby podczas jego niszczenia, wywołać operator delete dla tablicy a nie pojedynczego obiektu. W kodzie powyżej został przedstawiony przykład takiego użycia.

weak_ptr 

auto sh_ptr = make_shared<Widget>(7);
weak_ptr<Widget> wk_ptr (sh_ptr);

if(auto sh_ptr2 = wk_ptr.lock())
{
	cout << "sh_ptr2:" << *sh_ptr2 << endl;
}
else
{
	cout << "weak ptr is expired!: " << wk_ptr.expired() << endl;
}

Powiedzieliśmy już wiele o shared_ptr, pora na jego dopełnienie czyli weak_ptr.  Nie uczestniczy on we współposiadaniu obiektu, na który wskazuje. Używamy go do wskazywania na obiekty, które w trakcie naszej pracy mogą zostać usunięte. Weak_ptr udziela nam informacji, czy wskazywany obiekt nadal istnieje. Jeżeli tak, możemy na jego podstawie stworzyć shared_ptr za pomocą którego uzyskamy dostęp do pamięci. To ważna informacja, weak_ptr sam z siebie nie odnosi się nigdy do obiektu na który wskazuje, tym samym nie wykonuje na nim żadnych operacji- jedynie sprawdza, czy obiekt nadal istnieje i w razie potrzeby tworzy odpowiedni shared_ptr. Kod powyżej przedstawia najważniejsze fakty z tym związane. Na początku widzimy, że weak_ptr tworzymy za pomocą shared_ptr. Następnie, za pomocą weak_ptr metodą lock() uzyskujemy dostęp do zasobu. Rezultatem jej działania jest zwrócony shared_ptr z poprawnym adresem w przypadku gdy wskazywany obiekt istnieje, lub z nullptr w momencie, gdy został on już usunięty. Wynik działania metody możemy sprawdzić w wyrażeniu If i zależnie od jej rezultatu podjąć odpowiednie akcje. Metoda expired() jedynie utwierdzi nas w fakcie usunięcia obiektu. Konieczność zastsowoania metody lock wiąże się z możliwym data race pomiędzy sprawdzaniem czy obiekt istnieje a jego uzyskaniem. Lock() jest operacją atomową, tym samym zapobiega temu zagrożeniu. 

Istnieje kilka sytuacji, w których jego zastosowanie może ułatwić nam pracę. Przykładem, który przychodzi mi do głowy jest cache. Posiadamy rozbudowany program, który pobiera pewne dane z bazy danych i na ich podstawie realizuje zadania. W owym programie, funkcja Func1 prosi o ściągnięcie z bazy danych informacji o obiekcie, na którym ma pracować. Dokonujemy kosztownej operacji komunikacji z bazą danych, następnie tworzymy i udostępniamy obiekt Obj1 dla Func1. Chwile potem zgłasza się do nas funkcja Func2, która także prosi o ten sam obiekt Obj1. Nie chcemy ponownie łączyć się z bazą danych, skoro istnieje możliwość skopiowania wcześniej uzyskanego obiektu (Obj1 dla Func1). Dochodzimy do wniosku, że warto trzymać ściągnięte obiekty w cache. Nasza pamięć nie jest jednak nieograniczona i chcemy w niej przechowywać te obiekty, które aktualnie są używane i przez to tak czy inaczej znajdują się w naszej pamięci. Decydujemy się używać wskaźników na te obiekty. Jeżeli w cache będziemy przechowywać shared_ptr to obiekt będzie cały czas istniał w naszej pamięci, co doprowadzi do jej zaśmiecenia. Jeżeli jednak zdecydujemy się na inne rozwiązanie- w cache będziemy trzymać weak_ptr, natomiast do funkcji przekażemy shared_ptr to uzyskamy ciekawy efekt. Do póki funkcja będzie pracowała na shared_ptr, obiekt będzie istniał, a nasz weak_ptr będzie mógł posłużyć do skopiowania tego obiektu. Jeżeli natomiast func1 przestanie pracować na obiekcie, shared_ptr zostanie zniszczony, a wraz z nim usunięty zostanie obiekt wskazywany także przez weak_ptr. Główna część programu sprawdzająć swój cache, zobaczy czy weak_ptr nadal jest aktywny i jeżeli tak, to stworzy kolejny shared_ptr, a jeżeli nie, to dopiero wtedy połaczy się z bazą danych. Przykład oczywiście jest dużym uproszczeniem, ma on jednak jedynie pokazać różnice w działaniu shared_ptr i weak_ptr oraz wskazać ich możliwe zastosowanie. 

Control block, reference counter

Podczas rozmowy o shared_ptr kilka razy wspomnieliśmy o bloku kontrolnym. To do niego (a dokładniej jego elementu, reference countera) odnosił się shared_ptr po to by sprawdzić, czy jest ostatnim wskaźnikiem, który aktualnie wskazuje na obiekt, a co za tym idzie czy podczas swojej destrukcji powinien także zniszczyć wskazywany obiekt. W bloku kontrolnym możemy także znaleźć więcej informacji- znajduje się tutaj między innymi customowy deleter, który definiowaliśmy, czy na przykład licznik referencji dla weak_ptr. Cały ten mechanizm musi jednak generować pewne koszta. Przede wszystkim blok tworzony jest w pamięci dynamicznej, czyli na stercie za co płacimy w runtime podczas tworzenia pierwszego shared_ptr wskazującego na określony obiekt. Operacje na licznikach referencji są atomowe- za co płacimy podczas tworzenia kolejnych wskaźników, które będą wskazywały na ten sam obiekt. Warto o tym pamiętać- dzięki temu wiemy już dlaczego podczas omawiania unique_ptr jako dobrą radę przyjęliśmy używanie domyślnie unique_ptr- ten wskaźnik nie wiąże się z tymi kosztami. Wiemy kiedy blok kontrolny jest tworzony, natomiast kiedy jest niszczony? Pierwsza odpowiedź jaka się zwykle nasuwa, to "wraz z obiektem czyli gdy zniszczony zostanie ostatni shared_ptr". I niestety nie jest to poprawna odpowiedź. Dlaczego? Ponieważ w programie mogą istnieć wciąż weak_ptry, które wskazują na blok kontrolny i chcą sprawdzić czy istnieje wskazywany obiekt. Dlatego blok kontrolny zostaje usunięty na samym końcu, gdy usuwany zostaje ostatni z inteligentnych wskaźników, wskazujących na ten blok kontrolny.

make_shared

Przed chwilą wspomniałem o tym, że blok kontrolny jest lokowany na stercie. Zupełnie tak samo jak obiekt, na który będzie wskazywał nasz shared_ptr. Po co więc dwa razy lokować pamięć, skoro możemy to zrobić raz- zaalokować pamięć zarówno dla bloku kontrolnego jak i obiektu? No właśnie. Dlatego warto używać make_shared, ponieważ on tak właśnie działa. Oszczędzamy na czasie. Dodatkowo funkcja ta eliminuje pewne zagrożenie. Jeżeli tworzymy shared_ptr na podstawie surowego wskaźnika do obiektu, a następnie stworzymy tak samo drugi shared_ptr... to stworzone zostaną dwa bloki kontrolne- bo i skąd drugi shared_ptr ma wiedzieć, że blok kontrolny został już stworzony? Takie działanie prowadzi do niezdefiniowanego zachowania, poniważ pierwszy shared_ptr w momencie własnego usunięcia usunie także wskazywany obiekt- a drugi shared_ptr będzie błądził po pamięci, która została już zwolniona. To proste argumenty, powinny Cię przekonać do używania make_shared.

 

Wielowątkowość

Krótka i bardzo ważna informacja. Wskaźniki inteligentne nie są mutexami i nie synchronizują wielowątkowej pracy na wskazywanych zasobach. Oznacza to tyle, że jeżeli mamy dwa shared_ptr, które wskazują na ten sam obiekt i działanią równolegle w dwóch wątkach, to wystąpi na wskazywanym obiekcie data race. Shared_ptr nie ma za zadanie synchronizować pracę na wielu wątkach- służą do tego inne mechanizmy, takie jak np mutex. Natomiast shared_ptr zadba o to by data race nie nastąpił na liczniku referencji. Oznacza to, że możemy wielowątkowo kopiować i usuwać shared_ptr, natomiast licznik będzie zawsze wskazywał poprawną ilość zliczonych referencji. Tutaj jednak powinny kończyć się nasze oczekiwania względem wielowątkowości dla shared_ptr.

Podsumowanie

Zapoznaliśmy się z podstawami działania shared_ptr oraz weak_ptr. Wiemy w jaki sposób możemy się nimi posługiwać i czego od nich oczekiwać. Warto zapamiętać kilka najważniejszych faktów, przypominając sobie o nich gdy zajdzie potrzeba użycia poznanych wskaźników:

  1. Shared_ptr i weak_ptr są dobre dla zasobów dzielonych
  2. Oba wskaźniki mają rozmiar 2x większy niż raw ptr (+ blok kontroly)
  3. Blok kontrolny jest alokowany dynamicznie
  4. Licznik referencji jest atomowy
  5. Custom deleter nie zmienia typu wskaźnika
  6. Jeżeli chcesz shared bez prawa do obiektu, użyj weak_ptr