반응형

[PART13.비동기와 스레딩 기초(12/15)] lock 문 — 임계 구역을 한 스레드만 통과시키기

한 번에 한 스레드만 들여보내는 단일 차로 / Monitor.Enter·Monitor.Exittry/finally로 감싸진 것의 문법 설탕 / 락 객체는 반드시 private readonly object

[문제 제기] _counter++ 한 줄이 어떻게 값을 잃어버리는가

Unity 모바일 게임에서 백그라운드 다운로더가 동시에 8개의 에셋 번들을 받아옵니다. 각 다운로드가 완료될 때마다 진행률 카운터를 1씩 올려서 UI에 표시한다고 해봅시다.

C#
public class DownloadProgress
{
    private int _completed;

    public void OnAssetCompleted()
    {
        _completed++; // 한 줄이지만 안전하지 않습니다.
    }

    public int GetCompleted() => _completed;
}

이 코드를 8개의 스레드가 동시에 호출하면, 종료 시점에 _completed는 8이 아닐 수 있습니다. 6이나 7이 나올 때도 있고, 운이 나쁘면 4가 될 때도 있습니다. 다운로드는 분명 8개가 끝났는데 카운터가 일치하지 않습니다.

원인은 _completed++ 한 줄이 사실은 세 단계로 동작하기 때문입니다.

  1. 메모리에서 _completed 값을 CPU 레지스터로 읽기 (ldfld)
  2. 레지스터에서 1을 더하기 (add)
  3. 레지스터의 값을 다시 메모리에 쓰기 (stfld)

스레드 A가 1단계를 마친 직후 OS가 스레드를 바꿔치기 합니다. 스레드 B가 1·2·3단계를 모두 끝내고 나서 스레드 A가 다시 깨어나 자기가 읽었던 옛날 값에 1을 더해 메모리에 씁니다. 스레드 B의 작업이 통째로 사라집니다. 이것이 경쟁 상태(Race Condition)입니다.

_counter++ 의 경쟁 상태 시나리오 (시작값 5)

이 문제를 막는 가장 직관적인 도구가 lock 문입니다. 이 글에서는 lock이 IL 레벨에서 어떤 코드로 변환되는지, 왜 락 객체로 this나 문자열을 쓰면 안 되는지, 왜 lock 안에서 await이 금지되는지를 파헤쳐봅니다. 그리고 Unity 메인 스레드 모델에서 어떤 패턴으로 lock을 활용해야 하는지까지 봅니다.


[개념 정의] 임계 구역 · 상호 배제 · Monitor

임계 구역(Critical Section) 여러 스레드가 동시에 실행하면 안 되는 코드 블록. 공유 자원(필드·컬렉션·파일 핸들 등)을 읽거나 쓰는 부분이 대부분 여기에 해당한다.
예시: _counter++, _queue.Enqueue(item), _dict[key] = value 같이 공유 상태를 만지는 짧은 구간.
상호 배제(Mutual Exclusion) 한 시점에 단 하나의 스레드만 임계 구역에 들어가도록 강제하는 규칙. 줄여서 mutex라고 부른다.
예시: 화장실 한 칸에 한 명만 들어갈 수 있는 것과 같은 모델.
Monitor 클래스 .NET 런타임이 제공하는 동기화 프리미티브. Monitor.Enter(obj)로 들어가고 Monitor.Exit(obj)로 나온다. 이미 누가 점유한 락이면 호출 스레드는 차단된다.
예시: lock(obj) { ... }Monitor.Enter/Exit를 자동으로 감싸주는 문법 설탕이다.
lock 문 (lock statement) C# 언어 키워드. 참조 타입 객체 하나를 받아 그 객체에 대한 모니터를 잡고 해당 블록을 임계 구역으로 만든다. 컴파일러가 Monitor.Enter·Monitor.Exittry/finally로 풀어 준다.
예시: lock (_sync) { _counter++; }

lock은 새로운 동기화 메커니즘이 아닙니다. 이미 .NET에 있던 Monitor 클래스를 더 안전하고 짧게 쓰기 위한 언어 차원의 단축키입니다. 그래서 lock을 이해하려면 Monitor가 어떤 일을 하는지부터 봐야 합니다.

