ABONAMENTE VIDEO REDACȚIA
RO
EN
×
▼ LISTĂ EDIȚII ▼
Numărul 19
Abonament PDF

Multithreading în standardul C++11 (partea I)

Dumitrița Munteanu
Software engineer
@Arobs
PROGRAMARE

După treisprezece ani de la publicarea primului standard C++, membrii comitetului de standardizare au decis să ofere, odată cu publicarea noului standard C++11 sau C++0x, o schimbare majoră în ceea ce privește programarea multithreading. Pentru prima dată, limbajul C++ oferă suport, independent de platforma de dezvoltare, pentru implementarea aplicațiilor ce presupun programarea concurentă. Înainte de standardul C++11, aplicațiile multithreading se bazau pe extensii specifice platformei, spre exemplu Intel TBB, OpenMP, Pthreads, etc. .

Un avantaj major adus de această nouă caracteristică este portabilitatea aplicațiilor, spre exemplu ușurința cu care o aplicație multithreading specifică Windows se poate transfera pe o platform iPhone sau Android. Un avantaj pentru cei familiarizați cu librăria boost threads este că multe concepte din librăria standard C++11 păstrează aceeași denumire și structură ca și clasele din boost threads library.
Librăria standard C++ Threads conține clase pentru manipularea thread-urilor, protejarea datelor comune, operații de sincronizare între thread-uri și operații atomice de nivel scăzut. În continuare, vom exemplifica și vom descrie în linii mari modul în care aceste concepte sunt prezente în standardul C++11.

Lansarea unui thread

Declararea și lansarea unui nou thread în C++11 este la fel de simplă precum declararea și intanțierea unui nou obiect. Vom analiza o aplicație simplă de multithreading pentru a exemplifica modul în care se pot folosi thread-urile din librăria standard C++. Spre exemplu:


#include 
#include  //-- (1)
void execute()
{
    std::cout << "Hello Concurent World" << std::endl;
}
int main()
{ 
    std::thread worker_thread(execute); //-- (2)

worker_thread ().join(); //--(3)
}

Prima diferență este includerea header-ului #include (1). Acesta conține funcții și clase pentru managementul firelor de execuție. Thread-urile sunt lansate prin declararea unui obiect std::thread, care specifică în constructor o metodă inițială, în cazul nostru fiind metoda execute(), care reprezintă locul de unde noul thread își va porni execuția (2). După ce thread-ul a fost lansat și înainte ca obiectul std::thread fie distrus, prin ieșirea din funcția main, trebuie specificat explicit dacă se dorește ca thread-ul principal să aștepte până când thread-ul secundar își încheie execuția, prin apelarea funcției join() (3), sau dacă thread-ul secundar va rula independent de ciclul de viață al thread-ului principal, prin apelarea metodei detached(). În acest caz thread-ul va rula în background, fără niciun mijloc de a comunica cu el. Thread-urile detașate se mai numesc și daemon threads, după conceptul din sistemele UNIX, în care un daemon process rulează în background fără a avea o interfață specifică.

Transmiterea parametrilor către funcția unui thread

Transmiterea parametrilor către un nou thread în C++11 este la fel de simplă precum transmiterea parametrilor unui obiect apelabil.
Important este de reținut faptul parametrii transmiși sunt copiați local în stiva thread-ului, de unde pot fi accesați de către thread-ul în execuție, chiar dacă parametrii corespunzători din funcție așteaptă o referință. Spre exemplu:

void execute(const std::string& filename);
std::thread worker_thread(execute, "input.dat");

În acest caz se creează un nou thread, asociat variabilei worker_thread, care apelează funcția execute("input.dat"). Atenție, însă, la parametrul transmis. Chiar dacă funcția execute primește ca parametru o referință std::string, string-ul este transmis drept const char* și convertit numai în contextul noului thread către un std::string. Acest mecanism de funcționare a funcției std::thread poate conduce la două posibile probleme.

Primul caz ar fi cel în care parametrul transmis este un pointer către o variabilă locală. Spre exemplu:

