Серияның 3-посты: Advanced C# for Your Next Interview
Алдыңғы post ішінде SemaphoreSlim арқылы race condition түзеттік. Қай class problem шешетінін білу пайдалы, бірақ senior-level interview answer бұдан әрі баруы керек: неге кейбір synchronization primitives lightweight деп саналады, неге басқалары kernel-backed, және неге lock көбіне hybrid mechanism ретінде сипатталады?
Synchronization primitives concurrent execution coordination жасайды және shared resources қорғайды. Олардың purpose - multiple threads data-ны race conditions, broken invariants немесе lost updates тудыратындай өзгертуіне жол бермеу.
Олар бұл problem-ді бірдей шешпейді. Ең маңызды difference API емес, execution resource available болғанын күтіп тұрғанда не болатыны.
Кейбір primitives inexpensive atomic CPU instructions немесе brief active waiting қолданады. Басқалары operating-system mechanisms-ке сүйенеді: wait handles, system calls және thread scheduler.
Бұл choices cost түсіну үшін User Mode және Kernel Mode-тен бастау керек.
User Mode және Kernel Mode
Modern operating systems code-ты different privilege levels ішінде execute етеді.
User Mode - normal application code running болатын жер: C# applications, ASP.NET Core services, background workers және console programs.
User Mode code kernel memory, hardware немесе privileged processor instructions-қа directly access жасай алмайды. Бұл isolation маңызды. Егер application unhandled exception себебінен crash болса, process әдетте operating system-ді құлатпай terminates болады.
Kernel Mode - OS kernel, device drivers, thread scheduler және басқа low-level system components қолданатын privileged environment.
Application code User Mode ішінде directly орындалмайтын operation қажет етсе, operating system-нен оны орындауды сұрайды. Мысалдар: thread жасау, file оқу, OS synchronization object күту немесе low-level network I/O орындау.
Synchronization үшін бұл distinction waiting cost түсіндіреді:
- Atomic CPU instructions және runtime-managed state арқылы problem-ді process ішінде шешу әдетте арзанырақ.
- OS-тан thread park етуін, waiter ретінде enqueue жасауын және кейін scheduler арқылы wake етуін сұрау қымбатырақ.
Бұл lightweight user-space approaches және kernel-backed primitives арасындағы distinction негізі.
Lightweight User-Space Approaches
Common examples:
InterlockedSpinLockSpinWait
Бұл tools OS wait handle жасамайды және current thread suspend ету үшін operating system-ге бірден сұрау жібермейді.
Мысалы, Interlocked counter increment, value replace немесе compare-and-swap сияқты simple state changes үшін atomic operations береді:
Interlocked.Increment(ref counter);
Operation atomic. Multiple threads counter-ді concurrently increment ете алады және read-modify-write race салдарынан updates жоғалмайды.
SpinLock және SpinWait басқа idea қолданады. Resource busy болса, thread short time active күйде қалып, resource available болды ма деп қайта-қайта тексере алады. Бұл spinning деп аталады.
Thread бірден suspended болмайды. Ол waiting кезінде CPU core үстінде run ете береді.
Бұл wasteful естіледі, кейде солай. Бірақ lock өте short time ұсталып тұрса, briefly spinning мынадан cheaper болуы мүмкін:
- Operating system ішіне кіру.
- Thread park ету.
- Execution-ды басқа thread-ке switch ету.
- Original thread-ті scheduler арқылы wake ету.
- Оның execution context қалпына келтіру.
Wait ұзара бастағанда tradeoff өзгереді. Spinning thread useful work жасамай CPU consumes етеді және progress жасай алатын threads-пен competes етеді.
Сондықтан spin-based synchronization extremely short waits және low-level code үшін ең appropriate. Негізгі tradeoff:
OS-managed waiting-ке expensive transition болдырмау active CPU consuming cost әкелуі мүмкін.
Kernel-Backed Primitives және OS Handles
Synchronization primitives-тің басқа тобы operating-system waiting mechanisms үстінде құрылған:
MutexSemaphoreAutoResetEventManualResetEventEventWaitHandle
.NET ішінде бұл types WaitHandle-ден derive етеді және OS synchronization objects көрсетеді.
Thread already owned Mutex үстінде WaitOne() шақырса, operating system waiting thread-ті mutex available болғанша suspend ете алады. Thread енді spins етпейді және resource тексеру үшін CPU consume етпейді.
Бұл wait long болуы мүмкін кезде useful. Thread suspend ету processor time indefinitely wasting-тен better, бірақ transition free емес. System calls, scheduler work, context switches және parked thread wake ету simple atomic user-space operation-нан қымбатырақ.
Kernel-backed primitives-тің маңызды capability бар: кейбіреулері бір process-тен көп process coordination жасай алады.
Мысалы, named Mutex machine ішінде only one process particular operation орындауын enforce ете алады. Бұл single-instance applications немесе shared resource-қа cross-process access үшін useful.
Ordinary ASP.NET Core code ішінде бұл capability сирек керек. Бір backend process ішіндегі synchronization үшін lighter және more focused tools әдетте жақсырақ fit болады.
Modern primitives неге жиі hybrid болады
Lightweight және heavyweight synchronization арасындағы line әрқашан strict емес.
Modern .NET primitives contention жоқ кезде fast path үшін optimize етеді және necessary болғанда ғана more expensive waiting-ке өтеді.
lock keyword арқылы қолданылатын Monitor жақсы example:
lock (_sync)
{
UpdateSharedState();
}
lock process ішіндегі short synchronous critical sections қорғау үшін standard choice, бірақ ол жай ғана “kernel lock” емес.
Lock free болғанда runtime оны very fast path арқылы acquire ете алады. Contention кезінде runtime briefly spin етуі, ал lock unavailable болып қала берсе, more expensive waiting mechanisms қолдануы мүмкін.
Бұл Monitor-ды hybrid mechanism етеді:
Ол uncontended case ішінде lightweight болып қалуға тырысады, бірақ threads lock үшін compete еткенде waiting strategy escalate ете алады.
SemaphoreSlim тағы бір маңызды example. Ол process ішінде concurrency limits береді және WaitAsync() қолдайды:
private readonly SemaphoreSlim _gate = new(5, 5);
await _gate.WaitAsync(cancellationToken);
try
{
await CallExternalApiAsync(cancellationToken);
}
finally
{
_gate.Release();
}
Мұнда no more than five operations external API-ды бір уақытта call ете алады. Immediately enter ете алмайтын operation wait duration бойы thread block етпей, Task await ете алады.
Semaphore-дан айырмашылығы, SemaphoreSlim cross-process synchronization үшін intended емес. Ол in-process scenarios үшін жасалған: limiting concurrent operations және async critical sections protecting.
Ownership және Thread Affinity
Waiting cost primitive таңдаудың бір ғана criterion. Басқасы - ownership:
Primitive acquired болғаннан кейін оны release етуге кім allowed?
Бұл thread affinity concept-іне әкеледі.
Thread-affine primitive оны қай thread owns ететінін records етеді. Critical section ішіне кірген thread одан шығатын thread те болуы керек.
Thread-affine primitives:
Monitor/lockMutexReaderWriterLockSlim
Thread affinity жоқ primitives:
SemaphoreSemaphoreSlimAutoResetEventManualResetEventEventWaitHandle
Бір thread lock ішіне кірсе, басқа thread оның behalf үшін Monitor.Exit() дұрыс call ете алмайды. Басқа thread owned monitor release етуге тырысу SynchronizationLockException тастайды.
Бұл ownership rule shared state қорғайды. Бір thread басқа thread acquired еткен lock-ты accidentally release етіп, original critical section complete болмай тұрып concurrent access-ке жол аша алмайды.
Model synchronous critical sections-ке табиғи fit болады:
- Thread enters.
- Protected state updates етеді.
- Сол thread exits.
Mutex та ownership tracks етеді. Ол OS synchronization object болғандықтан, operating system owning thread немесе process unexpectedly terminates болғанын detect ете алады. Waiter кейін AbandonedMutexException алуы мүмкін.
Бұл exception protected data safe дегенді білдірмейді. Ол previous owner disappeared және state inconsistent қалдыруы мүмкін дегенді білдіреді. Дегенмен жоқ owner-ды forever waiting еткеннен better.
ReaderWriterLockSlim да thread-affine. Ол read, write және upgradeable read locks ажыратады. Ол useful болуы мүмкін, бірақ application writes-тен көп reads жасайды екен деп automatic түрде таңдалмауы керек. Оның additional complexity тек suitable contention және workload patterns кезінде pays off етеді.
Reentrancy - бөлек property
Thread affinity және reentrancy related, бірақ same thing емес.
Thread affinity сұрайды: lock кімге тиесілі?
Reentrancy сұрайды: owner same lock-ты қайта acquire ете ала ма?
Monitor reentrant, сондықтан мына code works:
lock (_sync)
{
lock (_sync)
{
// The same thread can enter again.
}
}
Monitor owner және acquisition count екеуін де tracks етеді. Thread monitor-дан қанша рет entered болса, сонша рет exit етуі керек.
Mutex те reentrant. Ал ReaderWriterLockSlim default бойынша recursion allow етпейді. Оның configurable recursion policy бар, бірақ recursion enabling complexity арттырады және deliberately жасалуы керек.
SpinLock та ordinary reentrant lock ретінде қарастырылмауы керек. Оны қайта enter ету owner-tracking configuration-ға байланысты failures немесе indefinite wait тудыруы мүмкін.
Thread Affinity неге async/await-пен conflict жасайды
C# compiler lock block ішінде await allow етпейді және CS1996 error көрсетеді.
lock (_sync)
{
await SaveAsync(); // CS1996
}
Бұл arbitrary syntax restriction емес.
lock Monitor-ға негізделген, ал Monitor thread affinity ұстайды. Enter жасаған thread exit те жасауы керек.
Async method басқа execution model ұстанады. Ол incomplete await-ке жеткенде method suspended болады және оның thread басқа work жасауға босайды. Awaited operation complete болғанда continuation басқа thread үстінде run етуі мүмкін.
ASP.NET Core ішінде await кейін execution same physical thread-те resume болады деген guarantee жоқ.
Егер await lock ішінде allowed болса, бір thread monitor acquire етіп, await кезінде suspend болып, кейін басқа thread-тен monitor release етуге тырысар еді. Бұл monitor ownership rule бұзады.
Async critical sections үшін әдетте SemaphoreSlim қолданылады:
await _gate.WaitAsync(cancellationToken);
try
{
await SaveAsync(cancellationToken);
}
finally
{
_gate.Release();
}
SemaphoreSlim thread affinity ұстамайды. Release() шақыратын continuation WaitAsync() шақырған same physical thread-те run етуі міндетті емес.
Thread affinity жоқ болуы universally better деген сөз емес. Бұл жай ғана different ownership model:
SemaphoreSlimasync waiting және concurrency limits үшін fit.lockshort synchronous protection of shared state үшін fit.Mutexcross-process synchronization үшін fit.Interlockedsimple atomic state transitions үшін fit.
Әр primitive-тің өз waiting cost және ownership semantics бар.
Practical selection guide
| Requirement | Typical choice |
|---|---|
| Protect a short synchronous critical section inside one process | lock |
| Increment, exchange, or compare a single value atomically | Interlocked |
| Protect an async critical section | SemaphoreSlim(1, 1) |
| Limit async concurrency to N operations | SemaphoreSlim(N, N) |
| Coordinate multiple processes | Named Mutex or another OS primitive |
| Allow concurrent readers and exclusive writers | ReaderWriterLockSlim, after measuring |
| Optimize an extremely short wait in low-level code | SpinWait or SpinLock, with care |
Маңызды qualification - after measuring. More specialized primitive automatically faster емес. Оның benefit contention, critical-section duration, workload shape және code synchronous немесе asynchronous екеніне байланысты.
Негізгі takeaway
Synchronization primitives тек names және APIs бойынша ғана ерекшеленбейді. Оларды салыстырғанда сұраңыз:
- Caller қалай waits етеді және waiting cost қандай?
- Primitive кім owns етеді және release етуге кім allowed?
- Оның execution model async code-пен жұмыс істей ме?
Бұл questions lock, SemaphoreSlim, Mutex, Interlocked және SpinWait неге different problems шешетінін көрсетеді.
Senior-level answer primitive не істейтінін ғана емес, оның waiting strategy, ownership model және scope артындағы tradeoffs түсіндіруі керек.