- Published on
Java에서 Thread, Synchronization 1
- Authors
- Name
- Chan Sol OH
목차
- 멀티쓰레드?
- 쓰레드 구현과 실행
- run을 구현했는데 start하는 이유? - 호출스택
- 멀티쓰레드에서 예외가 발생한다면?
- 데몬 쓰레드(daemon thread)
- Thread 동작을 잠시 멈추기 - sleep
- 쓰레드와 동기화
- 임계영역 설정을 통한 동기화 - synchronization
- 데드락을 발생 상황
- 데드락을 방지하자 - wait과 notify
DB의 Locking과 Transaction 격리 수준을 테스트하기 위해 싱글쓰레드 방식의 테스트는 어려울 것 같아 멀티쓰레드에 관심이 생겨서 이렇게 멀티쓰레드의 개념과 구현 방법을 정리한다.
물론 멀티쓰레드는 한번에 여러 사용자의 요청을 처리하는 웹 서버에 없어선 안될 중요한 기술이기 때문에 더 탄탄한 기본 바탕을 위함도 있다.
멀티쓰레드?
프로세스(process)란 실행 중인 프로그램
이다. 프로세스는 여러 쓰레드에게 메모리와 데이터 등 자원을 제공하고 작업을 시키는데 이를 멀티쓰레드 프로세스
라고 한다.
CPU core 한개는 동시에 하나의 작업을 수행할 수 있다. 그렇기에 core 수와 수행하는 작업의 수가 동일하다. 하지만, 코어가 아주 짧은 시간 동안 여러 작업을 번갈아 수행함으로써 여러 작업들을 동시에 수행할 수 있는 것처럼 보인다.
CPU가 멀티쓰레드를 수행하면 여러 쓰레드가 프로세스 내에세 자원을 공유하면서 걸리는 시간이나 동기화 문제 그리고 교착상태(deadlock) 같은 문제가 있음에도 왜 멀티쓰레드를 수행할까?
이유는 CPU 사용률과 사용자에 대한 응답성에 있다. 만약 한 작업을 하고 CPU가 기다려야하는 상황에 멀티쓰레드가 아니라면 CPU는 다른 작업을 하지 못하고 멈춰서 CPU 사용률이 낮아지게 된다. 즉 CPU를 쉴세 없이 계속 일을 시키도록하는 것이다. 또한 싱글쓰레드는 가벼운 작업을 시작하려고 해도 무거운 작업을 먼저 시작했으면 다 끝날 때까지 기다려야한다. 하지만, 멀티쓰레드는 여러 작업을 번갈아가면서 수행하기 때문에 가벼운 작업이 끝나는 시점은 싱글쓰레드 보다 더 이를 것이다. 이는 사용자에 더 빠른 응답성을 가져다준다.
쓰레드 구현과 실행
Java에서 쓰레드를 구현하는 방법은 아래와 같이 Runnable
인터페이스의 run
메서드를 구현하고 이 객체를 Thread
클래스의 생성자에 넣어 start
해주면 run 메서드 안에 있는 작업을 쓰레드가 수행한다.
public class ThreadEx implements Runnable{
@Override
public void run() { // Thread가 수행할 작업은 run 메서드 안에 작성
for(int i=0; i<10000; i++){
System.out.println(Thread.currentThread().getName());
}
}
}
public class LockApplication {
public static void main(String[] args) {
Runnable r = new ThreadEx();
Thread exe1 = new Thread(r);
Thread exe2 = new Thread(r);
exe1.start();
exe2.start();
}
}
...
// Thread-0
// Thread-0
// Thread-0
// Thread-1
// Thread-1
// Thread-1
// Thread-0
...
위 코드를 실행해보면 Thread-0
과 Thread-1
가 번갈아가면서 출력되는 것을 확인할 수 있다. 이는 싱글쓰레드 프로그램과 달리 exe1
과 exe2
이 순서대로 동작하지 않음을 알 수 있다.
run을 구현했는데 start하는 이유? - 호출스택
쓰레드를 실행할 때 run()
이 아닌 start()
를 호출하는 이유는 main 메서드에서 run()
을 호출하는 것은 쓰레드를 시작하는 것이 아니고 단순히 선언된 메서드를 호출할 뿐이다. 반면 start()
는 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택(call stack)을 생성한 다음 run()
을 호출해서, 생성된 호출스택에 run()
이 첫 번째로 올라가게된다.
모든 쓰레드는 작업을 수행하기 위한 자신만의 호출스택을 필요로 하기 때문에, 새로운 쓰레드를 생성하고 실행시킬 때마다 새로운 호출스택이 생성되고 쓰레드가 종료되면 호출스택은 소멸된다.
호출스택에서는 가장 위에 있는 메서드가 현재 실행 중인 메서드이고 나머지 메서드는 대기상태에 있다. 그러나 멀티쓰레드로 인해 여러 호출스택을 생성한 경우 맨 위에 있더라도 다른 호출스택 맨위에 있는 메서드를 실행하느라 대기상태에 있을 수 있다. 각 호출스택의 메서드들을 실행하는 순서는 스케줄러가 우선순위를 고려하여 결정한다.
start()
로 호출스택이 생성되고 run()
의 실행이 끝나면 호출스택이 소멸된다. 마치 main()
가 작업을 다하고 호출스택이 비워지면서 프로그램도 종료되는 것과 같다.
멀티쓰레드에서 예외가 발생한다면?
아래 그림처럼 멀티쓰레드에서 예외를 발생시켰다. 멀티쓰레드 실행은 이전과 동일하게 start()
를 호출해서 했다.
public class ThreadException implements Runnable{
@Override
public void run() {
try{
throw new Exception();
} catch (Exception e) {
e.printStackTrace();
}
}
}
java.lang.Exception
at com.solsol.lock.thread.ThreadException.run(ThreadException.java:7)
at java.base/java.lang.Thread.run(Thread.java:1589)
printStackTrace()
는 예외가 발생할 당시의 호출스택을 출력한다. 위 결과를 보면, 호출스택의 첫번째 메서드가 main
이 아니라 run()
임을 알 수 있다. 위 결과에서 main이 없는 이유는 main()
메서드의 호출스택은 main()
이 start()
메서드를 호출하고 소멸했기 때문이다.
데몬 쓰레드(daemon thread)
일반적으로 프로그램을 동작시키는 일반 쓰레드의 보조적인 역할을 수행하는 쓰레드이다. 그래서 일반 쓰레드가 모두 종료되면 데몬 쓰레드는 강제적으로 종료된다.
데몬 쓰레드는 무한 루프와 조건문을 이용해서 가비지 컬랙터, 화면자동갱신 등 작업을 실행 후 대기하고 있다가 특정 조건이 만족되면 작업을 실행하기를 반복한다.
데몬 쓰레드는 일반 쓰레드를 생성하는 방법으로 쓰레드를 생성한 다음 실행하기 전에 setDaemon(true)
를 호출하면 된다.
보통 데몬 쓰레드는 system 쓰레드 그룹
이나 main 쓰레드 그룹
에 속해서 가비지컬렉션, 이벤트 처리, 그래픽처리와 같은 보조작업을 수행한다.
Thread 동작을 잠시 멈추기 - sleep
아래 코드와 같이 try-catch
구문 안에 Thread.sleep
인 static 메서드를 실행시키면 해당 쓰레드가 잠시 작업을 멈추고 일시정지 상태가 된다. 일시정지 상태가 끝나고 CPU가 다른 쓰레드를 실행하는 동안 해당 쓰레드는 실행대기 상태가 되고 CPU가 해당 쓰레드를 실행하면 실행 상태가 되면 작업이 끝나면 소멸된다.
@Slf4j
public class ThreadSleep implements Runnable{
@Override
public void run() {
log.info("thread1 시작!");
try{
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.info("thread1 종료");
}
}
@Slf4j
public class ThreadNonSleep implements Runnable {
@Override
public void run() {
log.info("thread2 시작!");
log.info("thread2 종료");
}
}
@Slf4j
public class LockApplication {
public static void main(String[] args) throws Exception {
Runnable r = new ThreadSleep();
Runnable rn = new ThreadNonSleep();
Thread exe1 = new Thread(r);
Thread exe2 = new Thread(rn);
exe1.start();
exe2.start();
}
}
01:05:25.768 [Thread-1] INFO com.solsol.lock.thread.ThreadNonSleep -- thread2 시작!
01:05:25.768 [Thread-0] INFO com.solsol.lock.thread.ThreadSleep -- thread1 시작!
01:05:25.770 [Thread-1] INFO com.solsol.lock.thread.ThreadNonSleep -- thread2 종료
01:05:26.772 [Thread-0] INFO com.solsol.lock.thread.ThreadSleep -- thread1 종료
위 코드를 여러번 실행해도 **thread1 시작!**과 **thread2 시작!**의 순서만 바뀔 뿐 thread1 종료가 맨 마지막인 것은 동일하다. 그 이유는 thread2는 thread1과 달리 대기시간 없이 작업을 끝낼 수 있기 때문이다.
쓰레드와 동기화
만약 서로 다른 두 쓰레드가 하나의 자원을 동시에 업데이트하려면 어떻게 할까? 어느 쓰레드가 먼저 작업을 시작하나 끝내나에 따라 결과가 달라질 것이다. 멀티쓰레드끼리 자원을 공유하기 때문에 서로가 영향을 주게된다. 각 쓰레드끼리 영향을 줄이기 위해 임계영역(critical section)과 잠금(lack)이다. 아마 멀티쓰레드에서 나온 lock 개념을 데이터베이스에서 사용하는 것일 것이다.
공유 데이터를 사용하는 코드 영역을 임계영역으로 지정해놓고, 공유 데이터(객체)를 가져 lock을 획득한 쓰레드 이외에 다른 쓰레드는 간섭하지 못하도록 막는 것을 쓰레드의 동기화(synchronization)이라한다.
임계영역 설정을 통한 동기화 - synchronization
@Slf4j
public class ThreadSync implements Runnable{
Account acc = new Account();
@Override
public void run() {
while(acc.getBalance() > 0){
// 100, 200, 300 중의 한 값을 임의로 선택해서 출금
int money = (int) (Math.random()*3+1)*100;
acc.withdraw(money);
log.info("balance = "+acc.getBalance());
}
}
}
class Account{
private int balance = 1000;
public int getBalance(){
return balance;
}
public void withdraw(int money){
if(balance >= money){
try{
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
balance -= money;
}
}
}
@Slf4j
public class LockApplication {
public static void main(String[] args) throws Exception {
// 한 계좌를 출금하는 두 유저
Runnable r = new ThreadSync();
Thread user1 = new Thread(r);
Thread user2 = new Thread(r);
user1.start();
user2.start();
}
}
위 코드처럼 한 계좌를 두 유저가 출금한다고 가정한다 이럴 경우 두 유저 간의 계좌는 동기화되지 않기 때문에 아래 결과처럼 계좌 금액이 음수가 될 수 있다. 이러는 이유는 두 쓰레드가 계좌를 읽을 때는 양수라서 둘 다 출금을 수행했기 때문이다.
...
02:05:10.517 [Thread-1] INFO com.solsol.lock.thread.ThreadSync -- balance = 300
02:05:11.520 [Thread-0] INFO com.solsol.lock.thread.ThreadSync -- balance = 100
02:05:11.520 [Thread-0] INFO com.solsol.lock.thread.ThreadSync -- balance = 100
02:05:11.522 [Thread-1] INFO com.solsol.lock.thread.ThreadSync -- balance = -100
02:05:12.525 [Thread-0] INFO com.solsol.lock.thread.ThreadSync -- balance = -200
위와 같은 문제는 한 유저가 출금을 진행할 때 다른 유저가 출금하지 못하도록 막는 동기화를 통해 해결할 수 있다. 동기화 방법은 동기화하고 싶은 임계영역에 synchronized
를 붙이면 된다. 위 상황에서는 withdraw()
메서드 옆에 붙인다. 아래는 public void withdraw(int money)
를 public synchronized void withdraw(int money)
로 바꾼 결과이다.
...
02:10:57.354 [Thread-0] INFO com.solsol.lock.thread.ThreadSync -- balance = 300
02:10:58.359 [Thread-1] INFO com.solsol.lock.thread.ThreadSync -- balance = 200
02:10:59.364 [Thread-0] INFO com.solsol.lock.thread.ThreadSync -- balance = 100
02:11:00.368 [Thread-0] INFO com.solsol.lock.thread.ThreadSync -- balance = 0
02:11:00.368 [Thread-1] INFO com.solsol.lock.thread.ThreadSync -- balance = 0
이전 결과와 달리 계좌 금액이 0 밑으로 떨어지지 않고 정상적으로 종료된다. 하지만 계좌에 출금할 돈이 부족해서 한 쓰레드가 락을 보유한 채로 돈이 입금될 때까지 오랜 시간을 보낸다면, 다른 쓰레드는 모두 해당 객체의 락을 기다리느라 다른 작업을 못 할 것이다.
즉 락만하고 락을 풀지 않으면 데드락 상황이 발생할 수 있다.
데드락을 발생 상황
데드락 상황을 개선하기 위해 동기화된 임계 영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면, 일단 wait()
을 호출하여 쓰레드가 락을 반납하고 기다리게 한다. 그러면 다른 쓰레드가 해당 객체의 락을 얻어 작업을 수행할 수 있다. 기다렸던 쓰레드는 작업을 할 수 있는 상황이 되면 notify
이나 notifyAll
을 호출해 다시 락을 얻어 작업을 진행한다.
하지만 한번 wait
한 쓰레드가 계속 기다려도 락을 얻는다는 보장이 없다. 그렇기 때문에 쓰레드는 wait
에 지정된 시간동안만 기다린다.
아래 예시에는 고객
과 요리사
그리고 테이블
이 있다. 요리사는 주기적으로 요리를 만들어서 테이블에 추가하고, 고객은 주기적으로 테이블의 요리를 먹는다. 요리사와 고객이 공유하는 테이블은 추가와 제거로 요리를 넣고 먹기가 가능하다. 그리고 고객은 요리를 먹으려고 할 때 테이블에 요리가 없으면 요리가 채워질 때까지 기다린다.
@Slf4j
public class LockApplication {
public static void main(String[] args) throws Exception {
Table table = new Table(); // 고객과 요리사가 공유하는 객체
new Thread(new Chef(table), "Chef").start(); // 요리사가 테이블에 요리를 만들기 시작한다.
new Thread(new Customer(table, "donut"), "고객1").start(); // 도넛을 좋아하는 고객1이 테이블에 요리를 먹는다.
new Thread(new Customer(table, "burger"), "고객2").start(); // 버거를 좋아하는 고객2이 테이블에 요리를 먹는다.
Thread.sleep(5000); // 0.1초 후에 강제 종료한다.
System.exit(0); // 프로그램 종료하여 모든 쓰레드 종료
}
}
@Slf4j
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) {
log.warn(e.getMessage());
}
String name = Thread.currentThread().getName();
if(eatFood()) log.info(name+" eat a "+food);
else log.info(name+" failed to eat. ");
}
}
boolean eatFood(){return table.remove(food);}
}
@Slf4j
public class Chef implements Runnable{
private Table table;
public Chef(Table table){this.table = table;}
@Override
public void run() {
while(true){
// 임의의 요리를 하나 선택해서 table에 추가한다.
int idx = (int) (Math.random()*table.dishNumb());
table.add(table.dishName[idx]);
try{Thread.sleep(10);} catch (InterruptedException e) {
log.warn(e.getMessage());
}
}
}
}
@Slf4j
public class Table {
String[] dishName = {"donut", "donut", "burger"}; //donut을 더 자주 추가한다.
final int MAX_FOOD = 6; // 테이블에 놓을 수 있는 음식 수.
private ArrayList<String> dishes = new ArrayList<>();
public synchronized void add(String dish){
// 테이블에 음식이 가득찼으며 테이블에 음식을 추가하지 않는다.
if(dishes.size() >= MAX_FOOD) return;
dishes.add(dish);
log.info("Dishes: "+ dishes);
}
public synchronized boolean remove(String dishName){
while(dishes.size()==0){
String name = Thread.currentThread().getName();
log.info(name+" is wating.");
try{Thread.sleep(500);} catch (InterruptedException e) {}
}
// 지정된 요리와 일치하는 요리를 테이블에서 제거한다.
for(int i=0; i<dishes.size(); i++){
if(dishName.equals(dishes.get(i))){
dishes.remove(i);
return true;
}
}
return false;
}
public int dishNumb(){return dishName.length;}
}
위와 같이 요리사와 고객이 공유하는 테이블 객체에 상태를 바꾸는 add()
와 remove()
에는 동기화를 적용한 것을 볼 수 있다. 아래는 위 코드의 결과이다.
03:00:50.293 [Chef] INFO com.solsol.lock.thread.Table -- Dishes: [donut]
03:00:50.303 [고객2] INFO com.solsol.lock.thread.Customer -- 고객2 failed to eat.
03:00:50.306 [고객1] INFO com.solsol.lock.thread.Customer -- 고객1 eat a donut
03:00:50.306 [Chef] INFO com.solsol.lock.thread.Table -- Dishes: [donut]
03:00:50.316 [고객2] INFO com.solsol.lock.thread.Customer -- 고객2 failed to eat.
03:00:50.319 [Chef] INFO com.solsol.lock.thread.Table -- Dishes: [burger]
03:00:50.319 [고객1] INFO com.solsol.lock.thread.Customer -- 고객1 eat a donut
03:00:50.329 [고객2] INFO com.solsol.lock.thread.Customer -- 고객2 eat a burger
03:00:50.329 [Chef] INFO com.solsol.lock.thread.Table -- Dishes: [donut]
03:00:50.331 [고객1] INFO com.solsol.lock.thread.Customer -- 고객1 eat a donut
03:00:50.341 [고객2] INFO com.solsol.lock.thread.Table -- 고객2 is wating.
03:00:50.842 [고객2] INFO com.solsol.lock.thread.Table -- 고객2 is wating.
03:00:51.345 [고객2] INFO com.solsol.lock.thread.Table -- 고객2 is wating.
... 반복 후 프로그램 종료
결과를 분석하면 고객1이 마지막으로 음식을 먹고 고객2가 테이블에 음식이 채워질 때까지 기다린다. 그러나 public synchronized boolean remove(String dishName)
와 같이 동기화된 테이블 객체는 고객2에게 lock되어 요리사 쓰레드가 테이블에 접근할 수 없다.
결국 요리사는 계속 테이블 객체의 lock이 풀릴 때까지 기다리고, 고객2는 요리사가 음식을 채워넣기를 기다리는 데드락 상황이 발생하고 그 상황이 지속되고 프로그램은 종료된 것이다. 그리고 고객1도 요리사처럼 lock을 기다리면서 아무 작업도 하지 못한다.
위 문제는 고객2가 음식을 받지 못할 것을 확인하면 마냥 음식을 기다리는 것이 아니라 요리사에게 테이블을 양보했어야한다. 이를 위해 wait() & notify()
를 사용할 수 있다.
데드락을 방지하자 - wait과 notify
고객 쓰레드가 lock을 쥐고 기다리는 것이 아니라 wait()
으로 lock을 풀고 기다리다가 음식이 추가되면 notify()
로 통보 받고 다시 lock을 얻어 나머지 작업을 진행하게 할 수 있다. 이를 위해 Table
과 Customer
를 아래와 같이 수정할 수 있다.
@Slf4j
public class Table {
String[] dishName = {"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();
log.info(name + "is waiting.");
try{
wait(); //Chef에게 음식이 충분하니 기다리게 (lock 반납하게) 한다.
Thread.sleep(500);
} catch (InterruptedException e) {}
}
dishes.add(dish);
notify(); // 기다리던 고객에게 음식을 채웠음을 알린다.
log.info("Dishes: "+ dishes);
}
public synchronized void remove(String dishName){
String name = Thread.currentThread().getName();
while(dishes.size()==0){
log.info(name+" is wating.");
try{
wait(); // 고객에게 음식이 없으니 기다리게 (lock 반납하게) 한다.
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;
}
}
try{
log.info(name + " is waiting.");
wait(); // 원하는 음식이 없는 고객을 기다리게한다.
Thread.sleep(100);
} catch (InterruptedException e) {}
}
}
public int dishNumb(){return dishName.length;}
}
@Slf4j
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) {
log.warn(e.getMessage());
}
String name = Thread.currentThread().getName();
eatFood();
log.info(name+" eat a "+food);
}
}
void eatFood(){table.remove(food);}
}
03:34:29.719 [Chef] INFO com.solsol.lock.thread.Table -- Dishes: [donut, donut]
03:34:29.719 [고객1] INFO com.solsol.lock.thread.Customer -- 고객1 eat a donut
03:34:29.824 [고객2] INFO com.solsol.lock.thread.Table -- 고객2 is waiting.
03:34:29.825 [Chef] INFO com.solsol.lock.thread.Table -- Dishes: [donut, donut]
... 정상 작동
03:34:30.047 [고객1] INFO com.solsol.lock.thread.Table -- 고객1 is wating.
03:34:30.047 [Chef] INFO com.solsol.lock.thread.Table -- Dishes: [burger]
03:34:30.047 [고객2] INFO com.solsol.lock.thread.Customer -- 고객2 eat a burger
위 결과를 아래 그림으로 설명할 수 있다.
요리사가 음식을 추가할 때 요리사가 lock을 점유하고 고객은 테이블에 접근할 수 없다. 다만, 추가한 후 notify()
를 통해 고객에게 음식이 추가됐음을 알리고 요리사는 lock을 해제한다.
고객은 테이블에 음식이 있다면, 음식을 줄이고 notify()
로 요리사에게 작업을 시작하도록 요청하고 lock을 해제한다. 고객이 원하는 음식이 테이블에 없다면, 고객은 다음 음식 추가가 있을 때까지 wait()
로 기다린다. 또한 테이블에 음식이 없을 때, 고객은 다음 음식 추가가 있을 때까지 wait()
로 기다린다.
테이블에 음식 수가 꽉찬다면, 요리사에게 wait()
로 기다리게하여 lock을 해제한다. 고객은 lock을 받아 테이블에 접근할 수 있게되고, 만약 고객이 음식을 먹는다면 요리사의 wait이 풀려 lock을 얻고 다음 작업을 이어서 진행할 것이다.
위처럼 wait
과 notify
를 적절히 이용해서 세 쓰레드가 공통의 객체를 deadlock이 발생하지 않도록 동기화할 수 있다.