void execute(const std::string& filename);
void oops(const char* parameter)
{
    char buffer[50];
    sprintf(buffer, "%s", parameter);
    std::thread worker_thread(execute, buffer); //-- (1) 
    worker_thread.detach();
}

În acest exemplu, un pointer către variabila locală buffer este transmis către noul thread, care așteaptă în schimb un parametru de tip std::string. Este foarte posibil ca funcția oops să își încheie execuția înainte de momentul în care conversia de la char* la std::string să aibă loc. Pointer-ul transmis devine un dangling pointer (1),iar comportamentul firului de execuție va fi nedefinit.

În această situație,soluția ar fi conversia explicită a variabilei buffertre un std::string, înainte ca parametrul să fie transmis către constructorul noului thread:

std::thread worker_thread(do_work, std::string(buffer));


Al doilea caz ar fi cel în care parametrul transmis este copiat, chiar dacă intenția era de a se transmite o referință a obiectului a cărui valoare trebuia modificată de către thread. Spre exemplu:

void execute(std::string& str) //-- (1) 
{
    str.assign("Hallo Welt!"); 
}
void oops(const char* parameter)
{ 
    std::string greeting("Hello World!");
    std::thread worker_thread(execute, greeting); //-- (2) 
    worker_thread.join();
    std::cout<< greeting << std::endl; //-- (3)
}

Chiar dacă funcția execute (1) așteaptă o referință ca parametru, constructorul noului thread nu știe acest lucru, motiv pentru care pur și simplu copiază intern variabila my_string. Când thread-ul apelează funcția execute (2), acesta va transmite ca parametru o referință la copia internă a variabilei greeting. În consecință, atunci când noul thread își va încheia execuția, variabila my_string , cu noua valoare"Hallo Welt", va fi distrusă odată cu distrugerea copiilor interne ale parametrilor transmiși în constructorul thread-ului. Din acest motiv, valoarea variabilei greeting va rămâne nemodificată, afișând valoarea inițială "Hello World!" (3).

Soluția în acest caz ar fi transmiterea parametrilor, care trebuie să fie o referință, folosind funcția std::ref.

std::thread worker_thread(execute, std::ref(greeting));

Și această funcție std::ref este disponibilă doar odată cu standardul C++11, fiind o metodă folosită pentru a simula funcționalitatea unei referințe, astfel încât, în final variabila my_string (3) va avea valoarea modificată "Hallo Welt!".

Pentru cei familiarizați cu funcția std::bind, semantica transmiterii unui pointer către o funcție membră a unui obiect, ca parametru pentru constructorului unui std::thread, va fi surprinzătoare. Spre exemplu:

class Test
{
    public: 
    void execute();
};

Test custom_test;
std::thread worker_thread(&Test::execute, &custom_test); //-- (1)


Acest cod (1) va lansa un thread care va rula metoda custom_test.execute(). În cazul în care metoda execute() ar primi parametri, aceștia s-ar transmite în aceeași manieră, fiind al treilea, al patrulea ș.a.m.d parametru pentru constructorul thread-ului curent.

Transferarea posesiei unui thread

Să presupunem că se dorește o funcție std::thread create_thread() care să creeze un thread ce rulează în background dar care, în loc să aștepte ca noul thread să își termine execuția, returnează noul thread funcției din care a fost apelată. Sau putem presupune că funcția creează un thread și transmite proprietatea acestuia unei funcții oarecare, care trebuie să aștepte ca thread-ul nou creat să își încheie execuția.
În ambele cazuri este necesară transferarea posesiei unui std::thread care, la fel ca un std::ifstream sau std::unique_ptr, poate fi transferat, dar nu copiat. Spre exemplu:

void some_function(int n);
std::thread create_thread()
{
std::thread my_thread(some_function, 24);
return my_thread; 
} 

std::thread first_thread (some_function, 25); //-- (1)
std::thread second_thread = std::move(t1); //-- (2)
first_thread = create_thread(); //-- (3)


Mai întâi este lansat un nou thread (1) și asociat cu variabila first_thread. Posesia acestui thread este apoi transferată către variabila second_thread (2). Următorul transfer de posesie (3) nu necesită un apel către funcția std::move, deoarece posesorul curent este un obiect temporar, iar transferul este automat și implicit.

