Զուգահեռությունը և Ասինխրոնությունը C#–ում

Vachagan Mirzoian
9 min readSep 11, 2021

--

Կախված ծրագրավորողի մասնագիտական կարողություններից և ծրագրավորման լեզվի ընձեռած հնարավորություններից օգտվելու կարողությունից՝ կարելի է ծրագիր աշխատանքի և տվյալների մշական արագությունը մեծացնել գրեթե անվերջ։ Արագագործության համար կարևոր են համակարգչի տեխնիկական տվյալները և հնարավորությունները։ Սրանք օբյեկտիվ հանգամանքներ են, որոնց վրա ծրագրավորողը չունի որևէ ազդեցություն։ Բայց ծրագրավորման լեզվի ունեցած հնարավորություններից ճիշտ օգտվելը ծարգրի աշխատանքի վրա կարող է դրական ազդեցություն թողնել։ Ըստ Թյուրինգի ամբողջական՝ Turing complete լեզուներն առաջարկում են 2 մեծ գաղափար՝ զուգահեռություն և ասինխրոնություն։ Սրանք հասկանալու համար նախ պետք է ծանոթանալ ծրագիր «աշխաատանքային միավորներին»։

Ամեն ինչ սկսվում է OS, Process, Application Domain, Thread և Task հասկացություններից։ Դրանք ասես մատրյոշկայի պես իրար մեջ տեղավորված պայմանական միավորներ են։ Դիտարկենք դրանք։

Օպերացիոն համակարգը ծրագրերի հիերարխիկ դասակարգման սանդղակում գտնվում է ամենաբարձր տեղում։ Այն փոխազդեցության մեջ մտնում տեխնիկական ապահովման հետ, կառավորում մուտքի–ելքի սարքերը և հիշողության բաշխումը։ ՕՀ–ը կառավարում է այլ՝ ավելի ցածր մակարդակի ծրագրերը, որոշում, թե տեխնիկական ռեսուրսերն ինչպես և ում կողմից պիտի օգտագործվեն։

Process–ն օպերացիան համակարգի մակարդակի կոնցեպտ է, որը նկարագրում է ծրագրի աշխատանքի համար անհրաժեշտ ռեսուրսները։ Դրանք են մեքենայական լեզվով գրված ծրգրային կոդը, հիողության ծավալը՝ call stack, որը պահում է ակտվ ֆունկցիաների ցանկը և heap, արտաքին կոդային գրադարաները, հիմական թրեդը, ծրագրի անվտանք աշխատանքի համար նախատեսված՝ ծրագրային և տեխնիկական ապահովման թույլատրելի ռեսուրսները, ֆայլային և այլ դեսկրիտպորներ։ Ավելի պարզ՝ պրոցեսն այն է, ինչ օպերացիոն համակարգն օգտագոծում է ծրագրի աշխատանքն ապահովելու համար։ Օպերացիոն համակարգն ամեն ծրագրի համար առանձնացնում է ինքնուրույն գործող պրոցես, որի շնորհիվ մեկի խափանումը մյուսի վրա չի ազդում։ Ցանկացած պրոցես ունենում է «Process Identifier»։ Հետևյալ կոդը ստանում է ակտիվ պրոցեսների անունները և քանակը՝

Կոդի այս օրինակում ստատիկ մեթոդը ստանում է 2 արգումենտ, սկսում է պրոցես և աշխատեցնում բրաուզերը։ Սա ցույց է տալիս «Պրոցես» և «Ծրագիր» հասկացությունների կապը

Պրոցեսն իր հերթին կարող է ունենալ բազում Application Domain — ներ։

