반응형

[PART11.비동기와 동시성(9/12)] lock — 임계 구역을 보호하는 원리

lock 키워드의 실체 (Monitor.Enter/Exit) / SyncBlock과 Thin→Fat Lock 전환 / 데드락 4조건 / Unity 메인 스레드 블록킹을 피하는 법


1. 왜 lock이 필요한가

Unity로 모바일 RPG를 만든다고 하자. 백그라운드 스레드에서 서버로부터 "골드 +500" 보상을 받고, 같은 순간 메인 스레드는 UI에서 "100골드로 포션 구매" 처리를 한다. 두 스레드가 동시에 player.Gold 필드에 접근하면 어떻게 될까.

C#에서 count++ 같은 한 줄짜리 코드는 사실 CPU 수준에서는 세 단계로 쪼개진다 — "메모리에서 값을 읽고 → 1을 더하고 → 메모리에 쓴다." 이 세 단계 사이에 다른 스레드가 끼어들면, 두 스레드가 모두 같은 값(예: 1000)을 읽은 뒤 각자 +1, +500을 더해 쓰는 바람에 최종 값이 예상과 달라진다.

이처럼 여러 스레드가 공유 데이터에 동시 접근할 때 실행 순서에 따라 결과가 달라지는 상태를 경쟁 상태(Race Condition)라 부른다. 이를 막으려면 공유 데이터를 건드리는 코드 블록, 즉 임계 구역(Critical Section)을 한 번에 오직 한 스레드만 실행할 수 있도록 보호해야 한다.

lock 키워드는 이 임계 구역 보호를 위해 C#이 제공하는 가장 기본적인 도구다. 그리고 컴파일러가 실제로 어떤 IL(Intermediate Language, C# 컴파일러가 생성하는 중간 언어. JIT이 이를 기계어로 변환한다)을 생성하는지를 들여다보면, lock이 "마법의 키워드"가 아니라 System.Threading.Monitor 클래스 호출로 확장되는 문법적 설탕이라는 사실을 알 수 있다.


2. 개념 정의 — lock은 무엇인가

비유: 한 명만 들어갈 수 있는 화장실

lock을 가장 쉽게 떠올리는 비유는 "공용 화장실 열쇠"다.

  • 화장실 = 공유 데이터를 수정하는 코드 블록 (임계 구역)
  • 열쇠 = lock에 전달한 객체
  • 사람 = 스레드

한 사람이 열쇠를 받고 문을 잠그고 들어가면, 다른 사람들은 그 사람이 나와서 열쇠를 반납할 때까지 밖에서 줄을 서서 기다린다. 어떤 사람이 들어갔는지, 얼마나 오래 있는지는 열쇠가 보장한다.

구조 시각화

임계 구역 (Critical Section)

기본 예시 코드

Unity 신입 개발자가 가장 많이 쓰게 될 형태는 이것이다.

C#
using System.Threading;

public class Counter
{
    private readonly object _lockObj = new object(); // 잠금 전용 객체
    private int _count;

    public void Increment()
    {
        lock (_lockObj)
        {
            _count++;           // 임계 구역 — 한 스레드만 실행
        }
    }
}
readonly — 읽기 전용 (read-only) 필드 한정자 필드를 선언 시점 또는 생성자에서만 값을 할당할 수 있게 제한한다. lock 객체가 중간에 바뀌면 서로 다른 객체를 잠그게 되어 동기화가 깨지므로, 잠금 객체는 반드시 readonly로 선언한다.
예시: private readonly object _lockObj = new object(); 이후 _lockObj = ...로 재할당 시 컴파일 오류가 발생해 실수로 잠금이 깨지는 것을 막아준다.

코드 한 줄짜리 _count++를 지키려고 lock까지 써야 하는 이유는 위에서 설명한 대로 — count++가 CPU 수준에서 원자적이 아니기 때문이다.

IL 분석 — lock의 실체

Increment 메서드를 C# 컴파일러가 어떻게 변환했는지 IL로 확인해 보자.

