Серияның 3-мақаласы: «Келесі сұхбатыңызға арналған жетілдірілген C#»
Алдыңғы мақалада SemaphoreSlim көмегімен жарыс жағдайын түзеттік. Қай класс белгілі бір мәселені шешетінін білу пайдалы, алайда senior деңгейіндегі мықты сұхбат жауабы бұдан тереңірек болуы керек: неге синхрондаудың бір примитиві жеңіл деп саналады, енді бірі операциялық жүйе ядросына сүйенеді, ал lock неге жиі гибридті механизм деп аталады?
Синхрондау примитивтері кодтың қатар орындалуын үйлестіреді және ортақ ресурстарды қорғайды. Олардың мақсаты — бірнеше ағынның деректерді жарыс жағдайын тудыратындай, инварианттарды бұзатындай немесе жаңартулар жоғалатындай етіп өзгертуіне жол бермеу.
Бірақ олар бұл мәселені бірдей тәсілмен шешпейді. Ең маңызды айырмашылық — бағдарламалау интерфейсі емес, ресурс босағанша күту кезінде не болатыны.
Кейбір примитивтер процессордың арзан атомарлық нұсқауларын не қысқа белсенді күтуді қолданады. Басқалары операциялық жүйе тетіктеріне: күту дескрипторларына, жүйелік шақыруларға және ағын жоспарлаушысына сүйенеді.
Бұл шешімдердің құнын түсіну үшін пайдаланушы және ядро режимдерінен бастайық.
Пайдаланушы режимі және ядро режимі
Заманауи операциялық жүйелер кодты әртүрлі артықшылық деңгейлерінде орындайды.
Пайдаланушы режимі — әдеттегі қолданба коды жұмыс істейтін орта: C# бағдарламалары, ASP.NET Core қызметтері, фондық өңдеушілер және консольдік бағдарламалар.
Пайдаланушы режиміндегі код ядро жадына, жабдыққа немесе процессордың артықшылықты нұсқауларына тікелей қол жеткізе алмайды. Мұндай оқшаулау маңызды: егер қолданба өңделмеген ерекше жағдайдан істен шықса, әдетте операциялық жүйе емес, тек сол процесс қана тоқтайды.
Ядро режимі — операциялық жүйе ядросы, құрылғы драйверлері, ағын жоспарлаушысы және басқа да төменгі деңгейлі жүйелік компоненттер жұмыс істейтін артықшылықты орта.
Қолданбаға пайдаланушы режимінде тікелей орындауға болмайтын операция қажет болса, ол операциялық жүйеден соны орындауды сұрайды. Мысалы, ағын құру, файл оқу, ОС синхрондау нысанын күту немесе төменгі деңгейлі желілік енгізу-шығаруды орындау үшін.
Синхрондау тұрғысынан бұл айырмашылық күту құнын түсіндіреді:
- Процесс ішінде мәселені процессордың атомарлық нұсқауларымен және орындалу ортасы басқаратын күймен шешу, әдетте, арзанырақ.
- Операциялық жүйеден ағынды тоқтатуды, оны күту кезегіне қосуды және кейін жоспарлаушы арқылы оятуды сұрау қымбатырақ.
Осыдан жеңіл, процесс ішіндегі тәсілдер мен ядроға сүйенетін примитивтердің айырмашылығы туындайды.
Жеңіл, процесс ішіндегі тәсілдер
Кең таралған мысалдар:
InterlockedSpinLockSpinWait
Бұл құралдар ОС күту дескрипторын жасамайды және ағымдағы ағынды бірден тоқтату үшін операциялық жүйеге жүгінбейді.
Мысалы, Interlocked санауышты арттыру, мәнді ауыстыру немесе салыстырып ауыстыру секілді қарапайым күй өзгерістері үшін атомарлық операцияларды ұсынады:
Interlocked.Increment(ref counter);
Бұл операция атомарлық. Бірнеше ағын санауышты қатар арттыра алады, әрі «оқу — өзгерту — жазу» жарысы салдарынан жаңартулар жоғалмайды.
SpinLock пен SpinWait басқа қағидаға негізделген. Ресурс бос болмаса, ағын аз уақыт бойы белсенді қалып, оның босаған-босамағанын қайта-қайта тексере алады. Мұны белсенді күту деп атайды.
Ағын бірден тоқтатылмайды: күту кезінде ол процессор өзегінде орындалуын жалғастырады.
Бұл ысырапшыл көрінуі мүмкін, кейде расымен солай. Алайда бұғаттау өте қысқа уақыт ұсталып тұрса, аз уақыт белсенді күту мына әрекеттерден арзанырақ болуы мүмкін:
- Операциялық жүйеге өту.
- Ағынды тоқтату.
- Орындауды басқа ағынға ауыстыру.
- Бастапқы ағынды жоспарлаушы арқылы ояту.
- Оның орындалу контексін қалпына келтіру.
Күту ұзарған сайын бұл арақатынас өзгереді. Белсенді күтіп тұрған ағын пайдалы жұмыс атқармай, процессор уақытын жұмсайды және алға жылжи алатын ағындармен бәсекелеседі.
Сондықтан белсенді күтуге негізделген синхрондау көбіне өте қысқа кідірістер мен төменгі деңгейлі кодқа ғана жарайды. Негізгі ымыра мынадай:
ОЖ басқаратын күтуге қымбат ауысудан бас тарту процессор уақытын белсенді жұмсауға әкелуі мүмкін.
Ядроға сүйенетін примитивтер және ОС нысандары
Синхрондау примитивтерінің келесі тобы операциялық жүйенің күту тетіктеріне негізделеді:
MutexSemaphoreAutoResetEventManualResetEventEventWaitHandle
.NET жүйесінде бұл типтер WaitHandle класынан тарайды және ОС синхрондау нысандарын білдіреді.
Егер ағын иеленіп тұрған Mutex нысанында WaitOne() шақырса, операциялық жүйе күтіп тұрған ағынды мьютекс босағанға дейін тоқтата алады. Ағын белсенді күтуді доғарады және ресурстың қолжетімділігін тексеру үшін процессор уақытын жұмсамайды.
Бұл күту ұзаққа созылуы мүмкін кезде пайдалы. Ағынды тоқтату процессор уақытын шексіз ысырап етуден жақсы, бірақ мұндай ауысу тегін емес. Жүйелік шақырулар, жоспарлаушының жұмысы, контекст ауыстырулары және тоқтатылған ағынды ояту процесс ішіндегі қарапайым атомарлық операциядан қымбатырақ.
Ядроға сүйенетін примитивтердің тағы бір маңызды мүмкіндігі бар: олардың кейбірі бірнеше процестің жұмысын үйлестіре алады.
Мысалы, атауы бар Mutex бір машинада белгілі бір операцияны тек бір процесс орындайтынына кепілдік бере алады. Бұл бір ғана данамен жұмыс істейтін қолданбаларға немесе ортақ ресурсқа процестераралық қол жеткізуге пайдалы.
Кәдімгі ASP.NET Core кодында мұндай мүмкіндік сирек керек болады. Бір серверлік процестің ішіндегі синхрондау үшін әдетте жеңілірек әрі нақтырақ құралдар қолайлы.
Неліктен заманауи примитивтер жиі гибридті болады
Жеңіл және ауыр синхрондаудың арасы әрдайым анық шектелмейді.
Заманауи .NET примитивтері бәсекелестік жоқ кездегі жылдам жолға бейімделеді де, қажет болғанда ғана қымбатырақ күтуге көшеді.
Жақсы мысал — әдетте lock кілт сөзі арқылы қолданылатын Monitor:
lock (_sync)
{
UpdateSharedState();
}
lock — процесс ішіндегі қысқа синхронды сындарлы бөліктерді қорғаудың қалыпты таңдауы, бірақ ол жай ғана «ядро бұғаттауы» емес.
Бұғаттау бос болса, орындалу ортасы оны өте жылдам жолмен иелене алады. Бәсекелестік туғанда ол аз уақыт белсенді күтуі мүмкін, ал бұғаттау әлі босамаса, қымбатырақ күту тетіктерін қолданады.
Сол себепті Monitor гибридті механизм болып саналады:
Бәсекелестік болмағанда ол жеңіл болып қалуға тырысады, ал ағындар бұғаттау үшін таласқанда қымбатырақ күту стратегиясына ауыса алады.
Тағы бір маңызды мысал — SemaphoreSlim. Ол процесс ішіндегі қатар орындалу санын шектейді және WaitAsync() әдісін қолдайды:
private readonly SemaphoreSlim _gate = new(5, 5);
await _gate.WaitAsync(cancellationToken);
try
{
await CallExternalApiAsync(cancellationToken);
}
finally
{
_gate.Release();
}
Мұнда сыртқы API-ге бір мезгілде бестен артық операция жүгіне алмайды. Бірден кіре алмаған операция бүкіл күту уақытына ағынды бұғаттамай, Task нысанын күте алады.
Semaphore примитивінен айырмасы — SemaphoreSlim процестераралық синхрондауға арналмаған. Ол процесс ішіндегі сценарийлерге: қатар орындалатын операциялар санын шектеуге және асинхронды сындарлы бөліктерді қорғауға арналған.
Иелік ету және ағынға тәуелділік
Күту құны — примитив таңдаудағы жалғыз өлшем емес. Тағы бірі — иелік ету:
Примитив иеленілгеннен кейін оны босатуға кімнің құқығы бар?
Осы сұрақ ағынға тәуелділік ұғымына әкеледі.
Ағынға тәуелді примитив оны қай ағын иеленетінін есте сақтайды. Сындарлы бөлікке кірген ағын сол бөліктен өзі шығуы керек.
Ағынға тәуелді примитивтер:
Monitor/lockMutexReaderWriterLockSlim
Ағынға тәуелділігі жоқ примитивтер:
SemaphoreSemaphoreSlimAutoResetEventManualResetEventEventWaitHandle
Егер бір ағын lock ішіне кірсе, басқа ағын оның орнына Monitor.Exit() әдісін дұрыс шақыра алмайды. Басқа ағын иеленіп тұрған мониторды босатуға талпыныс SynchronizationLockException ерекше жағдайын тудырады.
Бұл иелік ету ережесі ортақ күйді қорғайды. Бір ағын басқа ағын иеленген бұғаттауды байқамай босатып, бастапқы сындарлы бөлік аяқталмай тұрып қатар қол жеткізуге жол аша алмайды.
Бұл модель синхронды сындарлы бөліктерге табиғи түрде сай келеді:
- Ағын бөлікке кіреді.
- Қорғалатын күйді өзгертеді.
- Сол ағын бөліктен шығады.
Mutex те иесін қадағалайды. Ол ОС синхрондау нысаны болғандықтан, жүйе иеленуші ағынның немесе процестің күтпеген жерден аяқталғанын анықтай алады. Сонда күтіп тұрған ағын AbandonedMutexException ерекше жағдайын алуы мүмкін.
Бұл ерекше жағдай қорғалатын деректердің қауіпсіз екенін білдірмейді. Ол алдыңғы иеленушінің жоғалып кеткенін және күйді келіспеушілікте қалдыруы мүмкін екенін білдіреді. Дегенмен енді жоқ иеленушіні шексіз күткеннен жақсы.
ReaderWriterLockSlim да ағынға тәуелді. Ол оқу, жазу және жаңартылатын оқу бұғаттауларын ажыратады. Бұл примитив пайдалы болуы мүмкін, бірақ қолданба жазғаннан гөрі көбірек оқиды екен деп оны автоматты түрде таңдауға болмайды. Қосымша күрделілік жүктеме мен бәсекелестік сипаты лайық болғанда ғана ақталады.
Қайта кіру — бөлек қасиет
Ағынға тәуелділік пен қайта кіру өзара байланысты болғанымен, бір ұғым емес.
Ағынға тәуелділік: бұғаттауды кім иеленеді?
Қайта кіру: иеленуші сол бұғаттауды қайтадан иелене ала ма?
Monitor қайта кіруге рұқсат береді, сондықтан мына код жұмыс істейді:
lock (_sync)
{
lock (_sync)
{
// Сол ағын қайтадан кіре алады.
}
}
Monitor иеленушіні де, иелену санын да қадағалайды. Ағын мониторға қанша рет кірсе, одан сонша рет шығуы керек.
Mutex те қайта кіруге мүмкіндік береді. Ал ReaderWriterLockSlim әдепкіде рекурсияға рұқсат етпейді. Оның рекурсия саясатын баптауға болады, бірақ бұл кодты күрделендіреді және саналы түрде қабылданатын шешім болуы тиіс.
SpinLock нысанын да қайта кіруге болатын кәдімгі бұғаттау ретінде қарастыруға болмайды. Оны қайта иелену иеленушіні бақылау баптауына қарай қате не шексіз күтуге әкелуі мүмкін.
Ағынға тәуелділік неге async/await-пен үйлеспейді
C# компиляторы lock блогының ішінде await қолдануға рұқсат бермейді және CS1996 қатесін шығарады.
lock (_sync)
{
await SaveAsync(); // CS1996
}
Бұл ерікті синтаксистік шектеу емес.
lock Monitor негізінде жұмыс істейді, ал Monitor ағынға тәуелді: ішке кірген және шыққан ағын бірдей болуы керек.
Асинхронды әдіс басқа орындалу моделін ұстанады. Ол аяқталмаған await нүктесіне жеткенде, әдіс тоқтатылады да, оның ағыны басқа жұмысқа босайды. Күтіліп отырған операция аяқталған соң жалғасы басқа ағынның үстінде орындалуы мүмкін.
ASP.NET Core жүйесінде await нүктесінен кейін орындау сол физикалық ағынға қайта оралады деген кепілдік жоқ.
Егер await сөзін lock ішінде қолдануға рұқсат етілсе, бір ағын мониторды иеленіп, await кезінде тоқтар еді, ал кейін басқа ағын оны босатуға тырысар еді. Бұл монитордың иелік ету ережесін бұзады.
Асинхронды сындарлы бөліктер үшін әдетте SemaphoreSlim қолданылады:
await _gate.WaitAsync(cancellationToken);
try
{
await SaveAsync(cancellationToken);
}
finally
{
_gate.Release();
}
SemaphoreSlim ағынға тәуелді емес. Release() шақыратын жалғастыру WaitAsync() шақырған физикалық ағынның өзінде орындалуға міндетті емес.
Ағынға тәуелділіктің жоқтығы бұл примитивті барлық жағдайда жақсы етпейді. Бұл — иелік етудің басқа моделі ғана:
SemaphoreSlimасинхронды күтуге және қатар орындалуды шектеуге қолайлы.lockортақ күйді қысқа синхронды қорғауға қолайлы.Mutexпроцестераралық синхрондауға қолайлы.Interlockedқарапайым атомарлық күй өзгерістеріне қолайлы.
Әр примитивтің өз күту құны және иелік ету ережелері бар.
Таңдауға арналған практикалық нұсқаулық
| Қажеттілік | Әдеттегі таңдау |
|---|---|
| Бір процестегі қысқа синхронды сындарлы бөлікті қорғау | lock |
| Бір мәнді атомарлық түрде арттыру, ауыстыру немесе салыстыру | Interlocked |
| Асинхронды сындарлы бөлікті қорғау | SemaphoreSlim(1, 1) |
| Асинхронды қатар орындалуды N операциямен шектеу | SemaphoreSlim(N, N) |
| Бірнеше процестің жұмысын үйлестіру | Атауы бар Mutex не ОС-тің басқа примитиві |
| Оқырмандарға қатар рұқсат беріп, жазушыға айрықша рұқсат беру | ReaderWriterLockSlim — өлшеуден кейін |
| Төменгі деңгейлі кодта өте қысқа күтуді оңтайландыру | SpinWait не SpinLock — абайлап |
Маңызды ескерту — өлшеуден кейін. Арнайы примитив автоматты түрде жылдамырақ болмайды. Оның пайдасы бәсекелестік деңгейіне, сындарлы бөліктің ұзақтығына, жүктеме сипатына және кодтың синхронды не асинхронды екеніне байланысты.
Негізгі ой
Синхрондау примитивтері атаулары мен бағдарламалау интерфейстерімен ғана ерекшеленбейді. Оларды салыстырғанда мына үш сұрақты қойыңыз:
- Шақырушы код қалай күтеді және бұл күтудің құны қандай?
- Примитивті кім иеленеді және оны босатуға кімге рұқсат бар?
- Оның орындалу моделі асинхронды кодпен үйлесе ме?
Осы сұрақтар lock, SemaphoreSlim, Mutex, Interlocked және SpinWait неліктен әртүрлі міндеттерді шешетінін түсіндіреді.
Senior деңгейіндегі жауап примитивтің не істейтінін ғана емес, оның күту стратегиясы, иелік ету моделі және қолданылу аясы арасындағы ымыраларды да түсіндіруі керек.