[Java][Thread] 쓰레드의 동기화 #1 synchronized, wait, notify

2022. 7. 1. 13:47JAVA/Language

동기화(synchronization)의 필요성

멀티쓰레드 프로세스의 경우 여러 쓰레드가 같은 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게됩니다. 예를 들어 쓰레드 A가 balance 변수의 값에서 -5000을 더하여 0으로 만들려는 작업을 수행할 때 쓰레드 B가 제어권을 가져서 먼저 0으로 만든다면 쓰레드 A가 다시 제어권을 찾을때 0에서 -5000을 더하여 balance 변수는 -5000이 될 것입니다. 이는 통잔 잔액이 0원 밑으로는 있을 수 없기에 잘못된 상황입니다.

 

위와 같은 상황을 방지하기 위해서는 어떤 한 쓰레드가 같은 프로세스 내의 공유 자원에 접근하여 작업을 마치기 전까지는 다른 쓰레드는 접근할 수 없도록 해야 합니다. 이러한 것을 쓰레드의 동기화라고 합니다.

 

쓰레드의 동기화란 무엇인가?

한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것입니다. 동기화를 이해하기 위해서는 임계 영역(critical section)과 잠금(lock) 개념이 필요합니다.

  • 임계 영역(critical section) : 프로세스 내의 공유 자원을 사용하는 코드 영역
  • 잠금(lock) : 임계 영역에 접근하고자 하는 쓰레드는 lock을 얻어야 접근할 수 있습니다. 공유 자원을 다 사용한 쓰레드는 lock을 반납해야만 합니다.

 

1. synchronized를 이용한 동기화

java에서는 쓰레드의 동기화를 위해서 synchronized 키워드를 제공합니다.

 

synchronized 형식

1. 메서드 전체를 임계 영역으로 지정 : 메서드 전체가 임계 영역
public synchronized void calcSum(){ 
	...
}

2. 특정한 영역을 임계 영역으로 지정 : 특정 부분만 임계 영역
public void calcSum(){ 
    synchronzied(객체의 참조변수){
    	...
    }
}

 

다음은 은행계좌(account)에서 잔고(balance)를 확인하고 임의의 금액을 출금(withdraw)하는 에제입니다.

public class Driver {
	public static void main(String[] args) {
		Runnable r = new MyThread(new Account(1000));
		
		new Thread(r).start(); // ThreadGroup에 의해 참조되므로 gc대상이 아닙니다.
		new Thread(r).start(); // ThreadGroup에 의해 참조되므로 gc대상이 아닙니다.
		 
		
	}
}

class Account{
	private int balance;
	
	public Account(int balance) {
		this.balance = balance;
	}

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

class MyThread implements Runnable{
	Account account;

	public MyThread(Account account) {
		this.account = account;
	}

	@Override
	public void run() {
		while(account.getBalance() > 0) {
			// 100, 200, 300 중의 한 값을 임의로 선택해서 출금
			int money = (int) (Math.random() * 3 + 1) * 100;
			account.withdraw(money);
			System.out.println("balance:" + account.getBalance());
		}
	}
	
}
balance:700
balance:600
balance:400
balance:300
balance:200
balance:100
balance:0
balance:0

 

2. wait()과 notify()

쓰레드 동기화 설계의 주의점

특정 쓰레드가 객체의 lock을 가진 상태로 오랜 시간을 보내지 않도록 하는 것이 중요합니다. 예를 들어 계좌에 출금할 돈이 부족해서 한 쓰레드가 락을 보유한 채로 돈이 입급될 때까지 오랜 시간을 보낸다면, 다른 쓰레드들은 모두 해당 객체의 lock을 기다리느라 다른 작업들도 진행되지 않을 것입니다.

 

wait()과 notify()의 역할

  • wait() : 동기화된 임계 영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니라면, wait()을 호출하여 쓰레드가 락을 반납하고 기다리도록 하는 역할을 수행합니다.
  • notify() : 작업을 진행할 수 있는 상황이 되면 다른 쓰레드가 notify()를 호출해서, 작업을 중단하고 있던 쓰레드들 중 하나를 선택해서 lock을 주고 작업을 진행하도록 하는 역할을 수행합니다.

notify() 호출의 특징

  • wait()을 호출하여 기다리고 있는 쓰레드들 중 오래 기다린 쓰레드가 lock을 얻는다는 보장이 없습니다.
  • wait() 호출시 해당 쓰레드는 waiting pool에서 들어가서 대기합니다.
  • notify() 호출시 waiting pool에서 대기하고 있던 임의의 쓰레드에게 lock을 줍니다.

wait()과 notify() 메서드 형식

void wait()
void wait(long timeout)
void wait(long timeout, int nanos)
void notify()    : waiting pool에서 대기중인 임의의 한 쓰레드를 깨움
void notifyAll() : waiting pool에서 대기중인 쓰레드 모두를 깨움

 

다음 예제는 식당에서 음식(Dish)을 만들어서 테이블(Table)에 추가(add)하는 요리사(Cook)와 테이블의 음식을 소비(remove)하는 손님(Customer)을 쓰레드로 구현한 예제입니다.

public class Driver {