IL
.method public hidebysig instance void Increment () cil managed
{
    .maxstack 3
    .locals init (
        [0] object,   // lock 대상 임시 변수
        [1] bool      // lockTaken 플래그
    )

    IL_0000: ldarg.0
    IL_0001: ldfld    object Counter::_lockObj   // _lockObj 필드를 스택에 로드
    IL_0006: stloc.0                              // 임시 변수에 저장 (필드 변경 대비)
    IL_0007: ldc.i4.0                             // false 푸시
    IL_0008: stloc.1                              // lockTaken = false

    .try
    {
        IL_0009: ldloc.0                          // _lockObj 로드
        IL_000a: ldloca.s  1                      // lockTaken의 주소 (ref bool)
        IL_000c: call      Monitor::Enter(object, bool&)  // ★ 락 획득
        IL_0011: ldarg.0
        IL_0012: ldarg.0
        IL_0013: ldfld     int32 Counter::_count
        IL_0018: ldc.i4.1
        IL_0019: add                              // _count + 1
        IL_001a: stfld     int32 Counter::_count  // _count = _count + 1
        IL_001f: leave.s   IL_002b                // try 블록 정상 탈출
    }
    finally
    {
        IL_0021: ldloc.1                          // lockTaken 확인
        IL_0022: brfalse.s IL_002a                // false면 Exit 호출 생략
        IL_0024: ldloc.0
        IL_0025: call      Monitor::Exit(object)  // ★ 락 해제
        IL_002a: endfinally
    }
    IL_002b: ret
}

IL을 한 줄씩 읽으면 lock 키워드가 어떻게 풀어지는지 보인다. 본질은 Monitor.EnterMonitor.Exit 한 쌍, 그리고 예외가 나도 반드시 해제되게 하는 try-finally다. 이를 C# 소스로 되돌리면 아래와 같다.

C#
// lock(_lockObj) { _count++; } 의 실제 모습
object temp = _lockObj;     // 필드가 중간에 바뀌어도 Enter/Exit이 같은 객체를 보도록 고정
bool lockTaken = false;
try
{
    Monitor.Enter(temp, ref lockTaken);   // 락 획득 시도
    _count++;                              // 임계 구역
}
finally
{
    if (lockTaken) Monitor.Exit(temp);    // 획득했을 때만 해제
}

여기서 lockTaken이라는 ref bool 플래그가 핵심이다. Monitor.Enter 호출 도중에 ThreadAbortException 같은 비동기 예외가 발생하면 "락은 잡았는데 호출자에게 제어가 돌아오지 않아 Exit 호출을 영영 놓치는" 상황이 생길 수 있다. C# 4.0부터는 Enter가 락을 실제로 획득하기 직전에 lockTakentrue로 세팅하는 ref bool 오버로드로 바꾸었고, finally에서는 이 플래그를 본 뒤에만 Exit을 호출하도록 컴파일한다. 이 덕분에 락 누수가 원천적으로 차단된다.


3. 내부 동작 — Monitor는 어떻게 작동하는가

모든 객체에 숨어 있는 동기화 슬롯

"lock에 아무 object나 넘길 수 있다"는 사실이 이상하게 느껴져야 정상이다. 어떻게 평범한 객체 하나에 "누가 들어갔는지", "몇 명이 줄 서 있는지"를 저장할 수 있을까.

답은 CLR(Common Language Runtime, .NET의 실행 환경)의 객체 레이아웃에 있다. 힙(Heap)에 할당되는 모든 참조 타입 객체는 머리에 객체 헤더(Object Header)라는 숨겨진 영역을 갖는다. 이 헤더에는 타입 정보 포인터(메서드 테이블 주소)뿐 아니라 동기화 블록 인덱스(Sync Block Index) 자리가 함께 들어있다.

구조 시각화

힙(Heap)에 할당된 객체

Thin Lock과 Fat Lock — 성능 최적화

CLR은 lock이 실제로 경합하는 경우가 드물다는 통계를 활용한다. 처음에는 Thin Lock으로 동작한다 — 객체 헤더의 Sync Block Index 자리를 그대로 사용해 "누가 들어갔는지"를 나타내는 스레드 ID만 비트 연산으로 CAS(Compare-And-Swap, CPU가 제공하는 원자적 비교-교환 명령)해서 기록한다. 이 경로는 커널 모드로 내려가지 않고 사용자 모드에서 10~20ns(나노초) 내에 끝난다.

