JAVA/JAVA Basic

[ JAVA ] 스레드(Thread) 동기화5 (Implicit Lock vs Explicit Lock )

IT록흐 2021. 6. 20. 09:26
반응형

 

 

동기화는 스레드 간

작업 순서를 결정하는 것이다.

 

작업순서를 결정하기 위해서는

두 가지가 고려되어야 한다.

 

1. 상호배제(Mutex)

2. 협동(Cooperation)

 

상호배제(Mutex)에 관해서는 '스레드 동기화 이해하기 1,2,3 포스팅에서 알아보았고 협동(Cooperation)에 관해서는 '동기화 이해하기 4'에서 알아보았다. 이번 포스팅은 '스레드 동기화 이해하기 5'로 스레드 동기화와 관련된 마지막 포스팅이다. 이번 포스팅에서는 협동(Cooperation)을 더 다루어 볼것이다.

 

협동이란, 스레드 간의 소통을 원할히 하여 동기화를 구현하는 것이다. '스레드 동기화 이해하기 4'에서는 join()과 yield()를 알아보았다. 두 메소드의 공통점은 둘 다 Thread 클래스에 소속된 메소드라는 것이다.

 

이번 포스팅에서 다룰 메소드는 wait(), notify(), notifyAll()이다. 이 세 가지 메소드는 모두 최고 조상 메소드인 Object 클래스에 소속된 메소드들이다. Object 클래스 소속이라는 것은 모든 객체에서 범용적으로 사용되는 메소드라는 의미다. 특히 이 세 가지 메소드는 synchronized 블럭 안에서 사용되어야 한다. 그럼 wait(), notify(), notifyAll()의 역할을 알아보자.

 

 

생산자 소비자 문제(producer/consumer problem)

 

생산자- 소비자 문제는 동기화에서 가장 기본적으로 소개되는 문제이다.

 

 

 

 

생산 스레드와 소비 스레드는 공유자원인 Task 버퍼에 접근하여 Task를 생산하고 소비한다. 두 가지 스레드가 동시에 Task 버퍼에 접근하면 데이터가 훼손될 가능성이 있다. 그래서 상호배제(Mutex)를 해야한다.

 

그러나 상호배제가 이루어져 한 번에 하나의 스레드만 Task 버퍼에 접근가능해진다 하더라도 문제가 생긴다. 만약 Task 버퍼가 가득찼는데, 소비스레드가 아닌 생산 스레드만 계속 접근한다면 어떻게 될까? 반대로 버퍼 안에 Task는 텅 비어있는데 버퍼에 소비 스레드만 계속 접근한다면 어떻게 될 까? 생산해야하는 스레드가 생산하지 못하고 소비해야하는 스레드가 소비하지 못하는 오류가 발생하게 된다.

 

이런 오류를 방지하기 위해 동기화 블럭 안에 '조건(condition)'을 둔다. 조건이 충족되어야 생산이 가능하고 소비가 가능해지는 것이다.

 

그래서 초기에는 '조건'을 루핑을 활용하여 구현했다.

public method producer() { // 생산 스레드
    while (true) {
        bufferLock.acquire(); // 동기화 검사를 통한 Lock획득

        // '조건'이 충족될 때까지 반복
        while (buffer.isFull()) { //버퍼가 가득 차 있는지 검사
            bufferLock.release(); //Lock 내려놓고 대기 중인 다른 생산 스레드에게 양보
            bufferLock.acquire(); // 다른 생산 스레드 Lock 획득
        }
        
        // '조건'이 충족되면 반복이 풀리고 Task 추가 작업 시작 

        buffer.enqueue(myTask);  // 버퍼에 Task 추가
        bufferLock.release();    // 버퍼 Lock 해제
    }
}

 

 

