14. 모니터와 자바 동기화: Chapter 6. Synchronization Tools (Part 4)

HotFried·2023년 9월 14일

모니터

뮤텍스와 세마포어는 타이밍 에러 (timing error)와 같은 문제가 자주 발생한다.
타이밍 에러는 항상 발생하지도 않고 발견도 쉽지 않기 때문에 굉장히 까다로운 문제이다.

ex) 이진 세마포어를 사용하여 1로 초기화한 경우, wait()을 수행한 뒤 signal()을 수행해야 하는 일련의 순서를 지켜야 한다. 이 순서를 지키지 않으면 두 프로세스가 동시에 임계 영역에 접근하게 되어 경쟁 상태와 같은 문제가 발생한다.

  • Situation 1) signal() -> wait() 순으로 동기화를 수행 →  signal()을 하는 순간 여러 프로세스가 동시에 임계 영역에 들어가게 된다.
  • Situation 2) wait() → wait()을 수행 or wait()나 signal()을 수행하지 않는 경우 → 동일한 문제가 발생

High - level의 동기화 도구인 모니터 (monitor)라는 동기화 도구를 사용하여 문제를 해결하자

모니터 타입 (monitor type)

  • 프로그래머가 정의한 연산의 집합인 추상자료형 (ADT. abstract data type) → 상호배제(mutual exclusion)과 같은 기능을 제공한다.

함수 + 인스턴스 상태를 정의하는 변수를 선언

모니터 선언 → 모니터 block내에서 함수 선언 : 모니터 내에서 함수들은 동기화 된다.


Conditional Variables (조건변수)

  • 모니터 타입으로는 동기화를 하기 충분하지 않기에 condition 변수를 추가하여 동기화 매커니즘을 따로 제공해 주어야 한다.
condition x, y;
x.wait();
x.signal();

각각의 condition 변수에 wait()signal() 메소드를 사용하여 모니터의 Operation이 각 변수에 따라 각각 초기화되고 동기화될 수 있음.


Java Monitors

자바에서는 주로 스레드 동기화(thread synchronization)를 위한 모니터 기능이 제공된다.
모니터 락 (monitor-lock) or 고유 락 (intrinsic-lock)이라고 한다

Synchronized keyword

  • 임계영역에 해당하는 코드 블록을 선언할 때 사용하는 자바 키워드
  • 임계영역은 모니터락을 획득해야 진입 가능
  • 모니터락을 가진 객체 인스턴스를 지정할 수 있다.
    synchronized (object)
    {
    	// critical section
    }
  • 메소드에 선언하면 매소드 코드 블록 전체가 임계영역으로 지정된다.
    public synchronized void add()
    {
    	// critical section
    }

wait() and notify() methods

  • java.lang.Object 클래스에 선언되어있다.  -> 자바의 모든 객체는 이 클래스를 상속받으므로 사실상 모든 객체가 wait() & notify() 메소드를 가지고 있다.
  • 쓰레드가 어떤 객체의 wait() 메소드를 호출하면 → 해당 객체의 모니터락을 획득하기 위해 대기 상태로 진입한다.
  • 쓰레드의 어떤 객체가 notify() 메소드를 호출하면 → 해당 객체 모니터에 대기중인 쓰레드 하나를 깨운다.
  • 쓰레드의 어떤 객체가 notifyAll() 메소드를 호출하면 → 해당 객체 모니터에 대기중인 쓰레드를 전부 깨운다.

1) 동기화를 이용하지 않은 경우

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 Exception
	{
		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);
	}
}
  1. Counter를 별도의 클래스로 만든다.
  2. Runnable인터페이스를 구현한 MyRunnable 클래스를 생성한다.
  3. 5개의 쓰레드를 생성 후 join하여 결과를 확인한다.

예상값인 50000 근처에도 가지 못한다. -> “동기화 문제 발생”

2) 메소드 전체에 synchronized 키워드를 통해 동기화

public class SynchExample2
{
	static class Counter
	{
		public static int count = 0;
		synchronized 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 Exception
	{
		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);
	}
}

Counter클래스의 increment() 메소드에 synchronized 키워드를 선언해준다.

  • increment() 메소드의 conut++가 임계영역이 된다.
  • 임계 영역에 진입할 때 모니터 락을 획득(acquire())하고, 빠져나올 때 반납(release())한다.

synchronized 키워드를 선언해줌으로서 동기화 문제를 해결한다.
예제처럼 메소드가 count++ 와 같이 임계 영역의 코드만 수행하면 상관이 없지만,
메소드가 remainder section까지 포함하는 경우 메소드 전체를 synchronized로 묶게 되면 멀티스레딩의 장점이 사라진다.

(실행시간이 긴 영역이 쓸데없이 포함되어 있으면? 끔찍하다…)


3) 특정 코드 블록만 synchronized 키워드를 통해 동기화

