Multi thread programming 을 할 때 고려해야하는 문제는 다음과 같다:
- 경쟁 상태 (Race Condition)
- 데드락 (Deadlock)
- 예상하지 못한 결과의 출력
이 글에서는 원하는 리소스를 얻기 위해 경합하는 'Race Condition' 에서는 다루지 않고, 나머지 두 문제인 'Deadlock' 과 '예상하지 못한 결과의 출력' 에 대해서 자세하게 살펴보겠다.
1. 예상하지 못한 결과의 출력: 메모리 미스터리
다음과 같은 자바 코드를 실행한다고 생각해보자. 어떤 결과가 출력될까?
public class Puzzle {
static boolean answerReady = false;
static int answer = 0;
static Thread thread = new Thread() {
public void run() {
answer = 42;
answerReady = true;
}
}
static Thread thread = new Thread() {
public void run() {
if (answerReady) {
System.out.println("The meaning of life is: " + answer);
} else {
System.out.println("I don't know the answer");
}
}
}
}
결과는 이렇게 출력된다. The meaining of life is: 0
이상하지 않은가?
이런 일이 일어나는 이유는 '코드 재배치' 때문이다.
우리가 작성하는 자바 코드 한 줄 한 줄은 JVM 에서 실행될 때 여러 줄의 바이트코드 (Bytecode) 로 변경되어서 실행된다.
그리고 이 바이트코드 들은 순서대로 실행되지 않는다. JVM 수준에서, 하드웨어 수준에서 최적화 작업 때문에 실행되는 순서는 변경된다.
그렇지만 Single Thread 로 실행할 때는 이에 대해서 신경쓰지 않아도 된다. 항상 예상하던 대로 실행이 될 것이다.
그러나 Multi Thread 환경에서는 하나의 스레드가 코드 재배치된 바이트코드를 실행하고 있을 때, 다른 스레드는 이 바이트코드 중에서 일부만 실행된 상태 즉 올바르지 않은 상태의 값을 읽어와서 실행을 하기 때문에 기괴한 결과가 나오는 것이다.
이런 문제는 자바에서 잘 알려준 문제인 'Double Checked Locking' 이 잘 작동하지 않는 이유에 대해서도 설명 해줄 수 있다.
자바의 'Double Checked Locking' 은 싱글톤 객체를 안전하게 생성하는 방법으로 알려져있다.
하지만 이는 제대로 작동하지 않는다.
다음 코드는 'Double Checked Locking' 을 설명하는 코드이다.
public class DoubleCheckedLockingExample {
private static Something instance = null;
public Something getInstance() {
if (instance == null) {
synchronized(this) {
if (instance == null) {
instance = new Something();
}
}
}
return instance;
}
}
이 코드가 올바르게 작동하지 않는 이유도 '코드 재배치' 현상 때문이다.
처리 과정은 다음과 같을 수 있다:
- 두 스레드가 동시에
getInstance()
를 호출했고, 하나의 스레드가 Lock 을 얻어서Something
객체를 만들고 있다고 가정해보자. Something
객체를 만드는 코드는 크게 생성자 로직을 실행하는 부분과 객체의 레퍼런스를 할당하는 부분으로 나눠셔 보자.- 객체의 생성자 로직을 실행하기전에 객체의 레퍼런스를 먼저 할당하는 코드 재배치 현상이 발생하면, Lock 을 얻지 못한 다른 스레드에서는 올바르게 생성되지 않은 상태의
Something
객체를 얻는 문제가 생길 수 있다.
이와 같은 문제를 막으려면 Multi Thead 환경에서 동기화를 잘해야한다.
동기화를 할 땐 읽는 쪽과 쓰는 쪽 둘 다 해야한다. 읽는 쪽만 하면 안된다.
자바에서는 volitile
이라는 키워드를 통해서 메인 메모리에서 읽는 작업을 통해서 마지막 스레드가 쓴 최신 데이터 읽기를 보장해줄 수 있는데 이를 잘 쓸려면 쓰기 쪽에서도 충돌없이 잘해줘야한다.
2. 데드락 문제
Lock 을 사용할 경우에는 항상 데드락 발생을 유의해야한다.
데드락은 다음 4가지 조건이 모두 발생할 때 일어난다:
- 상호 배제(Mutual Exclusion): 시스템 내의 자원이 동시에 여러 프로세스에 의해 공유될 수 없다는 것을 의미한다.
- 점유와 대기(Hold and Wait): 프로세스가 최소한 하나의 자원을 점유하고 있으면서, 동시에 추가적인 자원을 기다리는 상태를 의미한다.
- 비선점(No Preemption): 한 번 프로세스에 할당된 자원은 그 프로세스가 자발적으로 방출할 때까지 다른 프로세스에 의해 선점(강제 회수)될 수 없는 것을 말한다.
- 순환 대기(Circular Wait): 시스템 내의 프로세스 집합에서, 각 프로세스는 순환적으로 다음 프로세스가 요구하는 자원을 점유하고 있는 상태를 의미한다.
즉 이 조건 중에 하나라도 깰 수 있다면 데드락은 발생하지 않는다.
그 중에서 '순환 대기(Circular Wait)' 조건은 해결할 수 있는 방식이 있다.
이는 어떠한 규칙대로 순서대로 Lock 을 요구하는 것이다.
예를 들면, 계좌 이체를 하기 위해서 계좌에 대한 Lock 을 잡을 때, 계좌의 Id 값이 낮은 것을 먼저 Lock 을 소유하도록 하는 것이다.
이렇게 하면 순환 대기 원칙이 깨져서 데드락을 발생시키지 않는다.
3. Lock 을 사용할 경우 주의해야 할 점
Lock 을 소유할 수 있는 객체가 외부 객체의 메소드를 호출할 경우에는 이 메소드 호출로 인해서 또 다른 Lock 을 요구해서 Deadlock/Race Codnition 이 생길 수 있음을 유의해야한다.
그러므로 이런 상황에서는 Lock 을 경합하지 않게 오로지 자신만의 전용 Lock 을 사용하도록 만들어줘야한다.
이를 위한 방법으로는 외부 메소드를 호출할 때 해당 객체를 clone()
해서 사용하는 것이다. (객체와 객체에 대한 Lock 은 1:1 관게니까)
예시는 다음과 같다:
// AS-IS: 기존 외부 메소드를 호출하는 코드
private synchronized void updateProgress(int n) {
for (ProgressListener listener : listeners) {
listener.onProgress();
}
}
// TO-BE: 외부 메소드를 안전하게 호출하도록 변경한 코드
private void updateProgress(int n) {
ArrayList<ProgressListener> listenersCopy;
synchronized(this) {
listenersCopy = (ArrayList<ProgressListener>) listeners.clone();
}
for (ProgressListener listener : listenersCopy) {
listener.onProgress();
}
}
'Concurrent Programming' 카테고리의 다른 글
syncrhonized 가 아닌 ReentrantLock 을 통한 동시성 제어 (0) | 2023.11.24 |
---|