[운영체제][프로세스관리] 프로세스 동기화 #6 모니터(Monitor)와 자바 동기화

2022. 4. 19. 17:27OperatingSystem

세마포어 사용이 어려운 이유

  • 세마포어는 타이밍 에러(timing errors)가 발생할 수 있습니다.
  • 타이밍 에러는 어떤 특정한 실행 시퀀스에서 발생한다면 언제나 발생하지도 않고 탐색하기도 쉽지 않는 에러입니다.

세마포어의 문제 예제

  • 모든 프로세스들은 이진 세마포어인 1로 초기화된 뮤텍스를 공유합니다.
  • 각각의 프로세스는 임계 영역에 들어가기 전에 wait(mutex)를 호출해야합니다.
  • 임계영역의 작업을 마친 프로세스는 signal(mutex)를 호출하여 자원을 반납합니다.
  • wait(mutex) -> signal(mutex) 실행순서를 지키지 않는 경우 두개 이상의 프로세스가 동시에 임계 영역에 접근하는 문제가 발생할 수 있습니다.

세마포어의 wait(mutex), signal(mutex) 실행순서가 잘못되는 경우

상황 1. signal(mutex) -> wait(mutex) 순서로 호출한 경우

 

 

상황 2. wait(mutex) -> wait(mutex) 또는 signal(mutex) -> signal(mutex) 순서로 호출한 경우

상황 3. wait(mutex) 또는 signal(mutex) 호출을 한개 또는 둘다 생략하는 경우

모니터 동기화 도구가 필요한 이유는 무엇인가?

모니터 동기화 도구가 필요한 이유는 개발과정에서 동기화를 위해 프로그래머들이 세마포어(또는 뮤텍스 락)을 정확하지 않게 사용할 가능성이 높기 때문입니다. 이러한 부적절한 사용은 타이밍 에러와 같은 문제를 일으킬 가능성이 높습니다.

 

1. 모니터(Monitor) 개념

모니터(Monitor)란 무엇인가?

  • 모니터는 세마포를 실제로 구현한 프로그램
  • 모니터는 여러개의 프로세스들이 접근하는 공유자원을 할당하는데 사용됨
  • 데이터 및 프로시저를 포함하는 병행성 구조
    • 병행성 구조란 하나의 프로세스가 독점하는 것이 아닌 여러개의 프로세스들이 병행적으로 처리할 수 있도록 하는 구조입니다.
  • 공유 자원을 사용하고 싶은 프로세스는 모니터 내부의 프로시저 함수를 통하여 접근할 수 있습니다.
  • 한 프로세스가 모니터 내부의 프로시저 함수를 호출하여 공유 자원에 접근하는 동안 다른 프로세스들은 프로시저 함수를 호출할 수 없고 공유 자원에 접근이 불가능하여 대기큐에서 들어가 있게 됩니다.

 

모니터의 특징

  • 모니터 내의 정의된 프로시저만이 공유 자원(지역 데이터)에 접근할 수 있습니다.
  • 모니터는 항상 모니터 안에 하나의 프로세스만이 활성화되도록 하여 상호 배제 조건을 만족시킵니다.
  • 프로그래머들은 동기화 제약 조건을 세마포어처럼 명시적으로 코딩해야 할 필요가 없습니다.

모니터의 수도코드 문법

  • 모니터라는 구조체 안에서 여러개의 함수가 존재합니다. 이 함수들은 전부 동기화가된 함수입니다.

 

모니터의 구조

  • Mutual Exclusion Queue : 모니터에 진입하려는 프로세스들의 큐
  • 공유 자원 : 모니터 내부에 존재하는 공유 자원, 여러 프로세스들이 순차적으로 접근하여 사용할 수 있음
  • 프로시저 : 공유 자원에 접근하기 위한 접근 함수, 한 프로세스가 프로시저를 사용하여 공유 자원을 사용하고 있다면 다른 프로세스들은 접근할 수 없음
  • 초기화 코드 : 공유 자원을 초기화 해주는 코드
  • 조건 변수(Condition Variable) : 모니터에 접근하는 프로세스들의 순서를 보장하기 위한 구조물

 