Application Domain–ները պրոցեսում գործող, միմյանցից մեկուսի տրամաբանական միավորներ են։ Ամբողջական պրոցես զբաղեցնելու փոխարեն այդ նույն պրոցեսի առանձին հատվածներ աշխատեցնելով հիշողություն է խնայվում, իսկ ծրագրավորողն էլ զերծ է մնում ուղղակիորեն պրոցեսի հետ առնչվելուց։ Կոդում ստեղծված տիպերը հանդիսանում են Application Domain — ի սեփականությունը և այլ Application Domain — ների համար անհասանելի են։ CLR — ն աշխատանքի արդյունքում ստեղծված DLL — ներն այս մակաարդակում են։ Բայց այս արգելքը կարելի է շրջանցել marshal-by-reference կամ marshal-by-value մեխանիզմով։ Application Domain — ը .Net — ում կախված չէ կոնկրետ օպերացիոն համակարգից։ Սրա շնորհիվ է .Net — ը հանդիսանում cross-platform միջավայր։ Application Domain — ում կարող են գործել բազում թրեդներ։

Thread — ը հրահանգների տրամաբանական շղթա է, որն էլ հենց ապահովում է ծրագրի կատարումը։ Սա այն է, ինչի համար պրոցեսորը հատկացնում է ժամանակ, իսկ ՕՀ — ը՝ հիշողություն։ Ցանկացած պրոցեսի «Entry point» հանդիսացող Main մեթոդի կամ էլ «Top-level statement» — ներ պարունակող ֆայլի կիրառմամբ ավտոմատ կերպով ստեղծվում է հիմնական՝ «Primary» թրեդը։ .Net — ում կա գոնե 1 նախնական թրեդ, որից էլ սովորաբար սիզբ են առնում երկրորդայինները, որոնք սովորոբար ասինխրոն են։ Նույն պրոցեսի թրեդներն օգտագորնում են հիշողության նույն հատվածը և օգտվում մուտքի–ելքի նույն սարքերից։ Միաթրեդ պրոցեսը համարվում է անվտանգ՝ «Thread–safe», քանի որ ծրագրի ռեսուրսներն օգտագործում է միայն 1 թրեդ, հակառակ դեպքում մի քանի թրդ ընդհանուր ռեսուրս օգտագործելիս կարող են բախումներ առաջացնել։ Պատկերավոր ասած՝ թրեդները կարող են իրար խառնվել։ Դրանից խուսափելու համար պետք է օգտվել lock–ից Mutex — ից։ Բայց որոշ իրավիճակներում միայն 1 թրեդը բավական չէ կոմպլեքս գործողություններ կատարելու համար, ուստի ստեղծվում են երկրոդային, այլ կերպ՝ աշխատանքային, թրեդներ։ Սա արվում է աշխատանքի բաժանման համար, քանի որ գործոողություններն իրարից տարբերվում են իրենց աշխատանքի սկզբունքներով և կարևորությամբ, ինչպես գրաֆիկական ինտերֆեյսի աշխատանքն ու տվյալների բազյում կատարվող տրանզակցիան։ Կառավարվող թրեդներները 2 տիպի են՝ Foreground, որոնցից կախված է ծրագրի աշխատանքը, և Background, որոնք ընդհատվում են ծրագրի հետ մեկտեղ։ Գրաֆիկական ինտերֆեյսը կառավարվում է Foreground–ով։

Թրեդ ստեղծելու ձևերն են՝

1․ Thread կլասը,

2․ ThreadPool կլասը

3․ BackgroundWorker կլասը,

4․ Ասինխրոն դելեգատները,

5․ Task Parallel Library։

Thread կլասով աշխատելը պահանջում է մինչև 1 ՄԲ օպերատիվ հիշողություն և մի քանի հարյուր միլիվայրկայն։ ThreadPool կլասով աշխատելու դեպքում մենք գործը հանձնարարում ենք Common Language Runtime — ին, որն իր ալգորիթմներով որոշում է թրեդների օպտիմալ քանակը: BackgroundWorker–ը հիմնվում է ThreadPool–ի վրա և նրան հաղորդում է պարզություն։

Թրեդի հետ աշխատանքը ենթադրում է հետևյալ կարևոր հասկացությունների առկայությունը

