[C#] 스레드 사용법(2)
스레드 간의 동기화
프로그램을 구성하는 각 스레드는 여러 가지 자원을 공유하는 경우가 많습니다.
하지만, 스레드는 이기적이라 다른 상황은 신경쓰지 않습니다.
다른 스레드가 어떤 자원을 잡고 사용 중인데 갑자기 끼어들어 자기가 제멋대로 사용해버리는 경우가 대부분입니다.
그렇기 때문에 이런 스레드들을 순서에 맞게 자원을 사용하게 하는 것을 "동기화(Synchronization)"이라고 합니다.
스레드 동기화에서 가장 중요한 사명은 "자원을 한번에 하나의 스레드가 사용하도록 보장"하는 것입니다.
.NET 프레임워크가 제공하는 대표적인 도구는 2가지로
- lock 키워드와
- Monitor 클래스가 있습니다.
lock 키워드가 사용하기 쉽지만, Monitor 클래스가 더 섬세한 동기화 제어 기능을 제공합니다.
이제 사용법을 보겠습니다.
lock 키워드로 동기화하기
크리티컬 섹션(Critical Section): 한번에 한 스레드만 사용할 수 있는 코드 영역
C#에서는 lock 키워드로 감싸주기만 해도 평범한 코드를 크리티컬 섹션으로 바꿀 수 있습니다.
아래는 예제 코드입니다.
using System;
using System.Threading;
namespace Synchronize
{
class Counter
{
const int LOOP_COUNT = 1000;
//동기화 객체 필드를 선언
readonly object thisLock;
private int count;
public int Count
{
get { return count; }
}
public Counter()
{
thisLock = new object();
count = 0;
}
public void Increment()
{
int loopCount = LOOP_COUNT;
while (loopCount-- > 0)
{
lock (thisLock)
{
count++;
}
Thread.Sleep(1);
}
}
public void Decrement()
{
int loopCount = LOOP_COUNT;
while (loopCount-- > 0)
{
lock (thisLock)
{
count--;
}
Thread.Sleep(1);
}
}
}
class MainApp
{
static void Main(string[] args)
{
Counter counter = new Counter();
Thread incThread = new Thread(
new ThreadStart(counter.Increment));
Thread decThread = new Thread(
new ThreadStart(counter.Decrement));
incThread.Start();
decThread.Start();
incThread.Join();
decThread.Join();
Console.WriteLine(counter.Count);
}
}
}
코드에서 헷갈렸던 부분만 보겠습니다.
10행의 readonly object thisLock;은 C#의 필드 선언입니다. object 유형의 thisLock이라는 readonly 필드를 선언합니다.
'readonly' 키워드는 선언 시점이나 생성자에서 필드에 값을 한 번만 할당할 수 있음을 나타냅니다. 일단 지정되면 필드 값을 변경할 수 없습니다.
'객체' 유형은 다른 모든 참조 유형의 기본 클래스 역할을 하는 C#의 참조 유형입니다. 이 경우 'thisLock'은 한 번에 하나의 스레드만 공유 리소스를 수정할 수 있도록 잠금 목적의 동기화 개체로 사용됩니다.
'readonly'를 사용하고 'thisLock'을 '객체'로 선언함으로써 Counter 클래스는 'thisLock' 필드가 한 번만 초기화될 수 있고 동기화 목적으로 사용할 수 있는 객체임을 보장합니다.
29행과 42에서 lock (thisLock)
{
count++;
}
의 lock 키워드와 중괄호로 둘러싼 이 부분은 크리티컬 섹션이 됩니다. 한 스레드가 이 코드를 실행하다가 lock 블록이 끝나는 괄호를 만나기 전까지는 다른 스레드는 절대 이 코드를 실행할 수 없습니다.
그리고 lock 문은 인수로 참조형인 객체가 필요하며 count인 값 유형은 C#에서 객체가 아닙니다.
이 경우 thisLock은 참조 유형이며 lock 문의 인수로 사용할 수 있습니다.
thisLock을 잠그면 여러 스레드가 동시에 공유해야하는 자원인 count 변수를 마음대로 수정하는 것을 방지할 수 있습니다.
이렇게 크리티컬 섹션을 만들지 않으면 잘못된 결과를 초래할 수 있습니다.
요약하면 count와 같은 값 유형을 직접 잠글 수는 없지만 thisLock처럼 래핑하는 객체를 잠글 수 있습니다. 이렇게 하면 주어진 시간에 하나의 스레드만 공유하며 count 변수에 액세스할 수 있으므로 경합 상태를 방지하고 올바른 결과를 보장할 수 있습니다.
그렇게 1000번씩 1을 스레드들이 순서에 맞게 더하고 빼고 하면 결과는 0이 됩니다.
추가로 lock 키워드의 매개 변수로 사용하는 객체는 참조형이면 어는 것이든 쓸 수 있습니다.
하지만, public 키워드 등을 통해 외부 코드에서도 접근할 수 있는 다음 세 가지는 사용하지 않기 바랍니다.
- this: 클래스의 인스턴스는 클래스 내부뿐만 아니라 외부에서도 자주 사용됩니다. lock(this)
- Type 형식: typeof 연산자나 object 클래스로부터 물려받은 GetType() 메소드는 Type 형식의 인스턴스를 반환합니다. 즉, 코드의 어느 곳에서나 특정 형식에 대한 Type 객체를 얻을 수 있습니다. lock(typeof(SomeClass))나 lock(obj.GetType())
- string 형식: 절대 string 객체로 lock 하시면 안됩니다. lock("apple")은 안됩니다. "apple"은 어디든 얻을 수 있습니다.
Monitor 클래스로 동기화하기
Monitor 클래스는 스레드 동기화에 사용하는 몇 가지 정적 메소드를 제공합니다.
- Monitor.Enter()
- Monitor.Exit()
- Monitor.Wait()
- Monitor.Pulse()
먼저 Enter(),Exit()을 보면 이 두 메소드는 lock 키워드와 완전히 똑같은 기능을 합니다.
Enter()은 크리티컬 섹션을 만들고 Exit()은 크리티컬 섹션을 제거합니다,
괄호 {와 } 에 해당한다고 할 수 있습니다.
그러니 Enter()와 Exit()으로 동기화 할 것 같으면 차라리 간편하게 lock 키워드를 사용하는 편이 낫습니다.
코드도 읽기가 좋고 잘못 사용한 Monitor.Exit() 때문에 프로그램에 버그가 생길 가능성이 없으니까요.
다음은 lock 키워드 예시 코드에서 lock 키워드를 Monitor.Enter()와 Monitor.Exit()로 바꾼 코드입니다.
public void Increase()
{
int loopCount = LOOP_COUNT;
while (loopCount-- > 0)
{
Monitor.Enter(thisLock);
try
{
count++;
}
//무조건 크리티컬 섹션이 해제되게 finally 블록 안에서 호출
finally
{
Monitor.Exit(thisLock);
}
Thread.Sleep(1);
}
}
public void Decrease()
{
int loopCount = LOOP_COUNT;
while (loopCount-- > 0)
{
Monitor.Enter(thisLock);
try
{
count--;
}
//무조건 크리티컬 섹션이 해제되게 finally 블록 안에서 호출
finally
{
Monitor.Exit(thisLock);
}
Thread.Sleep(1);
}
}
}
Monitor.Wait()와 Monitor.Pulse()로 하는 저수준 동기화
monitor 클래스를 쓰는 이유는 솔직히 Wait()와 Pulse 메소드로 lock 키워드만을 사용할 때 보다 더 섬세하게 멀티 스레드 간의 동기화를 가능하게 하기 때문입니다. 대신 더 골치가 아픈게 단점입니다.
이 두 메소드는 반드시 lock 블록 안에서 호출해야 합니다. lock을 걸어 놓지 않은 상태에서 이 두 메소드를 호출한다면 CLR이 SynchronizationLockException 예외를 던지기 때문입니다.
Wait() 메소드는 스레드를 WaitSleepJoin 상태로 만듭니다. 이렇게 WaitSleepJoin 상태에 들어간 스레드는 동기화를 위해 갖고 있던 lock을 내려놓은 뒤 Waiting Queue라고 하는 큐에 입력되고, 다른 스레드가 lock을 얻어 작업을 수행합니다.
작업을 수행하던 스레드가 일을 마친 뒤 Pulse() 메소드를 호출하면 CLR은 Waiting Queue의 가장 첫 요소 스레드를 꺼낸 뒤 Ready Queue에 입력시킵니다. Ready Queue에 입력된 스레드는 입력된 차례에 따라 lock을 얻어 Running 상태에 들어갑니다. 이 말은 즉, 다시 작업을 수행한다는 것입니다.
한편, Thread.Sleep() 메소드도 스레드를 WaitSleepJoin 상태로 만들기는 하지만 Monitor. Pulse() 메소드에 의해 깨어날 수는 없습니다(Waiting Queue에 들어가지도 않고요).
다시 Running 상태로 돌아오려면 매개 변수로 입력된 시간이 경과되거나 인터럽트 예외(Interrupt() 메소드 호출에 의해)를 받아야 깨어납니다.
반면에 Monitor.Wait() 메소드는 Monitor.Pulse () 메소드가 호출되면 바로 깨어날 수 있습니다. 이 때문에 멀티 스레드 애플리케이션의 성능 향상을 위해서 Monitor.Wait()와 Monitor.Pulse()를 사용하는 것입니다.
사용법을 바로 보며 주석으로 설명하겠습니다.
using System;
using System.Threading;
namespace WaitPulse
{
class Counter
{
const int LOOP_COUNT = 1000;
//동기화 객체 필드를 선언
readonly object thisLock;
//스레드를 WaitSleepJoin 상태로 바꿔 블록시킬 조건(즉, Wait()를 호출할 조건)을 결정할 필드를 선언
//다른 말로는 lockedCount는 count 변수를 다른 스레드가 사용하고 있는지를 판별하기 위해 쓰임
bool lockedCount = false;
private int count;
public int Count
{
get { return count; }
}
public Counter()
{
thisLock = new object();
count = 0;
}
public void Increase()
{
int loopCount = LOOP_COUNT;
while (loopCount-- > 0)
{
lock (thisLock)
{
//count가 0보다 크거나 lockedCount가 다른 스레드에 의해 true로 바뀌어 있으면
//현재 스레드를 블록시킴
//다른 스레드가 Pulse() 메소드를 호출해줄 때까지 WaitSleepJoin 상태로 남음
while (count > 0 || lockedCount == true)
Monitor.Wait(thisLock);
//다른 스레드가 사용못하게 막음
lockedCount = true;
//count를 하나 더함
count++;
//다른 스레드가 사용할 수 있게 품
lockedCount = false;
//Waiting Queue에 대기하고 있던 다른 스레드가 깨어나게 되어
//false로 바뀐 lockedCount를 보고 작업을 수행함
Monitor.Pulse(thisLock);
}
}
}
public void Decrease()
{
int loopCount = LOOP_COUNT;
while (loopCount-- > 0)
{
lock (thisLock)
{
while (count < 0 || lockedCount == true)
Monitor.Wait(thisLock);
lockedCount = true;
count--;
lockedCount = false;
Monitor.Pulse(thisLock);
}
}
}
}
class MainApp
{
static void Main(string[] args)
{
Counter counter = new Counter();
Thread incThread = new Thread(
new ThreadStart(counter.Increase));
Thread decThread = new Thread(
new ThreadStart(counter.Decrease));
incThread.Start();
decThread.Start();
incThread.Join();
decThread.Join();
Console.WriteLine(counter.Count);
}
}
}