초기에는 조건을 충족시키지 못하면 루핑을 계속 돌려 버퍼가 가득 찬 상태에서 더 이상 Task를 생산하지 못하도록 막았다. 하지만 이는 CPU라는 자원 소모가 심한 작업이다. 그저 조건이 충족될 때 까지 Lock만 대기하는 스레드들끼리 돌리고 앉아 있는 것이다. 이는 비생산적이다.

 

그래서 나온 방법이 Monitor 방식이다.

 

Monitor 방식은 조건이 충족될 때까지 루핑을 돌리지 않는다. 조건이 충족되지 않으면 스레드를 일시정지시키고 Waiting Set(대기실) 안에 넣어놓는다. 그 후, 조건이 충족되었음을 알리는 signal이 들어오면 해당 대기실에 있는 스레드들 중 하나를 깨워 다시 실행시킨다.

 

JAVA에서 Monitor 방식의 구현은 두 가지로 나뉜다.

 

1. Implicit(내포되어 있는) Lock ( synchronized 키워드 )

2. Explicit(밖으로 드러난) Lock ( java.util.concurrent.locks )

 

Implicit Lock ( synchronized 키워드 )

 

Implicit Lock은 객체의 고유한 정보(Mark Word)를 이용한 Lock 방식이기에 Intrinsic Lock이라고도 부른다.

 

 

해당 포스팅에서, Intrinsic Lock(고유록) 방식에 대해서 구체적으로 설명해보았다. Intrinsic Lock은 객체마다 갖고있는 객체 헤더안 고유의 Mark Word를 이용한 방식이다. Mark Word는 경쟁의 정도에 따라, Baiesd Lock -> Light Weight Lock -> Heavy Weight Lock으로 상태를 바꾼다.

 

 

[ JAVA ] 스레드(Thread) 동기화1 ( Intrinsic Lock + 피터슨 알고리즘 )

동기화 지훈이네 가족은 아빠, 엄마 그리고 형까지 네 식구로 이루어져 있다. 그러나 집 안에 컴퓨터가 단 한 대 뿐이라 가족간의 충돌이 불가피했다. 그래서 지훈이네 가족은 컴퓨터를 쓰는 순

lordofkangs.tistory.com

 

[ JAVA ] 스레드(Thread) 동기화3 ( 모니터(Monitor) )

[ JAVA ] 스레드(Thread) 동기화2 (세마 포어 Semaphore) 이전 포스팅에서는 공유 객체를 향한 스레드의 경쟁이 적은 경우의 상황을 알아 보았다. 이번에는 공유객체를 향해 스레드의 경쟁이 심해지는

lordofkangs.tistory.com

 

스레드간의 공유객체를 향한 경쟁이 심해지면 Heavy Weight Lock 상태가 되는데, 이때 부터 Monitor 방식이 구체적으로 사용된다. Monitor에 대한 상세한 내용은 해당 포스팅에서 다루어 보았다. 해당 포스팅에서 다루었던 모니터는 Synchronized 키워드를 사용한 상호배제 방법이었다. 이는 JAVA에서 사용하는 기본적이 상호배제 방법이다.

 

Mesa Style Monitor vs Hoare Style Monitor

 

 

Mesa Style Monitor

 

 

 

그림의 Monitor 구조는 Mesa Style Monitor로 Non Blocking Monitor라고도 한다. Blocking이라는 단어는 '막는다'라는 의미다. 대부분의 OS는 Non Blocking Monitor 방식을 채택한다. Non Blocking은 막지 않는다는 의미다. 그럼 무엇을 막지 않는지 알아보자.

 

Monitor 안에는 Thread가 대기하는 Set가 두 곳이 있다. Entry Set은 Lock을 확보하기 위해 대기하고 있는 장소이고 Wait Set은 Lock을 확보하여 Monitor에 진입했었으나 어떤 조건이 맞지 않아 잠시 일시정지된 스레드들이 대기하는 장소이다. 스레드가 일시정지 될 때는 wait() 메소드가 호출된다.

 