lock — 한 칸짜리 화장실 모델

핵심은 두 가지입니다.

  • 락의 정체는 객체 하나입니다. CLR이 그 객체의 헤더에 어떤 스레드가 락을 점유 중인지 기록합니다.
  • 락은 재진입(re-entrant) 가능합니다. 같은 스레드가 같은 락을 두 번 잡아도 데드락에 빠지지 않고 카운트만 1 늘어납니다. 다만 Exit도 그만큼 호출해야 풀립니다.

[작동 원리] locktry/finally로 감싼 Monitor.Enter/Exit다 — IL로 직접 본다

가장 단순한 형태

C#
public class Counter
{
    private readonly object _sync = new();
    private int _value;

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

C# 컴파일러는 위 코드를 다음과 거의 동일한 코드로 풀어 씁니다 (C# 4 이후).

C#
public void Increment()
{
    object obj = _sync;
    bool lockTaken = false;
    try
    {
        Monitor.Enter(obj, ref lockTaken);
        _value++;
    }
    finally
    {
        if (lockTaken) Monitor.Exit(obj);
    }
}

여기서 두 가지가 핵심입니다.

  1. 락 대상이 되는 참조를 지역 변수에 먼저 복사합니다. lock(_sync) 라고 썼지만 실제로는 obj = _sync로 복사한 뒤 그 사본으로 Enter/Exit를 합니다. 임계 구역 안에서 _sync가 다른 객체로 바뀌더라도 들어갈 때 잡은 객체에 대해 Exit를 호출하기 위해서입니다. 이래서 락 객체는 readonly여야 합니다 — 어차피 컴파일러는 처음 잡은 객체 기준으로 닫으므로, 도중에 바꾸면 동기화가 깨집니다.
  2. bool lockTakenEnter에 ref로 전달됩니다. Monitor.Enter가 락을 실제로 잡은 직후 lockTaken = true로 셋팅됩니다. 이렇게 하는 이유는 Enter 호출 자체가 ThreadAbortException 같은 비동기 예외로 중단되더라도 lockTaken이 정확히 락 점유 여부를 반영하게 하기 위해서입니다. finally는 락을 잡은 경우에만 Exit를 호출합니다.

IL로 확인 — Monitor.Enter/Exit가 진짜 들어가는지

[/il-analysis] Counter.Increment 메서드의 IL을 디컴파일하면 다음 골격이 나옵니다.

IL
.method public hidebysig instance void Increment () cil managed
{
    .locals init (
        [0] object obj,
        [1] bool lockTaken)

    // obj = this._sync
    ldarg.0
    ldfld      object Counter::_sync
    stloc.0

    // lockTaken = false
    ldc.i4.0
    stloc.1
    .try
    {
        // Monitor.Enter(obj, ref lockTaken)
        ldloc.0
        ldloca.s   lockTaken
        call       void [System.Threading]System.Threading.Monitor::Enter(object, bool&)

        // _value++
        ldarg.0
        dup
        ldfld      int32 Counter::_value
        ldc.i4.1
        add
        stfld      int32 Counter::_value

        leave.s    END
    }
    finally
    {
        // if (lockTaken) Monitor.Exit(obj)
        ldloc.1
        brfalse.s  EXIT
        ldloc.0
        call       void [System.Threading]System.Threading.Monitor::Exit(object)
        EXIT: endfinally
    }
    END: ret
}

세 가지를 확인할 수 있습니다.

