Monitor.Wait()와 Monitor.Pulse()는 스레드 동기화를 위한 저수준 메서드로, 스레드 간의 협력적인 작업을 가능하게 한다. 마치 두 명의 요리사가 함께 요리하는 과정에서, 한 요리사가 재료를 준비하면 다른 요리사에게 알려주고, 다음 요리사는 재료를 사용하여 요리를 완성하는 것과 같은 협력 관계를 생각하면 된다. 따라서 단순히 lock 키워드만 사용할 떄 보다 섬세하게 멀티 스레드 간 동기화를 가능하게 해 준다.
Monitor 클래스는 이러한 협력 작업을 위한 잠금과 신호 메커니즘을 제공한다.
lock: 잠금은 주방의 냉장고와 같다. 한 번에 한 명의 요리사만 냉장고를 사용할 수 있고, 다른 요리사는 냉장고가 비워질 때까지 기다려야 한다.Monitor.Wait(): 요리사가 음식을 만들 재료가 없을 때, 냉장고 앞에서 기다리는 것과 같다. 이때 요리사는 냉장고를 잠시 놓아주고(잠금 해제), 다른 요리사가 재료를 채워 넣을 때까지 기다린다.Monitor.Pulse(): 다른 요리사가 냉장고에 재료를 채워 넣고, 기다리고 있는 요리사에게 "재료가 준비되었어요!"라고 알려주는 것과 같다.Monitor.Wait()를 호출하여 잠금을 놓아주고 조건이 만족될 때까지 기다리는 스레드들이 Waiting Queue에 있다.Monitor.Wait() / Monitor.Pulse() 동기화 과정
Monitor.Enter()를 호출하여 잠금을 획득한다. (냉장고를 사용하기 시작)Monitor.Wait()를 호출한다. (재료가 없어서 냉장고 앞에서 대기)Monitor.Enter()를 호출하여 잠금을 획득한다. (냉장고를 사용하기 시작)Monitor.Pulse()를 호출하여 Waiting Queue에 있는 스레드 A를 깨운다. (요리사 A에게 재료가 준비되었다고 알려줌)Monitor.Exit()를 호출하여 잠금을 해제한다. (냉장고 사용을 마침)이처럼 Monitor.Wait()와 Monitor.Pulse()를 사용하면 스레드 간에 협력하여 작업을 수행하고, 공유 자원에 대한 접근을 효율적으로 제어할 수 있다.
Monitor.Wait()
Pulse() 메서드 또는 PulseAll() 메서드를 호출할 때까지 현재 스레드를 대기 상태로 만든다. 즉, 현재 스레드는 잠시 작업을 멈추고 다른 스레드가 신호를 보낼 때까지 기다린다.lock 블록 안에서 호출해야 한다. 잠금을 획득한 스레드만 Wait() 메서드를 호출할 수 있기 때문이다. ( lock을 걸어놓지 않은 상태에서 Monitor.Wait()메서드를 호출하면, CLR이 SynchronizationLockException 예외를 던져버린다. )Wait() 메서드를 호출하면 잠금이 해제되므로, 다른 스레드가 잠금을 획득하고 작업을 수행할 수 있다.Pulse() 메서드를 호출하면, 대기 중인 스레드는 깨어나서 잠금을 다시 획득하고 작업을 계속 수행할 수 있다.Monitor.Pulse()
Wait() 메서드를 호출하여 대기 중인 스레드에게 작업을 계속해도 된다는 신호를 보낸다.lock 블록 안에서 호출해야 한다. 잠금을 획득한 스레드만 Pulse() 메서드를 호출할 수 있기 때문이다. ( lock을 걸어놓지 않은 상태에서 Monitor.Pulse()메서드를 호출하면, CLR이 SynchronizationLockException 예외를 던진다. )Pulse() 메서드는 대기 중인 스레드를 깨우지만, 잠금을 곧바로 넘겨주지는 않는다. 깨어난 스레드는 잠금을 다시 획득해야 작업을 계속 수행할 수 있다.Monitor.Wait()와 Monitor.Pulse()를 사용하는 이유
Wait()와 Pulse() 메서드를 사용하면 스레드 간의 협력적인 작업을 구현할 수 있다. 예를 들어, 생산자-소비자 패턴에서 생산자 스레드는 데이터를 생성하고 Pulse() 메서드를 호출하여 소비자 스레드를 깨우고, 소비자 스레드는 Wait() 메서드를 호출하여 대기하다가 깨어나면 데이터를 사용한다.lock 문은 단순히 코드 블록을 잠그는 기능만 제공하지만, Monitor.Wait()와 Monitor.Pulse()는 잠금을 해제하고 대기하는 기능을 제공하여 더욱 세밀하게 스레드 동기화를 제어할 수 있다.lock 블록 안에서 호출해야 하는 이유
Monitor.Wait()와 Monitor.Pulse() 메서드는 잠금을 획득한 스레드에서만 호출할 수 있다. lock 문은 Monitor.Enter()와 Monitor.Exit() 메서드를 사용하여 잠금을 획득하고 해제하는 것과 같은 역할을 한다. 따라서 lock 블록 안에서 Monitor.Wait()와 Monitor.Pulse() 메서드를 호출해야 잠금을 획득한 상태에서 메서드를 호출할 수 있다.Monitor.Wait()와 Monitor.Pulse() 사용 시 주의 사항
Wait() 메서드를 호출하기 전에 반드시 잠금을 획득해야 한다.Pulse() 메서드를 호출하기 전에 반드시 잠금을 획득해야 한다.Wait() 메서드를 호출한 후에는 잠금을 다시 획득해야 한다.Pulse() 메서드는 대기 중인 스레드를 깨우지만, 잠금을 곧바로 넘겨주지는 않는다.Monitor.Wait()와 Monitor.Pulse()는 저수준 스레드 동기화 메커니즘을 제공하며, 스레드 간의 협력적인 작업을 구현하는 데 유용하게 사용될 수 있다.
Monitor.Wait() / Monitor.Pulse() 동기화 과정
잠금 획득: 스레드가 Monitor.Enter()를 호출하여 공유 자원에 대한 잠금을 건다(획득한다). 이때 스레드는 Running 상태이다.
작업 수행: 스레드는 잠금을 건 상태에서 공유 자원을 사용하여 작업을 수행한다.
대기: 스레드가 Monitor.Wait()을 호출하면, 잠금을 해제하고 WaitSleepJoin(대기) 상태로 전환된다. 다음 주자를 기다리며 쉬는 것과 같다. WaitSleepJoin 상태에 들어간 스레드는, 동기화를 위해서 갖고 있던 lock을 내려놓은 뒤 Waiting Queue라는 큐(:먼저 입력된 요소가 먼저 출력되는 자료구조)에 입력된다.
다른 스레드 작업: 잠금이 해제되었으므로 다른 스레드가 Monitor.Enter()를 호출하여 잠금을 걸고 공유 자원을 사용할 수 있다.
신호 전송: 작업을 마친 스레드는 Monitor.Pulse() 메서드를 호출하여 대기 중인 스레드에게 신호를 보낸다. 다음 주자에게 "준비됐다"라고 신호를 보내는 것과 같다. 작업을 수행하던 스레드가 일을 마친 뒤에 Pulse() 메서드를 호출하면 CLR은 Waiting Queue에서 첫 번쨰 위치에 있는 스레드를 꺼낸 뒤 Ready Queue에 입력한다. Ready Queue에 입력된 스레드는 입력된 차례에 따라 잠금을 얻어 Running 상태에 들어간다.
잠금 재획득: 신호를 받은 스레드는 깨어나서 다시 Monitor.Enter()를 호출하여 잠금을 획득하려고 시도한다. 잠금을 획득하고나면 Running 상태로 돌아가 작업을 계속 수행한다.
Running 상태
Monitor.Enter()를 호출하여 잠금을 획득한 스레드는 Running 상태에서 작업을 수행한다.WaitSleepJoin 상태
Thread.Sleep()을 호출하여 일정 시간 동안 멈춘 경우Thread.Join())Monitor.Wait()을 호출하여 다른 스레드의 신호를 기다리는 경우예제
using System;
using System.Threading;
namespace WaitPulse
{
class Counter
{
const int LOOP_COUNT = 1000; // 반복 횟수를 나타내는 상수이다.
// (1) 클래스 안에 동기화 객체 필드를 선언한다.
readonly object thisLock; // 잠금 객체이다.
// (2) 스레드를 WaitSleepJain 상태로 바꿔 블록할 조건(Wait() 메서드를 호출할 조건)을
// 결정할 필드를 선언한다.
bool lockedCount = false; // count 변수에 대한 잠금 여부를 나타내는 변수이다.
private int count; // 카운터 값을 저장하는 필드이다.
public int Count // count 필드에 접근하기 위한 프로퍼티이다.
{
get { return count; }
}
public Counter() // 생성자이다.
{
thisLock = new object(); // 잠금 객체를 초기화한다.
count = 0; // 카운터 값을 0으로 초기화한다.
}
public void Increase() // 카운터 값을 증가시키는 메서드이다.
{
int loopCount = LOOP_COUNT; // 반복 횟수를 loopCount 변수에 저장한다.
while (loopCount-- > 0) // loopCount가 0보다 클 때까지 반복한다.
{
// (3) 스레드를 블록하고 싶은 곳에 lock 블록 안에서 (2)에서 선언한 필드를 검사하여,
// Monitor.Wait() 메서드를 호출한다.
lock (thisLock) // 잠금 객체를 사용하여 임계 영역을 보호한다.
{
// (4) (3)에서 선언한 코드는 count가 0보다 크거나 lockedCount가 true이면 해당 스레드는 블록된다.
while (count > 0 || lockedCount == true) // count가 0보다 크거나 lockedCount가 true이면 대기한다.
Monitor.Wait(thisLock); // 잠금을 해제하고 다른 스레드가 Pulse()를 호출할 때까지 대기한다.
// (5) 블록되어있던 스레드가 깨어나면 작업을 수행해야한다,
// 따라서 가장 먼저 (2)에서 선언한 lockedCount의 값을 true로 바꾼다.
// 이렇게 해두면 다른 스레드가 이 코드에 접근할 때 (3)에서 선언해둔 블록 코드에 걸려서
// 같은 코드를 실행할 수 없게 된다.
lockedCount = true; // count 변수에 대한 잠금을 설정한다.
count++; // 카운터 값을 1 증가시킨다.
// (6) 작업을 마치면, lockedCount의 값을 다시 false로 바꾼 뒤 Monitor.Pulse() 메서드를 호출한다,
// 그러면 Waiting Queue에 대기하고 있던 다른 스레드가 깨어나서,
// False로 바뀐 lockedCount를 보고 작업을 수행한다.
lockedCount = false; // count 변수에 대한 잠금을 해제한다.
Monitor.Pulse(thisLock); // 대기 중인 스레드를 깨운다.
}
}
}
public void Decrease() // 카운터 값을 감소시키는 메서드이다.
{
int loopCount = LOOP_COUNT; // 반복 횟수를 loopCount 변수에 저장한다.
while (loopCount-- > 0) // loopCount가 0보다 클 때까지 반복한다.
{
lock (thisLock) // 잠금 객체를 사용하여 임계 영역을 보호한다.
{
while (count < 0 || lockedCount == true) // count가 0보다 작거나 lockedCount가 true이면 대기한다.
Monitor.Wait(thisLock); // 잠금을 해제하고 다른 스레드가 Pulse()를 호출할 때까지 대기한다.
lockedCount = true; // count 변수에 대한 잠금을 설정한다.
count--; // 카운터 값을 1 감소시킨다.
lockedCount = false; // count 변수에 대한 잠금을 해제한다.
Monitor.Pulse(thisLock); // 대기 중인 스레드를 깨운다.
}
}
}
}
class MainApp
{
static void Main(string[] args)
{
Counter counter = new Counter(); // Counter 객체를 생성한다.
Thread incThread = new Thread(new ThreadStart(counter.Increase)); // Increase 메서드를 실행할 스레드를 생성한다.
Thread decThread = new Thread(new ThreadStart(counter.Decrease)); // Decrease 메서드를 실행할 스레드를 생성한다.
incThread.Start(); // incThread 스레드를 시작한다.
decThread.Start(); // decThread 스레드를 시작한다.
incThread.Join(); // incThread 스레드가 종료될 때까지 기다린다.
decThread.Join(); // decThread 스레드가 종료될 때까지 기다린다.
Console.WriteLine(counter.Count); // 카운터 값을 출력한다.
}
}
}
코드 설명
이 C# 코드는 Monitor.Wait()와 Monitor.Pulse()를 사용하여 스레드 동기화를 구현하는 예제이다. Counter 클래스는 count 필드를 갖고 있으며, Increase() 메서드는 count를 증가시키고, Decrease() 메서드는 count를 감소시킨다.
MainApp 클래스의 Main 메서드에서는 Counter 객체를 생성하고, Increase() 메서드와 Decrease() 메서드를 각각 실행하는 두 개의 스레드를 생성한다. 두 스레드는 동시에 실행되면서 count 필드에 접근하고 변경하기 때문에, 데이터 경쟁이 발생할 수 있다.
이를 방지하기 위해 Monitor.Wait()와 Monitor.Pulse()를 사용하여 스레드 동기화를 구현한다. Increase() 메서드와 Decrease() 메서드는 lock (thisLock) 문으로 count 필드에 대한 접근을 동기화한다.
while (count > 0 || lockedCount == true) 문은 count가 0보다 크거나 lockedCount가 true이면 Monitor.Wait(thisLock) 메서드를 호출하여 스레드를 대기 상태로 만든다. Monitor.Wait(thisLock) 메서드는 잠금을 해제하고 다른 스레드가 Pulse() 메서드를 호출할 때까지 대기한다.
lockedCount = true; 문은 count 변수에 대한 잠금을 설정한다. count++ 문은 카운터 값을 1 증가시킨다. lockedCount = false; 문은 count 변수에 대한 잠금을 해제한다.
Monitor.Pulse(thisLock); 문은 대기 중인 스레드를 깨운다. 깨어난 스레드는 잠금을 다시 획득하고 작업을 계속 수행할 수 있다.
Thread.Sleep(1) 문은 스레드를 1밀리초 동안 일시 중지하여, 다른 스레드가 실행될 기회를 줍니다. 이는 데이터 경쟁을 더욱 명확하게 보여주기 위한 것이다.
incThread.Join()과 decThread.Join()은 각 스레드가 종료될 때까지 기다리는 메서드이다. 모든 스레드가 종료된 후, Console.WriteLine(counter.Count); 문은 최종 카운터 값을 출력한다.
출력 결과
0
코드 해석
이 코드는 Monitor.Wait()와 Monitor.Pulse()를 사용하여 스레드 동기화를 구현하는 방법을 보여준다. Monitor.Wait() 메서드는 스레드를 대기 상태로 만들고 잠금을 해제하며, Monitor.Pulse() 메서드는 대기 중인 스레드를 깨운다. 이를 통해 스레드 간의 협력적인 작업을 구현할 수 있다.