쓰레드의 동기화
멀티쓰레드 프로세스의 경우에는 여러 쓰레드가 같은 프로세스 내의 자원을 공유합니다.
이로 인해 하나의 쓰레드의 작업이 다른 쓰레드에 영향을 주게 됩니다.
이러한 이유로 발생하는 여러 오류를 방지하기 위하여 한 쓰레드가 특정 작업을 끝마치지 전까지 다른 쓰레드에 의해 방해받지 않도록 하는 것이 필요합니다.
이를 위해 도입된 개념이 바로 임계영역(critical section)과 잠금(락, lock)입니다.
쓰레드 동기화(synchronization)
공유 데이터를 사용하는 코드 영역을 임계 영역으로 지정한 후, 공유 데이터가 각지고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있게 합니다.
그리고 해당 쓰레드가 임계 영역 내의 모든 코드를 수행하고 벗어나서 lock을 반납해야만 다른 쓰레드가 반납된 lock을 획득하여 임계 영역의 코드를 수행할 수 있게 됩니다.
이처럼 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 쓰레드 동기화라고 합니다.
자바에서는 synchronization 키워드와
jdk1.5 이후부터는 'java.util.concurrent.locks' 와 'java.util.concurrent.atomic' 패키지를 통해서 다양한 방식으로 동기화를 구현할 수 있도록 지원합니다.
synchronized 사용법
synchronized 키워드를 사용하면 임계 영역이 설정됩니다.
1. 메서드 전체를 임계 영역으로 지정
public synchronized void method() {
//...
}
첫 번째 방법은 메서드 앞에 synchronized 키워드를 붙이는 것입니다.
이를 통해 메서드 전체가 임계 영역으로 설정됩니다.
쓰레드는 synchronized 메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 lock을 얻어 작업을 수행하다가 메서드가 종료되면 lock을 반환합니다.
public class Example {
public static void main(String[] args) {
Run run = new Run();
new Thread(run).start();
new Thread(run).start();
}
static class Account {
private int balance = 1000;
public int getBalance() {
return balance;
}
public synchronized void withdraw(int money) {
if (balance >= money) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
balance -= money;
}
}
}
static class Run implements Runnable {
private Account account = new Account();
@Override
public void run() {
while (account.getBalance() > 0) {
int money = (int)(Math.random() * 3 + 1) * 100;
account.withdraw(money);
System.out.println("balance : " + account.getBalance());
}
}
}
}
2. 특정한 영역을 임계 영역으로 지정
synchronized(객체의 참조변수) {
//...
}
두 번째 방법은 메서드 내의 코드 일부를 블럭으로 감싸고 블럭 앞에 'synchronized(참조변수)'를 붙이는 것인데, 이때 참조변수는 락을 걸고자하는 객체를 참조하는 것이어야 합니다.
모든 객체는 lock을 하나씩 가지고 있으며, 해당 객체의 lock을 가지고 있는 쓰레드만 임계 영역의 코드를 수행할 수 있습니다.
public class Example {
public static void main(String[] args) {
Run run = new Run();
new Thread(run).start();
new Thread(run).start();
}
static class Account {
private int balance = 1000;
public int getBalance() {
return balance;
}
public void withdraw(int money) {
synchronized (this) {
if (balance >= money) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
balance -= money;
}
}
}
}
static class Run implements Runnable {
private Account account = new Account();
@Override
public void run() {
while (account.getBalance() > 0) {
int money = (int)(Math.random() * 3 + 1) * 100;
account.withdraw(money);
System.out.println("balance : " + account.getBalance());
}
}
}
}
wati()와 notify()
특정 쓰레드가 객체의 락을 가진 상태로 오랜 시간을 보내지 않도록 하는 것 역시 공유 데이터를 보호하는 것 만큼이나 중요합니다.
이를 위해 wati()와 notify()가 고안되었습니다.
동기화된 임계 영역의 코드를 수행하다가 작업을 더 이상 기다릴 상황이 아니라면 wait()를 호출하여 쓰레드가 락을 반납하고 기다리게 합니다.
그러면 다른 쓰레드가 락을 얻어 해당 객체에 대한 작업을 수행할 수 있게 됩니다.
나중에 작업을 진행할 수 있는 상황이 된다면 notify()를 호출하여 작업을 중단했던 쓰레드가 다시 락을 얻어 작업을 진행할 수 있게 합니다.
주의할 점은 notify()를 통해 특정 쓰레드가 락을 얻도록 통제할 수 없다는 것입니다.
wait()가 호출되면 실행 중이던 쓰레드는 해당 객체(공유객체)의 대기실(waiting pool)에서 통지를 기다립니다.
notify()가 호출되면 해당 객체(공유객체)의 대기실에 있던 모든 쓰레드 중에서 임의의 쓰레드만 통지를 받습니다.
notifyAll()을 사용하면 대기실에 있는 모든 쓰레드에 대해 통보를 하지만, 결국 lock을 얻는 것은 하나의 쓰레드일 뿐이고, 나머지 쓰레드는 통보를 받았음에도 불구하고 다시 lock을 기다려야 합니다.
wait()와 notify()는 특정 (공유)객체에 대한 것이며, Object 클래스에 정의되어 있습니다.
wait()는 매개변수로 long 타입 변수를 입력받을 수 있습니다.
이를 사용하면 지정된 시간동안만 기다리고, 지정된 시간이 지나면 자동으로 notify()가 호출되는 것과 같이 동작합니다.
중요한 점은 waitiong pool은 객체마다 존재하기에 notifyAll()을 호출한다고 해서 모든 객체의 waiting pool에 있는 쓰레드가 깨워지는 것이 아니라는 점입니다.
notifyAll()이 호출된 객체의 waiting pool에 대기 중인 쓰레드만 깨워집니다.
wati(), notify(), notifyAll()
- synchronized 블럭 내에서만 사용할 수 있습니다.
예시 코드
public class Table {
String [] dishNames = {"피자","피자","치킨"};//피자가 더 자주 나온다
final int MAX_FOOD = 6;
private ArrayList<String> dishes = new ArrayList<>();
public void add(String dish) {
if (isFull()) return;
dishes.add(dish);
System.out.println("Dishes: " + dishes.toString());
}
private boolean isFull() {
return dishes.size() >= MAX_FOOD;
}
public boolean remove(String dish) {
for (String dishName : dishes) {
if (dishName.equals(dish)){
dishes.remove(dish);
return true;
}
}
return false;
}
public double dishNum() {
return dishNames.length;
}
}
public 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) {}
eatFood();
}
}
private void eatFood() {
String name = Thread.currentThread().getName();
if (table.remove(food)) {
System.out.println(name + " ate a " + food);
}else {
System.out.println(name + " fail to eat :( ");
}
}
}
public class Cook implements Runnable {
private Table table;
public Cook(Table table) {
this.table = table;
}
@Override
public void run() {
while (true) {
int idx = (int)(Math.random() * table.dishNum());
table.add(table.dishNames[idx]);
try {
Thread.sleep(1);
} catch (InterruptedException e) {}
}
}
}
public class Example {
public static void main(String[] args) throws InterruptedException {
Table table = new Table();
new Thread(new Cook(table), "COOK1").start();
new Thread(new Customer(table, "피자"), "CUST1").start();
new Thread(new Customer(table, "치킨"), "CUST2").start();
Thread.sleep(100);
System.exit(0);
}
}
해당 코드는 실행 시 예외가 발생할 수도 있고 발생하지 않을 수도 있습니다.
예외가 발생한다면 요리사(Cook) 쓰레드가 테이블에 음식을 놓는 도중 손님(Customer) 쓰레드가 음식을 가져가려 했기 때문에 예외가 발생하거나, 손늠 쓰레드가 테이블의 마지막 음식을 가져가는 도중에 다른 손님 쓰레드가 먼저 음식을 낚아채려 해서 예외가 발생합니다.
이를 동기화를 통해 방지해 보도록 하겠습니다.
public class Table {
String [] dishNames = {"피자","피자","치킨"};//피자가 더 자주 나온다
final int MAX_FOOD = 6;
private ArrayList<String> dishes = new ArrayList<>();
public synchronized void add(String dish) {
if (isFull()) return;
dishes.add(dish);
System.out.println("Dishes: " + dishes.toString());
}
private boolean isFull() {
return dishes.size() >= MAX_FOOD;
}
public boolean remove(String dish) {
synchronized (this) {
while (isEmpty()) {
System.out.println(Thread.currentThread().getName() + " is waiting");
try { Thread.sleep(500); } catch (InterruptedException e) {}
}
for (String dishName : dishes) {
if (dishName.equals(dish)) {
dishes.remove(dish);
return true;
}
}
}
return false;
}
private boolean isEmpty() {
return dishes.isEmpty();
}
public double dishNum() {
return dishNames.length;
}
}
public 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) {}
eatFood();
}
}
private void eatFood() {
String name = Thread.currentThread().getName();
if (table.remove(food)) {
System.out.println(name + " ate a " + food);
}else {
System.out.println(name + " fail to eat :( ");
}
}
}
public class Cook implements Runnable {
private Table table;
public Cook(Table table) {
this.table = table;
}
@Override
public void run() {
while (true) {
int idx = (int)(Math.random() * table.dishNum());
table.add(table.dishNames[idx]);
try {
Thread.sleep(100);
} catch (InterruptedException e) {}
}
}
}
public class Example {
public static void main(String[] args) throws InterruptedException {
Table table = new Table();
new Thread(new Cook(table), "COOK1").start();
new Thread(new Customer(table, "피자"), "CUST1").start();
new Thread(new Customer(table, "치킨"), "CUST2").start();
Thread.sleep(5000);
System.exit(0);
}
}
결과는 다음과 같습니다.
위와 같은 결과가 발생하는 이유는 손님 쓰레드가 테이블 객체의 lock을 쥔 체 기다리기 때문입니다.
음식이 없으면 기다리는 while문이 동기화 블럭 내부에 들어 있기에 발생하는 현상인데, 이를 wait()와 notify()를 통해 해결해보도록 하겠습니다.
table 코드만 변경됩니다.
public class Table {
String [] dishNames = {"피자","피자","치킨"};//피자가 더 자주 나온다
final int MAX_FOOD = 6;
private ArrayList<String> dishes = new ArrayList<>();
public synchronized void add(String dish) {
if (isFull()) {
String name = Thread.currentThread().getName();
System.out.println(name + " is waiting");
try {
wait();
Thread.sleep(500);
} catch (InterruptedException e) {}
}
dishes.add(dish);
notify();
System.out.println("Dishes: " + dishes.toString());
}
private boolean isFull() {
return dishes.size() >= MAX_FOOD;
}
public boolean remove(String dish) {
synchronized (this) {
while (isEmpty()) {
System.out.println(Thread.currentThread().getName() + " is waiting");
try {
wait();
Thread.sleep(500);
} catch (InterruptedException e) {}
}
for (String dishName : dishes) {
if (dishName.equals(dish)) {
dishes.remove(dish);
notify();//COOK을 깨우기 위해
return true;
}
}
}
return false;
}
private boolean isEmpty() {
return dishes.isEmpty();
}
public double dishNum() {
return dishNames.length;
}
}
실행 결과를 확인해보면 잘 동작하는 것 같지만 문제가 하나 있습니다.
테이블 객체의 waiting pool에서 요리사 쓰레드와 손님 쓰레드가 같이 기다린다는 것입니다.
따라서 notify가 호출되었을 때 요리사 쓰레드와 손님 쓰레드 중에서 누가 통지를 받을지 알 수 없습니다.
기아(starvation) 현상
지독히 운이 나쁘면 요리사 쓰레드는 계속 통지를 받지 못하고 오랫동안 기다리게 되는데, 이를 기아 현상이라 합니다.
이 현상을 막으려면 notify() 대신 notifyAll()을 사용해야 합니다.
notifyAll()을 통해 waiting pool에 있는 모든 쓰레드에게 통지를 하면, 손님 쓰레드는 다시 waiting pool에 들어가더라도 요리사 쓰레드는 결국 lock을 얻어서 작업을 진행할 수 있기 때문입니다.
경쟁 상태(race condition)
notifyAll()을 통해 기아 현상은 방지하였으나, 손님 쓰레드까지 통지를 받아서 불필요하게 요리사 쓰레드와 lock을 얻기 위해 경쟁하게 됩니다.
이처럼 여러 쓰레드가 lock을 얻기 위해 서로 경쟁하는 것을 경쟁 상태라 하는데, 이 경쟁 상태를 개선하기 위해서는 요리사 쓰레드와 손님 쓰레드를 구분하여 통지해야 합니다.
그러나 이는 wait()와 notify()로는 불가능하며, 다음 글에서 배울 Lock과 Condition을 통해 가능합니다.
'☕️ Java > 기본' 카테고리의 다른 글
[Java] Thread (9) - ForkJoin 프레임워크 (0) | 2022.07.15 |
---|---|
[Java] Thread (8) - 쓰레드의 동기화(2) - Lock과 Condition (3) | 2022.07.15 |
[Java] Thread (6) - 쓰레드의 실행제어 (0) | 2022.07.14 |
[Java] Thread (5) - 데몬 쓰레드 (0) | 2022.07.13 |
[Java] Thread (4) - 쓰레드의 우선순위 (0) | 2022.07.13 |