  • lock 키워드의 IL 명령어는 따로 없습니다. call Monitor::Enter + call Monitor::Exit 두 개로 구성된 일반 메서드 호출일 뿐입니다.
  • .try/finally 블록이 실제로 들어가서, 임계 구역에서 예외가 터져도 Monitor.Exit가 반드시 실행됩니다.
  • Enter(object, ref bool) 오버로드를 씁니다. 옛날(C# 3 이전) 컴파일러는 Enter(object) 오버로드를 썼는데, 이 경우 "Enter는 성공했는데 직후 예외가 나서 lockTaken을 false로 인식"하는 미세한 누수가 있었습니다. ref 오버로드가 그 구멍을 막은 것입니다.

Increment 메서드의 정확성 — 락 안의 ++ 는 안전한가

lock(_sync) 안에서 _value++는 안전합니다. 다른 스레드는 lock이 풀릴 때까지 대기하고, 락 안에 있는 스레드는 read → +1 → write를 끊김 없이 실행한 뒤 락을 풉니다. 다른 스레드가 끼어들 틈이 없습니다.

다만 카운터 한 개만 증가시키는 단순한 경우라면 Interlocked.Increment(ref _value)가 락 없이 더 빠릅니다. lock여러 줄의 작업을 묶어서 일관성을 지켜야 할 때 진가를 발휘합니다 (예: 큐에서 dequeue + 처리 카운트 증가 + 마지막 처리 시각 기록).


[락 객체 선택의 규칙] 왜 private readonly object인가

lock은 임의의 참조 타입 객체를 받습니다. 그래서 다음 코드는 모두 컴파일은 됩니다.

C#
lock (this)                  { /* ... */ }   // ❌ 위험
lock (typeof(MyClass))       { /* ... */ }   // ❌ 위험
lock ("global_lock")         { /* ... */ }   // ❌❌ 매우 위험
lock (_publicField)          { /* ... */ }   // ❌ 위험
lock (_privateReadonlyObj)   { /* ... */ }   // ✓ 권장

C# 13부터는 후자에 가깝게 강제하는 새 타입 System.Threading.Lock도 등장했지만(다음 글에서 다룹니다), object 기반의 고전 lock 문에서는 여전히 개발자가 규칙을 지켜야 합니다.

왜 어떤 락 객체는 위험한가

1) lock(this) — 외부에 노출된 자물쇠

this는 클래스 인스턴스 자신입니다. 그런데 그 인스턴스는 보통 외부 코드가 변수로 들고 있는 객체입니다. 외부 코드가 우연히 또는 악의적으로 그 인스턴스에 lock을 걸 수 있다는 뜻입니다.

C#
public class Cache
{
    public void Refresh() { lock (this) { /* ... */ } } // 내부적으로 this 잠금
}

// 어딘가의 외부 코드
var cache = container.GetCache();
lock (cache) { Thread.Sleep(10000); } // 같은 객체를 외부가 10초 점유
// 이 사이 cache.Refresh()는 모두 블록됨 — 락 탈취(lock hijacking)

이걸 "내가 만든 자물쇠인데 다른 사람이 자기 마음대로 잠그고 안 풀어주는" 상황이라고 비유할 수 있습니다.

2) lock(typeof(X)) — 자물쇠가 전역으로 공유

typeof(X)Type 객체를 돌려주는데, 이 객체는 AppDomain 안에서 클래스당 하나만 존재합니다. 즉 같은 typeof(MyClass)는 어디서 부르든 같은 객체입니다.

C#
class Foo
{
    public static void Op1() { lock (typeof(Foo)) { /* ... */ } } // 같은 Type을 잡음
}

class Bar  // 완전히 다른 클래스, 같은 라이브러리도 아님
{
    public static void Op2() { lock (typeof(Foo)) { /* ... */ } } // 같은 Type을 잡음
}

Foo.Op1Bar.Op2는 서로 무관해 보이지만 사실상 같은 자물쇠를 두고 경쟁합니다. 어느 라이브러리가 어떤 Type을 잡고 있을지 예측할 수 없으므로 데드락 추적이 매우 어려워집니다.

3) lock("문자열 리터럴") — 가장 위험한 안티패턴

.NET은 메모리 효율을 위해 문자열 인터닝(interning)을 합니다. 컴파일 타임에 등장한 동일한 리터럴은 런타임에서 같은 단 하나의 string 인스턴스로 합쳐집니다.

C#
// 두 메서드, 두 라이브러리에 흩어져 있음
void A() { lock ("global_lock") { /* ... */ } }
void B() { lock ("global_lock") { /* ... */ } }

