Smart Pointers in C++ – pt.2

S

SMART POINTERS – PARTE 2

Nel precedente articolo Smart Pointers in C++, abbiamo fornito le motivazioni principali che rendono appetibile l’impiego degli smart pointers rispetto ai classici raw pointers.
Abbiamo quindi introdotto una prima tipologia di smart pointer disponibile dallo standard 11 del linguaggio, ovvero: std::unique_ptr.
Unique pointer, dicevamo, è stato concepito e disegnato per risolvere il problema dell’ownership esclusiva di un puntatore nell’ambito di uno scope.
Il suo impiego, inoltre, si rivela particolarmente efficace, nel momento in cui si rende necessario trasferire l’ownership esclusiva di un puntatore da uno scope ad un altro.

VERSO IL MULTITHREADING E OLTRE

Un unique pointer, tuttavia, non può essere impiegato in uno scenario che utilizzi la programmazione concorrente, con la presenza di N threads che condividano memoria tra di loro.
Se vogliamo allocare un oggetto per poi condividerlo tra più di un attore, ognuno con un proprio contesto di esecuzione, allora dobbiamo muoverci verso un altro smart pointer: std::shared_ptr.

SHARED OWNERSHIP

Shared pointer risolve il problema della condivisione della ownership di un oggetto tra N scopes, il cui controllo può essere distribuito su M <= N threads.
In altre parole: gli scopes possono essere distribuiti tutti su threads differenti, oppure, per esempio, anche essere tutti sotto il controllo di un unico thread.

Attenzione! Shared pointer non entra nel merito di ciò che si vuole condividere, quello è esclusivamente un nostro affare! Non aspettiamoci, ad esempio, che shared pointer fornisca un meccanismo implicito di sincronizzazione tra i threads per l’oggetto che si va a condividere.
Tutto ciò che è lecito aspettarsi, è che shared pointer si preoccupi per noi di capire quando l’oggetto condiviso non sia più accessibile da nessuno scope prendendosi carico di rilasciare le risorse quando serve.
Cercheremo di capire questi concetti con un esempio.
Prima però, parliamo brevissimamente di altre due key features dello standard 11.

TYPE INFERENCE

Ho voluto inserire di proposito nel codice dell’esempio un paio di key features disponibili nel C++ standard 11. Come ormai avrete capito, questo standard, rappresenta un vero e proprio spartiacque della vita del linguaggio.
Lo standard 11 porta in sè la type inference che molto sinteticamente, rappresenta la capacità del compilatore di inferire i tipi dal contesto, senza che debba essere il programmatore a doverli esplicitare.
Il compilatore può inferire i tipi quando si usano i literals oppure quando si inizializzano le variabili o le costanti da espressioni o anche dal risultato di funzioni da cui è possibile inferire un tipo.

auto
auto an_int = 12;
auto a_double = 0.23;
auto a_const_char_string = "my constant string";
auto a_shared_pointer_to_int = std::make_shared<int>(67);

void my_func(std::string str)
{

//we can declare a type inside a func.
struct my_struct{
  int a;
  double b;
};

auto a_my_struct = my_struct{98, 0.178};
}

auto a_my_func = my_func;

void anoher_func()
{

a_my_func("test"); //"test" literal can be implicitly converted to a std::string

}

Attenzione, il C++ è nato come un linguaggio fortemente tipato a tempo di compilazione o strongly statically typed.
L’utilizzo di auto, non rappresenta una violazione di questa caratteristica, infatti, una volta che il compilatore abbia inferito un tipo, quello, semplicemente, sarà.
In altre parole, non esiste un modo per cambiare il tipo di una variabile una volta che il compilatore ne abbia decretato il tipo, sia che questo sia stato esplicitato dal programmatore o che sia stato automaticamente inferito.

LAMBDAs

Ormai quasi tutti i linguaggi di programmazione fanno a gara per avere le lambda in una qualche forma e ovviamente il C++ non fa eccezione.
Le lambdas nel C++ sono molto intuitive da usare e foniscono un eccellente controllo rispetto agli elementi catturati dal contesto circostante.
Per esempio, è possibile catturare gli elementi via reference[&]oppure via copy: [=].
Nel caso della cattura via reference, una variabile sarà catturata nella lambda come se fosse una reference lvalue, se invece catturiamo via copy, la variabile sarà accessibile nel contesto lambda come una variabile locale copiata dalla variabile esterna.

