자바 동기화 이해하기 ( 1 ) synchronized , wait & notify

Chan Young Jeong·2023년 3월 5일
1

All About JAVA

목록 보기
4/10
post-thumbnail

동기화

멀티 쓰레드 프로세스에서는 다른 쓰레드의 작업에 영향을 미칠 수 있습니다. 그렇기 때문에 진행중인 작업이 다른 쓰레드에게 간섭받지 않게 하려면 동기화가 필요합니다. 동기화를 하려면 간섭받지 않아야 하는 문장들을 임계 영역으로 설정해주면 된다. 임계 영역은 락(lock)을 얻은 단 하나의 쓰레드만 출입할 수 있다. (객체 1개에 락 1개)

더 자세한 내용은 참고

synchronized를 이용한 동기화

synchronized를 이용해서 임계영역(lock이 걸리는 영역)을 설정하는 방법 2가지가 있습니다. 임계 영역은 한 번에 한 쓰레드만 사용할 수 있기 때문에 최소화해서 사용하는 것이 바람직합니다. 왜냐하면 멀티 쓰레드 프로그래밍의 장점을 활용할 수 없기 때문입니다.

1) 메서드 전체를 임계영역으로 지정 : 메서드 반환타입앞에 synchronized 입력

	public synchronized void withdraw(int money){
    	if(balance >= money){
        	try{
            	Thread.sleep(1000);
            }catch(Exception e){}
            balance -= money;
        }
    }

2) 특정한 영역을 임계 영역으로 지정 : synchronized(객체의 참조변수) {}

	synchronized(객체의 참조 변수){
    	// ..
    }
	public  void withdraw(int money){
    
    	synchronized(this){
        	if(balance >= money){
        	try{
            	Thread.sleep(1000);
            }catch(Exception e){}
            balance -= money;
        }
        
    }
        
}

wait()과 notify()

  • 동기화의 효율을 높이기 위해 wait(),notify()를 사용한다.
  • Object클래스에 정의되어 있으며, synchronized동기화 블록 내에서만 사용할 수 있습니다.
  • wait( ) - 객체의 락을 풀고 스레드를 해당 객체의 waiting pool에 넣음.
  • notify( ) - waiting pool에 있는 스레드 하나를 깨움. 어느 스레드를 깨울지 선택할 수 없기 때문에 제어가 어렵다. 그래서 보통은 notifyAll()을 사용.
  • notifyAll( )
    • 모두 깨우긴 하지만 잠들어 있던 모든 스레드가 실행되는 것은 아닙니다. 여기서 중요한 점은 잠든 코드가 synchronized 블록 안에 있다는 점입니다.
    • 즉 깨어난 모든 쓰레드들은 다시 락을 획득하기 위해 경쟁해야 합니다. 락을 획득한 스레드만이 wait()함수를 리턴시키고, 그 다음 로직을 실행할 수 있습니다.
      -또한 중요한 점은 waitiong pool은 객체마다 존재하기에 notifyAll()을 호출한다고 해서 모든 객체의 waiting pool에 있는 쓰레드가 깨워지는 것이 아니라는 점입니다. notifyAll()이 호출된 객체의 waiting pool에 대기 중인 쓰레드만 깨워집니다.

Table이라는 클래스에는 음식을 add() 하는 메서드와 remove() 하는 메서드가 정의되어 있습니다. 각 메서드는 ArrayList(자바에서 ArrayList는 동기화 되어 있지 않음)에 음식을 추가하거나 제거할 수 있습니다.

그리고 Cook 스레드와 Customer 스레드는 다음과 공유 자원인 Table 객체를 사용합니다. Cook 스레드에서는 Table 객체에 음식을 더하고 Customer 스레드는 Table 객체에서 음식을 제거합니다.

하지만 이대로 실행하면 동기화가 되어 있지 않기 때문에 다음과 같은 에러가 발생합니다. 첫 번째 오류는 ArrayList 읽기 수행 중 추가,삭제 등 변경이 발생하면 나는 오류입니다. 두 번째 오류는 IndexError로 size가 0인데 인덱스로 접근할 때 발생하는 오류입니다.

synchronized 이용하기

다음처럼 synchronized를 이용해서 동기화를 해줍니다. add() 메서드는 메서드 앞에 synchronized를 붙여서 동기화를 해주었고 remove() 메서드는 synchronized 블록을 이용해서 동기화를 해주었습니다. 여기서 바뀐 부분은 remove() 부분에서 while문을 이용해 dishes.size가 0일 때 기다리도록 추가해주었습니다.