A"global_lock"B"global_lock"메모리상 같은 객체입니다. NuGet으로 받은 라이브러리가 우연히 같은 문자열을 락으로 쓰면 우리 앱과 충돌합니다. 디버깅이 사실상 불가능합니다.

4) private readonly object _sync = new(); — 정답

  • private: 외부가 같은 객체에 접근할 방법이 없으니 락 탈취가 원천 차단됩니다.
  • readonly: 한 번 만들어진 락 객체가 다른 객체로 교체되지 않으므로, "들어갈 때 잡은 객체"와 "나갈 때 푸는 객체"가 항상 동일합니다.
  • object: 의미 없는 객체이므로 누구도 우연히 같은 객체를 잡을 일이 없습니다.
  • new(): 인스턴스마다 락도 별개입니다. Counter 두 개를 만들면 락도 두 개입니다 — 한 인스턴스의 락이 다른 인스턴스를 막지 않습니다.
정적(static) 필드를 잠그려면 락 객체도 static으로 둡니다 (private static readonly object _staticSync = new();). 인스턴스 락으로는 클래스 전체의 정적 상태를 보호할 수 없습니다.

값 타입은 락 객체로 쓸 수 없다

C#
int x = 42;
lock (x) { /* ... */ } // 컴파일 에러 CS0185: 'lock' 문에 사용된 식은 참조 형식이어야 합니다.

만약 컴파일러가 박싱(boxing)을 해서 통과시킨다면 매번 새로운 박스 객체가 만들어져 동기화가 전혀 일어나지 않을 것입니다. 컴파일러는 그래서 아예 거부합니다.


[락 안에서 await 금지] 스레드 소유권이라는 모델

C#
async Task FetchAndStore()
{
    lock (_sync)
    {
        await SomeApiAsync(); // ❌ CS1996: 'lock' 문 본문에서는 await을 사용할 수 없습니다.
        _cache = result;
    }
}

C# 컴파일러는 이 코드를 컴파일하지 않습니다. 이유는 Monitor의 동작 모델에 있습니다.

Monitor의 스레드 소유권(Thread Affinity) 락을 획득한 스레드만이 그 락을 해제할 수 있다. 스레드 A가 Monitor.Enter를 호출했다면, Monitor.Exit도 반드시 스레드 A에서 호출되어야 한다. 다른 스레드가 Exit을 호출하면 SynchronizationLockException이 던져진다.

async/await은 이 모델을 깨뜨릴 수 있습니다.

왜 lock 안의 await가 위험한가 — 스레드가 바뀐다

await SomeApiAsync() 지점에서 메서드는 일단 반환됩니다. API 응답이 도착해 콜백이 재개될 때, 그 콜백을 실행하는 스레드는 Task 라이브러리가 결정합니다 — ThreadPool 스레드일 수도 있고, SynchronizationContext가 살아 있다면 원래 스레드일 수도 있지만 보장은 없습니다. 다른 스레드가 Monitor.Exit을 호출하면 즉시 예외가 나거나, 더 나쁘게는 락이 영원히 풀리지 않습니다.

비동기 임계 구역의 정답 — SemaphoreSlim

SemaphoreSlim은 스레드 소유권 개념이 없습니다. "토큰 N개를 가진 풀"이라는 모델이라, 어떤 스레드가 WaitAsync로 토큰을 가져갔든 다른 스레드가 Release해도 됩니다. 그래서 await 와 안전하게 결합할 수 있습니다.

C#
public class AsyncCache
{
    private readonly SemaphoreSlim _gate = new(1, 1); // 카운트 1 = 사실상 mutex
    private string? _cached;

    public async Task<string> GetAsync(CancellationToken ct = default)
    {
        await _gate.WaitAsync(ct);   // ✓ 비동기 진입
        try
        {
            _cached ??= await FetchFromApiAsync(ct); // ✓ 안에서 await 가능
            return _cached;
        }
        finally
        {
            _gate.Release();
        }
    }
}

핵심 차이:

  lock (Monitor) SemaphoreSlim
