놀라움: C# lock 문을 'Intercept' 할 수 있다

발행: (2026년 2월 18일 오전 07:09 GMT+9)
9 분 소요
원문: Dev.to

Source: Dev.to

Cover image for “Surprise: You Can “Intercept” the C# lock Statement”

Dmitry Dorogoy

C#에서 lock을 “가로채”(hijack) 할 수 있습니다. 작동 방식과 절대 이렇게 하면 안 되는 이유를 살펴보세요.

많은 개발자들은 lock이 일종의 런타임 “마법”이라고 생각합니다. 사실 그렇지 않습니다. 대부분의 경우 이는 단순한 구문 설탕(syntax sugar)이며, 컴파일러가 System.Threading.Monitor에 대한 호출로 변환합니다.

그 변환 과정에 보기 흉한 모서리 케이스가 있습니다. 프로젝트에서 자체 System.Threading.Monitor를 정의하면, 컴파일러가 BCL(Basic Class Library) 버전이 아니라 당신의 타입에 바인딩할 수 있습니다. 즉, lock이 의미하는 바를 바꿀 수 있다는 뜻입니다.

이는 파티 트릭이자, 동시에 위험한 함정(foot‑gun)입니다. 기법이라기보다 경고적인 이야기로 받아들여 주세요.

Note: Starting with .NET 9 and C# 13, lock has a special fast‑path when the expression is a System.Threading.Lock. In that case it compiles to using (x.EnterScope()) { … } instead of Monitor.Enter/Exit.
The hijack shown below applies to the classic lock (object) path.

lock에 대해 컴파일러가 생성하는 코드

고전적인 lock (obj) { … } 경우에 C# 컴파일러는 다음과 동등한 코드를 생성합니다:

object _lockObj = obj;
bool _lockWasTaken = false;
try
{
    System.Threading.Monitor.Enter(_lockObj, ref _lockWasTaken);
    // Your code...
}
finally
{
    if (_lockWasTaken) System.Threading.Monitor.Exit(_lockObj);
}

따라서 lock은 런타임에서 “특별한” 것이 아닙니다. 알려진 호출로 확장되는 컴파일러 패턴일 뿐입니다.

하이재킹 트릭

BCL 타입과 동일한 완전한 이름을 가진 타입을 정의하면, 컴파일러가 당신의 메서드를 호출하도록 만들 수 있습니다.

최소 예제 (LINQPad 스니펫):

using System;

class Program
{
    static readonly object _sync = new();

    static void Main()
    {
        // 일반적인 lock처럼 보이지만, 실제로는 하이재킹되었습니다.
        lock (_sync)
        {
            Console.WriteLine("Working inside the lock...");
        }
    }
}

// 우리는 System 네임스페이스와 클래스를 "가짜"로 만들었습니다.
namespace System.Threading
{
    public static class Monitor
    {
        public static void Enter(object obj, ref bool lockTaken)
        {
            Console.WriteLine("Hijacked: Enter() was called");
            lockTaken = true; // 중요: 그렇지 않으면 Exit()가 호출되지 않습니다.
        }

        public static void Exit(object obj)
        {
            Console.WriteLine("Hijacked: Exit() was called");
        }
    }
}

실행하면 Hijacked: 메시지가 표시됩니다. 이것이 컴파일러가 lock당신의 System.Threading.Monitor에 바인딩했다는 증거입니다.

왜 이렇게 작동하나요?

이름 해석은 컴파일 시간에 발생하기 때문입니다.

컴파일에는 System.Threading.Monitor 타입이 포함되어 있고, 참조된 어셈블리에도 동일한 타입이 존재합니다. 컴파일러는 두 개를 모두 보고 하나를 선택합니다. 만약 컴파일러가 여러분의 타입을 선택하면, 리라이트는 여전히 발생하지만 여러분의 메서드를 대상으로 하게 됩니다.

이것이 바로 컴파일러 경고가 여러분에게 알려주려는 정확한 내용입니다.

경고를 무시하지 마세요

이 코드는 CS0436을 발생시켜야 합니다. 이 경우 이것은 “잡음”이 아니라 바로 핵심입니다.

CS0436은 다음을 의미합니다:

  • 컴파일에 포함된 타입과 가져온 타입 사이에 충돌이 있음
  • 컴파일러는 컴파일에 포함된 타입을 사용함

