반응형

[PART13.비동기와 스레딩 기초(13/15)] System.Threading.Lock — .NET 9의 새 락 타입 (C# 13)

이 글에서 다루는 것 - C# 13 / .NET 9 에서 새로 추가된 System.Threading.Lock 타입이 왜 도입되었는가 - 컴파일러가 lock 키워드를 만났을 때 락 대상의 타입에 따라 어떻게 다른 코드로 변환하는가 - EnterScope() 가 반환하는 ref struct Scope 의 의미와 using 패턴 - Lock 객체를 object 로 캐스팅하면 왜 새 API 가 동작하지 않는가 (CS9216 경고) - 기존 코드를 안전하게 마이그레이션하는 한 줄 변경법

앞 주제와의 관계 이전 글([12. lock 문](../12.%20lock%20문))에서는 고전 lock(_syncRoot) 패턴이 내부적으로 Monitor.Enter / Monitor.Exit 로 변환되는 과정을 IL 수준까지 들여다봤습니다. 이 글은 그 다음 단계입니다 — 같은 lock 키워드를 그대로 쓰면서도, 락 객체의 타입만 objectLock 으로 바꿨을 때 컴파일러가 완전히 다른 IL 을 생성한다는 사실을 보여주는 것이 핵심입니다.

1. 왜 새 타입이 필요했는가 — lock(object) 의 구조적 부담

lock(_syncRoot) 한 줄을 쓰는 순간 런타임은 다음 세 가지를 떠안습니다.

  1. 모든 참조 객체가 락이 될 수 있다는 일반성Monitor.Enter(object) 는 인자 타입이 object 입니다. 이 일반성을 지원하기 위해 CLR 은 힙에 있는 모든 객체에 "동기화 블록 인덱스(sync block index)" 를 걸 수 있도록 객체 헤더를 설계해 두었습니다. 한 번도 락 대상으로 쓰이지 않는 객체조차 이 메커니즘을 위해 비용을 지불합니다.
  2. 잘못된 락 대상으로 인한 데드락 위험lock(this), lock(typeof(MyClass)), lock("some-string") 처럼 외부에서 동일 참조에 접근 가능한 객체를 잠그면, 의도치 않은 코드와 락 경합·데드락이 발생합니다. 컴파일러는 이를 막을 수단이 없습니다.
  3. 확장의 한계Monitor 는 객체 헤더 비트를 사용하기 때문에 잠금 자체에 메타데이터를 더 붙이거나 진단 정보를 풍부하게 만들기 어렵습니다.

C# 13 / .NET 9 의 System.Threading.Lock 은 이 세 가지를 한꺼번에 정리합니다 — 잠금만을 위한 전용 타입을 만들고, 컴파일러가 그 타입을 인식해 전용 API 로 변환하는 방식입니다.


2. 30초 요약 — 한 줄만 바꾸면 됩니다

C#
// Before (.NET 8 이하 / 고전 패턴)
private readonly object _sync = new();

public void Increment()
{
    lock (_sync)            // → Monitor.Enter / Monitor.Exit 로 변환
    {
        _count++;
    }
}
C#
// After (.NET 9 / C# 13)
private readonly System.Threading.Lock _sync = new();

public void Increment()
{
    lock (_sync)            // → _sync.EnterScope() + Scope.Dispose() 로 변환
    {
        _count++;
    }
}

바뀐 것은 필드 타입 한 줄뿐입니다. lock 키워드는 그대로 유지하지만, 컴파일러가 락 대상의 정적 타입을 보고 완전히 다른 IL 을 생성합니다. 호출부 코드를 한 글자도 건드리지 않고도 새 API 의 이점을 받습니다.


3. ADEPT — 비유로 이해하는 Lock vs lock(object)

A (Analogy) — 회의실 사용

  • lock(object) (고전): 사무실에 회의실이 따로 없습니다. 회의가 필요하면 임의의 빈 책상 한 자리를 골라 "지금부터 여기 회의 중" 이라고 표시판을 둡니다. 표시판은 이 책상에 앉던 사람의 책상 헤더(객체 헤더의 sync block index) 에 붙입니다. 모든 책상이 표시판을 받을 수 있도록 처음부터 그 슬롯이 마련되어 있습니다.
  • Lock (.NET 9): 회의실 전용 공간을 만듭니다. 그 공간을 잡는 도구도 회의실에 내장되어 있습니다(EnterScope()). 일반 책상은 더 이상 회의용 표시판 슬롯을 비워둘 필요가 없습니다.

D (Diagram) — 컴파일러가 두 갈래로 갈라지는 지점

컴파일러는 락 대상의 정적 타입을 본다

E (Example) — 실제 동작

C#
using System.Threading;

public sealed class Counter
{
    private readonly Lock _sync = new();
    private int _count;

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

    public int Read()
    {
        lock (_sync)
        {
            return _count;
        }
    }
}

호출부에서 lock 키워드를 그대로 사용했지만 — 컴파일러가 _sync 의 타입이 System.Threading.Lock 인 것을 보고 자동으로 EnterScope() 호출 + Scope.Dispose() 패턴으로 IL 을 생성합니다. 다음 4번 절에서 IL 로 직접 확인합니다.

P (Plain language) — 한 문장

락 대상의 타입만 Lock 으로 바꾸면, lock 키워드는 자동으로 새 API 로 변환됩니다.

T (Test your understanding)

  • private object _sync = new Lock(); — 이렇게 선언하면 새 API 가 호출될까요? *(힌트: 정적 타입은 object 입니다.)*
  • lock((object)_sync) { ... } 처럼 명시적으로 캐스팅하면 어떻게 될까요?
  • 둘 다 컴파일은 되지만, 새 API 가 호출되지 않고 컴파일러 경고 CS9216 이 뜹니다. 6번 절에서 자세히 다룹니다.

4. IL 로 직접 확인하기 — 두 코드는 정말 다른 IL 로 컴파일됩니다

이 글의 핵심 주장은 "컴파일러가 락 대상의 타입을 보고 다른 IL 을 만든다" 입니다. 추측이 아니라 IL 디컴파일 결과로 직접 확인합니다.

4.1 object 락 — 고전 경로

C#
public sealed class CounterObject
{
    private readonly object _sync = new();
    private int _count;

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

Increment() 의 IL 핵심부 (Release, .NET 9):

IL
.method public hidebysig instance void Increment() cil managed
{
    .locals init (object V_0, bool V_1)
    ldarg.0
    ldfld      object CounterObject::_sync
    stloc.0
    ldc.i4.0
    stloc.1
    .try
    {
        ldloc.0
        ldloca.s   V_1
        call       void [System.Threading]System.Threading.Monitor::Enter(object, bool&)
        // _count++
        ldarg.0
        dup
        ldfld      int32 CounterObject::_count
        ldc.i4.1
        add
        stfld      int32 CounterObject::_count
        leave.s    EndFinally
    }
    finally
    {
        ldloc.1
        brfalse.s  EndOfFinally
        ldloc.0
        call       void [System.Threading]System.Threading.Monitor::Exit(object)
        EndOfFinally: endfinally
    }
    EndFinally: ret
}

핵심: Monitor.Enter(object, bool&)Monitor.Exit(object) 호출이 명시적으로 박혀 있습니다. 락 대상은 object 로 다뤄지고, 객체 헤더의 sync block index 가 사용됩니다.

4.2 Lock 락 — 새 경로

C#
public sealed class CounterLock
{
    private readonly System.Threading.Lock _sync = new();
    private int _count;

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

Increment() 의 IL 핵심부:

IL
.method public hidebysig instance void Increment() cil managed
{
    .locals init (valuetype [System.Threading]System.Threading.Lock/Scope V_0)
    ldarg.0
    ldfld      class [System.Threading]System.Threading.Lock CounterLock::_sync
    callvirt   instance valuetype [System.Threading]System.Threading.Lock/Scope
               [System.Threading]System.Threading.Lock::EnterScope()
    stloc.0
    .try
    {
        ldarg.0
        dup
        ldfld      int32 CounterLock::_count
        ldc.i4.1
        add
        stfld      int32 CounterLock::_count
        leave.s    EndFinally
    }
    finally
    {
        ldloca.s   V_0
        call       instance void
                   [System.Threading]System.Threading.Lock/Scope::Dispose()
        endfinally
    }
    EndFinally: ret
}

핵심: Monitor.Enter / Monitor.Exit 가 사라졌습니다. 대신 Lock::EnterScope()valuetype Lock/Scope 를 반환하고, finally 절에서 Scope::Dispose() 가 호출됩니다. 로컬 변수 V_0 의 타입이 valuetype Lock/Scope 인 것에 주목하세요 — class 가 아니라 valuetype 입니다. 이것이 ref struct 의 흔적입니다.

4.3 핵심 차이를 표로

항목 lock(object) lock(Lock)
호출하는 API Monitor.Enter / Monitor.Exit Lock.EnterScope() / Scope.Dispose()
락 식별자 객체 헤더의 sync block index Lock 인스턴스의 전용 필드
진입 경로 비용 헤더 비트 검사 + (경합 시) 동기화 블록 인덱스 할당 전용 필드 CAS
try/finally C# 컴파일러가 직접 생성 컴파일러가 직접 생성 (Scope.Dispose 호출)
로컬 변수 bool lockTaken Lock.Scope (ref struct)
객체 헤더 사용 사용함 사용 안 함

5. Lock.EnterScope()ref struct Scope — 왜 굳이 ref struct 인가

EnterScope() 의 시그니처는 다음과 같습니다 (개념적 표현).

C#
namespace System.Threading;

public sealed class Lock
{
    public Scope EnterScope();          // ⬅ ref struct 반환
    public bool TryEnter();
    public bool TryEnter(int millisecondsTimeout);
    public bool TryEnter(TimeSpan timeout);
    public void Enter();
    public void Exit();
    public bool IsHeldByCurrentThread { get; }

    public ref struct Scope             // ⬅ ref struct
    {
        public void Dispose();
    }
}

Scoperef struct 인 것은 단순한 스타일 선택이 아니라 의미적 보장입니다.

5.1 ref struct 가 강제하는 제약

ref struct 는 다음을 컴파일 시점에 보장합니다.

  • 스택에만 존재할 수 있다 — 힙 할당 없음, 박싱 불가능
  • 필드로 저장 불가 — 클래스의 필드, 다른 (일반) 구조체의 필드가 될 수 없음
  • async 메서드에서 await 경계를 넘을 수 없음 — 상태 머신이 힙으로 캡처할 수 없으므로
  • iterator (yield return) 안에서 yield 경계를 넘을 수 없음
  • 람다에 캡처될 수 없음
  • 다른 스레드로 전달될 수 없음 — 값이지만 ref-like 라서 의도적으로 한 스레드의 스택에 묶임

5.2 왜 락 해제에 이 제약이 필요한가

락 해제(Monitor.ExitLock.Exit 든)는 락을 획득한 그 스레드에서만 호출되어야 합니다. 다른 스레드에서 호출하면 SynchronizationLockException 이 터지거나 정의되지 않은 동작입니다.

Scoperef struct 라는 것은 컴파일러가 다음을 막는다는 뜻입니다.

C#
// ✗ 컴파일 에러: ref struct 는 필드로 저장 불가
class Holder
{
    Lock.Scope _s;
}

// ✗ 컴파일 에러: ref struct 는 await 경계를 넘을 수 없음
async Task Bad()
{
    using (var s = _lock.EnterScope())
    {
        await Task.Delay(100);   // CS4007 / CS8350 류 에러
        // 만약 허용되면 await 후 다른 스레드에서 Dispose 가 호출될 수 있음
    }
}

// ✗ 람다 캡처 불가
Action a = () =>
{
    using (var s = _lock.EnterScope())   // s 는 캡처 가능한 일반 변수지만
    {
        // 람다 외부로 새어 나갈 수 없음 (ref struct 제약)
    }
};

즉, ref struct 는 "락을 잡은 스레드의 스택 프레임을 떠나기 전에 반드시 해제하라" 는 규칙을 컴파일 시점에 강제합니다. 고전 lock(object) 에서는 컴파일러가 try/finally 를 만들어주지만, lockTaken 변수를 그냥 bool 로 보관하므로 약속의 강도가 다릅니다.

5.3 성능 측면 — 핫 패스 최적화

ref struct 라는 사실 자체와 별개로, 새 API 는 다음 최적화 여지를 얻습니다.

  • Dispose 가 같은 스레드에서 불린다는 보장EnterScope 가 락을 획득한 스레드와 Scope.Dispose() 가 호출되는 스레드가 동일하다는 것을 컴파일러가 보장하므로, 해제 경로에서 "현재 스레드 ID" 를 다시 조회하지 않아도 됩니다 (스레드-static 룩업 회피).
  • 객체 헤더의 sync block 활용 안 함 — 락 식별자가 Lock 인스턴스의 전용 필드이므로, 일반 객체에 강제로 부여되던 비용을 우회합니다.
  • JIT 인라이닝 여지EnterScope / Scope.Dispose 는 작은 메서드라 인라이닝되기 쉽습니다.

대략적인 지표로 Monitor 대비 무경합 락(uncontended lock) 진입/해제 비용이 약 25% 줄어든다는 벤치마크 보고가 여러 출처에서 인용됩니다 ([InfoWorld](https://www.infoworld.com/article/3632180/how-to-use-the-new-lock-object-in-c-sharp-13.html), [Anthony Giretti](https://anthonygiretti.com/2025/03/05/c-13-introducing-system-threading-lock/)). 다만 경합이 심한 시나리오에서는 락 자체의 대기 시간이 지배적이므로, 무경합 비용 절감의 체감 효과는 워크로드에 따라 다릅니다. 실제 측정해서 확인 하는 자세를 권장합니다.


6. 함정 — object 캐스팅·할당 시 새 API 가 사라진다

여기서 가장 자주 미끄러지는 지점입니다.

6.1 컴파일러는 정적 타입만 본다

lock 문이 새 API 로 변환되는 조건은 락 대상 표현식의 정적 타입이 System.Threading.Lock 인지 입니다. 런타임에 어떤 객체인지가 아니라 컴파일러가 보는 타입입니다.

C#
private readonly Lock _sync = new();

void Case1()
{
    lock (_sync) { /* OK — 새 API */ }
}

void Case2()
{
    object boxed = _sync;
    lock (boxed) { /* ✗ 새 API 아님 — boxed 의 정적 타입이 object */ }
}

void Case3()
{
    lock ((object)_sync) { /* ✗ 새 API 아님 — 캐스트로 정적 타입이 object */ }
}

void Case4(object o)
{
    lock (o) { /* ✗ 새 API 아님 — 매개변수 타입이 object */ }
}

6.2 CS9216 — 컴파일러가 미리 알려준다

C# 13 컴파일러는 위와 같은 코드에 CS9216 경고를 발생시킵니다.

warning CS9216: A value of type 'System.Threading.Lock' converted to a different type will use likely unintended monitor-based locking in 'lock' statement.

이 경고가 떴다는 것은 "당신은 새 Lock 타입을 선언했지만, 이 lock 문에서는 옛 Monitor 경로로 폴백되었다" 는 신호입니다. 경고 무시 금지 — 의도된 폴백이라면 Lock 이 아니라 object 를 락 객체로 선언해야 의도가 명확해집니다.

6.3 대표적인 함정 시나리오

  • 컬렉션에 락 객체 보관Dictionary<string, object> 같은 자료구조에 Lock 을 담아 꺼내면 정적 타입이 object 가 됩니다.
  • 제네릭 컨테이너의 T = objectList<object> 에 담는 경우.
  • 인터페이스로 받는 헬퍼 메서드void RunUnderLock(object lockObj, Action body) 처럼 디자인된 헬퍼에 Lock 을 넘기면 헬퍼 내부에서는 object 입니다.
  • 리플렉션·동적 타입dynamic 변수에 담거나 리플렉션으로 꺼내는 경로.

Lock 을 도입할 때는 락 객체의 선언과 사용 모두에서 정적 타입을 Lock 으로 유지하는 것이 원칙입니다. 헬퍼 메서드를 둔다면 인자 타입을 Lock 으로 받도록 다시 설계합니다.

C#
// ✗ 폴백 함정
static void RunUnderLock(object lockObj, Action body)
{
    lock (lockObj) { body(); }   // lockObj 정적 타입은 object
}

// ✓ Lock 인지 그대로 유지
static void RunUnderLock(Lock lockObj, Action body)
{
    lock (lockObj) { body(); }   // 새 API 로 변환됨
}

7. using 패턴으로 명시적으로 쓰기

lock 키워드 대신 EnterScope() 를 직접 부를 수도 있습니다. 동작은 동일하지만, lock 블록보다 더 풍부한 범위 제어가 필요할 때 쓸 수 있습니다.

C#
public int ReadCount()
{
    using Lock.Scope scope = _sync.EnterScope();
    return _count;
}

또는 명시적 using 블록.

C#
public void Update(Func<int, int> update)
{
    using (_sync.EnterScope())
    {
        _count = update(_count);
    }
}

언제 굳이 명시적 형태를 쓰는가?

  • TryEnter() 와 함께 조건부 진입을 다룰 때 — lock 키워드는 항상 Enter 하므로.
  • 읽는 사람에게 "이건 단순 락이 아니라 Scope 가 만들어진다" 는 점을 명시하고 싶을 때.
  • 락 객체와 Scope 의 라이프타임을 코드 리뷰에서 더 명확히 드러내고 싶을 때.

대부분의 일반적인 동기화에서는 lock 키워드를 그대로 쓰는 편이 가독성이 좋습니다. 컴파일러가 이미 같은 IL 을 만들어주니, 의미 차이가 있을 때만 명시적 형태를 선택합니다.


8. TryEnter — 시간 제한이 있는 락 시도

Monitor.TryEnter(object, TimeSpan) 의 새 API 대응은 Lock.TryEnter(TimeSpan) 입니다. 다만 lock 키워드와 결합되는 형태는 없으므로, 직접 호출해야 합니다.

C#
private readonly Lock _sync = new();

public bool TryUpdate(Action body, TimeSpan timeout)
{
    if (!_sync.TryEnter(timeout))
        return false;

    try
    {
        body();
        return true;
    }
    finally
    {
        _sync.Exit();
    }
}

주의: TryEnter / Enter / Exit 직접 호출 경로는 Scoperef struct 보호를 받지 않습니다. try/finally 와 호출 짝맞추기는 직접 책임집니다. 가능하면 EnterScope() + using 을 우선 고려하고, TryEnter 가 정말로 필요한 경우에만 직접 사용합니다.


9. 마이그레이션 가이드 — 점진적으로 옮기기

9.1 한 번에 한 클래스씩

큰 코드베이스에서 모든 object 락을 한 번에 Lock 으로 바꾸는 것은 위험합니다. 다음 순서를 권장합니다.

  1. 공유 자원이 있는 클래스를 식별 — 락이 같은 인스턴스 안에서만 쓰이는 경우가 안전합니다.
  2. 필드 타입 변경private readonly object _sync = new();private readonly Lock _sync = new();
  3. 빌드 + 경고 확인 — CS9216 이 뜨는 위치를 모두 점검합니다. 새 API 가 적용되지 않는 호출 경로를 발견하는 신호입니다.
  4. 헬퍼·인자 시그니처 정리RunUnderLock(object, Action) 류 헬퍼는 Lock 으로 다시 선언합니다.
  5. 단위 테스트 / 통합 테스트 — 동기화 로직 자체는 동일하지만, 회귀 가능성을 점검합니다.

9.2 절대 같이 쓰지 말 것

  • Lock 인스턴스를 object 락으로도 쓰는 혼용 — 예: Lock _sync 이지만 일부 코드에서 lock((object)_sync) 로 사용. 이 경우 두 메커니즘이 동시에 한 객체에 걸려 동작이 미정의입니다.
  • LockHashtable 키나 Dictionary 키로 사용 — 정적 타입이 흐려지면 새 API 가 사라집니다.

9.3 무엇이 바뀌지 않는가

  • 재진입(reentrancy) 의미LockMonitor 와 마찬가지로 같은 스레드의 재진입을 허용합니다 (recursive).
  • 공정성(fairness) — 두 메커니즘 모두 강한 공정성 보장은 없습니다.
  • 데드락 가능성 — 락 객체가 바뀌었을 뿐 락의 의미는 같습니다. 락 순서 규칙은 그대로 지켜야 합니다.

10. Unity 환경에서의 주의

10.1 .NET / C# 버전 호환성

System.Threading.Lock.NET 9 BCL 에 포함된 타입이고, 컴파일러 변환 동작은 C# 13 부터입니다. Unity 에서 사용 가능한지는 다음 두 축으로 확인합니다.

  • Unity 의 C# 컴파일러 버전 — Unity 6 이상이 C# 13 / .NET 9 까지 지원하는 방향으로 가고 있지만, 사용 중인 Unity 정확한 버전의 릴리스 노트와 Edit > Project Settings > Player > Other Settings > Api Compatibility Level 설정을 직접 확인하세요. 검증되지 않은 버전에서 Lock 을 쓰면 컴파일은 되더라도 IL2CPP 경로에서 런타임 동작이 다를 수 있습니다.
  • System.Threading.Lock 타입 가용성 — Unity 의 BCL 어셈블리(mscorlib 또는 System.Threading)에 Lock 클래스가 포함되어야 합니다. 미지원 Unity 버전에서는 타입을 못 찾아 컴파일 에러가 납니다. 사용 전에 Lock 심볼이 IDE 에서 즉시 인식되는지 먼저 확인합니다.
주의: Unity 의 Mono / IL2CPP 백엔드에서 Lock 의 ref struct 동작 (스택 강제, async 경계 막기 등)이 동일하게 강제되는지는 사용 중인 정확한 Unity 버전에 따라 다릅니다. 추정으로 판단하지 말고, 타겟 Unity 버전에서 테스트로 확인하세요.

10.2 게임 핫 패스에서의 가이드

  • 메인 스레드 단독 코드에는 락을 걸지 않는다 — Unity 의 MonoBehaviour API 대부분은 메인 스레드 한정입니다. 락 자체가 불필요합니다.
  • 백그라운드 스레드 ↔ 메인 스레드 데이터 교환에만 사용Job System, Task.Run, custom thread 에서 메인 스레드와 공유하는 자료구조에만 동기화를 적용합니다.
  • 무경합 락이 잦다면 새 Lock 타입의 이점이 큽니다 — 게임 루프에서 매 프레임 한 번 잡고 푸는 패턴이라면, sync block index 를 우회하는 새 API 가 GC 압박과 진입 비용 둘 다 줄여줍니다 (이론상). 측정으로 확인하세요.
  • async/await 안에서 락 잡지 말 것 — 새 LockScope 는 ref struct 라 await 경계를 넘을 수 없습니다. 컴파일 에러로 실수가 차단됩니다 (이 자체가 안전 보장의 일부입니다).

11. 자주 묻는 질문

Q1. Monitor.Wait / PulseLock 에서 쓸 수 있나요?

A. 쓸 수 없습니다. Monitor.Wait/Pulse/PulseAll 은 객체 헤더의 sync block 을 사용하는데, Lock 은 그 메커니즘을 우회합니다. 조건 변수 패턴이 필요하면 ManualResetEventSlim, SemaphoreSlim, Channel<T> 등 다른 동기화 프리미티브를 사용하거나 기존 lock(object) 를 유지합니다.

Q2. Lock 인스턴스 자체는 static 으로 선언해도 되나요?

A. 됩니다. 다만 static 락은 모든 인스턴스가 공유하므로 경합 가능성이 커집니다. 락의 보호 범위(인스턴스 단위 vs 타입 단위)를 의식해서 선택합니다.

Q3. Lock 도 직렬화 대상이 될 수 있나요?

A. 하지 마세요. 락 객체는 직렬화·역직렬화 의미가 없습니다. [NonSerialized] 또는 [JsonIgnore] 등으로 제외합니다.

Q4. lock(this) 대신 Lock _this = new() 를 두면 되나요?

A. 의도는 맞지만, 더 좋은 답은 "락 객체를 외부에 노출하지 말 것" 입니다. private readonly Lock _sync = new(); 로 클래스 내부에 가둡니다.

Q5. LockSemaphoreSlim(1, 1) 중 무엇을 쓰나요?

A. 동기 메서드 안에서 짧은 임계 영역이면 Lock. async/await 와 함께 임계 영역을 보호해야 하면 SemaphoreSlim 입니다 (WaitAsync/Release). 두 도구의 용도는 겹치지 않습니다.


12. 정리

  • C# 13 / .NET 9 의 System.Threading.Lock 은 락 전용 타입입니다. 객체 헤더의 sync block 메커니즘을 우회하고, 진입·해제 경로를 짧게 만듭니다.
  • lock 키워드 사용법은 동일하지만, 컴파일러는 락 대상의 정적 타입이 Lock 인지 보고 다른 IL 을 생성합니다. Monitor.Enter/Exit 가 사라지고 EnterScope() + Scope.Dispose() 로 바뀝니다.
  • Scoperef struct 인 것은 "락을 획득한 스레드의 스택을 떠나기 전에 반드시 해제하라" 는 규칙을 컴파일 시점에 강제하기 위해서입니다. async 경계, 람다 캡처, 필드 저장, 다른 스레드 전달이 모두 차단됩니다.
  • object 로 캐스팅·할당하는 순간 새 API 는 사라집니다 — CS9216 경고를 보면 의도치 않은 폴백입니다. 컬렉션·헬퍼 인자·dynamic 등의 경로를 점검합니다.
  • 마이그레이션은 한 줄 변경private readonly object _sync = new();private readonly Lock _sync = new(); 호출부는 그대로 유지됩니다.
  • Unity 에서는 타겟 버전의 C# / .NET 호환성을 먼저 확인한 뒤 사용합니다. 메인 스레드 단독 코드에는 락 자체가 불필요합니다.

다음 글([14. 경쟁 상태와 데드락](../14.%20경쟁%20상태와%20데드락))에서는 락이 잘못 쓰일 때 발생하는 경쟁 상태(race condition)와 데드락(deadlock)을 다룹니다.


참고 자료

  • [Lock Class (System.Threading) — Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/api/system.threading.lock?view=net-9.0)
  • [Obey lock object semantics for lock statements — C# 13.0 feature spec](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-13.0/lock-object)
  • [The lock statement — C# reference](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/statements/lock)
  • [Add first class System.Threading.Lock type — dotnet/runtime#34812](https://github.com/dotnet/runtime/issues/34812)
  • [How to use the new Lock object in C# 13 — InfoWorld](https://www.infoworld.com/article/3632180/how-to-use-the-new-lock-object-in-c-sharp-13.html)
  • [C# 13: Introducing System.Threading.Lock — Anthony Giretti](https://anthonygiretti.com/2025/03/05/c-13-introducing-system-threading-lock/)
반응형

+ Recent posts