소유권 모델 스레드 단위 토큰 단위 (스레드 무관)
await 가능 ❌ 컴파일 에러 WaitAsync
재진입 같은 스레드는 OK 같은 스레드여도 토큰 새로 필요
무경합 비용 매우 낮음 lock보다 약간 높음
퀴즈 — Unity에서 await UnityWebRequest.SendWebRequest() 다음 줄로 돌아왔을 때 어떤 스레드인가? 답은 다음 글들에서 다루는 SynchronizationContext 챕터에 있지만, 한 줄로 말하면 "기본 설정이라면 메인 스레드, ConfigureAwait(false) 하면 풀 스레드"입니다. 메인 스레드라도 lock을 풀고 들어왔다는 보장은 없으니 답은 변하지 않습니다 — 여전히 lock 안의 await은 금지입니다.

[데드락] 두 락을 다른 순서로 잡으면 영원히 풀리지 않는다

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

void Worker1()
{
    lock (_lockA)
    {
        Thread.Sleep(10);   // Worker2가 _lockB 잡을 시간 확보
        lock (_lockB) { /* ... */ }  // _lockA 가진 채 _lockB를 기다림
    }
}

void Worker2()
{
    lock (_lockB)
    {
        Thread.Sleep(10);
        lock (_lockA) { /* ... */ }  // _lockB 가진 채 _lockA를 기다림
    }
}

Worker1A → B 순서로, Worker2B → A 순서로 락을 잡습니다. 두 스레드가 거의 동시에 외부 락을 잡고 안쪽 락을 기다리면 둘 다 영원히 대기합니다. 데드락입니다.

데드락 — 락 획득 순서가 뒤바뀌면 양쪽이 영원히 대기

데드락을 막는 두 가지 정석:

1) 락 획득 순서를 통일

모든 스레드가 항상 같은 순서로 락을 잡도록 정합니다. 예를 들어 _lockA_lockB보다 먼저 잡도록 코드 리뷰에서 강제합니다.

C#
void Worker1() {
    lock (_lockA) lock (_lockB) { /* ... */ }   // A → B
}
void Worker2() {
    lock (_lockA) lock (_lockB) { /* ... */ }   // A → B (순서 통일)
}

2) Monitor.TryEnter로 타임아웃 도입

lock 키워드는 무한 대기만 지원합니다. 타임아웃이 필요하면 Monitor.TryEnter를 직접 씁니다.

C#
bool taken = false;
try
{
    Monitor.TryEnter(_sync, TimeSpan.FromSeconds(2), ref taken);
    if (!taken)
    {
        Debug.LogWarning("락 획득 실패 — 데드락 의심, 자원 반납");
        return;  // 다른 락도 풀고 후퇴 → 데드락 회피
    }
    // 임계 구역
}
finally
{
    if (taken) Monitor.Exit(_sync);
}

가장 좋은 데드락 대책은 락을 두 개 동시에 잡지 않는 것입니다. 자료 구조 설계 단계에서 락의 영역을 좁게 잡고, 한 락 안에서 다른 락이 필요한 콜백을 호출하지 않습니다.


[성능 특성] 무경합은 매우 싸고, 경합은 점점 비싸진다

Thin Lock → Fat Lock 승격

CLR은 lock이 거의 항상 무경합 상태에서 사용된다는 통계적 사실을 활용해 두 단계 구조를 씁니다.

Monitor의 두 단계 — Thin Lock에서 Fat Lock으로

무경합 시 (Thin Lock):

  • 락이 비어 있으면 CLR은 객체 헤더의 sync 비트를 CAS(Compare-And-Swap) 한 번으로 잡습니다.
  • 같은 스레드가 다시 잡으면 재진입 카운트만 1 증가시키고 끝납니다.
  • 측정해 보면 한 번의 lock { } enter/exit이 보통 수십 나노초 수준입니다. _value++ 한 줄을 보호하는 비용으로는 충분히 작습니다.