다른 스레드가 같은 락을 잡으려 해서 경합(Contention)이 발생하면 CLR이 해당 객체를 Fat Lock으로 승격한다. 전역 SyncBlock 테이블에서 빈 슬롯을 찾아 인덱스를 연결하고, 여기에 대기 큐·재귀 카운트·커널 이벤트 핸들 같은 풍성한 정보를 담는다. 이때부터 대기 스레드는 OS 커널의 대기 큐로 내려가 스케줄러가 깨워 준다 — 비용은 수 마이크로초 수준으로 뛴다.

정리하면:

상태 저장 위치 비용(대략) 언제 전환
Thin Lock 객체 헤더 비트 10~20 ns 락을 처음 잡을 때 기본
Fat Lock SyncBlock 테이블 수백 ns ~ 수 μs 경합·대기·재귀 시 승격

코드로 확인 — Monitor.Enter/Exit의 인자가 같아야 하는 이유

lock이 자동으로 만들어준 temp 지역 변수가 왜 필요한지는 다음 코드로 바로 드러난다.

C#
// ❌ 필드를 바로 Enter/Exit에 넘기면 위험
object _lockObj = new object();

void DangerousSwap()
{
    Monitor.Enter(_lockObj);
    _lockObj = new object();   // 필드를 교체
    Monitor.Exit(_lockObj);    // 다른 객체를 Exit → SynchronizationLockException
}
C#
// ✅ lock 컴파일 결과처럼 임시 변수에 고정
object _lockObj = new object();

void SafeEnterExit()
{
    object temp = _lockObj;
    bool taken = false;
    try
    {
        Monitor.Enter(temp, ref taken);
        // _lockObj = new object();  // 필드를 바꿔도
    }
    finally
    {
        if (taken) Monitor.Exit(temp); // Exit는 Enter와 같은 객체에 대해 호출
    }
}

컴파일러가 임시 변수를 만들어 고정하기 때문에, lock 블록 내부에서 _lockObj 필드 자체가 다른 값으로 바뀌어도 Exit은 원래 잠근 객체에 대해 호출된다. 이 동작이 없다면 Monitor.Exit은 "락을 잡지도 않은 객체를 풀려 하네"라며 SynchronizationLockException을 던진다.


4. 실전 적용 — Unity에서 lock 쓰기

판단 기준

Unity 모바일 게임에서 lock이 실제로 필요한 상황은 많지 않다. 게임 로직 대부분은 메인 스레드(=Update/LateUpdate 루프)에서만 돌아가므로 애초에 공유 경합이 없다. 하지만 다음 케이스에서는 lock을 피할 수 없다.

  • 네트워크 수신 스레드 ↔ 메인 스레드 — 서버에서 받은 이벤트를 큐에 쌓아 메인 스레드에서 소비
  • 백그라운드 I/O 스레드 ↔ 메인 스레드 — 비동기 파일 저장/로딩 완료 플래그 공유
  • 로깅 — 여러 스레드가 같은 로그 파일 스트림에 쓰기
  • 오브젝트 풀 / 리소스 캐시 — 여러 워커 스레드가 공통 풀에서 가져오고 반환

Before/After — Thread-safe 큐 구현

서버 메시지를 받는 네트워크 스레드와 이를 처리하는 메인 스레드를 잇는 큐를 만들어 보자.

❌ Before — 락 없이 List<T> 공유

C#
using System.Collections.Generic;
using UnityEngine;

public class NetEventBroker : MonoBehaviour
{
    private List<string> _queue = new List<string>();

    // 네트워크 스레드에서 호출
    public void Enqueue(string msg)
    {
        _queue.Add(msg);        // 다른 스레드가 동시에 Add/제거 → InvalidOperationException
    }

    // 메인 스레드의 Update에서 호출
    void Update()
    {
        foreach (var m in _queue) Debug.Log(m);  // foreach 도중 Add되면 예외
        _queue.Clear();
    }
}

List<T>는 스레드 안전하지 않다. 두 스레드가 동시에 Add를 호출하면 내부 배열 확장 중 인덱스가 꼬여 일부 데이터가 유실되거나, foreach 도중 컬렉션이 수정되면 InvalidOperationException: Collection was modified가 발생한다.

✅ After — lock으로 보호

C#
using System.Collections.Generic;
using UnityEngine;

public class NetEventBroker : MonoBehaviour
{
    private readonly object _queueLock = new object();
    private readonly List<string> _queue = new List<string>();
    private readonly List<string> _drainBuffer = new List<string>();