eccoci all’esempio con shared pointer

Abbiamo, a questo punto, tutti gli elementi per affrontare al meglio l’esempio che vi propongo, ovvero:

THREAD GANG
/**
This example is fully functional; you can grab the code and compile it with your std11+ compiler.
I try it with Microsoft visual studio 2017.
*/

#include <iostream>
#include <thread>
#include <random>
#include <memory>
#include <mutex>
#include <algorithm>
#include <vector>

//a single monitor object will be shared 
//across all spawned threads
struct monitor{
  monitor(int count) : count_(count) {}
  int count_;
  std::mutex mx_;
};

int main()
{
  //we create a shared pointer of a newly monitor object,
  //this call would be equivalent to: std::shared_ptr<monitor> token(new monitor(40));
  auto token = std::make_shared<monitor>(40);

  //we create a vector of 500 threads, none of them yet constructed!
  //we need a reference to each thread because later we want to join all of them. 
  std::vector<std::unique_ptr<std::thread>> threads(500);

  //outer for_each lambda captures context by *reference* [&]
  std::for_each(threads.begin(), threads.end(), [&] (auto &it) {

    //inner thread-ctor-lambda captures context by *value* [=] ,
    //this is crucial, because we want to *copy* the shared pointer.
    it.reset(new std::thread( [=] () { 
      std::random_device generator;
      std::uniform_real_distribution<double> distribution(0.0,1.0);

      //a scoped lock used to access count_ in an exclusive manner.
      std::unique_lock<std::mutex> lk(token->mx_);
      
      //we increment count_ by 1 or decrement it by 1 with a random policy.
      token->count_ = distribution(generator) < 0.5 ? token->count_-1 : token->count_+1;
      
      std::cout	<< "thread:" << std::this_thread::get_id() 
            << " set count:" <<  token->count_ 
            << " use_count is:" << token.use_count() 
            << std::endl;
    }));
  });

  //we wait for every thread to complete
  std::for_each(threads.begin(), threads.end(), [] (auto &it){
    it->join();
  });

  //at this point we know that use_count will be equals to 1
  //because all threads have finished their execution.
  std::cout	<< "main thread reads count:" << token->count_ 
        << " use_count is:" << token.use_count() 
        << std::endl;
}

Bene, vediamo prima a grandi linee quello che dovrebbe fare il codice.
L’idea è quella di condividere un oggetto di tipo monitortra ben 500 threads ognuno dei quali vuole effettuare una operazione di qualche tipo su quest’unica istanza.
L’operazione effettuata sull’oggetto condiviso è banale: ogni thread tira a caso una moneta e se viene, mettiamo testa, il membro count_di monitorviene incrementato di 1, altrimenti si decrementa di 1.
Il main thread attende l’esecuzione di tutti quanti i threads figli per poi stampare a video il valore finale di count_.

PERCHè senza std::shared_ptr questo codice avrebbe problemi

Se vogliamo essere sinceri, questo esempio in particolare, non li avrebbe affatto !
Avremmo potuto infatti, dichiarare l’oggetto tokendirettamente sullo stack, senza usare neppure lo heap.
Ma in questo esempio facciamo una grandissima assunzione: il main thread attende tutti i figli incondizionatamente con una thread join.
Dobbiamo però sforzarci di accettare che i programmi reali, nella stragrande maggioranza dei casi non si comportano in questo modo.
Quello che succede molto spesso nella realtà, è che un generico thread condivide un oggetto allocato sullo heap con uno o a più threads e poi torna a fare il suo mestiere senza attendere che gli altri threads abbiano finito il loro lavoro.
Non solo, la join di un thread si può ragionevolmente fare, se si ha la certezza che il thread prima o poi termini.
Questa assunzione è semplicemente troppo grande per programmi che non siano molto semplici.

scenari reali