조건 변수(Condition Variable)

  • 모니터 그 자체적으로 동기화 문제를 풀기 위해서는 아쉬운 부분이 있습니다. 그 부분은 공유 자원에 접근하려는 프로세스간의 실행 순서가 없기 때문입니다. 만약 공유자원을 쓸 수 있다면 권한을 어떤 프로세스에게 줄 지 정할 수 없는 문제가 있습니다.
  • 조견 변수는 모니터 내부에서 추가적인 동기화 메커니즘을 제공하기 위해서 정의되었습니다.

 

조건 변수의 선언

condition x, y;

 

만약 한 프로세스 Q가 프로시저 함수를 통하여 공유 자원에 접근하여 사용중에 특정한 호출로 인하여 대가히게 되는 경우 다음과 같은 호출을 합니다. 다음 호출은 조건 x에서 대기해야 할때 호출하는 경우입니다.

x.wait();

 

그리고 모니터 내부에는 다른 한 프로세스 P가 접근하여 공유 자원을 사용할 수 있으므로 공유 자원을 사용하다가 프로세스가 조건 x의 변화를 발견했을 때 다음과 같이 호출합니다. 다음 호출은 조건 x의 변화를 발견하고 조건 x의 대기큐에서 프로세스 Q의 수행을 재개시킵니다.

x.signal();

 

여기서 주목할점은 x.signal을 호출시 모니터 내부에는 두개의 프로세스(P, Q)가 존재한다는 점입니다. 따라서 한 프로세스는 대기를 해야 합니다. 이때 두가지 옵션중 하나를 선택해야 합니다.

  1. Signal and wait : x.signal()을 호출한 프로세스 P는 대기상태에서 막 빠져나온 프로세스 Q가 모니터를 떠날때까지 기다리거나 또는 다른 조건을 기다립니다.
  2. Signal and continue : 대기상태에서 막 빠져나온 프로세스 Q는 x.signal()을 호출한 프로세스 P가 모니터를 떠날때까지 기다리거나 또는 다른 조건을 기다립니다. (합리적인 옵션)

정리하면 모니터에서 수행하는 프로세스들의 실행 순서를 보장하기 위해서 조건 변수(Condition Variable)을 사용합니다. 모니터 내부에서 수행되는 한 프로세스는 공유 자원을 사용하여 수행하다가 특정한 조건(x, y 등)에 걸리게 되면 사용을 멈추고 해당 조건의 대기 큐로 이동하여 대기하게 됩니다. 그리고 다른 프로세스가 조건의 변화를 발견할때 해당 조건의 대기큐에 있는 프로세스의 수행을 재개시켜 순서를 보장합니다.

 

2. 자바 모니터(Java Monitor)

  • 자바는 모니터와 비슷한 쓰레드 동기화를 위한 동시성 메커니즘을 제공합니다. 이것을 monitor-lock 또는 intrinsic-lock이라고 부르기도 합니다.
  • 자바 동기화를 위한 기본적인 키워드
    • synchronized 키워드
    • wait(), signal() 메서드

synchronized 키워드

  • 임계 영역에 해당하는 코드 블록을 선언할 때 사용하는 자바 키워드입니다.
  • 해당 임계 영역에는 모니터락을 흭득해야만 진입이 가능합니다.
  • 모니터락을 가진 인스턴스를 지정할 수 있습니다.
  • 메서드에 선언하면 메서드 코드 블록 전체가 임계 영역으로 지정됩니다.
    • 이때 모니터락을 가진 인스턴스는 this 인스턴스입니다.
synchronized(object){
	// critical section
}

public synchronized void add(){
	// critical section
}

 

wait()과 notify() 메서드

  • java.lang.Object 클래스에 선언되어 있습니다.
  • notify() 메서드는 signal() 함수와 동일한 표현입니다.
  • wait() : 어떤 쓰레드가 어떤 인스턴스의 wait() 메서드를 호출하면 해당 객체의 모니터락을 흭득하기 위해 대기 상태로 진입합니다.
  • notify() : 쓰레드가 어떤 객체의 notify() 메서드를 호출하면 해당 객체 모니터에 대기중인 임의의 쓰레드를 하나 깨웁니
  • 다.
  • notifyAll() : 대기중인 모든 쓰레드를 깨워서 준비 큐에 넣습니다.

 

자바 동기화 예제

다음 예제는 synchronized 키워드를 적용하지 않고 5개의 쓰레드가 하나의 공유 자원인 count의 변수를 증가시키는 예제입니다.

public class SynchExample1 {
	static class Counter{
		public static int count = 0;
		public static void increment() {
			count++;
		}
	}
	