    // 네트워크 스레드에서 호출
    public void Enqueue(string msg)
    {
        lock (_queueLock)
        {
            _queue.Add(msg);
        }
    }

    // 메인 스레드의 Update에서 호출
    void Update()
    {
        lock (_queueLock)
        {
            // 락 구간 최소화 — 데이터만 스왑하고 바로 빠져나온다
            (_drainBuffer.Clear, _queue.ForEach);
            _drainBuffer.AddRange(_queue);
            _queue.Clear();
        }

        // 무거운 로그 출력은 락 바깥에서
        foreach (var m in _drainBuffer) Debug.Log(m);
    }
}

포인트는 락 구간을 최소화한 것이다. Debug.Log는 문자열 포매팅·파일 I/O를 동반하므로 락 안에서 호출하면 수 밀리초 동안 메인 스레드가 네트워크 스레드를 블록킹할 수 있다. 위 After 코드처럼 큐 데이터만 버퍼로 옮기고, 실제 처리는 락 바깥에서 한다.

IL로 확인 — After 코드의 락 구간이 얼마나 짧은지

After 버전의 Enqueue 메서드만 떼어서 IL을 보면 락 블록의 실질 크기가 눈에 들어온다.

IL
.method public hidebysig instance void Enqueue (string msg) cil managed
{
    .try
    {
        IL_0009: ldloc.0                          // _queueLock
        IL_000a: ldloca.s   1
        IL_000c: call       Monitor::Enter(object, bool&)
        IL_0011: ldarg.0
        IL_0012: ldfld      List`1 NetEventBroker::_queue
        IL_0017: ldarg.1                          // msg
        IL_0018: callvirt   void List`1::Add(!0)  // List<string>.Add
        IL_001d: leave.s    IL_0029
    }
    finally
    {
        IL_001f: ldloc.1
        IL_0020: brfalse.s  IL_0028
        IL_0022: ldloc.0
        IL_0023: call       Monitor::Exit(object)
        IL_0028: endfinally
    }
}

.try 블록 내부의 IL 명령어는 단 4개다 — 필드 로드, 인자 로드, callvirt List.Add, leave. 이 정도면 경합 없는 경로에서 Thin Lock이 수십 나노초 안에 끝난다. 반면 앞서 언급한 "나쁜 After"(락 안에서 Debug.Log 호출)는 callvirt 한 번이 수 밀리초로 뛸 수 있다. IL 레벨에서 .try 블록이 얼마나 얇은지를 보는 것이 락 최적화의 첫 단계다.

Unity 실전 — Monitor.TryEnter로 메인 스레드 프리징 방지

메인 스레드의 Update에서 락을 걸 때, 네트워크 스레드가 락을 오래 쥐고 있다면 게임 화면이 그 시간만큼 멈춘다(프리징). 60 FPS 기준으로 한 프레임 예산이 약 16.6ms인데, 락 대기만 5ms가 생기면 프레임 드랍이 눈에 띄게 드러난다.

C#
using System.Threading;
using UnityEngine;

public class CacheConsumer : MonoBehaviour
{
    private readonly object _cacheLock = new object();

    void Update()
    {
        // 락을 0ms 동안만 시도 — 즉시 실패면 다음 프레임에 재시도
        bool taken = false;
        try
        {
            Monitor.TryEnter(_cacheLock, 0, ref taken);
            if (taken)
            {
                // 캐시 스냅샷을 짧게 읽기만
            }
            // taken이 false면 이번 프레임은 건너뛰기
        }
        finally
        {
            if (taken) Monitor.Exit(_cacheLock);
        }
    }
}
Monitor.TryEnter — 타임아웃이 있는 락 획득 지정한 밀리초 안에 락을 얻으면 lockTakentrue로 세팅하고, 실패하면 false로 남겨둔 채 반환한다. 타임아웃 값을 0으로 주면 "지금 당장 안 되면 포기"라는 의미로 해석된다.
예시: Monitor.TryEnter(lockObj, 0, ref taken); takenfalse면 락을 잡지 않은 것이므로 Exit을 호출하면 안 된다.

Monitor.TryEnter는 메인 스레드가 "이번 프레임에 못 하면 다음 프레임에 하자"고 결정할 수 있게 해 준다. 모바일에서 프리징을 줄이는 가장 현실적인 방법이다.