1․ Thread kernel object, որն օպերացիոն համակարգին թույլ է տալիս կառավարել թրեդը, օրինակ՝ Thread Context — ի միոցով:

2 Thread stack, որը թրեդի աշխաանքի համար հատկացված հիշողությունն է։ User — mode stack — ի համար օպերացիոն համակարը հատկացնում է 1 մբ, իսկ kernel — mode stack — ի համար՝ 12 կամ 64 կբ,

3․ Thread Environment Block — ը, որ 4 կբ ծավալով տվյալների կառուցվածք է, որն զբաղվում է բացառիկ դեպքերի շղթայի մշակմամբ։

4. DLL thread-attach և thread-detach հաղորդագրություններ, որոնք վերաբերում են պրոցեսում դեռևս չբեռնավորված կամ unmanaged DLL ֆայլերի Entry point — ը պիտկավորելուն, երբ պրոցեսում համապատասխանաբար ստեղծվել կամ ավարտվել է գոնե 1 թրեդ։ Վիզուալ ստուդիոյում առօրեական աշխատանքի համար տասնյակ կամ հարյուրավոր լրացուցիչ DLL — ներ կարող են ներգրավված լինել, ուստի ամեն անգամ, երբ նոր թրեդ է ստեծվում կամ ավարտվում, բոլոր DLL — ներն ազդեցություն են կրում՝ կանչվում։ Սա նոր թրեդ ստեղծելու հիմական խոչընդոտն է, քանի որ զգալի դանդաղեցնում է ծրագրի աշխատանքը։

Task–ը .Net — ում ասինխրոնության հասնելու աբստրակցիա է։ Թասքը թրեդի փաթեթավորումն է ու արտաքին թաղանթը, ուստի թասքը կոդը դարձնում է ավելի ընթեռնելի։ Խիստ պայմանականորեն կարող ենք համարել թրեդի մի տեղամաս, որի ընթացքում կատարվում է առաջադրանք, օրինակ՝ առանձին մեթոդի աշխատանք, մաթեմատիկական գործողություն և այլն։ Թասքն օգտագործվում է արգագործությունը բարձրացնելու համար, երբ գործ ենք ունենում ասինխրոնության կամ անգամ զուգահեռության հետ։ TaskScheduler–ը սահմանում է թասքերի աշխատանքի գրաֆիկը և գործում է ThreadPool–ում։

Սինխրոն աշխատող կոդի դեպքում ժամանակի կոնկրետ պահին կատարվում է 1 միավոր գործողություն, նոր գործողություն կարող է կատարվել նախորդի ավարտից հետո միայն։ Ամբողջ կոդն աշխատում է 1 թրեդում՝ հաջորդաբար։ Որպեսզի մի երկար տևող գործողության պատճառով չկանգնի ամբողջ ծրագիր աշխատանքը, խնդրահարույց գործողությունը կարելի է կատարել ասինխրոն՝ լրացուցիչ թրեդով։

