[기본 개념] 8 | (1.5) 스레드 동기화(Synchronized, wait( ), notify( ))

728x90

[기본 개념] 8 | (1.5) 스레드 동기화(Synchronized, wait( ), notify( ))

1 프로세스와 쓰레드

2 쓰레드의 구현과 실행

3 start( )와 run( )

4 싱글쓰레드와 멀티쓰레드

5 쓰레드의 우선순위

6 쓰레드 그룹(thread group)

7 데몬 쓰레드(daemon thread)

8 쓰레드의 실행제어

9> 쓰레드의 동기화

    9.1> synchronized를 이용한 동기화

    9.2> wait( )와 notify( )

    9.3 Lock과 Condition을 이용한 동기화

    9.4 volatile

    9.5 fork & join 프레임웍

9. 쓰레드의 동기화

 싱글쓰레드 프로세스의 경우 별 문제가 없지만, 멀티쓰레드 프로세스의 경우 자원을 공유해서 작업하기 때문에 한 쓰레드가 특정 작업을 끝마치기 전까지 다른 쓰레드에 의해 방해받지 않도록 하는 것이 필요하다. 

 

 따라서 공유 데이터를 사용하는 코드 영역을 '임계 영역'으로 지정해놓고, 공유 데이터가 가지고 있는 'lock'을 획득한 단 하나의 쓰레드만 영역 내의 코드를 수행할 수 있게 한다.

 

 이처럼 한 쓰레드가 진행중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 '쓰레드의 동기화'라고 한다.

 

9.1. synchronized를 이용한 동기화

 임계 영역을 설정하는 데 2가지 방법이 있다.

 

1 메서드 전체를 임계 영역으로 지정

 

public synchronized void calcSum( ) {
. . .

}

 

2 특정한 영역을 임계 영역으로 지정

 

synchronized(객체의 참조변수) {

. . .

}

 

 모든 객체는 lock을 하나씩 가지고 있으며, 해당 객체의 lock을 가지고 있는 쓰레드만 임계 영역의 코드를 수행할 수 있다.

 

예제/ThreadEx22.java

public class ThreadEx22 {
    public static void main(String[] args) {
        Runnable r = new RunnableEx22();
        new Thread(r).start();
        new Thread(r).start();
    }
}

class Account {
    private int balance = 1000;        // private로 해야 동기화의 의미가 있다.

    public int getBalance() {
        return balance;
    }

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

class RunnableEx22 implements Runnable {
    Account ac = new Account();

    public void run() {
        while (ac.getBalance() > 0) {
            // 100, 200, 300중 임의의 값으로 출금
            int money = (int) (Math.random() * 3 + 1) * 100;
            ac.withdraw(money);
            System.out.println("balance : " + ac.getBalance());
        }
    }
}
실행결과

balance : 900
balance : 800
balance : 700
balance : 400
balance : 300
balance : 0
balance : 0

 

 만약 위의 코드에서 synchronized가 없었으면 동기화가 되지 않아 작업을 마치기 전 다른 쓰레드가 작업을 수행하여 음수의 값이 나올 수도 있다.

 

 또한 synchronied메서드로 설정하는 것보다 synchronized블럭으로 임계 영역을 최소화하여 효율적인 프로그램이 되도록 해야 한다.

 

9.2. wait( )와 notify( )

 특정 쓰레드가 객체의 lock을 가진 상태로 오랜 시간을 보내면 다른 쓰레드들도 해당 객체의 lock을 기다리느라 원활히 진행되지 않을 것이다.

 

 wait( )은 동기화된 임계 영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아닐 때 호출하여 lock을 반납하고 기다리게 한다. 다른 쓰레드가 lock을 얻어 진행하던 중 나중에 진행할 수 있는 상황이 될때, notify( )를 호출하여, 중단했던 쓰레드가 다시 진행할 수 있게 한다.

 

 이때 notify( )가 호출되면 해당 객체의 대기실에 있던 모든 쓰레드 중에서 임의의 쓰레드만 통지를 받는다. 하지만 notifyAll( )은 기다리고 있는 모든 쓰레드에게 통보를 하여 하나의 쓰레드만이 lock을 얻어 수행할 수 있다.

 

 wait( )은 지정된 시간 동안만 기다리며 시간이 지난 후 자동적으로 notify( )가 호출된다.

 

예제/ThreadWaitEx3.java

import java.util.ArrayList;

public class ThreadWaitEx3 {
    public static void main(String[] args) {
        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();

        try { Thread.sleep(2000); } catch (InterruptedException e) {}
        System.exit(0);
    }
}

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);
        }
    }
}

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) {}
        }
    }

}

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;
                    }
                }
                try {
                    System.out.println(name + " is waiting.");
                    wait();     // 원하는 음식이 없는 CUST쓰레드를 기다리게 한다.
                    Thread.sleep(500);
                } catch (InterruptedException e) {}
            }
        }
    }

    public int dishNum()    { return dishNames.length; }
}
실행결과

Dishes : [donut]
Dishes : [donut, donut]
Dishes : [donut, donut, donut]
Dishes : [donut, donut, donut, donut]
Dishes : [donut, donut, donut, donut, donut]
Dishes : [donut, donut, donut, donut, donut, donut]
COOK1 is waiting.	--> 테이블이 가득차서 요리사가 기다리고 있다.
CUST1 ate a donut	--> 테이블의 음식이 소비되어 notify( )가 호출된다.
CUST2 is waiting.	--> 요리사가 아닌 손님이 통지를 받고, 원하는 음식이 없어서 기다리고 있다.
Dishes : [donut, donut, donut, donut, donut, burger]	--> 요리사가 음식추가
CUST2 ate a burger	--> 음식추가 통지를 받고, notify( )가 호출된다.
Dishes : [donut, donut, donut, donut, donut, donut]     --> 이번엔 요리사가 통지받고 음식추가
CUST1 ate a donut	--> 테이블의 음식이 소비되어 notify( )가 호출된다.
Dishes : [donut, donut, donut, donut, donut, donut]
COOK1 is waiting.
CUST2 is waiting.
CUST1 ate a donut
Dishes : [donut, donut, donut, donut, donut, donut]

 

 notify( )가 호출되었을 때, 요리사 쓰레드와 손님 쓰레드 중에서 누가 통지받을지 알 수 없다. 이때, 운이 나쁘면 요리사 쓰레드는 계속 통지받지 못하고 오랫동안 기다리게 되는 현상이 발생하는데 이를 '기아 현상'이라고 한다.

 

 이 현상을 막으려면, notify( ) 대신 notifyAll( )을 사용해야 한다. 모든 쓰레드에게 통지를 하면 요리사 쓰레드는 결국 lock을 얻어서 작업을 진행할 수 있기 때문이다.

 

 이처럼 기아 현상은 막았지만, 손님 쓰레드와 요리사 쓰레드가 lock을 얻기 위해 경쟁하는 '경쟁 상태'가 되는데 이 상태를 개선하기 위해서 요리사 쓰레드와 손님 쓰레드를 구별해서 통지하는 것이 필요하다.

 

 

 

 

 

 

 

출처 | Java의 정석 (남궁 성)

728x90