[PART11.비동기와 동시성(10/12)] Mutex vs SemaphoreSlim vs lock — 동기화 도구 선택 기준
같은 "상호 배제"를 위한 세 도구의 내부 구조 / async 지원 / 재진입 / 성능 트레이드오프를 한 장으로
목차
1. [문제 제기] 동기화 도구 하나만 쓰면 될 줄 알았다
Unity에서 백그라운드 스레드로 리소스를 로드하다 보면 반드시 이런 장면을 만난다.
- 네트워크 스레드가 받은 패킷을 큐에 쌓고, 메인 스레드의
Update()가 꺼내 쓴다. async메서드로 에셋 번들을 로드하는데, 같은 번들을 두 곳에서 동시에 요청해 두 번 로드되어 버린다.- PC 빌드에서 "이미 실행 중인 인스턴스가 있습니다" 메시지를 띄우려고 한다.
선배가 "lock 으로 감싸"라고 하길래 감쌌더니 이번엔 이런 컴파일 에러가 뜬다.
CS1996: Cannot await in the body of a lock statement
그래서 SemaphoreSlim 을 찾아 쓰고, 또 어디선가는 Mutex 예제를 베껴와 붙여 넣는다. 그리고 며칠 뒤 Profiler 에서 이상한 결과를 본다.
- 어떤 락은 한 번 거는 데 20ns, 다른 락은 1,000ns.
- 재귀 호출이 있는 함수에서
SemaphoreSlim으로 감싸자 앱이 멈췄다. Mutex로 감싼 초기화 코드에서 앱이 강제 종료되니 다음 실행이 이상해졌다.
이 글의 목표는 하나다.
상호 배제(mutual exclusion) 가 필요할 때, 세 도구 중 무엇을 고를지 10초 안에 결정할 수 있게 한다.
그러려면 세 도구가 왜 다르게 만들어졌는지, 내부에서 실제로 무엇이 일어나는지를 알아야 한다.
2. [개념 정의] 셋 다 "한 번에 한 명만" 을 위한 도구다
2.1 비유로 먼저 잡기
| 도구 | 비유 | 특징 |
|---|---|---|
| lock | 방 문에 달린 "간이 걸쇠" — 집 안에서만 쓰는 가장 가벼운 잠금 | 빠르다 · 집 밖으로 못 나감 · 같은 사람이 여러 번 잠가도 OK |
| SemaphoreSlim | 도서관 좌석 번호표 — 몇 자리까지 허용할지 직접 정할 수 있는 번호표 | 비동기 대기 가능 · 번호표 주인이 누군지 모름 · 같은 사람이 두 번 뽑으면 본인이 막힘 |
| Mutex | 건물 입구의 공용 열쇠 — 다른 집(프로세스) 사람도 같은 열쇠로 들어옴 | 프로세스 간 공유 가능 · 무겁다 · 반드시 받은 사람이 돌려줘야 함 |
2.2 한 눈에 보는 비교표
SyncBlock — 동기화 블록 (Sync Block) CLR이 모든 힙 객체의 헤더에 붙여놓는 내부 인덱스 필드. 평소에는 비어 있다가lock대상이 되는 순간 활성화되어 소유 스레드 ID와 대기 큐를 관리한다.lock이 빠른 이유의 핵심.
예시:lock(obj)를 호출하면obj의 SyncBlock 이 "누가 지금 이 객체를 잠갔는지" 를 기록한다.
2.3 가장 기본적인 C# 예시
세 도구로 같은 일(카운터 증가)을 감싸 본다.
Unity 에서 네트워크 스레드가 패킷을 받을 때마다 카운터를 올리는 상황이다.
using System.Threading;
public class LockSample
{
private readonly object _gate = new object();
private int _counter;
public void Increment()
{
lock (_gate)
{
_counter++;
}
}
}
.method public hidebysig instance void Increment () cil managed
{
.maxstack 3
.locals init (
[0] object, // 잠금 대상 _gate 복사본
[1] bool // lockTaken 플래그
)
IL_0000: nop
IL_0001: ldarg.0
IL_0002: ldfld object LockSample::_gate
IL_0007: stloc.0 // 1. _gate 를 로컬에 저장 (나중 Exit 용 동일 참조 보장)
IL_0008: ldc.i4.0
IL_0009: stloc.1 // 2. lockTaken = false 초기화
.try
{
IL_000a: ldloc.0
IL_000b: ldloca.s 1
IL_000d: call void [System.Threading]System.Threading.Monitor::Enter(object, bool&) // 3. Monitor.Enter 진입
IL_0012: nop
IL_0013: nop
IL_0014: ldarg.0
IL_0015: ldarg.0
IL_0016: ldfld int32 LockSample::_counter
IL_001b: ldc.i4.1
IL_001c: add
IL_001d: stfld int32 LockSample::_counter // 4. _counter++
IL_0022: nop
IL_0023: leave.s IL_0030
}
finally
{
IL_0025: ldloc.1
IL_0026: brfalse.s IL_002f // 5. lockTaken == false 면 Exit 생략 (Enter 전 예외 안전)
IL_0028: ldloc.0
IL_0029: call void [System.Threading]System.Threading.Monitor::Exit(object) // 6. Monitor.Exit 해제
IL_002e: nop
IL_002f: endfinally
}
IL_0030: ret
}
IL 분석 포인트
lock은 문법적 설탕이다.Monitor.Enter/Monitor.Exit을try-finally로 감싼 것에 불과하다. 언어가 아니라 런타임 기능이다.lockTakenbool 플래그 패턴. Enter 호출 *직전* 에 예외가 나더라도 finally 에서 Exit 을 부르지 않도록 한다. 락을 획득하지 못한 상태에서 Exit 을 부르면SynchronizationLockException이 터진다.- 값 타입 연산(
_counter++) 이 IL 로는ldfld → add → stfld3단계다. 중간에 다른 스레드가 끼어들면 값이 덮어써진다 — 이것이lock이 필요한 이유. - Unity 의
Update()루프에 이 코드가 들어가도 비경쟁이면 SyncBlock thin lock 덕분에 20ns 수준이라 프레임 예산(모바일 60fps 기준 16.6ms)에 거의 영향이 없다.
3. [내부 동작] 왜 성능이 50배나 차이 나는가
세 도구의 비경쟁 비용이 lock ~20ns, SemaphoreSlim ~60ns, Mutex ~1000ns 로 무려 50배 가까이 벌어지는 이유는 "어디까지 내려가느냐" 에 있다.
3.1 lock — Thin Lock 과 SyncBlock 승격
.NET의 모든 참조 타입 객체 앞에는 8바이트짜리 헤더가 숨어 있다. 그 중 한 조각이 SyncBlock 인덱스. 평소에는 비어 있다가 lock(obj) 순간 활성화되어 소유 스레드 ID 를 기록한다. 별도 커널 객체도, 힙 할당도 없다. 이것이 "thin lock" 이다.
경쟁이 생기면 그제서야 SyncBlock 구조체가 힙에 할당되고(= heavy lock 승격) 대기 큐와 커널 이벤트가 붙는다. 즉 "필요할 때만 무거워진다".
3.2 SemaphoreSlim — 하이브리드 카운터
SpinWait — 스핀 대기 스레드를 커널 대기 상태로 보내지 않고 CPU에서 짧게 반복문을 돌며 "락이 금방 풀리겠지" 하고 기다리는 기법. 경합이 수 μs 이내에 끝날 것 같을 때 커널 전환 비용을 아끼는 용도.
예시:Thread.SpinWait(30)— 약 30회 busy loop 후 양보
SemaphoreSlim 은 내부에 단순 정수 카운터(m_currentCount) 를 들고 있다. Wait() 호출 시 흐름은 이렇다.
Interlocked.Decrement로 카운터를 원자적으로 줄여 본다 → 성공하면 끝(= fast path, ~60ns).- 실패(
count == 0) 하면 짧게SpinWait을 돌며 다른 스레드가Release()하길 기다린다. - 그래도 실패하면 그때서야 lazy 로 내부
ManualResetEventSlim/WaitHandle을 생성하여 커널 대기.
이 "3단 폴백" 덕분에 비경쟁 시 사용자 공간에만 머물며 lock 과 큰 차이가 없고, 무거워질 때만 커널로 내려간다.
3.3 Mutex — OS 커널 객체
반면 Mutex 는 처음부터 OS 커널 객체다. 생성자에서 이미 Win32 CreateMutex (혹은 Linux pthread mutex) 시스템 콜을 호출하고, WaitOne / ReleaseMutex 가 불릴 때마다 managed → native → kernel 세 단계를 뚫고 내려갔다 올라온다.
using System.Threading;
public class MutexSample
{
private static readonly Mutex _mutex = new Mutex(false, "Global\\MyApp.Lock");
public void DoWork()
{
_mutex.WaitOne();
try
{
// 보호 구역
}
finally
{
_mutex.ReleaseMutex();
}
}
}
.method public hidebysig instance void DoWork () cil managed
{
.maxstack 1
IL_0000: nop
IL_0001: ldsfld class [System.Threading]System.Threading.Mutex MutexSample::_mutex
IL_0006: callvirt instance bool [System.Runtime]System.Threading.WaitHandle::WaitOne() // 1. WaitHandle 로 커널 진입
IL_000b: pop
.try
{
IL_000c: nop
IL_000d: nop
IL_000e: leave.s IL_001e
}
finally
{
IL_0010: nop
IL_0011: ldsfld class [System.Threading]System.Threading.Mutex MutexSample::_mutex
IL_0016: callvirt instance void [System.Threading]System.Threading.Mutex::ReleaseMutex() // 2. 명시적 Release 호출
IL_001b: nop
IL_001c: nop
IL_001d: endfinally
}
IL_001e: ret
}
// 정적 생성자
.method private hidebysig specialname rtspecialname static void .cctor () cil managed
{
.maxstack 8
IL_0000: ldc.i4.0
IL_0001: ldstr "Global\\MyApp.Lock"
IL_0006: newobj instance void [System.Threading]System.Threading.Mutex::.ctor(bool, string) // 3. OS 레벨 Named Mutex 생성
IL_000b: stsfld class [System.Threading]System.Threading.Mutex MutexSample::_mutex
IL_0010: ret
}
IL 분석 포인트
lock과 달리 try-finally 를 컴파일러가 자동으로 감싸주지 않는다. 개발자가 직접ReleaseMutex()를 finally 에 넣어야 한다. 빼먹으면 AbandonedMutex 가 발생한다.WaitHandle::WaitOne()호출 자체가 managed → native 경계를 넘는다. 한 번 호출에 P/Invoke 비용이 들어간다.Global\\접두사 로 이름을 주면 Windows 전 세션에서 공유되는 네임드 커널 객체가 된다. 다른 프로세스가 같은 이름으로 열면 동일한 뮤텍스를 공유한다.- Unity 에서 이 코드가
Update()에 있다면 프레임당 수 μs 를 그냥 버리는 셈. 모바일 60fps 예산(16.6ms) 에 비하면 작지만, 핫패스에는 절대 들어가면 안 된다.
3.4 세 도구의 내부 구조 요약
| lock (Monitor) | SemaphoreSlim | Mutex | |
|---|---|---|---|
| 기본 위치 | 사용자 공간 (SyncBlock) | 사용자 공간 (카운터) | OS 커널 (WaitHandle) |
| 경쟁 시 | spin → 커널 이벤트 | spin → lazy 커널 세마포어 | (이미 커널) 커널 대기 |
| 힙 할당 | 비경쟁 = 없음, 경쟁 시 SyncBlock | 비경쟁 = 없음 (WaitAsync 시 Task 할당) | 생성 시 SafeWaitHandle |
| 비경쟁 비용 | ~20ns | ~60ns | ~1000ns |
4. [실전 적용] 네 가지 축으로 자르면 선택이 보인다
세 도구의 차이는 네 가지 축으로 수렴한다. 이 네 줄을 외우면 실전 선택이 자동화된다.
4.1 축 1 — 프로세스 경계를 넘는가
| 질문 | 답 |
|---|---|
| 같은 PC의 다른 프로세스와 동기화해야 하나? | Mutex (Named Mutex) |
| 아니다, 내 프로세스 안의 스레드끼리만 | lock 또는 SemaphoreSlim |
lock 과 SemaphoreSlim 은 힙 위 객체이므로 다른 프로세스는 볼 수 없다. Mutex 만 OS 커널 객체라서 이름을 통해 프로세스 경계를 넘는다.
Unity 모바일 게임 클라이언트 기준으로는 이 축에서 거의 100% "NO" 다. 모바일 앱은 단일 프로세스이고 OS 가 서드파티 프로세스 간 동기화를 허용하지도 않는다. 그래서 "Mutex 가 게임 코드에 보이면 뭔가 이상하다" 는 규칙이 성립한다.
4.2 축 2 — 보호 구역 안에 await 가 있는가
await— 비동기 대기 연산자 (await operator)Task나ValueTask가 완료될 때까지 스레드를 blocking 하지 않고 양보한 뒤, 완료되면 이어서 실행을 재개한다. 재개는 원래 스레드가 아닐 수 있다.
예시:await _gate.WaitAsync();— 세마포어가 풀릴 때까지 스레드를 반납하고 기다린다
lock 블록 안에서 await 를 쓰면 Roslyn 이 거부한다.
// ❌ 컴파일 에러: CS1996
lock (_gate)
{
await _httpClient.GetAsync(url);
}
이유는 thread affinity (스레드 친화성). Monitor.Enter 는 스레드 ID 를 기록해 둔다. 그런데 await 가 끝나고 실행을 재개하는 스레드는 다른 스레드일 수 있다. 다른 스레드가 Monitor.Exit 을 부르면 SynchronizationLockException 이다. Mutex 도 동일한 이유로 async 와 맞지 않는다.
반면 SemaphoreSlim 은 소유자 개념이 없고 단순 카운터다. 아무 스레드나 Release() 를 부를 수 있으므로 await 를 자유롭게 포함할 수 있다.
using System.Threading;
using System.Threading.Tasks;
public class AsyncGuardSample
{
private readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1);
public async Task InitAsync()
{
await _gate.WaitAsync();
try
{
await Task.Delay(10);
}
finally
{
_gate.Release();
}
}
}
// InitAsync 본체 — 상태 기계 인스턴스 생성 후 Start
.method public hidebysig instance class [System.Runtime]System.Threading.Tasks.Task InitAsync () cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.AsyncStateMachineAttribute::.ctor(...)
.locals init ( [0] class AsyncGuardSample/'<InitAsync>d__1' )
IL_0000: newobj instance void AsyncGuardSample/'<InitAsync>d__1'::.ctor() // 1. 상태 기계 클래스 할당 (힙)
IL_0007: call AsyncTaskMethodBuilder::Create()
IL_000c: stfld '<>t__builder'
IL_0013: stfld '<>4__this' // 2. this 포인터 저장
IL_001a: stfld '<>1__state' // 3. state = -1 초기화
IL_0020: ldflda '<>t__builder'
IL_0027: call AsyncTaskMethodBuilder::Start<...>(!!0&) // 4. 상태 기계 실행 시작
IL_0032: call AsyncTaskMethodBuilder::get_Task() // 5. Task 반환
}
// 컴파일러가 만든 상태 기계 MoveNext (핵심 부분만)
.method instance void MoveNext () cil managed
{
IL_001d: ldfld class SemaphoreSlim AsyncGuardSample::_gate
IL_0022: callvirt SemaphoreSlim::WaitAsync() // 6. WaitAsync — Task 반환
IL_0027: callvirt Task::GetAwaiter()
IL_002f: call TaskAwaiter::get_IsCompleted() // 7. 이미 완료면 동기 처리
IL_0034: brtrue.s IL_0056
IL_003a: stfld '<>1__state' // 8. 미완료면 state 저장
IL_0052: call AsyncTaskMethodBuilder::AwaitUnsafeOnCompleted<...> // 9. 스케줄러에 continuation 등록 → 스레드 양보
// ... Task.Delay(10) 도 같은 패턴으로 두 번째 await 처리 ...
IL_00f7: ldfld class SemaphoreSlim AsyncGuardSample::_gate
IL_00fc: callvirt SemaphoreSlim::Release() // 10. finally 에서 Release
IL_012e: call AsyncTaskMethodBuilder::SetResult()
}
IL 분석 포인트
async메서드는<InitAsync>d__1이라는 클래스로 변환된다.newobj= 힙 할당 1회. 이 자체가 async/await 의 고정 오버헤드.WaitAsync()는Task를 반환한다. 커널로 내려가지 않고 사용자 공간에서 continuation 을 큐에 건다.AwaitUnsafeOnCompleted가 호출되는 순간 스레드는 풀로 반납된다. 메인 스레드라면 Unity 는 즉시 다음 프레임을 그릴 수 있다.- 같은 코드를
lock으로 바꾸면 아예 컴파일조차 안 된다. Roslyn 이 이 상태 기계 변환을 막기 위해 CS1996 을 낸다.
4.3 축 3 — 같은 스레드가 재진입하는가
같은 스레드가 이미 잡은 락을 또 잡으러 들어오는 경우가 있다. 재귀 함수, 또는 락 안에서 다른 메서드를 부르다가 그 메서드가 같은 락을 또 거는 경우다.
// lock — 재진입 OK (카운트만 증가)
lock (_gate)
{
lock (_gate) // 같은 스레드 → 그냥 통과
{
// ...
}
}
// SemaphoreSlim — 재진입 금지 (자기 자신과 데드락)
_gate.Wait();
_gate.Wait(); // ❌ 카운트가 0 이므로 자기 자신을 영원히 기다린다
lock 과 Mutex 는 소유 스레드 ID + recursion count 를 기록하므로 재진입 시 카운트만 올리고 통과한다. 해제도 동일 횟수만큼 불러야 진짜 해제된다.
SemaphoreSlim 은 소유자 개념이 없고 단순 카운터라, 같은 스레드든 다른 스레드든 Wait 한 번에 카운트 1이 소모된다. 초기 카운트가 1인 세마포어라면 같은 스레드의 두 번째 Wait 에서 자기가 만든 락에 자기가 막힌다.
재진입이 필요하다면 설계가 잘못됐을 가능성이 크다. 락 안에서 외부 메서드를 호출하면 예측 불가능한 재진입이 일어난다. 모던 C# 에서는 락 구간을 좁게, 그 안에서는 외부 호출 금지 를 원칙으로 삼는다.
4.4 축 4 — 성능이 임계인가
| 호출 빈도 | 권장 |
|---|---|
| 프레임당 수천 번 (Update 핫패스) | lock 또는 Interlocked / 아예 lock-free 설계 |
| 초당 수~수십 번 (네트워크 패킷 큐 등) | lock · SemaphoreSlim 어느 쪽이든 OK |
| 초당 1번 이하 (초기화, 로그인) | 어느 것이든 상관없음 |
모바일 기기의 main thread 에서 한 번의 락이 100ns 늦어지면, 60fps(16.6ms) 프레임에는 0.0006% 영향이지만, 그 락이 프레임당 1,000번 걸리면 0.6% · 10,000번이면 6% 다. "무조건 Mutex 는 피한다" 는 게 이 축의 결론.
4.5 선택 의사결정 플로우
4.6 Before/After — Unity 패킷 큐 보호
Before — 잘못된 패턴: 메인 스레드에서 SemaphoreSlim.Wait() 동기 호출
Unity 의 네트워크 스레드가 패킷을 받아 큐에 넣고, 메인 스레드 Update() 가 꺼내는 전형적인 시나리오다.
// ❌ 메인 스레드에서 동기 Wait → 프레임 drop · 자기 자신과 데드락 위험
private readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1);
private readonly Queue<Packet> _queue = new();
void Update()
{
_gate.Wait(); // 백그라운드 스레드와 경쟁 시 커널 대기까지 내려감
try
{
while (_queue.Count > 0) Process(_queue.Dequeue());
}
finally { _gate.Release(); }
}
SemaphoreSlim은 재진입 불가 →Process()내부에서 실수로_gate.Wait()을 또 부르면 영구 데드락.- 동기
Wait()는 보호 구간이 짧아도 ~60ns + 경쟁 시 커널 비용. - 여기에 await 가 없으니 SemaphoreSlim 을 쓸 이유가 없다.
After — lock 으로 교체
// ✅ 재진입 가능, thin lock → 비경쟁 ~20ns
private readonly object _gate = new object();
private readonly Queue<Packet> _queue = new();
void Update()
{
lock (_gate)
{
while (_queue.Count > 0) Process(_queue.Dequeue());
}
}
- 재진입 허용으로 실수 방어.
- SyncBlock thin lock 으로 비경쟁 시 초경량.
async가 없으니lock이 자연스럽다.
5. [함정과 주의사항] 세 도구 각각의 지뢰밭
5.1 lock(this) / lock(typeof(X)) / lock(string) 금지
// ❌ 외부 코드도 같은 인스턴스를 잡을 수 있음
public class BadLock
{
public void Do()
{
lock (this)
{
// ...
}
}
}
// ✅ private readonly 전용 객체
public class GoodLock
{
private readonly object _gate = new object();
public void Do()
{
lock (_gate)
{
// ...
}
}
}
두 코드의 IL 을 비교해 본다.
// BadLock::Do — lock(this)
.locals init (
[0] class BadLock, // 잠금 대상 = this 자신!
[1] bool
)
IL_0001: ldarg.0 // this
IL_0002: stloc.0 // 잠금 대상으로 저장
IL_0005: ldloc.0
IL_0008: call Monitor::Enter(object, bool&)
// ... _gate 필드 접근이 아예 없음
// GoodLock::Do — lock(_gate)
.locals init (
[0] object, // 잠금 대상 = _gate (private object)
[1] bool
)
IL_0001: ldarg.0
IL_0002: ldfld object GoodLock::_gate // 별도 전용 객체 로드
IL_0007: stloc.0
IL_000a: ldloc.0
IL_000d: call Monitor::Enter(object, bool&)
IL 분석 포인트
lock(this)는 IL 상에서ldarg.0(this) 을 잠금 대상으로 그대로 사용한다. 외부에서BadLock인스턴스를 참조할 수 있는 누구나 같은 객체를 잠글 수 있다. 교착이 터져도 원인 추적이 지옥이다.lock(typeof(X))는 전역 Type 객체를 잠그므로 모든 AppDomain 이 같은 락을 공유한다.lock("상수문자열")은 인터닝(interning) 때문에 다른 어셈블리와 공유된다.- 규칙:
private readonly object _gate = new object();전용 객체를 만들어라. 잠금 객체는 다른 용도로 절대 쓰지 말 것.
5.2 SemaphoreSlim 재진입 데드락
// ❌ 같은 스레드가 두 번 Wait → 두 번째에서 영구 대기
private readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1);
public async Task Outer()
{
await _gate.WaitAsync();
try { await Inner(); }
finally { _gate.Release(); }
}
public async Task Inner()
{
await _gate.WaitAsync(); // ❌ 카운트가 0 → 자기 자신 대기
// 도달 불가
}
Outer 가 await 로 양보한 뒤 Inner 가 다른 스레드 풀 스레드에서 실행되든 같은 스레드에서 실행되든, SemaphoreSlim 은 소유자 개념이 없으므로 카운트가 0 이면 무조건 대기한다.
해결: Inner 는 락을 잡지 않는 private 헬퍼로 분리하거나, "락 안에서 다른 async 메서드를 호출하지 않는다" 원칙을 지킨다.
5.3 Mutex AbandonedMutexException
// ❌ ReleaseMutex 없이 스레드 종료 → 다음 WaitOne 에서 AbandonedMutexException
Mutex m = new Mutex();
m.WaitOne();
// 예외 발생! ReleaseMutex 호출 없음
throw new Exception();
Mutex 를 획득한 스레드가 ReleaseMutex() 없이 종료되면 OS 는 해당 뮤텍스를 "버려진(abandoned)" 상태로 표시한다. 다음으로 WaitOne() 하는 스레드는 AbandonedMutexException 을 받는데, 기본적으로 락은 획득된 상태로 넘어오므로 보호 구역의 데이터가 깨져 있을 수 있다.
해결: 반드시try-finally로 감싸고 finally 에ReleaseMutex()를 넣는다.lock은 컴파일러가 자동으로 해주지만Mutex는 수동이다.
5.4 Unity 함정: 메인 스레드에서 WaitAsync 의 sync-over-async
// ❌ 메인 스레드에서 .Result / .Wait() → SynchronizationContext 데드락
void OnClick()
{
_gate.WaitAsync().Wait(); // WaitAsync의 continuation 이 메인 스레드 복귀를 기다리는데, 메인 스레드는 Wait() 로 막혀있음
}
Unity 의 기본 SynchronizationContext (Unity main thread) 에서 Task.Wait() / .Result 를 부르면 고전적인 sync-over-async 데드락이 터진다. async void OnClick() 으로 바꾸고 await _gate.WaitAsync(); 로 자연스럽게 흘려 보내야 한다.
6. [C# 버전별 변화] async/await 이후의 진화
| 버전 | 변화 | 영향 |
|---|---|---|
| C# 1.0 | lock 키워드 · Monitor · Mutex 등 기본 제공 |
동기 시대의 도구들 |
| .NET 4.0 (2010) | SemaphoreSlim 도입 |
커널 전환 비용 없는 경량 세마포어 — 서버 환경 확장성 대응 |
| .NET 4.5 (2012) · C# 5 | async/await + SemaphoreSlim.WaitAsync |
비동기 상호 배제의 표준 탄생. lock 은 async 와 공존 불가로 확정 |
| C# 8.0 (2019) | IAsyncDisposable · await using |
SemaphoreSlim 해제 패턴이 깔끔해짐 — 실제 API 는 Disposable 래퍼 수준 |
| .NET 9 · C# 13 (2024) | System.Threading.Lock 타입 도입 (new Lock()) |
lock 문에 object 대신 전용 Lock 타입 사용 가능 — 컴파일러가 정적으로 최적화 |
6.1 C# 13 — System.Threading.Lock (Before/After)
Before (C# 12 이전): 전통적인 object 기반 lock
// C# 12 이전
private readonly object _gate = new object();
public void Do()
{
lock (_gate)
{
// ...
}
}
IL 은 앞서 본 것과 동일한 Monitor.Enter / Monitor.Exit 패턴이다.
After (C# 13+): System.Threading.Lock 타입 사용
// C# 13+
using System.Threading;
private readonly Lock _gate = new();
public void Do()
{
lock (_gate) // 컴파일러가 Lock 타입을 감지해 EnterScope() 로 최적화
{
// ...
}
}
컴파일러는 lock 문의 대상이 System.Threading.Lock 타입이면 Monitor.Enter 대신 Lock.EnterScope() 를 호출하는 IL 을 생성한다. 내부적으로 ref struct 기반 스코프 해제를 써서 힙 할당 없이 처리한다.
주의: C# 13 의Lock타입 자체가 없는 Unity 구형 런타임에서는 여전히object기반lock을 쓴다. Unity 2023 LTS · 6.0 이 각각 어느 C# 버전을 지원하는지 확인하고 도입한다.
6.2 SemaphoreSlim 은 언어 변화와 거의 무관
SemaphoreSlim 의 API 표면은 .NET 4.0 도입 이후 크게 바뀌지 않았다. 단, async/await 와 함께 쓰는 관용 패턴 이 정착됐다:
// 표준 관용구 — 반드시 try-finally
await _gate.WaitAsync(cancellationToken);
try
{
// 보호 구역 (await 포함 가능)
}
finally
{
_gate.Release();
}
이 3줄 패턴을 외워 두면 async 상호 배제의 99% 를 처리한다.
7. [정리] 10초 결정 체크리스트
이것만 기억하자
lock→ 단일 프로세스, 동기 코드, 짧은 임계 구역. 기본값.SemaphoreSlim(1, 1)→ 같은 조건 +await가 포함된 경우. async 세계의lock대체.SemaphoreSlim(N, N)→ 동시 접근 수 제한이 필요한 경우 (다운로드 3개까지, DB 커넥션 풀 등).Mutex→ 프로세스 간 동기화가 필요한 경우에만. 모바일 게임 코드에 등장하면 거의 실수다.lock(this)/lock(typeof(X))/lock("literal")금지.private readonly object _gate = new object();.SemaphoreSlim은 재진입 금지. 같은 스레드가 두 번Wait하면 데드락.Mutex는 반드시try-finally로ReleaseMutex()보장. 안 하면 AbandonedMutexException.- C# 13+ 에서는
System.Threading.Lock타입으로 lock 을 더 안전하게 쓸 수 있다 (런타임 지원 확인 필수).
Unity 모바일 실전 코드 템플릿
// 1. 프레임당 호출되는 핫패스의 공유 큐 — lock
private readonly object _gate = new object();
// 2. 비동기 초기화 중복 방지 — SemaphoreSlim(1,1)
private readonly SemaphoreSlim _initGate = new SemaphoreSlim(1, 1);
public async Task InitOnceAsync()
{
await _initGate.WaitAsync();
try { if (!_initialized) await InitCoreAsync(); }
finally { _initGate.Release(); }
}
// 3. 동시 다운로드 3개 제한 — SemaphoreSlim(3,3)
private readonly SemaphoreSlim _downloadGate = new SemaphoreSlim(3, 3);
// 4. Named Mutex — 모바일에서는 거의 사용 안 함. 데스크톱 단일 인스턴스만.
이 네 템플릿이면 Unity 모바일 클라이언트의 상호 배제 요구사항을 거의 다 커버한다. 고민이 될 때마다 다시 이 글의 플로우차트를 따라가면 된다.
'C# 심화' 카테고리의 다른 글
| [PART11.비동기와 동시성(12/12)] Channel<T> — 생산자-소비자 패턴의 현대적 구현 (0) | 2026.04.14 |
|---|---|
| [PART11.비동기와 동시성(11/12)] volatile과 Interlocked — lock 없이 스레드 안전을 확보하는 법 (1) | 2026.04.14 |
| [PART11.비동기와 동시성(9/12)] lock — 임계 구역을 보호하는 원리 (0) | 2026.04.14 |
| [PART11.비동기와 동시성(8/12)] IAsyncEnumerable<T> — 비동기 스트림이란 무엇인가 (0) | 2026.04.14 |
| [PART11.비동기와 동시성(7/12)] ValueTask — 언제 Task 대신 쓰는가 (0) | 2026.04.14 |