Monitor에는 한 가지 스레드만 접근할 수 있다. Monitor에 접근한 스레드는 작업을 완료하면 Lock을 내려놓아야한다. Lock을 내려놓기전, 스레드가 notify() 메소드를 호출하면 Wait Set에서 대기하던 스레드 하나가 Entry Set으로 들어간다. 그리고 스레드는 Lock을 내려놓는다. Lock을 내려놓으면 Entry Set에서 대기하고 있는 스레드 중 하나가 임의로 깨어난다.

 

notify()의 목적은 어떤 조건이 충족되지 않아 Wait Set에 대기하고 있던 스레드들에게 그 조건이 충족되었음을 알리고 Entry Set에 진입시켜, Monitor에 접근할 수 있는 기회를 주는 것이다.

 

Hoare Style Monitor

 

이와 반대로 Hoare Style Monitor가 있다. Blocking Monitor라고도 불리는 이 방법은 조건(C) 별로 조건에 맞는 Wait Set이 존재한다. (C1 Wait Set... Cn Wait Set). 그러므로 wait()와 notify()도 조건 별로 따로 존재한다. 여기서는 notify() 대신 signal()을 사용하겠다. C1.wait()는 조건 C1에 부합하지 않아 대기하는 스레드들이 모여 있는 Set이다.

 

예를 들어, 생산자 스레드들은 "조건 C1 : 버퍼가 가득차 있으면 생산할 수 없다" 라는 조건을 지켜야한다. 만약 버퍼가 가득차 있으면 조건 C1의 Wait Set에서 대기하고 있어야 한다. 그리고 소비자 스레드들은 "조건 C2 : 버퍼가 비워져 있으면 소비할 수 없다."라는 조건을 지켜야한다. 만약 버퍼가 비워져 있으면 소비자 스레드는 조건 C2의 Wait Set에 대기하고 있어야 한다.

 

Monitor의 Lock을 확보한 한 소비 스레드는 Task들로 가득차 있는 버퍼에서 Task를 하나 소비하였다. 그리고 소비 스레드는 C1.signal()을 보내, 버퍼가 가득차 있어 일을 하지 못하고 대기하고 있던 생산스레드들에게 이 사실을 알린다. 여기서 Hoare Sytle Monitor의 가장 큰 특징이 나온다.

 

Wait Set에서 대기하는 생산 스레드들 중 하나가 깨어나면, Entry Set으로 이동하지 않고 바로 Lock을 차지해버린다. signal을 보낸 소비스레드를 Blocking 시키고 Lock을 찬탈한다. 이렇게 Entry Set을 거치지 않고 해당 조건의 signal이 들어오면 Lock 획득의 우선권을 획득하는 Monitor 방식이 Hoare Style Monitor(Blocking Monitor) 방식이다.

 

하지만 위에서 말했듯, 대부분의 OS는 Mesa Style Monitor방식을 사용한다. 왜 Hoare가 아닌 Mesa 방식이 주로 사용되는지 그 이유는 잘 모르겠다. 아마도 Lock을 찬탈하는 Blocking 방식에 문제가 있을 것으로 예상되지만 정확한 이유는 잘 모르겠다. Mesa 방식은 대표적으로 JAVA의 synchronized 키워드를 통한 모니터 방식이 있다. 하지만 이같은 방식의 문제점은 공평성(fairness)이 부족하다는 것이다.

 

Wait Set은 조건별로 나뉘지 않은 채, 각종 스레드들로 득실거린다. 이 중에서 notify()를 통해 한 가지만 Entry Set으로 이동시킬 수 있다. Entry Set에 진입했어도 Lock을 확보하려면 대기해야한다. notify()를 통해 Entry Set에 진입한 스레드는 막 처음으로 Entry Set에 진입한 스레드들 보다 Lock 획득의 우선 순위가 높지 않다. 그러므로 한번 wait() 되어졌다가 실행도 못 해보고 주구장창 기다리는 스레드들이 발생할 수 있다.

 

