Կատարման հոսքեր
Կատարման հոսքը (thread of execution կամ պարզապես` thread) ցածր մակարդակով իրենից ներկայացնում է պրոցեսորի համար նախատեսված հրամանների իրականացման անկախ ճանապարհ (independent path of execution): «Անկախ» այն իմաստով, որ դրանց իրականացումը և իրականացման ժամանակը հերթագրվում է (scheduled) այլ հոսքերի հերթագրումից անկախ:
Աշխատող ծրագրում main()
ֆունկցիան հանդիսանում է հիմնական հոսքը: Բացի main()
-ից, ծրագրում հնարավոր է ստեղծել նաև այլ հոսքեր: Այդպիսի ծրագրերը կոչվում են բազմահոսք (multi-threaded): Այսինքն՝ հոսքերը ըստ էության սովորական ֆունկցիաներ են այն միակ տարբերությամբ, որ դրանք կանչվում և աշխատում են նշված իմաստով անկախ (որին նաև ասում են՝ «ասինխրոն»):
Լրացուցիչ հոսք կարելի է ստեղծել, օրինակ, այսպես.
void fun()
{
std::cout << "Hello from fun() thread!" << std::endl;
}
int main()
{
std::thread t(f);
t.join();
}
Այստեղ լրացուցիչ հոսքը տրվում է f()
ֆունկցիայի միջոցով: t.join()
(«միացիր») կանչը անհրաժեշտ է, քանի որ առանց դրա անկախ աշխատող, ծրագրի հիմնական main()
հոսքը կարող է ավարտել իր աշխատանքը մինչև fun()
ֆունկցիայով աշխատող հոսքը կավարտի իրենը: Այդ դեպքում fun()
ֆունկցիայով աշխատող հոսքը կմնա «օդում կախված», ինչը կանխելու նպատակով լեզվում սահմանված է նման իրավիճակում std::thread
օբյեկտի դեստրուկտորում կանչել std::terminate()
ֆունկցիան, որի ստանդարտ պահվածքը անմիջապես կանխում է ծրագրի աշխատանքը (կանչելով std::abort()
):
Բազմահոսք ծրագրերում առաջանում են յուրահատուկ խնդիրներ, որոնք միահոսք (single-threaded) ծրագրերում չեն առաջանում: Օրինակ՝ եթե std::cout
օբյեկտի մեջ մի քանի հոսքեր միաժամանակ փորձեն գրել, արդյունքում կարող են արտածվել այդ մի քանի հոսքից եկած, անկանխատեսելի կարգավորությամբ բառեր կամ սիմվոլներ՝ կախված նրանից, թե տվյալ միջավայրում ժամանակի որ պահին որ հոսքին և ինչ տևողությամբ է աշխատելու հնարավորություն տրվել ՕՀ-ի հերթագրող մոդուլի (scheduler) կողմից: Օրինակ, հետևյալ ծրագիրը
void f() { std::cout << "Hello "; }
void g() { std::cout << "World!"; }
int main()
{
std::thread t1(f);
std::thread t2(g);
t1.join();
t2.join();
}
կարող է արտածել հետևյալ “անսպասելի” արդյունքը.
HeWollo rld!
Բազմահոսք ծրագրավորման ոլորտում այս իրավիճակի պատճառը ունի հատուկ անուն. տվյալներին հասնելու մրցակցություն (data race), քանի որ տարբեր հոսքեր կարծես «մրցում» են ընդհանուր տվյալներին առաջինը հասնելու համար:
Նկատենք նաև, որ այս իրավիճակը առաջանում է միայն այն դեպքում, երբ այդ բազմաթիվ հոսքերից գոնե մեկը փորձում է փոփոխել ընդհանուր տվյալը: Այսինքն՝ կարելի է ունենալ ցանկացած քանակությամբ միայն կարդացող հոսքեր, դա միշտ անվտանգ է և երբեք նշված իմաստով մրցակցության չի հանգեցնի:
Այս իրավիճակից խուսափելու համար գոյություն ունեն այսպես կոչված սինխրոնիզացիայի օբյեկտներ: Դրանցից են, օրինակ, std::mutex
(mutual exclusion - փոխադարձ բացառում) օբյեկտը, որով կարող ենք մեր օրինակի արդյունքը կանխատեսելի դարձնել հետևյալ կերպ.
std::mutex m;
void f()
{
m.lock();
std::cout << "Hello ";
m.unlock();
}
void g()
{
m.lock();
std::cout << "World!";
m.unlock();
}
int main()
{
std::thread t1(f);
std::thread t2(g);
t1.join();
t2.join();
}
Քանի որ թե f()
, թե g()
ֆունկցիաներով ստեղծված հոսքերում մենք օգտագործում ենք std::mutex
տիպի նույն m
օբյեկտը, m.lock()
(lock — փակել) և m.unlock()
(unlock — բացել) ֆունկցիաները կանչելով մենք ժամանակավորապես «փակում» ենք այդ երկու կանչերի արանքում ընկած հրամանների իրականացման հնարավորությունը, ինչը կերաշխավորի հետևյալ արդյունքներից մեկնումեկը.
Hello World!
World!Hello
Երկրորդ տարբերակը հնարավոր է, քանի որ, ինչպես նշեցինք, այդ երկու հոսքերը ստեղծվելուց հետո աշխատում են անկախ: Այսինքն՝ այն փաստից, որ «երևացող» ծրագրի մեջ սկզբից ստեղծվում է «Hello
» արտածող հոսքը, հետո նոր՝ «World!
» արտածողը, չի հետևում, որ իրենց արդյունքները պիտի արտածվեն նույն հերթականությամբ: Եթե մեր նպատակը լիներ արտածել հենց այդ հերթականությամբ, մենք, առանց որևէ հոսք ստեղծելու, ավանդական ձևով (որին նաև ասում են՝ «սինխրոն») պարզապես կկանչեինք f()
, այնուհետև g()
ֆունկցիաները: Այսինքն՝ հոսքեր ստեղծելու հիմնական նպատակն է մոդելավորել ծրագրի մեջ (սովորաբար երկար տևող) անկախ հաշվարկների կտորները — այն կտորները, որոնք կարող են աշխատել անկախ` զուգահեռացնելով ծրագրի աշխատանքը:
Այստեղ պետք է զգույշ լինել. չնայած ցանկացած ծրագրում կան նման անկախ կտորներ, ոչ միշտ է դրանց՝ հոսքերի տրոհելը բերում ծրագրի արագագործության բարձրացման, քանի որ հոսք ստեղծելը, բազմաթիվ հոսքեր ունենալը, ինֆորմացիայի փոխանակման նպատակով դրանց միջև հաղորդագրությունների փոխանցումը և սինխրոնիզացիայի օբյեկտների հետ կապված վերադիր ծախսերը (overhead) կարող են վերջնահաշվարկում ավելին լինել, քան եթե ծրագիրը աշխատեր ավելի քիչ քանակով կամ նույնիսկ առանց հոսքերի: Հետևաբար, հոսքերի միջոցով ծրագիրը արագացնելու ցանկացած դատողություն պետք է ենթարկվի համապատասխան չափումների, որոնց արդյունքում միայն կարելի է ընդունել ծրագրի՝ անկախ կտորների տրոհման ճիշտ որոշում։
m.lock()
անելուց հետո m.unlock()
անելը նունքան հեշտ է մոռանալ, որքան new
անելուց հետո՝ delete
: Դրանից խուսափելու համար ստանդարտ գրադարանը առաջարկում է std::lock_guard
տիպի օբյեկտ, որը lock()
է անում կոնստրուկտորում և ավտոմատ unlock()
է անում դեստրուկտորում (ճիշտ ինչպես new
/delete
զույգը անելու համար կան «խելացի ցուցիչներ»՝ std::unique_ptr
, std::shared_ptr
, և այլն), օրի օգնությամբ մեր օրինակը կարելի է գրել հետևյալ կերպ.
std::mutex m;
void f()
{
std::lock_guard<std::mutex> lk(m);
std::cout << "Hello ";
}
void g()
{
std::lock_guard<std::mutex> lk(m);
std::cout << "World!";
}
int main()
{
std::thread t1(f);
std::thread t2(g);
t1.join();
t2.join();
}
Մոռանալուց ավելի կարևոր է այն հանգամանքը, որ std::lock_guard
տիպի օբյեկտների միջոցով փակված std::mutex
-ները ավտոմատ կբացվեն, եթե աշխատող ծրագրում ինչ-ինչ պատճառներով որևէ բացառություն (exception) առաջանա: Օրինակ, եթե f()
ֆունկցիայի մեջ <<
արտածման օպերատորի աշխատանքի արդյունքում բացառություն առաջանա, համակարգի կողմից երաշխավորված կկանչվի lk
օբյեկտի դեստրուկտորը, որն էլ իր հերթին կկանչի m.lock()
բացող մեթոդը: Ճիշտ նույն պատճառով նույն սկզբնունքն է կիրառվում նաև խելացի ցուցիչներում, որոնց կոնստրուկտորներում արվում է new
, իսկ դեստրուկտորներում՝ delete
: Ընդանրապես, բացառությունների դեպքում կոռեկտ աշխատելու երաշխիքների առկայությունը կոչվում է ապահովություն բացառություններից (exception safety). C++-ում նման երաշխիքների սովորաբար հասնում են կոնստրուկտորներում ու դեստրուկտորներում իրականացնելով համապատասխան վերցնել / արձակել (acquire / release) համալուծ գործողությունները: Այս մեխանիզմի տարածված անունն է RAII (resource allocation is initialization)։