lock 문 - 공유 리소스에 대한 단독 접근 보장하기

lock 문 - 공유 리소스에 대한 단독 접근 보장하기

멀티 스레드 환경에서 여러 스레드가 같은 데이터를 동시에 읽거나 쓰면 결과가 꼬이기 마련이에요. lock 문은 바로 그런 상황에서 한 스레드만 공유 리소스에 접근하도록 보장해 주는 C#의 문법입니다.

출처: Microsoft Learn

본문

lock 문은 인자로 받은 객체에 상호 배제(mutual-exclusion) 잠금을 획득하고, 그 안의 문장 블록을 실행한 뒤 잠금을 해제해요. 잠금이 걸려 있는 동안에는 잠금을 쥔 스레드만 그 잠금을 여러 번 다시 획득하고 해제할 수 있고, 다른 스레드는 잠금이 풀릴 때까지 기다리며 접근이 막히죠. 즉 lock 문은 어느 순간에도 그 본문을 실행하는 스레드가 최대 하나라는 걸 보장합니다.

lock 문은 다음과 같은 형태로 써요:

lock (x)
{
    // Your code...
}

여기서 xxref:System.Threading.Lock?displayProperty=fullName 타입의 식이거나 참조 타입의 식입니다. 컴파일러가 xLock 타입임을 알면, 이 코드는 다음 코드와 정확히 동일해요:

using (x.EnterScope())
{
    // Your code...
}

EnterScope()가 반환하는 객체는 Dispose() 메서드를 가진 ref struct예요. 여기서 생긴 using 문 덕분에, lock 본문 안에서 예외가 발생해도 스코프(잠금)가 반드시 해제됩니다.

그 외의 경우, 즉 x가 일반 참조 타입이라면 lock 문은 다음 코드와 정확히 동일해요:

object __lockObj = x;
bool __lockWasTaken = false;
try
{
    System.Threading.Monitor.Enter(__lockObj, ref __lockWasTaken);
    // Your code...
}
finally
{
    if (__lockWasTaken) System.Threading.Monitor.Exit(__lockObj);
}

이 코드가 try-finally을 쓰고 있기 때문에, lock 본문 안에서 예외가 발생하더라도 잠금은 해제돼요.

다만 lock 문의 본문 안에서는 await을 쓸 수 없어요. await 때문에 코루틴이 중단되는 동안 잠금을 계속 들고 있을 수 없고, 또 다른 스레드가 그 잠금을 기다리며 블록될 수 있기 때문이죠.

지침

.NET 9과 C# 13부터는 성능을 위해 xref:System.Threading.Lock?displayProperty=nameWithType 타입의 전용 객체 인스턴스를 잠그는 걸 권장해요. 컴파일러는 아는 Lock 객체를 다른 타입으로 캐스팅해서 잠그는 경우 경고도 내줍니다. 더 오래된 .NET과 C# 버전을 쓰고 있다면, 다른 용도로 쓰이지 않는 전용 객체 인스턴스를 잠그세요.

서로 다른 공유 리소스에 같은 잠금 객체를 쓰면 데드락이나 잠금 경합(lock contention)이 생길 수 있으니 피해야 해요. 특히 다음 인스턴스들은 잠금 객체로 쓰지 않는 게 좋아요:

  • this — 호출하는 쪽에서도 this를 잠글 수 있어요.
  • Type 인스턴스 — typeof 연산자나 리플렉션으로 얻어질 수 있어서요.
  • 문자열 인스턴스(문자열 리터럴 포함) — 인터닝(interned) 될 수 있어서요.

잠금 경합을 줄이려면 잠금을 가능한 한 짧게 유지하는 게 좋아요.

예제

다음 예제는 전용 balanceLock 인스턴스에 잠금을 걸어 Account 클래스의 private balance 필드 접근을 동기화해요. 잠글 때 같은 인스턴스를 쓰면, 두 스레드가 Debit이나 Credit 메서드를 동시에 호출해 balance 필드를 갱신할 수 없게 돼요. 이 샘플은 C# 13과 새로운 Lock 객체를 사용하기 때문에, 더 오래된 C#이나 .NET 라이브러리를 쓴다면 object 인스턴스를 잠그면 됩니다.

using System;
using System.Threading.Tasks;

public class Account
{
    // Use `object` in versions earlier than C# 13
    private readonly System.Threading.Lock _balanceLock = new();
    private decimal _balance;

    public Account(decimal initialBalance) => _balance = initialBalance;

    public decimal Debit(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "The debit amount cannot be negative.");
        }

        decimal appliedAmount = 0;
        lock (_balanceLock)
        {
            if (_balance >= amount)
            {
                _balance -= amount;
                appliedAmount = amount;
            }
        }
        return appliedAmount;
    }

    public void Credit(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "The credit amount cannot be negative.");
        }

        lock (_balanceLock)
        {
            _balance += amount;
        }
    }

    public decimal GetBalance()
    {
        lock (_balanceLock)
        {
            return _balance;
        }
    }
}

class AccountTest
{
    static async Task Main()
    {
        var account = new Account(1000);
        var tasks = new Task[100];
        for (int i = 0; i < tasks.Length; i++)
        {
            tasks[i] = Task.Run(() => Update(account));
        }
        await Task.WhenAll(tasks);
        Console.WriteLine($"Account's balance is {account.GetBalance()}");
        // Output:
        // Account's balance is 2000
    }

    static void Update(Account account)
    {
        decimal[] amounts = [0, 2, -3, 6, -2, -1, 8, -5, 11, -6];
        foreach (var amount in amounts)
        {
            if (amount >= 0)
            {
                account.Credit(amount);
            }
            else
            {
                account.Debit(Math.Abs(amount));
            }
        }
    }
}

C# 언어 사양

더 자세한 내용은 C# 언어 사양The lock statement 섹션을 참고하세요.

더 알아보기