경합 시 (Fat Lock):

  • 다른 스레드가 점유 중이면 CLR은 짧은 시간 동안 spin-wait(busy loop)을 합니다. 락이 매우 짧게만 점유되는 흔한 경우라면 컨텍스트 스위치 없이 끝납니다.
  • spin-wait 시간 안에 락이 풀리지 않으면 OS 커널의 동기화 객체를 사용해 스레드를 sleep 시킵니다. 이때 객체의 SyncBlock이 별도로 할당되며 락은 "fat lock"으로 승격됩니다.
  • 일단 fat lock으로 승격되면 enter/exit 비용이 마이크로초 단위로 늘어나고, 컨텍스트 스위치 비용까지 더해집니다. 한번 승격된 락은 보통 다시 thin으로 내려가지 않습니다.

정리: 락은 짧게 머물수록 빠르다

  • 락 안에서 I/O, 네트워크, 콘솔 출력을 하지 않습니다 — 점유 시간을 ms 단위로 늘립니다.
  • 락 안에서 이벤트 발생, 콜백 호출을 하지 않습니다 — 외부 코드가 거기서 무엇을 할지 모르기 때문입니다 (또 다른 락을 잡으면 데드락).
  • 락 안에서 할당이 큰 작업(new T[10000])을 하지 않습니다.
  • 가능하면 락 안의 작업은 한 두 줄로 끝나도록 자료 구조를 설계합니다. 락 밖에서 준비물을 모두 만들고, 락 안에서는 "한 번에 바꿔치기"만 합니다.

[Unity 실전 패턴] 메인 스레드 모델과 lock의 사용처

Unity에서는 Transform, GameObject, MonoBehaviour 같은 엔진 객체를 메인 스레드(Update 스레드)에서만 만질 수 있습니다. 백그라운드 스레드가 직접 transform.position = ...을 하면 즉시 던져지는 예외나, 더 나쁘게는 데이터 손상이 발생합니다.

그래서 Unity에서 lock이 등장하는 자리는 보통 다음 세 가지입니다.

1) 메인 스레드 디스패치 큐 (Action Queue)

백그라운드 스레드(Task.Run, 네트워크 콜백, 빌트인 UnityWebRequest의 일부 콜백)에서 메인 스레드에 작업을 위임할 때 쓰는 큐입니다.

C#
public class MainThreadDispatcher : MonoBehaviour
{
    private static MainThreadDispatcher _instance;
    private readonly Queue<Action> _queue = new();
    private readonly object _sync = new();

    public static void Enqueue(Action action)
    {
        // 어느 스레드에서 호출되든 안전해야 함
        lock (_instance._sync)
        {
            _instance._queue.Enqueue(action);
        }
    }

    private void Update()
    {
        // 매 프레임 메인 스레드에서 한 번씩 비웁니다
        while (true)
        {
            Action next;
            lock (_sync)
            {
                if (_queue.Count == 0) return;
                next = _queue.Dequeue();
            }
            // ⚠ 락 밖에서 실행 — 콜백이 다시 Enqueue를 호출해도 데드락 없음
            try { next(); }
            catch (Exception e) { Debug.LogException(e); }
        }
    }
}

핵심은 Dequeue는 락 안에서, 실행(next())은 락 밖에서 한다는 점입니다. 콜백이 무거울 수도 있고, 콜백 안에서 다시 Enqueue가 호출될 수도 있어서 락을 잡은 채 콜백을 부르면 위 두 시나리오 모두에서 막힙니다.

2) 백그라운드 스레드 풀에서의 객체 풀링

게임에서 자주 만들어지는 작은 데이터 객체(PathfindingResult, DamageEvent 등)를 GC 압박 없이 재사용하려면 풀이 필요합니다. 풀 자체는 여러 스레드가 동시에 빌리고 반납하므로 보호가 필요합니다.

C#
public class ResultPool<T> where T : new()
{
    private readonly Stack<T> _stack = new();
    private readonly object _sync = new();

    public T Rent()
    {
        lock (_sync)
        {
            if (_stack.Count > 0) return _stack.Pop();
        }
        // 락 밖에서 new — 객체 생성 비용이 락 점유 시간에 끼지 않음
        return new T();
    }

    public void Return(T item)
    {
        lock (_sync) { _stack.Push(item); }
    }
}

