[Thread] 03.데이터의 공유와 동기화
이번 포스트에서는 스레드의 장점 중 하나인 데이터의 공유와 이와 연관된 동기화 문제를 살펴보자.
데이터 공유와 동기화
데이터 공유
멀티 스레드 프로그램의 장점 중 하나는 힙의 데이터를 공유한다는 점이다. 다음의 그림은 Account 라는 객체를 출금 스레드 2개와 입금 스레드 2개가 공유하고 있는 모습을 보여준다. 4개의 Account를 만들 필요 없이 하나의 객체가 사용되므로 효율적일 수 있다.
출금을 위한 뱅킹 시스템
Account 객체는 잔금인 balance와 출금, 입금을 위한 withdraw, deposite 메서드를 갖는다. 문제는 출금 과정이 복잡해서 주토피아에 나오는 나무늘보처럼 살짝 느리게 동작한다고 생각해보자.
package ch14.banking;
public class Account {
protected int balance;
public Account(int balance) {
this.balance = balance;
}
public int withdraw(int money) {
String threadName = Thread.currentThread().getName();
if (balance >= money) { // balance는 절대 음수가 될 수는 없겠다!
try {
Thread.sleep(100); // 약간 느리네 ㅠㅠ
} catch (InterruptedException e) {
e.printStackTrace();
}
balance -= money;
System.out.printf("threadName: %s, 잔액: %d\n", threadName, balance);
} else {
System.out.printf("threadName: %s, 잔액 부족 출금 불가\n", threadName);
}
return balance;
}
public int deposit(int money) {
return balance += money;
}
}
다음은 출금을 위한 스레드이다.
public class WithdrawThread extends Thread {
Account account;
int money;
boolean flag = true;
public WithdrawThread(String name, Account account, int money) {
super(name);
this.account = account;
this.money = money;
}
@Override
public void run() {
for (int i = 0; i < 6 || flag; i++) { // 최대 출금 회수와 flag 지정
int balance = account.withdraw(money);// 공유객체 활용
if (balance <= money) {
flag = false;
}
}
}
}
전혀 문제 없어 보이는 코드이다.
경쟁 조건(Race Condition) 문제
코드에는 문제가 없어 보이지만 스레드들은 자신들만의 스케줄로 동작하면서 공유 객체를 사용하다 보니 특정한 상태에서 문제가 될 수 있다. 예를 들어 다음의 상황을 생각해보자.
- 출금 스레드 1이 Account에 접근에서 100원의 잔금이 있음을 확인하고 20원을 출금해 80원으로 만들기로 한다. 하지만 느려서 아직 반영은 못한 생태이다.
- 이 상태에서 출금 스레드 2 역시 Account에 접근해서 100원의 잔금이 있음을 확인하고 60원을 출금 40원으로 만들기로 한다. 역시 아직 반영은 못했다.
- 이제 출금 스레드 1이 80원을 반영하고 나서 출금 스레드 2가 다시 40원으로 반영해버린다. 원래는 20원이 되어야 하는데..
public class BankingClient {
public static void main(String[] args) {
Account account = new Account(5000); // 이것이 공유 자원
WithdrawThread iBanking = new WithdrawThread("인터넷뱅킹", account, 1000);
WithdrawThread mBanking = new WithdrawThread("모바일뱅킹", account, 1000);
iBanking.start();
mBanking.start();
}
}
// 출력 예시
threadName: 인터넷뱅킹, 잔액: 3000
threadName: 모바일뱅킹, 잔액: 3000
threadName: 인터넷뱅킹, 잔액: 1000
threadName: 모바일뱅킹, 잔액: 1000
threadName: 인터넷뱅킹, 잔액: 0
threadName: 인터넷뱅킹, 잔액 부족 출금 불가
threadName: 인터넷뱅킹, 잔액 부족 출금 불가
threadName: 인터넷뱅킹, 잔액 부족 출금 불가
threadName: 모바일뱅킹, 잔액: -1000
threadName: 모바일뱅킹, 잔액 부족 출금 불가
실행해보면 매번 결과가 달라지고 코드를 잘 작성한 것 같은데 잔액이 '-'가 되는 상황이 발생하게 된다. 이처럼 여러 스레드가 동시에 같은 데이터에 접근할 때 시점이나 순서에 따라 결과가 달라지는 것을 경쟁조건(race condition)이라고 한다.
synchronized를 이용한 동기화 처리
MUTAX(Mutual Exclustion: 상호배제)를 통한 문제 해결
위와 같은 문제의 원인은 공유 데이터에 두 개의 스레드가 동시에 접근해서 상태를 변경하기 때문이다. 이를 해결할 수 있는 가장 좋은 정책은 한번에 하나씩만 접근하게 하는 것이다. 프로그래밍에서는 이를 위해 mutax라는 개념을 사용하는데 화장실을 사용하는 것과 동일하다.
일반적으로 화장실을 만들 때는 비용을 고려해서 만들고 구성원들이 공유해서 사용하는 자원이다. 공유자원이므로 동시에 여러 사람이 화장실에서 양치질할 수 있다. 하지만 혼자 써야하는 경우는 사용중이라고 표시해두면 독점적으로 사용할 수도 있다. 이런 개념을 상호 간(mutual) 배제(exclusion)형태라고 한다.
프로그래밍에서는 Mutual Exclusion을 줄여서 mutax라고 하고 이를 위해 객체가 가진 lock을 활용한다.
화장실 시스템 | 멀티 스레드 프로그래밍 | Banking 애플리케이션 예 |
사용자 | 스레드 | iBanking, mBanking |
화장실 | 공유 객체 | account |
혼자 사용해야할 시간 | 임계 영역(critical section) | account의 withdraw(), deposit() |
사용중 표시 | 객체의 락(lock) | account의 내장 lock |
표시 장치 설치 | synchronized 영역 설정 | synchronized void withdraw() 등 |
synchronized 키워드와 동작 방식
자바에서 mutax 개념을 적용하기 위해서는 synchronized 키워드를 사용한다. 모든 객체에는 내장된 lock이 있으며 "자원을 사용하기 위해서는 lock이 필요합니다."라는 의도를 전달하기 위해서 synchronized를 사용한다 .
공유자원인 객체에는 기본적으로 lock이라는 것이 있다. 그 자원의 임계 영역(여기서는 withdraw 메서드)은 동시에 하나의 스레드만 접근해야 안정적으로 동작할수 있다. 이를 위해 메서드에 synchronized 키워드를 추가해주면 준비는 끝이다.
예를들어 스레드 t1이 처음 withdraw에 접근하게 되면 아직 lock을 사용하고 있는 스레드가 없기 때문에 문제 없이 lock을 점유하고 메서드를 사용한다. 이 와중에 두 번째 스레드 t2가 withdraw에 접근하면 lock을 얻을 수 없기 때문에 사용이 거부된다. 어느 순간 t1이 작업을 마치고 lock을 반환하면 비로서 t2가 lock을 얻어 작업을 진행할 수 있다.
어차피 lock이 없어서 동작하지 못하는 t2가 계속 임계 영역에 기웃거릴 필요는 없다. 이 경우 t2는 t1의 작업이 끝날 때까지 조용한 곳에서 대기 하는데 이를 lock pool이라고 한다. t1이 작업을 끝내고 lock pool에 대기중인 스레드들에게 키의 반납 사실을 통보하면 대기중이던 스레드들이 다시 runnable 상태로 이동하게 된다.
이처럼 synchronized는 설정된 임계 영역에 대해 한 번에 하나의 스레드만 동작할 수 있게 한다. 따라서 공유데이터에 대한 동기화 처리는 가능하지만 멀티스레드의 장점인 병렬성은 일보 후퇴해서 성능상 불이익이 있을 수 있다. 이와 관련된 이야기로 Vector나 Hashtable과 같은 초창기의 컬렉션들과 ArrayList, HashMap같은 이후의 컬렉션들의 특성을 살펴볼 필요가 있다.
초창기의 컬렉션들은 멀티스레드에 대해서 안전하게 설계되어있지만 성능이 좋지 않다. 이후의 컬렉션들은 정확히 그 반대인데 그 이유가 바로 synchronized에 있다. Vector등의 경우에는 과도하게 synchronized가 삽입되어있고 ArrayList등의 경우는 synchronized가 없다.
/*Vector의 size()*/
public synchronized int size() {
return elementCount;
}
/*ArrayList의 size()*/
public int size() {
return size;
}
그렇다면 멀티 스레드에서 컬렉션을 어떻게 써야할지에 대한 궁금증이 생겨야 하는 것도 당연한 이야기이다.
synchronized 설정 방법
synchronized는 메서드 선언부에 선언하거나 block 형태로 사용할 수 있다. 소소하게 차이점을 생각해보면 메서드 내에 공유데이터와 연관된 작업이 있고 그렇지 않은 작업이 있을 때 메서드 선언부에 synchronized를 선언하면 공유데이터와 무관한 작업까지 동기적으로 처리되므로 성능상 약간의 손해가 있을 수 있다 반면 synchronized block의 경우에는 번거롭지만 동기 처리되는 영역을 최소화 할 수 있다.
public synchronized void method(){
// do something - 공유 데이터와 연관된 작업
// do something - 공유 데이터와 무관한 작업
}
public void method(){
synchronized(this){
// do something - 공유 데이터와 연관된 작업
}
// do something - 공유 데이터와 무관한 작업
}
여기서는 메서드의 전체 영역이 임계 영역이기 때문에 메서드에 synchronized를 적용해보자.
public class SynchronizedAccount extends Account {
public SynchronizedAccount(int balance) {
super(balance);
}
public synchronized int withdraw(int money) {
String threadName = Thread.currentThread().getName();
if (balance >= money) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
balance -= money;
System.out.println(threadName + " 출금: 잔액: " + balance);
} else {
System.out.println(threadName + ": 잔액 부족 출금 불가");
}
return balance;
}
public synchronized int deposit(int money) {
return balance += money;
}
}
// 출력 예시
인터넷뱅킹 출금: 잔액: 4000
인터넷뱅킹 출금: 잔액: 3000
인터넷뱅킹 출금: 잔액: 2000
인터넷뱅킹 출금: 잔액: 1000
인터넷뱅킹 출금: 잔액: 0
인터넷뱅킹: 잔액 부족 출금 불가
모바일뱅킹: 잔액 부족 출금 불가
모바일뱅킹: 잔액 부족 출금 불가
모바일뱅킹: 잔액 부족 출금 불가
모바일뱅킹: 잔액 부족 출금 불가
모바일뱅킹: 잔액 부족 출금 불가
모바일뱅킹: 잔액 부족 출금 불가
SynchronizedAccount를 사용하면 마이너스 통장 문제는 발생되지 않지만 아쉬운 점이 있다. 위의 예에서 모바일 뱅킹을 잔액 부족으로 어차피 출금을 못하는데 왜 계속 동작하는 것일까? 뭔가 비효율적이다.
wait와 notify를 통한 효율성 제고
기존 시스템의 비 효율성
이제 마지막으로 뱅킹 시스템의 효율을 개선해볼 때다.
두 개의 출금 스레드가 출금하는 금액을 각각 다르게 설정해보자. t1은 1000원을 출금하고 t2는 100원을 출금한다. 그런데 통장의 잔액은 500원이다!
만약 t1이 먼저 lock을 획득했다면 어떻게 될까? t2는 lock을 획득 못하니 lock pool로 이동했을 것이다. 하지만 정작 lock을 가진 t1도 잔액이 부족하니 출금을 못하기는 매한가지다. 다시 lock이 반환되고 t2로 lock pool에서 탈출하고 다시 t1, t2는 실행 순서를 경합하게 된다. 슬프게 또 t1이 선택된다면?
사실 이 상황에서 t1의 동작은 계좌에 충분한 입금으로 잔고가 채워질때 까지 의미가 없다. 그럼 어떻게 t1이 필요할 때만 동작할 수 있게 처리할 수 있을까?
wait와 notify
이때 사용할 수 있는 메서드가 wait()와 notify()이다.
wait()는 lock을 가진 스레드가 더 이상 작업을 수행할 수 없는 상황에서 lock을 풀고 대기 pool로 이동한다. 따라서 다른 스레드는 lock을 쉽게 획득할 수 있게 된다.
notify() 혹은 notifyAll()은 wait() 메서드 호출 결과 대기 풀로 이동했던 스레드들이 활동할 수 있는 조건이 되었을 때 호출하는 메서드 이다.
메서드 명 | 선언부와 설명 |
wait() | public final void wait() throws InterruptedException |
다른 스레드가 notify(), notifyAll()을 호출하기 전까지 현재 스레드를 WAITING 상태로 유지한다. | |
public final native void wait(long timeout) throws InterruptedException | |
다른 스레드가 notify(), notifyAll()을 호출하거나 timeout 시간이 지나기 이전까지 스레드를 WAITING 상태로 유지한다. | |
notify() | public final native void notify() |
이 객체의 락이 필요한 스레드 하나를 WAITING 상태에서 RUNNABLE로 변경한다. | |
notifyAll() | public final native void notifyAll() |
이 객체의 락이 필요한 모든 스레드를 WAITING 상태에서 RUNNABLE로 변경한다 |
일단 입금 처리를 위한 DepositThread를 추가해보자.
public class DepositThread extends Thread {
Account account;
int money;
public DepositThread(String name, Account account, int money) {
super(name);
this.account = account;
this.money = money;
}
public void run() {
try {
Thread.sleep(1000 * 5);
} catch (InterruptedException e) {
e.printStackTrace();
}
account.deposit(money);
}
}
그리고 기존의 Account에 wait()와 notify()를 적용해보자.
public class NotiAccount extends Account {
public NotiAccount(int balance) {
super(balance);
}
@Override
public synchronized int withdraw(int money) {
String threadName = Thread.currentThread().getName();
if (balance >= money) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
balance -= money;
System.out.println(threadName + ": 출금, 잔액: " + balance);
} else {
System.out.println(threadName + ": 잔액 부족 출금 불가로 wait 호출");
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return balance;
}
@Override
public synchronized int deposit(int money) {
String threadName = Thread.currentThread().getName();
balance += money;
this.notifyAll();
System.out.println(threadName + ": 입금, 잔액: " + balance);
return balance;
}
}
이제 애플리케이션을 실행시켜보면 필요할 때 스레드들이 잘 동작하는 것을 확인할 수 있다.
public class BankingClient3 {
public static void main(String[] args) {
Account account = new NotiAccount(5000);
WithdrawThread iBanking = new WithdrawThread("인터넷뱅킹", account, 1000);
WithdrawThread mBanking = new WithdrawThread("모바일뱅킹", account, 1000);
DepositThread tBanking = new DepositThread("텔레뱅킹", account, 5000);
iBanking.start();
mBanking.start();
tBanking.start();
}
}
// 출력 예시
인터넷뱅킹: 출금, 잔액: 4000
인터넷뱅킹: 출금, 잔액: 3000
인터넷뱅킹: 출금, 잔액: 2000
인터넷뱅킹: 출금, 잔액: 1000
인터넷뱅킹: 출금, 잔액: 0
인터넷뱅킹: 잔액 부족 출금 불가로 wait 호출
모바일뱅킹: 잔액 부족 출금 불가로 wait 호출
텔레뱅킹: 입금, 잔액: 5000
모바일뱅킹: 출금, 잔액: 4000
모바일뱅킹: 출금, 잔액: 3000
모바일뱅킹: 출금, 잔액: 2000
모바일뱅킹: 출금, 잔액: 1000
모바일뱅킹: 출금, 잔액: 0
이제 쓸데 없이 방황하는 스레드들이 없어진 것을 확인할 수 있다.