이렇게 작성하면 에러는 안 나지만 작업이 더 이상 진행이 안 됩니다.

동기화한 것은 좋으나 비효율적인 작업이 진행되고 있는 것입니다.

wait()과 notify() 사용하기

이를 해결하기 위한 방법이 바로 wait()과 notify()입니다. 손님은 음식이 없을 때 wait()을 호출하여 락을 반납하고 대기실에 기다리게 됩니다. 마침내 add() 메서드에서 음식을 추가하면 notify()를 통해 기다리고 있는 손님을 깨우게 됩니다.

그리고 요리사가 add()할 때도 테이블이 꽉 차면 기다리게 하도록 추가하였습니다. 마찬가지로 테이블이 다 차면 wait()하고 remove()에서 notify()를 통해 기다리고 있는 요리사를 깨우게 됩니다.

전체 코드

import java.util.ArrayList;

class Customer implements Runnable {
	private Table table;
	private String food;

	Customer(Table table, String food) {
		this.table = table;  
		this.food  = food;
	}

	public void run() {
		while(true) {
			try { Thread.sleep(100);} catch(InterruptedException e) {}
			String name = Thread.currentThread().getName();
			
			table.remove(food);
			System.out.println(name + " ate a " + food);
		} // while
	}
}

class Cook implements Runnable {
	private Table table;
	
	Cook(Table table) {	this.table = table; }

	public void run() {
		while(true) {
			int idx = (int)(Math.random()*table.dishNum());
			table.add(table.dishNames[idx]);
			try { Thread.sleep(10);} catch(InterruptedException e) {}
		} // while
	}
}

class Table {
	String[] dishNames = { "donut","donut","burger" }; // donut의 확률을 높인다.
	final int MAX_FOOD = 6;
	private ArrayList<String> dishes = new ArrayList<>();

	public synchronized void add(String dish) {
		while(dishes.size() >= MAX_FOOD) {
				String name = Thread.currentThread().getName();
				System.out.println(name+" is waiting.");
				try {
					wait(); // COOK쓰레드를 기다리게 한다.
					Thread.sleep(500);
				} catch(InterruptedException e) {}	
		}
		dishes.add(dish);
		notify();  // 기다리고 있는 CUST를 깨우기 위함.
		System.out.println("Dishes:" + dishes.toString());
	}

	public void remove(String dishName) {

		synchronized(this) {	
			String name = Thread.currentThread().getName();

			while(dishes.size()==0) {
					System.out.println(name+" is waiting.");
					try {
						wait(); // CUST쓰레드를 기다리게 한다.
						Thread.sleep(500);
					} catch(InterruptedException e) {}	
			}

			while(true) {
				for(int i=0; i<dishes.size();i++) {
					if(dishName.equals(dishes.get(i))) {
						dishes.remove(i);
						notify(); // 잠자고 있는 COOK을 깨우기 위함 
						return;
					}
				} // for문의 끝

				try {
					System.out.println(name+" is waiting.");
					wait(); // 원하는 음식이 없는 CUST쓰레드를 기다리게 한다.
					Thread.sleep(500);
				} catch(InterruptedException e) {}	
			} // while(true)
		} // synchronized
	}

	public int dishNum() { return dishNames.length; }
}

class ThreadWaitEx3 {
	public static void main(String[] args) throws Exception {
		Table table = new Table();

		new Thread(new Cook(table), "COOK1").start();
		new Thread(new Customer(table, "donut"),  "CUST1").start();
		new Thread(new Customer(table, "burger"), "CUST2").start();
	
		Thread.sleep(2000);
		System.exit(0);
	}
}

하지만 이 코드에서도 문제가 있습니다.

기아 문제

만약 운이 없으면 한 스레드는 계속해서 실행이 안될 수도 있습니다. 왜냐하면 현재 notify()를 이용해서 한 스레드만 깨우기 때문입니다. 따라서 이를 해결하기 위해서는 notifyAll()을 사용하는 것이 좋습니다.

경쟁 상태(Race Condition)

하지만 notifyAll()을 해도 아직 남아있는 문제가 있습니다. 바로 손님과 요리사가 같은 waiting pool에서 대기하기 때문에 notifyAll()을 했을때 만약 Table이 꽉 차있는 상태면 쓸데없이 요리사 스레드까지 깨워나서 락을 차지하기 위해 경쟁하기 때문입니다. 바로 어떤 스레드가 실행될지 모르는 불확실성이 문제입니다. 따라서 이를 해결하기 위한 방법이 lock과 condition입니다.

다음

출처
자바의 정석 - 남궁성

0개의 댓글