[기본 개념] 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의 정석 (남궁 성)
'💠프로그래밍 언어 > Java' 카테고리의 다른 글
[기본 개념] 9 | (1.1) 람다식, 람다식 인터페이스 (0) | 2022.01.25 |
---|---|
[기본 개념] 8 | (1.6) 스레드 동기화(Lock, Condition, Volatile, Fork&Join 프레임웍 (0) | 2022.01.24 |
[기본 개념] 8 | (1.4) 스레드 실행제어 (0) | 2022.01.22 |
[기본 개념] 8 | (1.3) 스레드 실행제어 (0) | 2022.01.22 |
[기본 개념] 8 | (1.2) 스레드 우선순위, 스레드 그룹, 데몬 스레드 (0) | 2022.01.20 |