5. 함정과 주의사항

❌ 함정 1 — lock(this)는 절대 쓰지 않는다

외부 코드가 해당 인스턴스를 그대로 lock할 수 있기 때문에, 완전히 무관한 코드가 서로의 락을 방해할 수 있다.

❌ 잘못된 코드

C#
public class BadService
{
    private int _count;

    public void Increment()
    {
        lock (this)           // 외부에서 BadService 인스턴스를 lock할 수 있음
        {
            _count++;
        }
    }
}

// 어디선가 — 예: 별 상관없는 다른 클래스
var svc = new BadService();
lock (svc)                    // 내부 lock(this)와 같은 객체를 잠가 버림
{
    Thread.Sleep(10_000);     // svc.Increment()가 10초 동안 블록
}

✅ 올바른 코드

C#
public class GoodService
{
    private readonly object _lockObj = new object();  // 외부에서 접근 불가
    private int _count;

    public void Increment()
    {
        lock (_lockObj)
        {
            _count++;
        }
    }
}

IL 관점에서 lock(this)lock(_lockObj)와 같은 Monitor.Enter/Exit 쌍을 만든다 — 유일한 차이는 락 대상 객체뿐이다. 하지만 그 "대상이 무엇이냐"가 캡슐화를 살리느냐 깨느냐를 결정한다.

❌ 함정 2 — lock(typeof(T)), lock("문자열 리터럴"), lock(값 타입)

세 가지 금지 패턴 모두 전역 공유 객체를 잠그는 결과가 되거나, 매번 박싱(Boxing)으로 다른 객체를 잠그게 된다.

C#
// ❌ 모두 금지
lock (typeof(MyClass)) { }     // Type 객체는 AppDomain 전역 — 엉뚱한 코드까지 블록
lock ("GAME_LOCK") { }          // 문자열 인터닝으로 같은 리터럴은 하나의 객체 — 전역 락과 동일
int id = 42;
lock (id) { }                   // 컴파일 오류 — 값 타입은 lock 대상이 될 수 없음
문자열 인터닝 (String Interning) 동일한 문자열 리터럴은 CLR이 하나의 string 인스턴스로 통합 관리한다. 코드 A의 "GAME_LOCK"과 코드 B의 "GAME_LOCK"같은 참조이므로, lock("GAME_LOCK")은 애플리케이션 전역 락이 된다.

C# 컴파일러는 lock에 값 타입을 넘기면 명시적으로 컴파일 오류(CS0185: 'int' is not a reference type as required by the lock statement)를 낸다. 과거에는 박싱된 object로 동작했지만, 매번 다른 박스 객체가 생성되어 동기화가 전혀 안 되는 미묘한 버그를 만들었다. 지금은 아예 막혀 있다.

❌ 함정 3 — lock 블록 안에서 await 사용 금지 (CS1996)

C#
// ❌ 컴파일 오류: CS1996 Cannot await in the body of a lock statement
lock (_lockObj)
{
    await SomeAsyncOp();    // 컴파일러가 거부
}

Monitor스레드 친화성(Thread Affinity)을 갖는다 — 락을 획득한 스레드만 해제할 수 있다. await는 현재 스레드를 반납하고 이후 코드를 다른 스레드 풀 스레드에서 재개할 수 있는데, 그러면 해제 스레드가 획득 스레드와 달라져 SynchronizationLockException이 터진다. C# 컴파일러는 이 위험을 원천 차단하려고 lock 블록 내부에 await가 나오면 CS1996 오류를 낸다.

✅ 해결책 — SemaphoreSlim

C#
private readonly SemaphoreSlim _asyncGate = new SemaphoreSlim(1, 1);

public async Task SafeAsync()
{
    await _asyncGate.WaitAsync();
    try
    {
        await SomeAsyncOp();   // 이제 허용됨
    }
    finally
    {
        _asyncGate.Release();
    }
}

SemaphoreSlim은 스레드 친화성 제약이 없다 — 누가 Release해도 된다.

❌ 함정 4 — 데드락의 4조건과 "락 순서"

데드락(Deadlock)은 두 개 이상의 스레드가 서로가 가진 락을 기다려 영영 진행하지 못하는 상태다. 운영체제 이론에서는 Coffman이 정리한 4가지 조건모두 충족될 때만 데드락이 발생한다고 말한다.

