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(); 
    }
}

+ Recent posts