사용자 정의 Enter 메서드가 실제로 잠금을 걸지 않는다면, 여러 스레드가 동시에 가정된 임계 구역 안에서 실행될 수 있습니다. 이는 불변 조건을 깨뜨리고 가장 심각한 종류의 버그를 초래합니다: 재현하기 어렵고 디버거 아래에서는 사라지는 드문 레이스 상황.

우연히 발생할 수도 있습니다:

  • 헬퍼 클래스가 Monitor라는 이름을 가지고 잘못된 네임스페이스에 포함됨
  • 의존성이 충돌하는 타입을 제공함
  • 경고가 전역적으로 억제되어 CS0436이 놓침

System.Threading.Monitor와 관련된 CS0436을 한 번이라도 보면, 즉시 멈추고 조사하십시오.

더 안전한 목표: 락 경쟁 측정

관찰성이 목표이고 방해가 아니라면, 더 나은 접근법이 있습니다: lock을 전혀 건드리지 않고 경쟁을 측정합니다.

.NET은 모니터 경쟁에 대한 런타임 이벤트를 제공합니다:

  • ContentionStart_V2 – event id 81
  • ContentionStop_V1 – event id 91 (includes DurationNs)
  • 키워드는 ContentionKeyword (0x4000)

이러한 이벤트는 EventListener를 통해 수신할 수 있습니다.

(LINQPad 스니펫):

using System;
using System.Diagnostics.Tracing;
using System.Threading;

sealed class LockMonitor : EventListener
{
    public long TotalWaitTimeNs;
    int _durationIndex = -1;

    protected override void OnEventSourceCreated(EventSource source)
    {
        if (source.Name == "Microsoft-Windows-DotNETRuntime")
        {
            EnableEvents(source, EventLevel.Informational, (EventKeywords)0x4000);
        }
    }

    protected override void OnEventWritten(EventWrittenEventArgs eventData)
    {
        // ContentionStop_V1
        if (eventData.EventId != 91 || eventData.PayloadNames is null)
            return;

        if (_durationIndex < 0)
            _durationIndex = eventData.PayloadNames.IndexOf("DurationNs");

        if (_durationIndex < 0)
            return;

        // DurationNs is documented as a Double. Convert and keep nanoseconds as a long for easy accumulation.
        var durationNs = (long)Convert.ToDouble(eventData.Payload![_durationIndex]!);
        Interlocked.Add(ref TotalWaitTimeNs, durationNs);
    }
}

위 스니펫은 원본에 맞게 의도적으로 축약되었습니다.

이 접근 방식으로 얻는 것

  • lock은 실제 잠금으로 유지됩니다.
  • 스레드가 대기하는 시간을 수집합니다.
  • “좋은” 코드 경로와 “나쁜” 코드 경로를 비교하고 경쟁 상황을 명확히 볼 수 있습니다.

보다 자세한 보고서(잠금 객체별, 스택 트레이스, 타임라인)가 필요하면 dotnet‑trace, PerfView, 또는 ETW/EventPipe 기반 프로파일러와 같은 도구를 사용하세요. 위의 작은 EventListener는 좋은 최소 데모입니다.

요약

  • lock은 일반적으로 System.Threading.Monitor.Enter/Exit으로 확장되는 컴파일러 설탕(sugar)입니다.
  • 형식 이름 충돌은 컴파일러가 생성하는 코드를 바꿀 수 있습니다. CS0436은 의미가 바뀌고 있음을 경고합니다.
  • lock을 가로채는 것은 깔끔한 데모이지만 실제 코드에서는 매우 나쁜 생각입니다.
  • 진단을 위해서는 트릭보다 경쟁 이벤트와 적절한 도구를 사용하는 것이 좋습니다.
0 조회
Back to Blog

관련 글

더 보기 »

Windows Signal용 보충 자료

Signal은 인터럽트 신호 처리를 설정합니다. 구문: c void __cdecl signalint sig, int funcint, int; 매개변수 - sig – 신호 값. - func – 함수에 대한 포인터.

Windows Native Development를 고쳤다

빌드 요구 사항: Visual Studio 설치 > 아직 이 사실을 모를 정도로 운이 좋다면, 부러워합니다. 안타깝게도 이제는 보로미어조차도 알고 있습니다… 잘 표현했네요,…