	public static void main(String[] args) throws InterruptedException {
		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(10000);
		System.exit(0); // 프로그램 전체를 종료 (모든 쓰레드가 종료됨)
	}

}

class Customer implements Runnable{
	private Table table;
	private String food;
	
	public Customer(Table table, String food) {
		this.table = table;
		this.food = food;
	}

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

class Cook implements Runnable{
	private Table table;

	public Cook(Table table) {
		this.table = table;
	}

	@Override
	public void run() {
		while(true) {
			// 임의의 요리를 하나 선택해서 table에 추가함
			int idx = (int)(Math.random() * table.dishNum());
			table.add(table.dishNames[idx]);
			
			try {
				Thread.sleep(10);
			}catch (InterruptedException e) {}
		}// end while
	}
}



class Table{
	String[] dishNames = {"donut", "donut", "burger"};
	final int MAX_FOOD = 6;
	
	private List<String> dishes = new ArrayList<String>();
	
	public synchronized void add(String dish) {
		// 이미 테이블에 음식이 꽉차있는 경우
		while(dishes.size() >= MAX_FOOD) {
			String name = Thread.currentThread().getName();
			System.out.println(name + " is wating");
			
			try {
				wait(); // 요리사(COOK) 쓰레드를 기다리게 한다.
				Thread.sleep(500);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
		dishes.add(dish);
		notify(); // 기다리고 있는 손님(CUSTOMER)에게 알려줌
		System.out.println("Dishes : " + dishes);
	}
	
	public void remove(String dishName) {
		synchronized (this) {
			String name = Thread.currentThread().getName();
			// 손님이 테이블에 먹을 음식이 없는 경우
			while(dishes.size() == 0) {
				System.out.println(name + " is wating");
				
				try {
					wait(); // 손님을 기다리게 한다.
					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(); // 요리사에게 음식을 먹었다고 알려줌
						return;
					}
				} // end for
			
				try {
					System.out.println(name + " waiting");
					wait(); // 원하는 음식이 없는 손님(CUSTOMER) 쓰레드는 대기합니다.
					Thread.sleep(500);
				}catch (InterruptedException e) {}
			}// end while
		}// end synchronized
	}
	
	public int dishNum() {
		return dishNames.length;
	}
}
Dishes : [donut]
CUST1 ate a donut
Dishes : [donut]
CUST2 waiting
Dishes : [donut, donut]
CUST2 waiting
Dishes : [donut, donut, burger]
CUST1 ate a donut
CUST2 ate a burger
Dishes : [donut]
CUST1 ate a donut
CUST2 waiting
Dishes : [donut, burger]
CUST2 ate a burger
Dishes : [donut, donut]
CUST1 ate a donut
Dishes : [donut, donut]
CUST2 waiting
CUST1 ate a donut

 

wait(), notify()의 문제점

위 예제에서는 한가지 문제점을 가지고 있습니다. 그것은 테이블 객체의 waiting pool에서 요리사 쓰레드와 손님 쓰레닥 같이 기다리는 것입니다. 그래서 notify()가 호출되었을 때, 요리사 쓰레드와 손님 쓰레드 중에서 누가 선택될지를 알 수가 없다는 점입니다.

 

만약 운이 좋게도 요리사 쓰레드가 선택받아서 요리를 만들면 좋겠지만 대기중인 손님 쓰레드가 선택받아 lock을 얻어도 여전히 자신이 원하는 음식이 없기 때문에 다시 waiting pool에 들어가게 됩니다.

 

기아 현상과 경쟁 상태

정말 운이 좋지 않아서 notify()를 호출했음에도 계속 대기중인 손님 쓰레드가 선택된다면 요리사 쓰레드는 계속 통지를 받지 못하고 오랫동안 기다리게 될 것입니다. 이렇게 어떤 한 쓰레드가 계속해서 제어권을 할당 받지 못하고 장시간 대기하고 있는 현상기아(starvation) 현상이라고 합니다.

 

기아 현상을 막기 위해서 notifyAll()을 호출하여 요리사 쓰레드를 깨울 수 있습니다. 하지만 문제점은 다른 손님 쓰레드들도 깨우기 때문에 요리사 쓰레드는 손님 쓰레드들과 lock을 얻기 위해서 경쟁하게 됩니다. 이처럼 여러 쓰레드가 lock을 얻기 위해 서로 경쟁하는 것'경쟁 상태(race condition)'이라고 합니다.

 

위와 같은 경쟁 상태를 개선하기 위해서는 요리사 쓰레드와 손님 쓰레드를 구별해서 통지(notify)하는 것이 필요합니다. 이러한 문제를 해결하기 위해서 Lock과 Condition을 이용하면 wait() & notify()로는 불가능한 선별적인 통지가 가능합니다.

 

References

source code : https://github.com/yonghwankim-dev/java_study/tree/main/ch13
[도서] Java의 정석, 남궁 성 지음