여기서도 new T()락 밖에서 한다는 점이 중요합니다. 풀 안의 데이터를 만지는 시간은 매우 짧고, 객체 생성은 GC가 끼어들 수 있는 비용 큰 작업입니다.

3) 로깅 큐 (정확하지만 짧은 임계 구역)

여러 스레드가 동시에 로그를 찍는 상황에서 파일 I/O를 직접 하면 충돌이 납니다. 큐에 모아 두고 별도 스레드가 비우는 패턴이 표준입니다.

C#
public class LogBuffer
{
    private readonly Queue<string> _queue = new();
    private readonly object _sync = new();

    public void Add(string line)
    {
        lock (_sync) { _queue.Enqueue(line); }    // 짧다
    }

    public string[] Drain()
    {
        lock (_sync)
        {
            if (_queue.Count == 0) return Array.Empty<string>();
            var arr = _queue.ToArray();
            _queue.Clear();
            return arr;
        }
    }
}

Drain은 별도 스레드가 1초에 한 번 돌면서 호출하고, 받은 배열을 락 밖에서 디스크에 씁니다. 락 점유 시간이 큐의 크기와 무관하지는 않지만 메모리 복사 한 번이라 매우 짧습니다.

Unity에서 피해야 할 패턴

C#
// ❌ 메인 스레드 객체를 백그라운드에서 lock으로 보호한다고 안전해지지 않음
lock (_sync) { transform.position = newPos; }
// → Unity는 transform 접근 자체를 메인 스레드에서만 허용. lock으로는 풀리지 않음.

// ❌ static 라이브러리(예: Resources)를 lock으로 직접 보호
lock (typeof(Resources)) { ... }
// → Resources는 Unity 내부에서 자체 동기화. 외부 lock은 의미 없고 데드락 위험만 큼.

// ❌ Coroutine 안에서 lock 잡고 yield return WaitForSeconds
IEnumerator Foo() {
    lock (_sync) {
        yield return new WaitForSeconds(1); // ← yield는 컴파일러에서 메서드를 쪼갠다.
    }                                       //    yield 이후 부분은 다음 프레임에 다시 호출되며,
}                                           //    그 사이 lock 상태는 정의되지 않음. 사실상 await의 친척.
코루틴은 메인 스레드에서만 실행되므로 보통 lock이 필요 없습니다. 락이 필요하다면 그건 다른 스레드와 데이터를 주고받는 부분 — 그 부분은 코루틴이 아니라 일반 메서드여야 합니다.

[다른 동기화 도구와의 비교] lock이 답이 아닌 경우

상황 도구 이유
단순 카운터·플래그 증감 Interlocked 락 없이 CPU 명령어 한 두 개로 처리. lock보다 5~10배 빠름
비동기 코드의 임계 구역 SemaphoreSlim + WaitAsync 스레드 소유권 없음 → await 가능
읽기 많고 쓰기 적은 캐시 ReaderWriterLockSlim 동시 읽기를 허용해 처리량 증가
임계 구역이 매우 짧고 경합 거의 없음 SpinLock (전문가용) 커널 진입 회피, 단 잘못 쓰면 CPU 낭비
한 시점에 N개 스레드 허용 SemaphoreSlim(N, N) 자원 풀 모델
여러 줄을 묶어 일관성 보장 lock 가장 단순하고 안전
Interlocked vs lock _counter++ 같은 단일 연산이라면 Interlocked.Increment(ref _counter)가 정답입니다. if (_count > 0) _count-- 처럼 "검사 후 수정"이 두 줄에 걸쳐 있다면 lock 또는 Interlocked.CompareExchange 루프가 필요합니다.
ConcurrentQueue vs lock + Queue 단순히 큐를 보호하는 거라면 ConcurrentQueue<T>가 락 없이 (대부분의 경우) 동작합니다. lock + Queue<T> 패턴은 "큐 외에도 여러 필드를 같이 일관되게 바꿔야 할 때" 가치가 있습니다.

[자주 하는 실수와 해법]