Supponendo che il thread che condivide un oggetto allocato sullo heap con altri threads non effettui la join perchè ha bisogno di fare altro, a chi spetta l’onere di rilasciare l’oggetto precedentemente allocato e poi condiviso?
Bella domanda! Chi lo sà? Nessun thread che riceve l’oggetto condiviso può sapere se qualche altro attore non deve o non dovrà usare quell’oggetto.

potremmo usare dei tricks!

Perchè non usare per esempio, il membro count_dell’oggetto token? Magari riusciamo a estrapolare qualche logica su questo campo per capire quando l’istanza condivisa non sarà più usata da nessuno!
Purtroppo però, ci accorgiamo ben presto che dal membro count_difficilmente riusciremo a ideare una strategia valida per decidere che è il momento di sbarazzarci di token, quel campo viene aggiornato con una politica random!
Niente da fare, nessun campo applicativo di tokenpuò essere usato per capire l’oggetto può essere rilasciato.

potremmo mettere un campo non applicativo apposta per questo scopo!

Si, potremmo e sicuramente con le dovute accortezze il meccanismo funzionerebbe.
C’è però un problema, anzi più di uno:

  1. Dobbiamo sporcare la nostra implementazione di tokencon del codice accessorio, tecnico, non applicativo, per gestire un aspetto che nulla ha a che vedere con il business del tipo.
  2. Dobbiamo farlo per tutti i tipi le cui istanze intendiamo condividere con N threads.
  3. Nel mondo reale il software è scritto da N programmatori e ognuno, se siamo fortunati, implementa il suo meccanismo per gestire la deallocazione delle istanze condivise.
    Se siamo sfortunati, il problema non è stato nemmeno preso in considerazione.
  4. Difficilmente i programmatori implementeranno una soluzione ingegnerizzata per il problema e il codice avrà una miriade di bugs subdoli e difficili da individuare.

Non è una questione di bravura, il programmatore più bravo del mondo quando deve risolvere un problema in ambito industriale ha poco tempo per scrivere bene il codice.
Le problematiche di contorno, extra business, sono spesso abbozzate per prediligere, come è giusto che sia, l’aspetto applicativo.

potremmo usare shared pointer!

Si! E questo, ragazzi miei, è la panacea di tutti i mali.
Shared pointer fornisce un pattern standard, intuitivo ed efficiente per risolvere uno dei problemi più difficili per il programmatore C++.
Shared pointer implementa il reference counting di un oggetto garantendoci la migliore implementazione per l’utilizzo reale più complesso: la condivisione di un’istanza allocata nello heap in uno scenario multi-threaded.
Il reference counting non viene gestito da un garbage collector, è incapsulato nell’implementazione di std::shared_ptr.

conclusioni

Bene, abbiamo finito.
In questo articolo e nel precedente spero di avervi trasmesso la consapevolezza delle problematiche più comuni che affliggono il programmatore C++ quando deve avere a che fare con la memoria dinamica nei contesti single e multi thread.
Volevo inoltre precisare che questi miei articoli non hanno la pretesa di essere esaustivi: la materia trattata infatti, non è affatto banale: ci si fanno sopra interi corsi universitari.
Quello che io vi consiglio, se la cosa vi interessa personalmente o se cercate nuove metodologie per migliorare il vostro lavoro con il C++, è di sperimentare il più possibile e di leggere buoni testi di riferimento; ne esistono molti che trattano questi ed altri argomenti.
Vi consiglio sicuramente i testi di Scott Meyers ed in particolare Effective Modern C++.
Inoltre cplusplus.com è la sorgente di riferimento per quando il codice lo si deve scrivere per davvero!
Buona programmazione a tutti e alla prossima!

 

 

 

A proposito di me

Giuseppe Baccini

Sin dalla tenera età è un grande appassionato di informatica e tecnologie.
Dopo gli studi universitari pensa che tutti i problemi possano essere risolti con il Java.
Successivamente, quando viene assunto in un'azienda che si occupa di finanza e il trading, tradisce Java e comincia a corteggiare il C++.
Ancora adesso non è convinto che il divorzio con il primo amore sia stata una buona idea: la seconda moglie è indubbiamente molto problematica.
Non esclude che in futuro possa esserci una seconda ex!

Di Giuseppe Baccini

I nostri Partner

Gli articoli più letti

Articoli recenti

Commenti recenti