Multithreading
Multithreading este un proces care permite rularea mai multor părți de cod simultan, pentru a maximiza utilizarea procesorului. Fiecare parte separată din această execuție se numește thread
și este
Utilizare
Pentru a lansa un thread se apelează std::thread obj(f, params)
. De asemenea, funția poate fi declarată și ca un lambda, apoi apelată.
Exemplu
#include <iostream>
#include <thread>
void f(int i) {
std::cout << i << '\n';
}
int main() {
std::thread thread_obj(f, 1);
return 0;
}
Pentru a aștepta ca threadurile să se termine, se apelează .join()
. Asta garantează că codul așteaptă terminarea threadului, apoi trece mai departe.
Exemplu
#include <iostream>
#include <thread>
void f(int i) {
std::cout << i << '\n';
}
int main() {
std::thread thread_obj(f, 1);
thread_obj.join();
std::cout << 2;
return 0;
}
Acesta este un exemplu care adună elementele unui vector de 100 de elemente, 10 câte 10, în același timp. Teoretic eficiența acestui program ar trebui să fie O(sqrt(n)), dar din cauza latenței inițializerii unui thread, acesta nu este chiar cazul. Exemplu
#include <iostream>
#include <thread>
#include <vector>
int v[100];
int u[100];
void Init() {
for (int i = 0; i < 100; i++) {
v[i] = i;
u[i] = i;
}
}
void Sum(int idx) {
std::cout << v[idx] + u[idx] << '\n';
}
int main() {
Init();
std::vector <std::thread*> vec;
for (int i = 0; i < 100; i++) {
auto t = new std::thread(Sum, i);
vec.push_back(t);
}
for (auto x : vec) {
x->join();
}
return 0;
}
Task
Scrieți o funcție care ia ca parametru un id
și afișează "Eu sunt threadul cu IDul id
". Porniți câte un thread cu această funcție având ca id
toate valorile de la 0 la 99. Observați outputul.
Thread Pool
Este costisitor să creăm threaduri noi de fiecare data. Din această cauză, se obișnuiește utilizarea thread poolurilor. Acestea sunt practic niște object pooluri, prezentate în cursul de Design Patterns.
Race Condition
Race condition apare atunci când mai multe threaduri manipulează aceeași informație. În exemplul de mai jos este exemplificat acest lucru. Problema la threaduri este că nu putem știi ordinea de execuție, așa că pot apărea probleme când manipulăm aceeași informație.
Exemplu
#include <iostream>
#include <thread>
int i;
void f() {
i++;
std::cout << i << '\n';
}
int main() {
std::thread thread_obj1(f);
std::thread thread_obj2(f);
thread_obj1.join();
thread_obj2.join();
return 0;
}
Mutex
O soluție este folosirea unui mutex. Acesta poate permite sau bloca execuția programului după următoarea regulă: doar după ce s-a apelat lock() se poate apela unlock(), iar lock() nu se poate apela dacă nu s-a dat unlock() la lock-ul anterior. Acest lucru asigură executarea în ordinea dorită a threadurilor.
Exemplu
#include <iostream>
#include <thread>
#include <mutex>
std::mutex m;
int i;
void f() {
m.lock();
i++;
std::cout << i << '\n';
m.unlock();
}
int main() {
std::thread thread_obj1(f1);
std::thread thread_obj2(f2);
thread_obj1.join();
thread_obj2.join();
return 0;
}
În general e bine ca un mutex să dea lock la o singură informație în parte, pentru a nu exista conflicte.
Deadlock
Deadlock este o problemă care se poate întâlni atunci când lucrăm cu threaduri si mutexuri. Ceea ce se întâmplă este că execuția se poate bloca. Este mult mai ușor de ilustrat cum se întamplă printr-un exemplu.
Exemplu
#include <iostream>
#include <thread>
#include <mutex>
std::mutex A;
std::mutex B;
void f1() {
A.lock(); // op 1
B.lock(); // op 3 (begins waiting)
A.unlock();
B.unlock();
}
void f2() {
B.lock(); // op 2
A.lock(); // op 4 (begins waiting)
A.unlock();
B.unlock();
}
int main() {
std::thread thread_obj1(f1);
std::thread thread_obj2(f2);
thread_obj1.join();
thread_obj2.join();
return 0;
}
Similar, se aplică și la alocare, deoarece crearea și ștergerea de memorie respectă regula parantezării corecte.
Exemplu
new a;
new b;
delete b;
new c;
new d;
delete d;
delete c;
delete a;
O altă situație mai concretă poate fi reprezentată de întâlnirea dintre un gang care are un prizonier și polițiștii care au valiza cu bani pentru răscumpărare. Atunci când polițiștii le spun bandiților să le dea mai întâi prizonierul, ei cer valiza și vice versa.
Semaphore
Semaphore este un fel de mutex cu flaguri. Practic ne putem imagina o cameră cu mai multe uși în care pot intra maxim 5 oameni în orice moment din timp. Cum obținem asta? Ținând un contor. Dacă intră unul creștem contorul, iar dacă iese îl scădem. Dacă contorul este egal cu 5, nu mai lăsăm lume să intre.
Un exemplu mai concret este înfățișat în minecraft de mob cap. Dacă avem 5 spawnere și o limită de spawnat a mobilor de 32, fiecare spawner va încerca să spawneze un mob, dacă sunt mai puțini de 32. De asemenea, mobii pot muri, astfel eliberând mob capul.
Exemplu
#include <iostream>
#include <thread>
#include <semaphore>
#include <mutex>
std::counting_semaphore<32> prepareSignal(0);
std::mutex mobLock;
int mobCount = 0;
void HandleDeath(int ticksToGo) {
if (ticksToGo-- <= 0) {
return;
}
prepareSignal.release();
mobLock.lock();
mobCount--;
mobLock.unlock();
}
void HandleSpawning(int ticksToGo) {
if (ticksToGo-- <= 0) {
return;
}
prepareSignal.acquire();
mobLock.lock();
mobCount++;
mobLock.unlock();
}
int main() {
std::thread spawnThread(HandleSpawning, 10000);
std::thread deathThread(HandleSpawning, 10000);
return 0;
}
Acquire și Release se mai pot întâlni și sub denumirea de Up și Down.
Task
Acum este rândul vostru să folosiți noțiunile învățate.
- Creați o clasă GameHandler, singleton, care să aibă un game loop.
- Simulați un autobuz în care se pot urca pasageri. Acesta are o variabilă constantă cu numărul maxim de pasageri, egală cu 20, o variabilă cu numărul pasagerilor, o funcție prin care îl poate crește și alta prin care îl poate descrește.
- Scrieți o clasă statică care să genereze inturi aleatorii.
- Simulați cum la fiecare stație se urcă și coboară un număr aleator de oameni în autobuz.
- Simulați urcarea și coborârea pasaherilor folosind threaduri.