실수 증상 해법
lock(this) 외부에서 락 탈취 가능, 디버깅 불가 데드락 private readonly object _sync
lock("문자열 리터럴") 모르는 라이브러리와 락 공유 같음
락 객체를 new로 매번 생성 동기화가 전혀 안 됨 객체를 필드로 한 번만 만들기
락 안에서 await CS1996 컴파일 에러 SemaphoreSlim.WaitAsync
락 안에서 이벤트/콜백 호출 외부 코드가 다른 락을 잡으면 데드락 락 밖에서 호출, 락 안에서는 데이터만 복사
락 객체가 static이 아닌데 정적 데이터 보호 인스턴스마다 락이 달라 동기화 실패 private static readonly object _staticSync
락 안에서 Thread.Sleep / I/O 다른 스레드들이 길게 대기, 처리량 급락 I/O는 락 밖, 락 안에선 메모리 작업만
두 락을 다른 순서로 획득 데드락 락 순서 통일 또는 Monitor.TryEnter 타임아웃
lock이 필요 없는데 그냥 다 거는 습관 처리량 저하, 무경합이라도 비용 발생 진짜 공유 상태인지 먼저 따져 보기

[버전별 변화]

  • C# 1.0 (2002): lock 키워드 도입. Monitor.Enter + try/finally + Monitor.Exit 로 단순 변환.
  • C# 4.0 (2010): 컴파일러가 Enter(object, ref bool lockTaken) 오버로드를 사용하도록 변경. Enter 호출 직후 비동기 예외(예: ThreadAbortException)가 발생해도 락 상태가 정확히 추적되도록 안전성이 강화됨.
  • C# 5.0 (2012): async/await 도입과 함께 lock 본문에서 await 사용 시 컴파일러 에러(CS1996)가 명시화됨. 비동기 임계 구역에는 SemaphoreSlim.WaitAsync 권장.
  • .NET 8: 64비트 타깃에서 객체 헤더가 변경되며 thin lock 인코딩이 미세 조정됨. 사용자 입장에선 큰 차이 없음.
  • .NET 9 / C# 13 (2024): System.Threading.Lock 타입 도입. lock 문이 일반 object 대신 이 새 타입을 받으면 컴파일러가 Lock.EnterScope() 기반의 새로운 패턴으로 변환해 더 빠르고 더 안전한 동기화를 제공한다. 다음 글(13)에서 자세히 다룬다. 단, 이 글에서 살펴본 고전 lock(object) 패턴은 .NET 9 이후에도 그대로 유효하며, 기존 코드를 바꿀 필요는 없다.

[요약]

  • lock (obj)는 컴파일러가 Monitor.Enter/Exit + try/finally로 풀어 주는 문법 설탕이다. IL 레벨에서는 메서드 호출 두 개와 try 블록 하나일 뿐이다.
  • 락 객체는 항상 private readonly object _sync = new(); 형태로 선언한다. this, typeof(X), 문자열 리터럴은 외부와 락을 공유하게 만들어 데드락의 원인이 된다.
  • 락 안에서는 await 금지다 — Monitor는 스레드 소유권 모델이라 콜백을 다른 스레드가 받으면 Monitor.Exit이 실패한다. 비동기에는 SemaphoreSlim.WaitAsync를 쓴다.
  • 락 안에서는 짧게 머문다 — I/O, 콜백, 이벤트, 큰 할당을 락 안에서 하지 않는다. 무경합이면 매우 싸지만, 한 번 fat lock으로 승격되면 비용이 마이크로초 단위로 뛴다.
  • Unity에서는 메인 스레드 디스패치 큐, 객체 풀, 로깅 큐 같은 워커-메인 사이의 데이터 채널lock을 쓴다. Unity 엔진 객체 자체는 lock으로 보호되지 않는다 — 메인 스레드에서만 만져야 한다.
  • 단순 카운터는 Interlocked, 비동기는 SemaphoreSlim, 읽기 위주는 ReaderWriterLockSlim — 상황에 맞는 도구를 고른다. lock은 "여러 줄을 묶어 일관성을 보장"하는 자리에 가장 잘 맞는다.
반응형

+ Recent posts