	static class MyRunnable implements Runnable{

		@Override
		public void run() {
			for(int i=0; i<10000; i++)
			{
				Counter.increment();
			}
		}
		
	}
	public static void main(String[] args) throws InterruptedException {
		Thread[] threads = new Thread[5];
		for(int i=0; i<threads.length; i++) 
		{
			threads[i] = new Thread(new MyRunnable());
			threads[i].start();
		}
		for(int i=0; i<threads.length; i++)
		{
			 threads[i].join();
		}
		System.out.println("counter = " + Counter.count);	// not 50000
	}

}
실행 결과
counter = 47212

위 실행결과를 보면 쓰레드간의 동기화를 적용하지 않았기 때문에 count = 50000이 나오지 않은 것을 볼 수 있습니다. 

 

다음 예제는 위 예제에서 increment() 메서드에 synchronized 키워드를 적용하여 5개의 쓰레드가 공유 자원 접근을 동기화 시키는 예제입니다.

synchronized public static void increment() {
    count++;
}
실행 결과
counter = 50000

실행 결과 처음 의도했던 1개의 쓰레드가 count 변수를 10000개씩 증가시켜 50000을 저장한 것을 볼 수 있습니다. 이는 5개의 쓰레드가 동기화를 지켰다는 것을 알 수 있습니다.

 

위 예제와 같이 메서드에 synchronized 키워드를 적용하게 되면 메서드 전체가 임계 영역으로 지정이 되어 만약 메서드의 길이가 길어지게 된다면 어떤 한 쓰레드가 메서드를 수행중인데 반해 다른 쓰레드들은 전부 대기를 해야하는 상황이 발생할 수 있습니다. 따라서 위 문제를 해결하기 위해서 메서드 전체에 synchronized 키워드를 적용하는 것이 아닌 일정 범위에 블록으로 지정할 수 있습니다. 다음 예제는 같은 increment 메서드이지만 메서드 전체가 아닌 일정 부분만을 동기화한 예제입니다.

public static void increment() {
    synchronized (object) {
        count++;
    }
}
  • synchronized(object) : 해당 object 인스턴스의 락을 가지는 쓰레드가 임계영역에 접근할 수 있습니다.
  • 현재 object는 클래스 멤버이지만 만약 increment 메서드가 인스턴스 메서드라면 synchronized(this)와 같이 표현할 수 있습니다.

다음 예제는 Counter 정적 클래스의 메서드를 호출하는 것이 아닌 Counter 인스턴스를 생성한 다음에 synchronized(this)로 동기화를 설정하는 예제입니다.

public class SynchExample5 {
	static class Counter{
		
		public static int count = 0;
		public void increment() {
			synchronized (this) {
				count++;
			}
		}
	}
	
	static class MyRunnable implements Runnable{
		Counter counter;
		
		public MyRunnable(Counter counter) {
			this.counter = counter;
		}
		
		@Override
		public void run() {
			for(int i=0; i<10000; i++)
			{
				counter.increment();
			}
		}
		
	}
	public static void main(String[] args) throws InterruptedException {
		Thread[] threads = new Thread[5];
		Counter counter = new Counter();
		for(int i=0; i<threads.length; i++) 
		{
			threads[i] = new Thread(new MyRunnable(counter));
			threads[i].start();
		}
		for(int i=0; i<threads.length; i++)
		{
			 threads[i].join();
		}
		System.out.println("counter = " + Counter.count);	// 50000
	}

}
  • 주목할점은 쓰레드를 생성할 때 Counter 인스턴스도 5개를 생성하는 것이 아닌 한개의 Counter 인스턴스를 생성한 다음에 생성자를 통해서 주입하였다는 점입니다.
  • Counter 인스턴스를 매 반복문마다 생성하여 각각의 쓰레드에 각각 주입하게 되면 synchronized(this)가 무력화되어 동기화가 제대로 수행되지 않습니다. 여기서 this는 Counter 인스턴스를 가리키게 됩니다.

 

 

References

source code : https://github.com/yonghwankim-dev/OperatingSystem_Study/tree/main/java_lang/chap06_00_examples/chap06_00_05_synchronization
Operating System Concepts, 7th Ed. feat. by Silberschatz et al.
[인프런] 운영체제 공룡책 강의
[운영체제] 모니터 정의 및 구조
[운영체제] 모니터