Ասինխրոն մեթոդը գործում է ֆոնային ռեժիմում և դրան կանչող մեթոդը շարունակում է իր աշպատանքը՝ չսպասելով ասինխրոն մեթոդի ավարտին, այսինքն՝ 2 մեթոդ գործում են իրարից անկախ։ Ասինխրոնությունը շատ լայն հասկացություն է։ Օրինակ՝ եթե միայն 1 թրեդ ունենալու դեպքում տվյալների բազա հարցում ուղարկենք, ստիպված ենք լինելու սպասել հարցման կատարման ավարտին, իսկ եթե բազային ուղղված հարցման մշակմամբ զբաղվի առանձին թրեդ, դրա դանդաղագործությունը չի ազդի ամբողջ ծրագրի վրա։ Նույն կերպ գրաֆիկական ինտերֆեյսի՝ User interface — ի, հետ աշխատելիս պետք է ունենալ լրացուցիչ թրեդ, քանի որ միակ թրեդի զբզղված լինելու դեպքում ինտեֆեյսը կդադարի օգտատիրոջը ենթարկվել։ Ասինխրոն կոդն արդյունավետ է I/O–bound գործողությունների դեպքում, երբ աշխատանքը տևում է երկար, պրոցեսորային ռեսուրս համեմատաբար քիչ է սպառում, օրինակ՝ հիշողությունից տվյալներ վերցնել և գրանցելը, ինտերնետային հարցումներ կատարելը, փոքրածավալ տվյալների հետ գիտական հաշվարկներ կատարելը և այլ։ Եթե մենք գրենք ասինխրոն կոդ, որն I/O–bound չէ, Graphical UI չէ, ինտերնետային հարցում չէ, մենք արդյունավետություն չենք նկատելու։ Այսպիսի CPU-bound՝ պրոցեսորային ռեսուրս սպառող գործողությունների համար ավելի հարմար են Multithreading–ը կամ Task–based ասինխրոնությունը, որոնք արդեն ավելի շատ առընչվում են զուգահեռությանը։ Ասինխրոն գործողությունները սերտորեն կապված են թասքերի հետ։ Այսինքն՝ 1 մեթոդի համար ստեղծվում է ասինխրոն թասք, որի աշխատանքը չի արգելափակում թրեդի աշխատելուն։ Այլ կերպ սա կոչվում է «Fire and Forget»։ C#–ում ասինխրոն ծրագրավորման հիմնաքարերը async և await բանալի բառերն են։ Մեթոդը պիտի ունենա async մոդիֆիկատորը, վերադարձի տիպը պիտի լինի Task կամ Task<T>, իսկ մեթոդի անվանումը պիտի պարունակի Async բառը։ Դիտարկենք օրինակ՝

GetStringAsync — ը կասեցնում է ծրագրի աշխատանքը, քանի որ պետք է սպասել տեքստային ձևով կայքի անվանման վերադարձին։ Անիմաստ չսպասելու համար GetStringAsync մեթոդը կառավարումը փոխանցում է AccessTheWebAsync — ին, որի կողմից էլ կանցվել էր։ Ինչ — որ պահի GetStringAsync — ը վերադարձնելու է Task<TResult> տիպի թասք, մեր դեպքում TResult — ը string է։ DoIndependentWork() — ը սինխրոն մեթոդ է, որն աշխատում և կառավարումը վերադարձնում է իրեն կանչողին։ Թասքը, որը և հանդիսանում է մեթոդի կանչ, վերագրվում է getStringTask փոփոխականին։ AccessTheWebAsync() մեթոդն await բառից սկսած դադարեցնում է իր հետագա քայլերը և սպասում է թասքի ավարտին, չնայած կառավարումուը վերադարձվել էր թասքը կանչող մեթոդին՝ AccessTheWebAsync() — ին։

Դիտարկենք Windows Forms — ով գրաֆիկական ինտերֆեյս ստեղծելը:

«Get Name»–ը սեղմելու դեպքում կանչվում է button1_Click(object sender, EventArgs e) ֆունկցիան, որն իր հերթին կանչում է ReadTextAsync() ասինխրոն ֆունկցիան, որն էլ 2 վայրկյան տևաողությամբ աշխատանքի սիմուլյացիայից հետո վերադարձնում է տեքստային արժեք։ Մինչև տեքստային արժեքի վերադարձը պատուհանը շարունակում է ակտիվ մնալ և արձագանքել մկնիկի շարժին։ «Block»–ը սեղմելու դեպքում պատուհանը դադարում է արձագանքել, քանի որ դրան համապատասխանող button2_Click(object sender, EventArgs e) մեթոդը կաչում է ReadHelloWorldAsync(string value) մեթեդը, որն աշխատում է սինխրոն, քանի որ button2_Click(object sender, EventArgs e)–ում await բանալի բառը չկա։ Այսինքն «private static async Task<string> ReadHelloWorldAsync(string value)» գրառումն իմաստով նման է «private static string ReadHelloWorldAsync(string value)» գրառմանը։