public class SynchExample3
{
	static class Counter
	{
		private static Object object = new Object();
		public static int count = 0;
		public static void increment()
		{
			synchronized (object)
			{
				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 Exception
	{
		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);
	}
}

예제에서는 static메소드이기 때문에 인스턴스가 생성되지 않아도 실행될 수 있어야 한다.
따라서 오브젝트 인스턴스를 생성하고 synchronized (object) 코드를 이용했다.

만약 메소드가 static하지 않다면 synchronized(this) 코드를 이용해도 되겠다.
→자신의 모니터락을 획득할 수 있는 것이다.

4) 특정 코드 블록만 synchronized 키워드를 통해 동기화(static 메소드를 이용하지 않음)

public class SynchExample4
{
	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 Exception
	{
		Thread[] threads = new Thread[5];

		for (int i = 0; i < threads.length; i++)
		{
			threads[i] = new Thread(new MyRunnable(new Counter()));
			threads[i].start();
		}

		for (int i = 0; i < threads.length; i++)
			threads[i].join();

		System.out.println("counter = " + Counter.count);
	}
}

increment()메소드가 정적 메소드가 아니기 때문에,

  1. Counter 인스턴스 생성
  2. MyRunnable 클래스에서 run() 메소드를 오버라이딩 할 떄생성된 인스턴스의 메소드 사용

5개의 스레드는 각각의 인스턴스 counter를 가진다.(인스턴스를 생성했기 때문)
count는 static으로 선언되었기 때문에 각 쓰레드으 ㅣ서로 다른 counter 인스턴스가 공유 변수인 count를 증가시킨다.

하지만 인스턴스를 따로 생성하게 되면 synchronized (this) 코드에서, 각 인스턴스(this)가 가리키는 객체가 다르기 때문에 결국 생성된 인스턴스 별로 모니터를 갖는 것과 동일함. -> 동기화 문제 발생한다. (서로 다른 인스턴스, 스레드 간 동기화가 되지 않는다.)

5) Counter 인스턴스를 생성 후 MyRunnable 클래스의 생성자로 이용

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 Exception
	{
		Thread[] threads = new Thread[5];
		Counter counter = new Counter(); // 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);
	}
}

서로 다른 스레드 사이에서도 동기화가 제대로 이루어진다.

5개의 스레드가 1개의 모니터락(Counter의 increment()메소드 입장권)을 가진다.
→ Binary Semaphore(이진 세마포어) 와 비슷한 형태를 보인다.
하나의 인스턴스만 생성하면 된다는 점을 주의하도록 하자.

라이브니스 (liveness)

  • Process, Bounded Waiting을 해결할 수 있는 기술

뮤텍스 락, 세마포어, 모니터는 상호 배제(Mutual Exclusion)는 확실하게 보장해 주지만
Process(데드락 방지), Bounded-Waiting (기아 방지)은 제공하지 않는다.

→ 세마포어는 Busy Waiting으로 인해 오히려 데드락을 제공해버린다.

데드락 (Dead lock)

  • 두개이상의 프로세스가 영원히 기다리는 상태

P0은 P1이 실행되길 기다리고, P1은 P0가 실행되길 기다리는 교착상태에 빠진다.

<<< P0 >>>
wait(S);
wait(Q);

...

signal(S);
signal(Q);
<<< P1 >>>
wait(Q);
wait(S);

...

signal(Q);
signal(S);

→ 이후 8장에서 더 자세하게 다룬다.

우선순위 역전 (priority inversion)

  • 프로세스 간 우선순위가 존재할 때, 높은 우선순위를 갖는 프로세스보다 낮은 우선순위를 갖는 프로세스가 먼저 실행되는 현상.

ex) 높은 우선순위를 갖는 프로세스 (H)가 커널 데이터에 접근하고자 할 때, 비교적 낮은 우선순위를 갖는 프로세스 (L)가 이미 그 데이터에 접근하여 사용중인 상황.

스케쥴링 개념만 적용한다면 H가 L를 쫓아내고 선점 (preemption)할 수는 있다.
하지만 L이 Shared-Data를 사용하고 있기 때문에 H는 Waiting Queue에서 대기하고 있을 수밖에 없다.

(L 이 실행되는 도중에, H가 접근을 시도한다면 L의 우선순위를 임시적으로 H의 우선순위로 바꾼다. 그렇다면, M 이 접근하더라도 M은 상대적으로 우선순위가 더 낮은 프로세스라 판단되고, CPU를 할당받지 못하고 기다리게 된다.)

∴ 우선순위 상속 (priority inheritance)을 사용한다.

→ 낮은 우선순위를 갖는 프로세스가 CPU를 점유하는 동안에는 잠시동안 높은 우선순위를 갖는 프로세스를 상속하여 우선순위를 높인다.


참고 :

Silberschatz et al. 『Operating System Concepts』. WILEY, 2020.

주니온TV@Youtube: 자세히 보면 유익한 코딩 채널

profile
꾸준하게

0개의 댓글