이런 문제를 보완하는 표준 API가 JAVA에 존재한다. Mesa Style Monitor를 유지한 채 공평성(fairness)를 획득할 수 있는 방법은 위에서도 말했던 Explicit Lock ( java.util.concurrent.locks )이다.

 

Explicit Lock ( java.util.concurrent.locks )

 

java.util.concurrent.locks 패키지를 살펴 보자.

 

JAVA API 문서

 

 

여기서 눈여겨 볼 것은 Lock 인터페이스와 Condition 인터페이스다. Lock 인터페이스는 원자성 있는 Lock의 획득과 해제를 담당한다. 그리고 Condition은 Hoare 방식에서 보았던 조건별 Wait Set을 만드는데 사용된다. 그러므로 Mesa와 Hoare가 어느정도 혼합된 방식인 것이다.

 

 

 

하지만 Hoare 방식과는 달리, signal()이 호출되었을 때, Monitor Lock을 확보한 스레드를 Blocking 시키고 Lock을 찬탈하지 않는다. Mesa방식처럼 signal()로 깨어난 스레드는 조용히 Entry Set으로 들어간다.

 

이런 방식을 취하면, 어느정도 공평성(fairness)문제를 해결할 수 있다. 조건이 구분되니, 충족된 조건의 Wait Set에 대기하는 스레드만 Entry Set로 보낼 수 있다. 어떤 조건이 충족되든 상관없이 한 Wait Set에 단체로 들어가 있다가 임의로 한 스레드가 Entry Set로 보내지는 것과는 다르다. 그럼 이를 코드를 통해 구현해보자.

 

Explicit Lock 코드 구현

 

생산자 - 소비자 문제를 응용해서 만들어 보았다.

 

제빵사 스레드는 베이커리에 빵을 생산하려고 접근하다. 손님 스레드는 베이커리에 빵을 소비하려고 접근한다. 베이커리에 빵은 최대 3개만 있을 수 있다. 베이커리에 빵이 3개가 있으면 제빵사는 생산을 멈추고 대기해야한다. 반대로 손님은 베이커리에 빵이 0개가 있으면 마찬가지로 소비하지 못하고 대기해야한다.

 

나는 제빵사 10명을 만들고 손님도 10명 만들었다. 제빵사 한 명이 한 개의 빵을 만들고 손님 한 명이 한 개의 빵을 소비할 때, 베이커리가 빵을 한 개도 없는 상태에서 시작함을 가정하면, 실행 후, 베이커리에 남는 빵의 개수는 0개가 되어야한다. 10개를 만들고 10개를 사가니 0개가 되어야 한다.

 

물론 정확히 상호배제와 Wait() -Signal()이 정확히 실행되어야 가능한 일이다. 그럼 코드를 통해 살펴보겠다.

 

 

< 공유 자원, Bakery >

