[ Item 40 : 동기화에는 lock()을 최우선으로 사용하라 ]
스레드들은 서로 통신할 수 있어야 한다.
즉, 같은 애플리케이션에 속한 스레드들은 데이터를 안전하게 주고받을 수 있는 수단이 있어야 한다.
데이터 무결성 오류로 인한 잠재적 문제를 피하려면 모든 공유 데이터의 상태가 일관되게 유지되고 있음을 확신할 수 있어야 하고, 그렇게 하려면 동기화 요소(synchronization primitive)를 사용해서 공유 데이터에 대한 접근을 제어해야 한다.
동기화 요소는 특정 스레드가 임계 영역 내에서 연산을 수행하는 동안 다른 스레드로부터 이를 보호하는 역할을 수행한다.
그래서 C# 에서는 동기화 작업을 수월하게 할 수 있도록 lock() 블록을 사용해서 임계 영역을 지정하고, 동기화를 올바르게 제어할 수 있는 코드를 작성할 수 있다.
아래 코드를 살펴보자.
private readonly object _lock = new object();
private int _total = 0;
public void IncrementTotal()
{
lock(_lock)
{
_total++;
}
}
lock은 특정 객체에 대한 배타적인 모니터를 획득한 후, 락을 해제하기 전까지 다른 스레드가 그 객체에 접근하지 못하게 한다.
이 말을 풀어서 자세히 설명하면 다음과 같다.
코드의 lock 문에 의해 보호되는 코드 영역은 한 시점에 단 하나의 스레드에 의해서만 실행될 수 있음을 의미한다.
여기서 "배타적인"이란 "상호 배타적인(mutually exclusive)"의 줄임말로, 다른 스레드들이 동시에 같은 자원에 접근하는 것을 방지하는 개념이다.
C#의 lock
C#에서 lock 키워드는 내부적으로 Monitor.Enter와 Monitor.Exit 메서드를 사용하여 작동한다.
- 스레드가 lock으로 보호되는 영역에 진입하려고 할 때, Monitor.Enter 메서드는 해당 스레드가 사용하려는 객체의 모니터(lock 객체)에 대한 소유권을 가지려고 시도한다.
- 만약 다른 스레드가 이미 모니터를 소유하고 있다면, 이 스레드는 대기 상태로 전환되어, 모니터가 사용 가능해질 때까지 기다린다.
- 모니터가 사용 가능해지면, 대기 중이던 스레드 중 하나가 모니터의 소유권을 얻어 해당 코드 영역을 실행할 수 있게 된다.
- 스레드가 lock 영역을 벗어날 때 Monitor.Exit 메서드가 호출되어, 모니터의 소유권을 해제하고 다른 스레드가 해당 모니터를 획득할 수 있게 된다.
이런 과정을 lock 이라는 키워드 하나로 사용할 수 있게 해주며, 개발자가 일반적으로 저지르는 실수를 줄여주는 다양한 검증 기능도 수행한다.
lock의 검증 기능
1. 소유권을 행사할 객체의 타입이 값 타입이 아니라 참조 타입인지 확인하는 역할을 한다.
왜 참조타입인지 확인해야 할까?
Monitor.Enter 의 원형을 보면 매개변수로 Object 타입을 취한다.
그래서 만약 값 타입이 들어온다면 그 값을 박싱한 객체를 소유하게 되고, 이 부분이 첫 번째 버그를 유발하는 곳이다.
예를 들어 1번 스레드가 임계 영역에 들어와 락을 얻고(값 타입이 박싱된 객체) 코드를 수행하는 중에, 2번 스레드가 임계 영역에 들어왔다고 가정하자. 그럼 2번 스레드도 락을 얻는데 성공하게 된다. 2번 스레드의 락도 값 타입을 박싱한 또다른 객체이기 때문이다.
이런 상황이 발생하기 때문에 Monitor.Enter 에 전달되는 건 반드시 참조타입이어야 하고 lock은 이를 검증해준다.
이후 락을 반환하는 부분에서 두번째 버그가 발생한다.
두 스레드들 중 하나가 락을 해제하려고 하면 SynchronizationLockException을 던질 것이다. Monitor.Exit 메서드가 해제하려는 락 객체와 Monitor.Enter 에서 얻은 락 객체가 다르기 때문이다. Monitor.Exit 메서드도 값 타입을 박싱한 락 객체를 얻기 때문에 이런 결과가 발생한다.
2. 따로 호출되는 Enter와 Exit 메서드에 대해 서로 다른 락 객체를 바라보게되는 오류를 방지해준다.
이건 바로 위에서 말한 두번째 버그의 방지 방법이기도 하다.
그런데 꼭 lock 만을 사용해야 할까?
객체에 대한 단일 연산을 동기화 하는게 목적이라면 System.Threading.Interlocked 클래스를 사용해서 동기화하는 것이 더 효율적이다.
public void IncrementTotal() => total++;
위와 같은 메서드는 멀티 스레드 환경에서 데이터 일관성이 깨질 수 있다.
그래서 Interlocked 클래스는 여러 메서드를 지원하는데, 위와 같은 수치 증가에 대한 동기화 문제를 해결하기 위해서 아래와 같이 Interlocked.Increment() 메서드를 사용할 수 있다.
public void IncrementTotal() => Interlocked.Increment(ref total);
나머지 Interlocked 클래스의 내장 데이터 타입용 메서드는 다음과 같다.
- Interlocked.Decrement() → 값을 감소시킨다.
- Interlocked.Exchange() → 값을 새로운 값으로 바꾼 후 바꾸기 전의 값을 반환한다.
- Interlocked.CompareExchange() → 공유 데이터의 일부를 읽어서 비교하는 값과 같은 경우에만 값을 갱신한다. 어떤 경우든 CompareExchange() 메서드는 이전 값을 반환한다.
[ Item 41 : 락은 가능한 한 좁은 범위에 적용하라 ]
애플리케이션에 동기화 요소를 사용하는 곳이 많으면 많을수록 교착상태와 락 유실 등의 동시성 프로그래밍 문제를 피하기 어려워진다.
살펴봐야 할 곳이 늘어나면 문제를 놓치기 쉬워진다.
짧은 코드를 예시로 살펴보자
public class LockingExample
{
public void MyMethod()
{
lock (this)
{
// ...
}
}
}
var x = new LockingExample();
lock (x)
{
x.MyMethod();
}
이런 구성으로 작성된 lock 전략은 데드락에 빠지기 쉽다.
LockingExample 객체로 락을 획득하고, MyMethod() 내부에서는 똑같은 객체로 또 다른 락을 얻으려고 하기 때문이다.
A 스레드에서 LockingExample 객체를 생성하고 lock 을 획득한 다음,
B 스레드에서 프로그램 내의 전혀 다른 부분에서 LockingExample 객체를 생성하고 lock 을 획득하는 경우 문제가 발생한다.
MyMethod() 메서드 내부에서 this에 대한 lock을 소유해야하는데, 이때 어디에서 락을 요청했는지 프로그램이 찾기 어렵기 때문에 데드락이 발생하게 된다.
이를 해결하기 위한 최선의 방법은 private Object 변수를 동기화 핸들로 사용하는 것이다.
바로 Item 40의 가장 처음 부분에서 보았던 코드가 최선의 방법이었던 것이다.
private readonly object _lock = new object();
private int _total = 0;
public void IncrementTotal()
{
lock(_lock)
{
_total++;
}
}
위 코드의 _lock 필드를 동기화 핸들이라고 한다.
락을 효율적으로 사용할 수 있는 지침
- 클래스 안에서 서로 다른 값을 위해 여러 개의 동기화 핸들이 필요하다면, 그 클래스를 여러 클래스로 분할해야 한다는 강력한 신호이다. 즉, 접근을 통제해야 할 변수가 몇 개 있는데 각 변수에 각기 다른 락을 사용해야 한다면 해당 클래스를 서로 다른 역할을 수행하는 여러 개의 클래스로 분할해야 한다.
- 여러 개의 타입을 하나의 유닛으로 볼 수 있다면 동기화를 제어하기가 훨씬 수월하다. 이 경우 동기화 핸들을 하나만 이용해도 공유 리소스를 보호할 수 있다.
[ Item 42 : 잠긴 영역에서는 외부 코드 호출을 삼가라 ]
락을 사용해 보호받는 코드에서 외부 코드를 호출하는 경우, 다른 스레드가 애플리케이션을 데드락 상태에 빠뜨릴 가능성이 있다.
아래 예시 코드를 통해 외부 코드 호출을 삼가야 하는 이유를 알아보자.
public class WorkerClass
{
private readonly object _syncHandle = new object();
private int _progressCounter = 0;
public int Progress
{
get
{
lock (_syncHandle)
{
return _progressCounter;
}
}
}
public event EventHandler<EventArgs> RaiseProgress;
public void DoWork()
{
for (var cnt = 0; cnt < 100; cnt++)
{
lock (_syncHandle)
{
Thread.Sleep(100);
_progressCounter++;
RaiseProgress?.Invoke(this, EventArgs.Empty);
}
}
}
}
void OnRaiseProgress(object sender, EventArgs e)
{
if (sender is WorkerClass engine)
{
Console.WriteLine(engine.Progress);
}
}
위와 같은 코드를 사용하면서 문제가 없었다면 그건 그냥 여태까지 운이 좋았던 것 뿐이다.
왜 문제가 되는 코드인지 살펴보자.
DoWork() 메서드에서 for 문을 순회하며 락을 얻고, 보호된 코드에서 매번 이벤트를 트리거하게 된다.
RaiseProgress?.Invoke(this, EventArgs.Empty); 이 코드는 이벤트 핸들러를 호출하게 된다.
이벤트 핸들러가 수행되면 Progress 프로퍼티에 접근하게 되는데, 이때 데드락이 발생하게 된다.
핸들러를 실행하기 위한 for문 내부에서 _syncHandler 를 통해 락을 얻고, 실행된 핸들러에서 Progress에 접근하기 위해 똑같은 객체에 대해 락을 얻기를 시도하기 때문이다.
위와 같은 데드락 문제는 디버깅하기 매우 어려운데 그 이유를 아래 콜스택을 통해 살펴보자.
위 코드가 윈도우 폼 애플리케이션 코드라고 가정해보자.
그러면 이벤트 핸들러를 UI 스레드로 다시 마샬링 해야한다.
이때 필요하다면 Control.Invoke() 를 통해서 수행해야 할 델리게이트를 UI 스레드로 마샬링한다. 그리고 Control.Invoke()는 수행해야 할 델리게이트가 끝날 때까지 원본 스레드를 블로킹한다.
메서드 | 스레드 |
DoWork | 백그라운드 스레드 |
RaiseProgress | 백그라운드 스레드 |
OnUpdateProgress | 백그라운드 스레드 |
OnRaiseProgress | 백그라운드 스레드 |
Control.Invoke | 백그라운드 스레드 |
UpdateUI | 메인 스레드 |
Progress() | 메인 스레드(데드락) |
위와 같은 시나리오는 첫 번째 lock 과 두 번째 lock 사이에 수많은 메서드가 존재한다. 메서드가 많아질 수록 통제권 밖의 코드를 언제 실행했는지는 알기 어려워진다.
지금 예시로 보고 있는 시나리오는 문제의 원인인 외부 코드를 찾기가 비교적 수월한 편이다.
하지만 대부분의 클래스에 숨어있는 외부 코드의 근원지인 가상메서드는 찾기가 쉽지 않다.
가상 메서드는 파생 클래스가 재정의한 것일 수도 있고, 파생클래스가 자신의 상위 클래스에 정의된 public 혹은 protected 메서드를 모두 호출할 수도 있기 때문이다.
어떤 식으로 외부 코드가 관여하든 그 패턴은 모두 유사하다.
1. 클래스가 락을 얻고
2. 동기화된 영역 안에서 제어권 밖에 있는 코드 호출
이런 상황이 발생하지 않도록 사전에 차단해야하므로 lock 영역 내에서는 절대 외부 코드를 호출해서는 안된다.
'Lang > C#' 카테고리의 다른 글
[More Effective C#] Chapter 4. 요약 (1) (1) | 2024.04.21 |
---|---|
[More Effective C#] Chapter 3. 요약 (2) (0) | 2024.04.07 |
[More Effective C#] Chapter 3. 요약 (1) (0) | 2024.03.31 |
[More Effective C#] Chapter 2. 요약 (2) (0) | 2024.03.24 |
[More Effective C#] Chapter 2. 요약 (1) (4) | 2024.03.17 |