조건 의미
상호 배제 (Mutual Exclusion) 자원을 한 번에 한 스레드만 점유 가능
점유와 대기 (Hold and Wait) 자원을 쥔 채로 다른 자원을 추가로 요청
비선점 (No Preemption) 다른 스레드의 자원을 강제로 빼앗을 수 없음
순환 대기 (Circular Wait) A→B→C→A 처럼 대기 관계가 원형을 이룸
Thread 1

❌ 데드락이 발생하는 코드

C#
private readonly object _lockA = new object();
private readonly object _lockB = new object();

void Worker1()
{
    lock (_lockA)
    {
        Thread.Sleep(10);
        lock (_lockB) { /* ... */ }   // lockA 쥔 채 lockB 기다림
    }
}

void Worker2()
{
    lock (_lockB)
    {
        Thread.Sleep(10);
        lock (_lockA) { /* ... */ }   // lockB 쥔 채 lockA 기다림
    }
}
// Worker1, Worker2를 다른 스레드에서 동시에 호출하면 거의 매번 데드락

✅ 해결 — 락 획득 순서 고정 + TryEnter 타임아웃

C#
void Worker2_Fixed()
{
    // 두 함수 모두 반드시 lockA → lockB 순서로 획득
    lock (_lockA)
    {
        lock (_lockB) { /* ... */ }
    }
}

// 또는 타임아웃으로 순환 대기 자체를 포기
void Worker2_Timeout()
{
    bool gotB = false, gotA = false;
    try
    {
        Monitor.TryEnter(_lockB, 100, ref gotB);
        if (!gotB) return;
        Monitor.TryEnter(_lockA, 100, ref gotA);
        if (!gotA) return;
        // ...
    }
    finally
    {
        if (gotA) Monitor.Exit(_lockA);
        if (gotB) Monitor.Exit(_lockB);
    }
}

락 순서 고정은 "순환 대기" 조건을 깨는 가장 단순하고 확실한 방법이다. 프로젝트 규모가 커지면 락을 번호로 관리하는 팀 규칙을 두기도 한다.

❌ 함정 5 — Unity 메인 스레드 블록킹과 API 호출 금지

C#
// ❌ 절대 금지 — 락 안에서 UnityEngine API 호출
new Thread(() =>
{
    lock (_lockObj)
    {
        transform.position = Vector3.zero;  // UnityException: main thread only
    }
}).Start();

Unity의 Transform, GameObject, 대부분의 UnityEngine.* API는 메인 스레드에서만 호출 가능하다. 워커 스레드가 락을 잡고 이걸 호출하면 UnityException: get_position can only be called from the main thread가 터지고 락도 풀리지 않아 시스템이 멈춘다. 락 안에서는 POCO(Plain Old CLR Object) 데이터 구조만 조작하고, Unity API 호출은 메인 스레드로 디스패치하는 큐로 넘겨야 한다.


6. C# 버전별 변화

lock은 C# 1.0부터 있던 키워드지만, 컴파일러가 생성하는 IL은 여러 차례 바뀌었다. 그리고 .NET 9에서 가장 큰 변화가 찾아왔다.

C# 1.0 ~ C# 3.0 — 단순 Monitor.Enter/Exit

C#
// lock(_lockObj) { body } 의 초기 컴파일 결과
Monitor.Enter(_lockObj);
try
{
    body
}
finally
{
    Monitor.Exit(_lockObj);
}

문제는 Monitor.Enter가 락을 획득하고 돌아오기 직전ThreadAbortException이 발생하면, 락은 잡혔는데 try 블록에 진입하지 못해 finally에서 Exit이 호출되지 않는다는 점이었다. 이 경로로 락이 새면 해당 객체는 영영 풀리지 않는 좀비 락이 된다.

C# 4.0 — Enter(object, ref bool) 오버로드 도입

C#
bool lockTaken = false;
try
{
    Monitor.Enter(_lockObj, ref lockTaken);
    body
}
finally
{
    if (lockTaken) Monitor.Exit(_lockObj);
}