package explicitlock;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Bakery {
	
	private final Lock lock = new ReentrantLock(); //Lock 생성
	private final Condition notFull = lock.newCondition(); // 생산스레드 조건 생성
	private final Condition notEmpty = lock.newCondition();// 소비스레드 조건 생성
	private static final int MAX_BREADCOUNT = 3; // 빵집이 가질 수 있는 최대 빵 개수
	private int breadCount = 0; // 현재 빵집 안 빵의 개수
	
	
	// 빵 만들기(생산) 메소드
	public void makeBread() throws InterruptedException {
		
		lock.lock(); // Lock 확보
		
		try {
            // 생산 스레드 조건 검사 ( while문 )
			while (breadCount == MAX_BREADCOUNT) { // 빵집에 빵이 3개면 FULL 가득참
					System.out.println(Thread.currentThread().getName() + " : 어후.. 빵이 너무 많아!");
					System.out.println();
					notFull.await(); 
                    // notFull 조건의 Wait Set에서 대기 	
			}
			
            // 조건 충족시 생산 스레드 일 시작

			Thread.sleep(1000); // 잠시 쉬어주기

			System.out.println(Thread.currentThread().getName() + " : 빵 하나 생산");
			breadCount += 1;  // 빵 생성
			System.out.println("빵 개수 : "+breadCount); 
			System.out.println();
	
			notEmpty.signal(); 

           // 빵집에 빵이 존재하니 빵이 없어서 notEmpty 조건 Wait Set에  
           //대기하던 소비 스레드들중 하나를 Entry Set으로 보냄
           //만약 Waiting Set이 비어져 있으면 signal이 실종될 가능성이 있다.
           //이를 missed signal이라 하는데 몇 가지 오류가 발생할 시킬수 있어
		   //몇 가지 조치를 해야하지만 정확한 방법을 모르겠어서 일단 넘어가겠다.

		}finally {
			lock.unlock();
			//finally는 오류가 발생해도 반드시 실행되는 구문을 블럭안에 담는다.
            //한 스레드가 작업을 하던 중 에러로 인하여 인터럽트되어 졌을 때
            // lock을 내려놓아야 다음 스레드들이 이어서 일을 할 수 있다.
            // 그러므로 스레드가 인터럽트되어도 lock을 놓을 수 있도록
            //  finally 블럭 안에 lock. unlock()을 넣어준다.
		}
		
	}
	
	public void buyBread() throws InterruptedException {
		
		lock.lock(); // lock 획득

        // 소비스레드 조건 검사 ( while문 )
		try {
			while (breadCount == 0) {  // 손님은 빵집에 빵이 없으면 구매할 수 없음 
					System.out.println(Thread.currentThread().getName() + " : 에휴.... 빵이 없네...");
					System.out.println();
					notEmpty.await(); 
                    // 빵이 없으니 notEmpty의 Wait Set에 잠시 대기
			}
			
            // 조건이 충족되면 실행

			Thread.sleep(1000); // 잠시 쉬어주기
			
			System.out.println(Thread.currentThread().getName() + " : 빵 하나 소비");
			breadCount -= 1; // 빵 한 개 소비하기
			System.out.println("빵 개수 : "+breadCount);
			System.out.println();
			notFull.signal(); 
            // 더 이상 가득차있지 않으니 notFull Wait Set에서 
            //대기하던 생산스레드 하나를 Entry Set으로 이동
			
		}finally {
			lock.unlock();
		}
	}
	
    // 빵의 총 개수 출력하기 
	public void getBreadCount() {
		System.out.println("총 남은 빵 개수 : " + breadCount);
	}
	

}

 

 

조건 검사를 할 때 while문 사용을 확인할 수 있다. while문의 사용은 Spurious(비논리적인) WakeUp을 대비하기 위해서다. Spurious WakeUp이란 별 다른 이유없이 대기 상태에서 깨어나는 스레드들을 의미한다.

 

Wait Set에서 대기하는 스레드들이 모두가 얌전히 있는 것은 아니다. 알지 못할 이유로 갑자기 혼자 깨어나 Lock을 확보할 수 있다. 조건이 충족되어 signal()을 받고 깨어난 것이 아니기에, while문을 통해 한번 더 조건 검사를 실행해주지 않으면 데이터를 훼손시킬 위험이 있다.

 

그러므로 wait() 를 while문으로 감싸서 wait()에서 깨어난 스레드가 바로 못 다한 일을 시작하는 것이 아니라, 한번 더 조건검사를 하여 데이터의 무결성을 지키는 것이다.

 

 

< 제빵사 스레드 (생산 스레드) >

package explicitlock;

public class Baker extends Thread {
	
	private static int nameCount = 1; //스레드 이름 짓기용 카운트
	private Bakery bakery; // 공유 객체 변수
	