Պետք է button2_Click(object sender, EventArgs e) մեթոդում անել հետևյալ փոփոխությունները`

Այս անգամ «Block»–ը սեղմելու դեպքում պատուհանը շարունակում է արձագանքել մկնիկին, քանի որ տեքստային արժեք տպող գործողությունը կատորվում է ասինխրոն՝ առանձին։

Մեթոդն «async» բառով նշելով՝ .NET Core Runtime–ը ստեղծում է նոր թրեդ։ Հետո, երբ կանչում ենք async մեթոդ, await բանալի բառն ավտոմոտ կերպով կանգնեցնում է ընթացիկ թրեդի աշխատանքը մինչև թասքի ավարտը և թույլ տալիս կանչող թրեդին շարունակել աշխատանքը։ Առանց await — ի օգտագործման կունենանք սինխրոն մեթոդ, ուստի ասինխրոնության հասնելու համար պետք է գոնե 1 await արտահայտություն։

Զուգահետություն կամ Parallelism նշանակում է պրոցեսորների միաժամանակյա աշխատանքով ծրագրի ընթացք։ Պետք է հասկանալ, որ զուգահեռությունը, ինչպես նաև ասինխրոնությունը, վերացական հասկացություն է, քանի դեռ կոնկրետ չի նկարագրվել։ Պրոցեսորը մշակում է կա՛մ հրագանգներ, կա՛մ տվյալներ, հետևաբար գործնականում ունենում ենք համապատասխանաբար Task Parallelism կամ Data Parallelism։ Դրանք կարող են իրար հետ համատեղ օգտագործվել։

Մուլտիպրոցեսորային համակարգում Task Parallelism–ի դեպքում նույն կամ տարբեր տվյալների համար պրոցեսոր աշխատեցնում է առանձին թրեդ կամ պրոցես, որոնք աշխատում են ասինխրոն։ Արագագործության տեսանկյունից երբեմն խնդրահարույց է, քանի որ թրեդներ կամ պրոցեսներ շատ կան, իսկ զուգահեռացման աստիճանը կախված է դրանց քանակից։ Thread–level parallelism — ը միաժամանակ շատ թրեդներ աշխատեցնելն է։ Այն էֆեկտիվ է սերվերների աշխատանքի համար։ ․Net–ում նախատեսված է Task Parallel Library–ն

Data Parallelism ստացվում է, երբ բազում պրոցեսորներ նույն հրահանգն աշխատեցնում են միատեսակ տվյալների համար։ Հրահանգներն աշխատում են սինխրոն։ Ավելի արագագործ է, քանի որ միայն 1 թրեդ է աշխատում։ Սա նույն «Single instruction, multiple data» ճարտարապետությունն է։ ․Net–ում նախատեսված է Parallel LINQ–ը։

Զուգահեռ ծրագրավորումն արդյունավետ է դիսկրետ և մեծածավալ տվյալներով աշխատելու դեպքում։ Այդպիսի օրինակ է երկչափ զանվածների՝ մատրիցների բազմապատկումը։ Բազմապատկման համար 1–ին զանգածի սյուների և 2–րդ զանգվածի տողերի քանակները պիտի հավասար լինեն, ինչպես A[m,n] և B[p,q] մատրիցների դեպքում, երբ n=p։ Հետևյալ կոդում նախ հայտարարվում են երկչափ զանգվածներ և լրացվում կամայական տվյալներով։

C–ի 1 տող հաշվարկելու համար պետք է վերցնել A–ի 1 տող՝ 535 տարր և ամբողջ B–ն՝ 528*531 = 280368 տարր, ինչը շատ երկար է։ Պատճառն այն է, որ զանգվածի տարրերը հիշողության մեջ գրանցվում են հաջորդաբար և օրինակ՝ A–ի 10–րդ տողի 1–ին տարրին հասնելու համար պետք է հերթով կարդալ նախորդող բոլոր տարրերը, մեր դեպքում 528*9 = 4752 հատ։

Stopwatch–ով կարելի է հաշվել ներդրված ցիկլեր տարբեր կոմբինացիաների աշխատանքի տևողությունները։ Ինդեքսների «i», «k» և «j» հերթականության դեպքում գումարումն արվում է 2.4998915 վայրկյանում, իսկ «i», «j» և «k» հերթականությունը՝ 2.9703115 վայրկյանում։ Պատճառն այն է, որ 1–ին դեպքում «j» ինդեքսը հանդիսանում է սյուն և աճում է 1 միվորով, ուստի անհրաժեշտ տարրերը գտնվում են իրար կոողք։ Մյուս դեպքում՝ «j»–ն տողի ինդեքսն է և փոխվում է «k» ինդեքսի աճելուց հետո, արդյունքում «j» ինդեքսով տարրերը ցրվում են հիշողության մեջ։ Ավելի պատկերավոր բացատրության համար օգտվենք նկարներից՝

Բայց առաջին ցիկլը զուգահեռ աշխատեցնելով՝ զանգվածների գումարումն էլ ավելի արագ է կատարվում՝ 1.4263624 վայրկյանում։ Միևնույն ցիկլի աշխատանքի տևոողությունը յուրաքանչյուր կոմպիլյացիայի ժամանակ կարող է տարբերվել։

Ամեն ինչ շատ պարզ է՝ Parallel.For()–ն աշխատում է լրացուցիչ թրեդով և ժամանակի կոնկրետ պահին մեկից ավել հաշվարկ է կատարվում։ Այս կոդը ցույց է տալիս, որ անհրաժեշտության դեպքում Parallel.For()–ի ամեն գործողության համար ստեղծվում են զուգահեռ աշխատող թրեդներ։

Կոդը ստսնում է ցիկլն աշխատեցնող թրեդները։ Սովորական ցիկլը կատարվում է 1 թրեդով, իսկ զուգահեռ ցիկլը՝ բազում։

Զուգահետությունը և ասինխրոնությունը սկզբունքորեն տարբեր գործիքներ են, բայց պարբերաբար պատահելու են իրավիճակներ, երբ թվալու է, որ դրանց միջև հնարավոր չէ միանշանակորեն տանել բաժանարար գիծ։ 2 դեպքում էլ խիստ պայմանականորեն կարելի է ասել, որ աշխատանքը բաժանվել է մի քանի մասի, որոնք իրար չեն խանգարում, անգամ եթե իրար հետ կապված են։

Ամբողջ կոդը կարող եք գտնել գիտհաբում՝ WindowsFormsAsynchronous և ParallelismAsynchronicityThreadAndTask

Կարող եք օգտվել հետևյալ աղբյուրներից՝

1. Pro C# 9 with .NET 5, Foundational Principles and Practices in Programming, 3rd Ed.

2. Hands-On Parallel Programming with C# 8 and .NET Core 3, Build solid enterprise software using task parallelism and multithreading,

3. CLR via C#, Developer Reference, 4th Ed.

4. CLR via C#, Full Coverage of Multicore Programming, 4th Ed.

5. An Introduction to Parallel Programming, Peter S. Pacheco, 1st ed.,

6. Asynchronous Programming Succinctly, Dirk Strauss, Foreword by Daniel Jebaraj

7. www.youtube.com/watch?v=o7h_sYMk_oc

Հարցերի դեպքում գտեք ինձ LinkedIn–ում, Instagram–ում, Tiktok–ում և Facebook–ում։

--

--

Vachagan Mirzoian
Vachagan Mirzoian

Written by Vachagan Mirzoian

Acumatica ERP Developer, Biz-Tech Services, Inc.

No responses yet