세마포어와 뮤텍스를 통해서 임계구역에 대해 상호배제를 통한 동기화를 알아보았다. 두개의 동기화 방법의 단점이라면 임계구역으로 들어가기전 wait(), 임계구역을 빠져나올때 signal 혹은 release를 해주는 등의 코드를 프로그래머가 직접 넣어주어야 하는것이다. 프로그램이 복잡해지면 실수가 발생하기 쉽다.
이번에는 자바와같은 고급언어에서 임계구역에 대한 스레드 세이프한 연산을 지원해주는 모니터(Moniter)에 대해서 알아보겠다.
모니터는 문자 그대로 객체에 대한 모니터링을 지원하는 기능이다. 즉, 객체안에 있는 변수에 대한 제3자적 제어를 통해서 변수가 여러개의 스레드에 의해 경합상황(race condition)에 빠지는것을 방지한다. 자바에서 모든 객체는 하나의 모니터를 소유하고 있다. 이 모니터는 여러개의 스레드가 객체에 있는 멤버변수에 접근할때 하나의 스레드만 접근을 허용하고, 여러개의 스레드를 줄세워서 대기시키는 역할을 한다.
public class Main {
private static int THREAD_COUNT = 5; // [1]
public static void main(String[] args) {
System.out.println("thread start");
Count count = new Count(); // [2]
Thread threads[] = new Thread[THREAD_COUNT]; // [3]
for (int i = 0; i < THREAD_COUNT; ++i) {
threads[i] = new Thread(() -> { // [4]
for (int j = 0; j < 100000; ++j) {
count.increase();
}
});
threads[i].start();
}
for (int z = 0; z < THREAD_COUNT; ++z) {
try {
threads[z].join(); // [5]
} catch (InterruptedException e) {
}
}
System.out.println("count: " + count.getVal());
}
}
메인함수에서 5개의 스레드를 만들어서 멤버변수인 count
객체에 있는 값을 하나씩 올려주는 연산을 해보자. 스레드 하나당 10만번의 증가를 해주니깐 총 count
객체에 있는 val
은 50만이 되어야 한다.
여기서 임계영역은 count
객체 안에 있는 val
변수이다. 여러개의 스레드가 val
변수에 동시에 접근해서 연산을 하기 때문에 count.increase()
매서드는 임계구역에 대한 처리를 해주어야 하는데 여기서 역할을 하는게 모니터(Moniter)이다.
public class Count {
private int val = 0;
public synchronized void increase() { // [1]
++val;
}
public int getVal() {
return val;
}
}
위에서 자바에서의 모든 객체는 모니터를 하나씩 들고있다고 했다. Count
클래스의 객체도 마찬가지로 모니터를 하나 들고있고, 이 모니터는 synchronized
키워드를 통해서 간접적으로 사용된다. synchronized 키워드가 붙은 increase 매서드는 멤버변수 val에 대해서 thread safe한 동작을 제공한다.
모니터의 구조가 어떻게 생겼는지 의사코드를 보면서 이해를 해보자.
moniter CountMoniter
{
boolean busy;
condition x; // [A]
void acquire(int time) { // [B]
if (busy) {
x.wait(time);
}
busy = true;
}
void release() { // [C]
busy = false;
x.signal();
}
initialization_code() {
busy = false;
}
}
모든 모니터는 하나의 lock 변수와 하나의 conditional variable을 가진다. conditional variable은 os에서 지원되는걸 그대로 사용하고 객체하나당 하나만 부여된다고 보면 된다. 여기서 busy
는 lock변수, x
는 조건 변수(conditional variable)에 해당한다.
synchronized 메서드의 실행은 다음과 같다.
Count
클래스의 [1]synchronized
메서드인increase
가 실행될 때, 가장 먼저 임계구역에 접근하기 전에 [B]acquire
을 호출한다.- 만약 이미 임계구역에 대해 연산중인 스레드가 있다면, lock변수인
busy
가true
이고, 조건변수에 해당하는x.wait()
를 호출해서 해당 프로세스를 os수준에서 wait queue에 넣는다. 그리고 해당 객체에 연결된CriticalSection
이라는 자료구조의 큐 안에 이 wait상태의 스레드가 들어간다.(여기서CriticalSection
이라는 자료구조는 OS에서 관리되는 자료구조이다.)- 만약 임계구역에 대해 연산중인 스레드가 없다면 lock변수
busy
를true
로 바꾸고synchronized
메서드인 [1]increase
안에서 임계구역에 대한 연산++val
를 수행한다- 마지막으로 모니터 안에있는
release
메서드를 호출해서 lock변수인busy
를false
로 바꾼다. 그리고x.signal()
를 호출해서 해당 객체의CriticalSection
자료구조의 대기큐에서 wait 상태에 있는 스레드 하나를(혹은 대기큐에 있는 모든 스레드를 전부 다) ready 상태로 바꿔주고 ready queue로 보낸다. 이제 ready queue에선느 공평하게 스케줄러의 선택을 받아서 스레드가 cpu를 할당받는다.
이런 과정을 통해 synchronized 키워드가 붙은 increase 메서드는 count 객체의 멤버변수 val에 대해서 상호배제를 보장한다.
충분히 큰 횟수의 반복문을 여러개의 스레드가 실행해도 원자성이 보장된 연산이 실행됨을 알수 있다