	public Baker(Bakery bakery) {
		this.bakery = bakery; // 공유 객체 획득
		this.setName("제빵사"+ nameCount++); // 스레드 이름 짓기
	}
	@Override
	public void run() {
		try {
			
			bakery.makeBread(); // 빵 만들기 시작
			
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
}

 

 

< 손님 스레드 (소비 스레드) >

package explicitlock;

public class Consumer extends Thread {
	
	private static int nameCount = 1; // 스레드 이름짓기용 카운트
	private Bakery bakery; // 공유 객체 변수
	
	public Consumer(Bakery bakery) {
		this.bakery = bakery; // 공유 객체 획득
		this.setName("손님"+ nameCount++); // 스레드 이름 짓기	
	}
	
	@Override
	public void run() {
		
		try {
			bakery.buyBread(); // 빵 구매하기
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
}

 

 

< Main 클래스 >

package explicitlock;

public class Main {

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		Bakery bakery = new Bakery(); // 공유 객체 생성
		Baker[] baker = new Baker[10]; // 제빵사 10명		
		Consumer[] consumer = new Consumer[10]; // 손님 10명
		
		
		for(int i =0; i<baker.length; i++) {
			baker[i] = new Baker(bakery); // 제빵사 스레드 생성
			baker[i].start(); // 제빵사 스레드 실행
		}
		
		for(int i =0; i<consumer.length; i++) {
			consumer[i] = new Consumer(bakery); // 손님 스레드 생성
			consumer[i].start(); // 손님 스레드 실행
		}
		
        // 스레드가 다 끝날 때까지 Main 스레드 일시정지
		try {
			
			for(int i =0; i<baker.length; i++) {
				baker[i].join();
			}
		
			
			for(int i =0; i<consumer.length; i++) {
				consumer[i].join();
			
			}
		
		} catch (Exception e) {}
		
		// 현재 빵의 개수 출력
		System.out.println();
		bakery.getBreadCount();
	}

}

 

 

<출력 결과>

 

 

 

 

 

왼쪽 첫 번째 빨간 상자를 보면, 빵의 개수가 3개가 넘자, 빵을 생산하러 온 제빵사 2와 제빵사 4가 생산을 못 했음을 확인할 수 있다. 왼쪽 두 번째 빨간 상자에서는 빵의 개수가 0개가 되자, 손님이 빵을 구매하지 못했음을 확인할 수 있다.

 

그렇다고 이들이 평생 빵을 못 만들거나 구매하지 못한 것이 아니다. 잠시 대기하고 있던 이들은 나중에 기회를 받아 생산하고 구매를 하였음을 오른쪽 첫 번째 상자와 두 번째 상자를 통해 알 수 있다.

 

그리고 총 남은 빵 개수는 0개로 동기화가 제대로 이루어졌음을 확인할 수 있다.

 

 



정리

 

1. 동기화는 상호배제(Mutex)와 협동(cooperation)을 통해 이루어진다.

2. JAVA 모니터 구현 방식에는 Implicit Lock과 Explicit Lock 방식이 있다.

3. Monitor는 Mesa Style Monior와 Hoare Style Monitor 방식이 있다.

4. Implicit Lock과 Explicit Lock은 둘 다 Mesa Style Monitor지만 Explicit Lock은 Hoare 방식처럼 조건별로 Wait Set이 있다.

5. 뜬금없이 대기 상태에서 깨어나 Lock 획득하는 Spurious WakeUp이 발생할 수 있으므로, 조건검사는 while 반복문으로 만든다.

 

 


참고자료

 

 

https://en.wikipedia.org/wiki/Monitor_(synchronization)

https://www.cs.cornell.edu/courses/cs4410/2018su/lectures/lec09-mesa-monitors.html

http://db.cs.duke.edu/courses/cps110/fall09/handouts/threads2.pdf

https://inst.eecs.berkeley.edu/~cs162/fa16/static/lectures/9.pdf

https://pages.mtu.edu/~shene/NSF-3/e-Book/MONITOR/monitor-types.html

https://littlepenguin.tistory.com/2

https://techdifferences.com/difference-between-semaphore-and-monitor-in-os.html#KeyDifferences

https://en.wikipedia.org/wiki/Spurious_wakeup

 

 

반응형