.NET Framework 4.0에서 Monitor.Enter(object, ref bool) 오버로드가 새로 추가되었고, C# 4.0 컴파일러부터 lock 키워드가 이 오버로드를 호출하도록 바뀌었다. 앞서 본 IL이 바로 이 형태다. 이 덕분에 락 획득 도중 비동기 예외가 터져도 누수가 없다.

.NET 9 / C# 13 — System.Threading.Lock 타입 등장

.NET 9(2024년 11월)에 새로운 동기화 프리미티브 System.Threading.Lock이 도입되었다. lock 키워드는 피연산자가 Lock 타입이면 Monitor 대신 Lock.EnterScope()를 호출하도록 확장되었다.

C#
// .NET 9 이상
using System.Threading;

public class FastCounter
{
    private readonly Lock _lock = new Lock();   // 새 Lock 타입
    private int _count;

    public void Increment()
    {
        lock (_lock)               // 컴파일러가 Lock.EnterScope()를 호출하도록 확장
        {
            _count++;
        }
    }
}

이 코드의 IL은 Monitor.Enter 대신 Lock::EnterScope를 호출하고, try-finally 대신 using 패턴(IDisposable.Dispose)으로 해제한다.

IL
.method public hidebysig instance void Increment () cil managed
{
    .try
    {
        IL_0000: ldarg.0
        IL_0001: ldfld valuetype [System.Threading]System.Threading.Lock FastCounter::_lock
        IL_0006: callvirt instance valuetype Lock/Scope Lock::EnterScope()   // ★ 새 방식
        // ... _count++
        IL_0022: leave.s IL_002e
    }
    finally
    {
        IL_0024: ldloca.s 0
        IL_0026: call instance void Lock/Scope::Dispose()   // ★ using 패턴
        IL_002c: endfinally
    }
    IL_002d: ret
}

System.Threading.Lock의 장점

  • 성능: Monitor의 SyncBlock 간접 참조를 제거하여 무경합 경로가 약 20% 빠르다.
  • 타입 안전성: lock 대상이 Lock 인스턴스여야 한다는 의도가 타입으로 드러난다. lock(this), lock("str") 같은 실수를 줄인다.
  • 디버깅: 소유자·대기자 정보를 Lock 인스턴스 자체에 담아 진단이 쉽다.

단, Unity의 현재 스크립팅 런타임은 .NET Standard 2.1 / .NET 4.x 수준이므로 System.Threading.Lock은 아직 쓸 수 없다. Unity 2023 LTS, Unity 6에서도 미지원이며, 향후 .NET 9 호환 런타임이 채택되면 사용 가능하다. 지금 단계에서는 "이런 방향으로 진화 중"이라는 맥락만 알아두자.


7. 정리

lock은 화려한 키워드처럼 보이지만, 결국 Monitor.Enter/Exittry-finally로 풀리는 문법적 설탕이다. 이 본질을 알고 나면 어떤 실수가 왜 문제가 되는지, 어떤 상황에서 어떤 대안을 선택해야 하는지가 선명해진다.

핵심 체크리스트

  • lock은 컴파일 시 Monitor.Enter(obj, ref lockTaken) + try-finally { if (lockTaken) Monitor.Exit(obj); }로 확장된다. IL에서 .try 블록을 눈으로 보며 락 구간 크기를 가늠하라.
  • 락 객체는 반드시 private readonly object _lockObj = new object();. this, typeof(T), 문자열 리터럴, public 필드, 값 타입은 전부 금지.
  • 데드락을 피하려면 락 획득 순서를 프로젝트 전역에서 고정하거나 Monitor.TryEnter로 타임아웃을 준다.
  • lock 블록 안에서 await는 컴파일 오류(CS1996) — 비동기 경로는 SemaphoreSlim.WaitAsync로 교체.
  • Unity에서는 락 안에서 UnityEngine API 호출 금지, 락 구간 최소화, 메인 스레드에서는 TryEnter(0)을 고려해 프리징을 막는다.
  • 경합 없는 경로는 Thin Lock으로 20ns, 경합이 나면 Fat Lock으로 승격되어 커널까지 내려간다. Interlocked, ConcurrentDictionary 같은 대안을 먼저 검토하라.
  • .NET 9의 System.Threading.Lock은 타입 안전성과 성능을 개선했지만 현재 Unity 런타임에서는 아직 쓸 수 없다. 존재를 알고 로드맵에 대비하라.
반응형

+ Recent posts