Mecanisme de sincronizare

Pentru sincronizarea între thread-uri, standardul C++11 pune la dispoziție reprezentări ale mecanismelor clasice de sincronizare precum mutex-uri (std::mutex, std::recursive_mutex, etc.), variabile condiționale (std::contition_variable, std::condition_variable_any), ce pot fi acceste prin intremediul unor RAII locks (resource acquisition is initialization, std::lock_quard si std::unique_lock), precum și mecanisme numite futures and promises, utilizate pentru transmiterea rezultatelor între thread-uri sau std::package_task pentru a "ambala" un apel către o funcție care poate genera un astfel de rezultat.

Mutex

În exemplele anterioare am văzut cum se poate lansa și cum se pot transmite parametrii către funcția unui thread. Acest mecanism nu este însă suficient atunci când se dorește ca anumite resurse să poată fi utilizate (modificate) de mai multe thread-uri care rulează simultan.
În această situație este necesară utilizarea unui mecanism de excludere mutuală, care să asigure integritatea datelor atunci când există posibilitatea ca mai multe thread-uri să modifice aceeași resursă, în același timp.

Cea mai utilizată primitivă de sincronizare este un mutex. Înainte de a accesa resurse care pot fi modificate simultan de către thread-uri diferite, un thread trebuie să blocheze mutex-ul asociat resursei comune ,iar când thread-ul nu mai operează asupra datelor comune, acesta trebuie deblocat. În cazul în care un mutex este deja blocat și un alt thread încearcă să îl blocheze din nou, acesta trebuie să aștepte până când thread-ul care a blocat cu succes mutex-ul, îl deblochează.

Standardul C++11 oferă primitiva std::mutex, care poate fi folosită prin includerea header-ului #include . Un obiect std::mutex pune la dispoziție și funcții membre, lock() și unlock(), pentru a bloca sau debloca explicit un mutex. Cel mai frecvent caz de utilizare a mutex-ului este atunci când se dorește protejarea unui anumit bloc de cod.
În acest sens este oferit de către librăria standard C++ și template-ul std::lock_guard<>, al cărui mecanism se bazează pe principiul RAII (resource aquisition is initialization). În constructorul obiectului std::lock_quard, mutex-ul este blocat, iar în destructor mutex-ul este automat deblocat. Spre exemplu:

std::mutex my_mutex;
unsigned int counter = 0;
unsigned int increment()
{
std::lock_quard lock_counter(my_mutex);
return ++counter;
}
unsigned int query()
{
std::lock_quard lock_counter(m);
return counter;
}

În acest exemplu, accesul la variabila counter este serializat. Dacă mai mult de un thread apelează concurent metoda query(), atunci acestea vor fi blocate, până când singurul thread care a blocat cu succes mutex-ul îl va debloca. Deoarece ambele funcții blochează același mutex, dacă un thread apelează query() iar altul increment(), în același timp, atunci doar unul dintre ele va reuși să blocheze mutex-ul și să acceseze variabila counter.

Atunci când este pusă în discuție tratarea excepțiilor, o variabilă de tip std::lock_quard<> aduce beneficii suplimentare comparativ cu deblocarea manuală, prin apelarea directă a metodelor lock() și unlock() asupra unui mutex. Atunci când se folosește blocarea manuală, trebuie asigurat faptul că mutex-ul este deblocat la fiecare punct de ieșire din regiunea protejată de către mutex, inclusiv în regiunile care își termină execuția prematur, din cauza lansării unei excepții. Prin folosirea unui obiect std::lock_quard, acest lucru este asigurat deoarece, în cazul lansării unei excepții, destructorul obiectului std::lock_quard va fi automat apelat datorită mecanismului de stack unwind.




Conferință

Sponsori

  • ntt data
  • 3PillarGlobal
  • Betfair
  • Telenav
  • Accenture
  • Siemens
  • Bosch
  • FlowTraders
  • MHP
  • Connatix
  • UIPatj
  • MetroSystems
  • Globant
  • Colors in projects