멀티 쓰레드 프로세스에서는 여러 쓰레드가 프로세스의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 끼치게 됨.
공유 데이터를 사용하는 코드 영역을 임계 영역(critical section)이라고 하고,
임계 영역의 코드를 수행할 수 있는 권한을 lock이라고 표현함.
lock을 가진 쓰레드만이 임계 영역에서 작업을 할 수 있음
작업이 끝나면 lock을 반납해야하고, lock을 획득한 다른 쓰레드가 임계 영역의 코드를 수행할 수 있게 됨.
이렇게 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하게 막는 것을 쓰레드의 동기화(synchronization)이라고 함.
synchronized
키워드는 임계 영역을 설정하는 데에 사용.
synchronized 메서드
메서드 전체를 임계 영역으로 설정
public synchronized void sum() {
// 임계 영역
}
synchronized 블럭
메서드 내부의 특정 부분만 임계 영역으로 설정
synchronized(객체의 참조 변수) {
// 임계 영역
}
객체의 참조 변수는 lock을 걸고자하는 객체를 참조
class SynchronizedExample {
public static void main(String[] args) {
Runnable r1 = new R1();
new Thread(r1).start();
new Thread(r1).start();
// 잔고 1000원이 들어있는 계좌를 공유하고 있는 두 쓰레드를 생성하여 실행시킴
}
}
class Account {
private int balance = 1000;
public int getBalance() {
return balance;
}
public void withdraw(int amount) {
if (balance >= amount) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {}
balance -= amount;
}
}
}
class R1 implements Runnable {
Account acc = new Account();
@Override
public void run() {
while (acc.getBalance() > 0) {
int money = (int)(Math.random() * 3 + 1) * 100;
acc.withdraw(money); // 잔고가 0원이 되기 전까지 100, 200, 300원 중 랜덤한 금액을 인출
System.out.println("Current balance: " + acc.getBalance());
}
}
}
실행 결과)
Current balance: 900
Current balance: 900
Current balance: 700
Current balance: 600
Current balance: 400
Current balance: 300
Current balance: 100
Current balance: -200
withdraw()
메서드에서 발생하는 동기화 문제 때문에 잔고가 음수가 되는 문제 발생.
잔고가 300원 남은 상태에서 1번 쓰레드가 200원을 인출하려고함. 그런데 if
문 조건식을 통과한 직후에 2번 쓰레드에게 제어권이 넘어갔다고 가정.
2번은 300원을 인출하려 했고, 정상적으로 인출하여 잔고가 0원이 됨. 다시 1번에게 제어권이 돌아와 조건식 이후의 인출 작업을 수행하면 잔고가 -200원이 되어버림.
if
문 전체를 임계 영역으로 만들어야지 위와 같은 사고가 방지됨.
public void withdraw(int amount) {
synchronized(this) {
if (balance >= amount) {
...
}
}
}
그런데 이 경우에는 메서드의 내용이 if
문 밖에 없으므로 아예 메서드 자체를 임계 영역으로 만들어도 같음
public synchronized void withdraw(int amount) {
if (balance >= amount) {
...
}
}
synchronized
는 공유 자원을 한번에 하나의 쓰레드만 접근할 수 있게 해주는 장점이 있지만,
이 과정에서 lock을 보유한 쓰레드가 불필요하게 lock을 오래 가지고 있지 않도록 조절해줄 필요가 있음.
lock을 보유한 쓰레드가 작업 도중 어떠한 이유에서든 진행이 불가능한 상태가 되면, wait()
을 호출해서 무의미하게 기다리며 시간을 날리지 않고 lock을 반납.
다른 쓰레드는 이 lock을 얻어 작업을 하게 되고, 나중에 작업이 가능한 상태일 때 notify()
를 호출하면 작업을 중단했던 쓰레드가 다시 lock을 얻을 수 있게 됨.
단, wait()
으로 인해 waiting pool로 들어간 쓰레드는 여러 개일 수 있는데, 이 중에서 가장 오래 기다린 쓰레드가 가장 먼저 lock을 얻는다는 보장은 없음. notify()
는 객체가 가진 waiting pool 내의 임의의 쓰레드에게만 통보를 해줌.
notifyAll()
은 객체가 가진 waiting pool 내의 모든 쓰레드에게 통보를 해주긴 하지만, 그래도 lock을 얻는 건 그 중 단 하나이므로 나머지는 그대로 기다리는 신세.
wait
()wait
(long ms)wait
(long ms, int ns)시간을 지정하면 해당 시간이 지난 후 자동적으로
notify()
호출
notify
()notifyAll
()
wait()
,notify()
,notifyAll()
은 전부synchronized
블럭 내에서만 사용 가능
class WaitExample {
public static void main(String[] args) throws Exception {
Table table = new Table(); // 공유 객체 table
new Thread(new Cook(table), "COOK_1").start();
new Thread(new Customer(table, "burger"), "CUSTOMER_1").start();
new Thread(new Customer(table, "taco"), "CUSTOMER_2").start();
Thread.sleep(5000); // 5초 후
System.exit(0); // 모든 쓰레드 종료
}
}
class Customer implements Runnable {
private Table table;
private String dish;
Customer(Table table, String dish) {
this.table = table;
this.dish = dish;
}
@Override
public void run() {
while (true) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {}
String customerName = Thread.currentThread().getName();
if (eat()) {
System.out.println(customerName + " got a " + dish);
} else {
System.out.println(customerName + " failed to get a " + dish);
}
}
}
boolean eat() { return table.remove(dish); }
}
class Cook implements Runnable {
private Table table;
Cook(Table table) {
this.table = table;
}
@Override
public void run() {
while (true) {
int index = (int)(Math.random() * table.menuNum());
table.add(table.menus[index]);
try {
Thread.sleep(100); // 요리사는 0.1초에 한번씩 랜덤 요리를 테이블에 추가함
} catch (InterruptedException e) {}
}
}
}
class Table {
String[] menus = {"burger", "burger", "taco"};
final int MAX_DISH = 6;
private ArrayList<String> dishes = new ArrayList<>();
public synchronized void add(String dish) {
if (dishes.size() >= MAX_DISH) return;
dishes.add(dish);
System.out.println("Dishes: " + dishes);
}
public boolean remove(String dish) {
synchronized (this) {
while (dishes.size() == 0) {
String customerName = Thread.currentThread().getName();
System.out.println(customerName + " is waiting...");
try {
Thread.sleep(500);
// 손님은 테이블에 음식이 생겼는지 0.5초마다 확인하며 기다림
} catch (InterruptedException e) {}
}
for (int i = 0; i < dishes.size(); i++) {
if (dish.equals(dishes.get(i))) {
dishes.remove(i);
return true;
}
}
}
return false;
}
public int menuNum() { return menus.length; }
}
실행 결과)
Dishes: [burger]
CUSTOMER_2 failed to get a taco
CUSTOMER_1 got a burger
CUSTOMER_1 is waiting...
CUSTOMER_1 is waiting...
CUSTOMER_1 is waiting...
CUSTOMER_1 is waiting...
CUSTOMER_1 is waiting...
CUSTOMER_1 is waiting...
CUSTOMER_1 is waiting...
CUSTOMER_1 is waiting...
CUSTOMER_1 is waiting...
CUSTOMER_1 is waiting...
synchronized
로 공유 객체인 table
을 두고 충돌이 생길 수 있는 add()
와 remove()
메서드를 동기화시켰음.
그런데도 요리사가 테이블에 요리를 추가하지 않고, 손님은 계속 기다리기만 하고 있음.
remove()
내의 synchronized
블럭에서 손님 쓰레드가 table의 lock을 계속해서 쥐고 있기 때문.
요리사 쓰레드가 요리를 추가하려고 해도, 손님이 lock을 놓지 않아서 table에 접근할 수가 없게됨.
이런 비정상적인 상황을 wait()
와 notify()
를 사용해서 해결 가능
class WaitExample {
public static void main(String[] args) throws Exception {
Table table = new Table();
new Thread(new Cook(table), "COOK_1").start();
new Thread(new Customer(table, "burger"), "CUSTOMER_1").start();
new Thread(new Customer(table, "taco"), "CUSTOMER_2").start();
Thread.sleep(2000);
System.exit(0);
}
}
class Customer implements Runnable {
private Table table;
private String dish;
Customer(Table table, String dish) {
this.table = table;
this.dish = dish;
}
@Override
public void run() {
while (true) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {}
String customerName = Thread.currentThread().getName();
table.remove(dish);
System.out.println(customerName + " got a " + dish);
}
}
}
class Cook implements Runnable {
private Table table;
Cook(Table table) {
this.table = table;
}
@Override
public void run() {
while (true) {
int index = (int)(Math.random() * table.menuNum());
table.add(table.menus[index]);
try {
Thread.sleep(150);
} catch (InterruptedException e) {}
}
}
}
class Table {
String[] menus = {"burger", "burger", "taco"};
final int MAX_DISH = 6;
private ArrayList<String> dishes = new ArrayList<>();
public synchronized void add(String dish) {
while (dishes.size() >= MAX_DISH) {
String cookName = Thread.currentThread().getName();
System.out.println(cookName + " is waiting because the table is full.");
try {
wait(); // 요리사 쓰레드를 대기시킴
Thread.sleep(500);
} catch (InterruptedException e) {}
}
dishes.add(dish);
notify(); // 기다리고 있을 손님 쓰레드를 깨움
System.out.println("Dishes: " + dishes);
}
public synchronized void remove(String dish) {
String customerName = Thread.currentThread().getName();
while (dishes.size() == 0) {
System.out.println(customerName + " is waiting because there's no dish at all.");
try {
wait(); // 음식이 나올 때까지 손님 쓰레드를 대기시킴
Thread.sleep(500);
} catch (InterruptedException e) {}
}
while (true) {
for (int i = 0; i < dishes.size(); i++) {
if (dish.equals(dishes.get(i))) {
dishes.remove(i);
notify(); // 대기중이던 요리사 쓰레드를 깨움
return;
}
}
try {
System.out.println(customerName + " is waiting because there isn't a dish he's looking for.");
wait(); // 원하는 음식이 없는 손님 쓰레드를 대기시킴
Thread.sleep(500);
} catch (InterruptedException e) {}
}
}
public int menuNum() { return menus.length; }
}
실행 결과
Dishes: [taco] Dishes: [taco, burger] Dishes: [taco, burger, burger] Dishes: [taco, burger, burger, taco] Dishes: [taco, burger, burger, taco, burger] Dishes: [taco, burger, burger, taco, burger, burger] COOK_1 is waiting because the table is full. CUSTOMER_1 got a burger Dishes: [taco, burger, taco, burger, burger, taco] CUSTOMER_1 got a burger CUSTOMER_2 got a taco Dishes: [taco, burger, burger, taco, burger] Dishes: [taco, burger, burger, taco, burger, taco] COOK_1 is waiting because the table is full. CUSTOMER_2 got a taco CUSTOMER_1 got a burger Dishes: [burger, taco, burger, taco, burger] CUSTOMER_2 got a taco CUSTOMER_1 got a burger Dishes: [burger, taco, burger, burger] Dishes: [burger, taco, burger, burger, burger] Dishes: [burger, taco, burger, burger, burger, burger] COOK_1 is waiting because the table is full. CUSTOMER_2 got a taco CUSTOMER_1 got a burger Dishes: [burger, burger, burger, burger, taco] CUSTOMER_1 got a burger CUSTOMER_2 got a taco Dishes: [burger, burger, burger, burger] Dishes: [burger, burger, burger, burger, burger] Dishes: [burger, burger, burger, burger, burger, burger] COOK_1 is waiting because the table is full. CUSTOMER_1 got a burger CUSTOMER_2 is waiting because there isn't a dish he's looking for.
어떤 쓰레드든 기다려야 하는 상황에는 wait()
으로 lock을 반납한 채로 대기하도록 하고, 다시 작업을 해야할 상황에는 notify()
로 깨움.
문제는 공유 객체인 table
의 waiting pool은 당연히 하나이기 때문에 같은 waiting pool 안에 요리사와 손님이 모두 섞여있다는 점.
그래서 notify()
가 호출되었을 때 대기중이던 요리사와 손님 중 누가 깨워질지 모름.
실행 결과 마지막 4줄을 보면, 테이블에 음식이 가득 차서 요리사는 waiting pool에 들어감. 손님1이 음식을 가져가서 다시 요리를 시작해야 할 때, 요리사를 깨울 목적으로 notify()
를 호출해도 쌩뚱 맞게 손님2를 깨웠음.
기아 현상(starvation)
최악의 케이스에는 요리사가 계속해서 통보 받지 못하고 waiting pool에 갇혀있을 수도 있는데, 이런 케이스를 "기아 현상"이라 부름.
경쟁 상태(race condition)
기아 현상을 막고자 notifyAll()
을 호출하면, 요리사를 깨우기 위해 손님까지 전부 다 깨워버리는 비효율적인 상황이 발생하게 됨.
게다가 하나의 lock을 두고 깨어난 요리사와 손님이 모두 경쟁을 하게 되는데, 이를 "경쟁 상태"라고 부름.
따라서 요리사와 손님을 구분해서 깨울 장치가 필요함.
Lock
과Condition
을 활용하면 대기 중인 쓰레드를 선별적으로 깨울 수 있음
JDK
1.5부터는 synchronized
외에도 java.util.concurrent.locks
패키지 내의 lock
관련 클래스들을 활용해 동기화가 가능.
synchronized
의 장점:
자동적으로 lock이 걸리거나 풀림
블럭 내에서 예외가 발생하면 자동적으로 lock이 풀림
단점:
같은 메서드 내에서만 lock을 걸 수 있음
그럴 땐
synchronized
대신에 lock 관련 클래스를 사용하면 됨.
ReentrantLock
모든 작업에 배타적인 lock.
재진입이 가능한 lock.
lock을 반납하고 waiting pool에 갔다가 다시 lock을 얻어 임계영역으로 돌아와 작업이 가능하다는 의미
ReentrantReadWriteLock
읽기 작업은 공유적, 쓰기 작업은 배타적인 lock
읽기를 위한 lock과 쓰기를 위한 lock이 별도로 존재
한 쓰레드가 읽기 lock을 갖고 있어도, 다른 쓰레드가 중복해서 읽기 lock을 얻을 수 있음
한 쓰레드가 쓰기 lock을 갖고 있으면, 다른 쓰레드는 중복해서 쓰기 lock을 얻을 수 없음
읽기 lock을 가진 상태로 쓰기 lock을 거는 것, 또는 반대의 경우 모두 허용 안됨.
StampedLock
ReentrantReadWriteLock
에 낙관적인 읽기 lock 기능을 추가한 것
lock을 걸거나 풀 때 스탬프를 사용
(JDK
1.8부터 추가됨)
낙관적이라는 것은 일단 내가 읽기를 시도할 때, 아무도 이 순간에 내가 읽으려는 데이터에 쓰기 작업을 하지 않을 것이라는 낙관적인 가정을 한다는 의미.
1번 쓰레드가 읽기 작업을 위해 낙관적인 읽기 lock을 걸었을 때, 2번 쓰레드의 쓰기 lock이 감지되면 즉시 낙관적인 읽기 lock을 풀어버림.
낙관적인 읽기 lock을 잃은 1번 쓰레드는 다시 읽기 lock을 얻기 위해 기다리고, 2번 쓰레드의 쓰기 작업이 끝난 다음에야 읽기 lock을 얻음.
만약 낙관적인 읽기 lock을 걸었을 때 다른 쓰레드가 쓰기 lock을 걸지 않는다면 그대로 읽기 작업을 함.
ReentrantLock
()
ReentrantLock
(boolean fair)
fair에 true
를 대입해서 생성하면, lock이 풀렸을 때 가장 오래 기다린 쓰레드에게 lock을 줌.
누가 가장 오래 기다렸는지 확인이 필요하므로 성능은 떨어짐.
대부분의 상황에서는 공정성이 중요하지 않으므로 성능상의 이점이 있는 1번 생성자를 사용
lock 관련 클래스들은 수동으로 lock을 걸거나 해제해야함
void lock
()
lock을 검
void unlock
()
lock을 품
boolean isLocked
()
현재 lock이 걸려있는지 확인함
임계 영역 안에서 예외가 발생하거나 return
문으로 임계 영역을 빠져나가면 lock이 풀리지 않을 수 있기 때문에 unlock()
은 try-finally
문으로 감싸줌.
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// 임계 영역
} finally {
lock.unlock();
}
lock()
은 호출 당시에 다른 쓰레드에게 lock이 있으면 반납이 되어 자신이 사용할 수 있을 때까지 기다림.
기다리는 동안 쓰레드를 block 시키기 때문에 응답성이 떨어짐
이 기다림을 원치 않거나 일정 시간 만큼만 기다리기 원하면 tryLock()
사용
boolean tryLock
()
boolean tryLock
(long timeout, TimeUnit unit) throws InterruptedException
지정된 시간 동안 기다릴 때,
interrupt()
에 의해 기다림을 취소할 수 있기 때문에 예외가 붙음
waiting pool에 넣거나 깨울 때 통보 대상을 명확히 구분하기 위해 Condition
을 사용
애초에 waiting pool에 넣을 때 구분될 필요가 있는 쓰레드를 위해 개별 Condition
을 만들면 별도의 waiting pool 안에 넣을 수 있음.
Condition
은 lock으로부터 생성 가능
private ReentrantLock lock = new ReentrantLock();
private Condition cookCondition = lock.newCondition();
private Condition customerCondition = lock.newCondition();
wait()
대신 await()
, notify()
대신 signal()
을 사용하여 waiting pool로 넣거나, 깨울 수 있음.
class WaitExample {
public static void main(String[] args) throws Exception {
Table table = new Table();
new Thread(new Cook(table), "COOK_1").start();
new Thread(new Customer(table, "burger"), "CUSTOMER_1").start();
new Thread(new Customer(table, "taco"), "CUSTOMER_2").start();
Thread.sleep(2000);
System.exit(0);
}
}
class Customer implements Runnable {
private Table table;
private String dish;
Customer(Table table, String dish) {
this.table = table;
this.dish = dish;
}
@Override
public void run() {
while (true) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {}
String customerName = Thread.currentThread().getName();
table.remove(dish);
System.out.println(customerName + " got a " + dish);
}
}
}
class Cook implements Runnable {
private Table table;
Cook(Table table) {
this.table = table;
}
@Override
public void run() {
while (true) {
int index = (int)(Math.random() * table.menuNum());
table.add(table.menus[index]);
try {
Thread.sleep(10);
} catch (InterruptedException e) {}
}
}
}
class Table {
String[] menus = {"burger", "burger", "taco"};
final int MAX_DISH = 6;
private ArrayList<String> dishes = new ArrayList<>();
private ReentrantLock lock = new ReentrantLock();
private Condition customerCondition = lock.newCondition();
private Condition cookCondition = lock.newCondition();
public void add(String dish) {
lock.lock(); // 공유자원인 table에 lock을 검
try {
while (dishes.size() >= MAX_DISH) {
String cookName = Thread.currentThread().getName();
System.out.println(cookName + " is waiting because the table is full.");
try {
cookCondition.await(); // 요리사 쓰레드를 대기시킴
Thread.sleep(500);
} catch (InterruptedException e) {}
}
dishes.add(dish);
customerCondition.signal(); // 기다리고 있을 손님 쓰레드를 깨움
System.out.println("Dishes: " + dishes);
} finally {
lock.unlock(); // 반드시 lock을 풀어줘야함
}
}
public void remove(String dish) {
lock.lock(); // 공유 자원인 table에 lock을 검
String customerName = Thread.currentThread().getName();
try {
while (dishes.size() == 0) {
System.out.println(customerName + " is waiting because there's no dish at all.");
try {
customerCondition.await(); // 음식이 나올 때까지 손님 쓰레드를 대기시킴
Thread.sleep(500);
} catch (InterruptedException e) {}
}
while (true) {
for (int i = 0; i < dishes.size(); i++) {
if (dish.equals(dishes.get(i))) {
dishes.remove(i);
cookCondition.signal(); // 대기중이던 요리사 쓰레드를 깨움
return;
}
}
try {
System.out.println(customerName + " is waiting because there isn't a dish he's looking for.");
customerCondition.await(); // 원하는 음식이 없는 손님 쓰레드를 대기시킴
Thread.sleep(500);
} catch (InterruptedException e) {}
}
} finally {
lock.unlock();
}
}
public int menuNum() { return menus.length; }
}
실행 결과
Dishes: [taco] Dishes: [taco, taco] Dishes: [taco, taco, taco] Dishes: [taco, taco, taco, burger] Dishes: [taco, taco, taco, burger, taco] Dishes: [taco, taco, taco, burger, taco, burger] COOK_1 is waiting because the table is full. CUSTOMER_2 got a taco Dishes: [taco, taco, burger, taco, burger, taco] CUSTOMER_1 got a burger CUSTOMER_2 got a taco Dishes: [taco, taco, burger, taco, burger] Dishes: [taco, taco, burger, taco, burger, burger] COOK_1 is waiting because the table is full. CUSTOMER_2 got a taco CUSTOMER_1 got a burger Dishes: [taco, taco, burger, burger, burger] CUSTOMER_1 got a burger CUSTOMER_2 got a taco Dishes: [taco, burger, burger, taco] Dishes: [taco, burger, burger, taco, burger] Dishes: [taco, burger, burger, taco, burger, burger] COOK_1 is waiting because the table is full. CUSTOMER_1 got a burger CUSTOMER_2 got a taco Dishes: [burger, taco, burger, burger, burger] CUSTOMER_1 got a burger CUSTOMER_2 got a taco Dishes: [burger, burger, burger, burger] Dishes: [burger, burger, burger, burger, burger] Dishes: [burger, burger, burger, burger, burger, burger] COOK_1 is waiting because the table is full. CUSTOMER_1 got a burger CUSTOMER_2 is waiting because there isn't a dish he's looking for.
이전의 예제에서 synchronized
를 빼고, ReentrantLock
과 Condition
으로 대체함.
전과 달리 깨울 쓰레드를 특정할 수 있기 때문에 요리사를 깨우려다가 손님을 깨우는 불상사는 일어나지 않음.
서로 다른 종류의 쓰레드 간의 기아 현상과 경쟁 상태는 개선되었으나, 여전히 같은 종류의 쓰레드(손님)끼리는 기아 현상과 경쟁 상태가 발생할 수 있음.
이는 burger를 원하는 손님과 taco를 원하는 손님을 구분하기 위해 별도의 손님 Condition을 생성해서 해결할 수 있음.
CPU 코어는 메모리에서 읽어온 값을 캐시에 저장하고, 캐시에 저장된 값을 읽어서 작업함.
다시 같은 값을 읽어올 일이 있을 때에는 캐시를 먼저 확인하고 없으면 메모리에서 읽어옴.
만약 메모리에 저장된 값이 바뀌었는데, 코어가 여전히 캐시에 저장된 낡은 값을 참조하면 문제가 발생함.
volatile
키워드를 사용하면 캐시가 아닌 메모리에서 값을 읽어오도록 만들어 이 문제를 해결할 수 있음.
synchronized
를 사용해도 같은 효과가 있음쓰레드가
synchronized
블럭 안에 들어가거나 나올 때 메모리와 캐시 간의 동기화가 이루어지기 때문.
volatile boolean isOn = false;
JVM
은 데이터를 4 byte 단위로 처리함
따라서 4 byte 이하의 범위에 있는 데이터(int
와 그 아래)를 가지고 작업할 때는 하나의 명령어(작업의 최소 단위)로 처리 가능. (다른 쓰레드가 끼어들 염려가 없음)
그러나 4 byte 보다 큰 타입의 데이터(long
, double
)를 다룰 때에는 하나의 명령어로 처리가 불가하므로 작업 도중에 다른 쓰레드가 끼어들 수 있음
이 때 volatile
을 사용하면 4 byte 이상의 크기를 가진 데이터도 원자화해서 이 문제를 방지할 수 있음.
원자화: 작업을 더 이상 나눌 수 없게 한다는 뜻
📌 [주의] 상수에는
volatile
사용 불가.애초에 변할 값이 아니기 때문에 다른 쓰레드의 끼어들기를 굳이 방지할 필요가 없음.
volatile
이 읽기/쓰기를 원자화하고, 데이터를 메모리로부터 참조하게끔 강제한다고 해서 동기화를 보장해주지는 않음. (여러 쓰레드가 쓰기 작업을 할 수 있는 상황 등)
오직 1개의 쓰레드만 읽기/쓰기가 가능하고, 나머지 쓰레드는 읽기만 가능한 상황이라면
volatile
만으로도 동기화 상태 유지 가능
따라서 volatile
이 붙은 변수를 활용할 때, synchronized
를 적절히 사용해 동기화를 시켜줘야함.
volatile long counter;
synchronized int getBalance() {
return balance; // 잔고 값 반환 (읽기)
}
synchronized void withdraw(int amount) {
if (balance >= amount) {
balance -= amount; // 잔고에서 특정 금액만큼 차감 (쓰기)
}
}
만약 getBalance()
에 synchronized
를 붙이지 않는다면, withdraw()
가 호출되어 인출하고 있는 도중에도 getBalance()
를 호출하는 것이 가능해져 동기화가 안됨.
하나의 작업을 작은 단위로 나눠서 여러 쓰레드가 동시에 처리할 수 있도록 만들어줌.
JDK
1.7부터 추가됨
작업의 종류에 따라 2가지 클래스 중 하나를 상속받아 구현
RecursiveAction
: 반환값이 없는 작업을 구현할 때
RecursiveTask
: 반환값이 있는 작업을 구현할 때
2가지 모두 compute()
라는 추상메서드를 갖고 있는데, 상속 후 이 메서드를 구현하는 것이 핵심
ex)
class SumTask extends RecursiveTask<Long> {
long from, to;
SumTask(long from, long to) {
this.from = from;
this.to = to;
}
public Long compute() {
// ...수행할 작업의 내용으로 구현
}
}
그리고 나서 쓰레드풀과 작업 인스턴스를 생성하고, invoke()
로 작업을 개시함.
Thread
를 시작할 때run()
이 아닌start()
을 쓰듯이, fork & join 프레임웍으로 수행할 작업도compute()
가 아닌invoke()
로 시작
ForkJoinPool pool = new ForkJoinPool();
SumTask task = new SumTask();
Long result = pool.invoke(task);
ForkJoinPool
fork & join 프레임웍에서 제공하는 thread pool.
지정된 수의 쓰레드(코어와 동일한 개수)를 생성해놓고, 계속해서 재사용할 수 있게 해줌.
쓰레드를 계속 생성해주지 않아도 되고, 과다한 쓰레드 생성으로 성능이 저하되는 문제를 방지해줌.
쓰레드가 수행할 작업이 담긴 queue를 제공
각 쓰레드는 자신의 작업 queue에 있는 작업을 순서대로 처리
수행할 작업의 내용과 함께 작업을 어떻게 작게 쪼갤 것인지도 정해야함.
ex) 1부터 n까지의 정수 합을 구하는 작업
from
부터to
까지의 합을 구하는sum
(long from, long to) 메서드가 따로 존재한다고 가정.
public Long compute() {
long size = to - from + 1;
if (size <= 5) {
return sum(); // 쪼개고 쪼개다가 더해야할 수가 5개 이하가 되면 더해서 결과를 반환
}
long half = (from + to) / 2;
SumTask leftSum = new SumTask(from, half);
SumTask rightSum = new SumTask(half + 1, to);
leftSum.fork(); // 작업 큐에 넣음
return rightSum.compute() + leftSum.join();
}
주어진 범위를 반으로 쪼갠 다음
좌측 절반은 작업 큐에 넣고, 우측 절반은 이 쪼개기 작업을 재귀적으로 계속하게 만듦. (size
가 5 이하가 될 때까지)
fork()
가 호출되어 작업 큐에 추가된 작업 또한 compute()
에 의해 더 이상 쪼개질 수 없을 때까지 반복해서 쪼개지고,
작업 큐가 비어있는 쓰레드는 이 과정에서 다른 쓰레드의 작업 큐에서 작업을 가져와 수행함. 이를 작업 훔쳐오기(work stealing)라고 부름.
fork()
: 작업을 쓰레드의 작업 큐에 넣음 (비동기 메서드)
join()
: 작업 수행이 끝날 때까지 기다렸다가 결과를 반환함 (동기 메서드)
비동기 메서드는 호출 후 결과를 기다리지 않고 다음 문장으로 넘어감.
class ForkJoinExample {
static final ForkJoinPool pool = new ForkJoinPool();
public static void main(String[] args) {
long from = 1L, to = 100_000_000L;
SumTask task = new SumTask(from, to);
long start = System.currentTimeMillis();
Long result = pool.invoke(task);
System.out.println("8 Core: " + (System.currentTimeMillis() - start) + "ms");
System.out.println(result);
result = 0L;
start = System.currentTimeMillis();
for (long i = from; i <= to; i++) {
result += i;
}
System.out.println("1 Core: " + (System.currentTimeMillis() - start) + "ms");
System.out.println(result);
}
}
class SumTask extends RecursiveTask<Long> {
long from, to;
SumTask(long from, long to) {
this.from = from;
this.to = to;
}
public Long compute() {
long size = to - from + 1;
if (size <= 5) {
return sum();
}
long half = (from + to) / 2;
SumTask leftSum = new SumTask(from, half);
SumTask rightSum = new SumTask(half + 1, to);
leftSum.fork();
return rightSum.compute() + leftSum.join();
}
long sum() {
long tmp = 0L;
for (long i = from; i <= to; i++) {
tmp += i;
}
return tmp;
}
}
실행 결과)
8 Core: 576ms
5000000050000000
1 Core: 304ms
5000000050000000
8코어 멀티 쓰레드로 돌린 작업이 더 오래걸린 이유는, compute()
로 작업을 쪼개고 다시 결과를 합치는 시간이 추가적으로 들기 때문.
이 경우에는 싱글 쓰레드에서 단순 for
문으로 총합을 구하는게 더 빠름
따라서 멀티 쓰레드가 항상 빠른 것은 아니고, 테스트 결과와 상황에 맞춰 사용해야함.