lock 문 - 공유 리소스에 대한 단독 접근 보장하기
lock 문 - 공유 리소스에 대한 단독 접근 보장하기
멀티 스레드 환경에서 여러 스레드가 같은 데이터를 동시에 읽거나 쓰면 결과가 꼬이기 마련이에요. lock 문은 바로 그런 상황에서 한 스레드만 공유 리소스에 접근하도록 보장해 주는 C#의 문법입니다.
출처: Microsoft Learn
본문
lock 문은 인자로 받은 객체에 상호 배제(mutual-exclusion) 잠금을 획득하고, 그 안의 문장 블록을 실행한 뒤 잠금을 해제해요. 잠금이 걸려 있는 동안에는 잠금을 쥔 스레드만 그 잠금을 여러 번 다시 획득하고 해제할 수 있고, 다른 스레드는 잠금이 풀릴 때까지 기다리며 접근이 막히죠. 즉 lock 문은 어느 순간에도 그 본문을 실행하는 스레드가 최대 하나라는 걸 보장합니다.
lock 문은 다음과 같은 형태로 써요:
lock (x)
{
// Your code...
}
여기서 x는 xref:System.Threading.Lock?displayProperty=fullName 타입의 식이거나 참조 타입의 식입니다. 컴파일러가 x가 Lock 타입임을 알면, 이 코드는 다음 코드와 정확히 동일해요:
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 섹션을 참고하세요.