인생을 코딩하다.

자바 병렬 프로그래밍 - 스레드 안전성(Thread Safe) (2) 본문

Java

자바 병렬 프로그래밍 - 스레드 안전성(Thread Safe) (2)

Hyung1 2021. 11. 18. 01:13
728x90
반응형

스레드 안전성이란 무엇일까요?

여러 스레드가 클래스에 접근할 때, 실행 환경이 해당 스레드들의 실행을 어떻게 스케줄하든 어디에 끼워 넣든, 호출하는 쪼에서 추가적인 동기화나 다른 조율 없이도 정확하게 동작하면 해당 클래스는 스레드 안전(Thread-Safe) 하다고 말할 수 있습니다. 간단히 말해서 여러 스레드가 클래스에 접근할 때 계속 정확하게 동작하면 해당 클레스는 스레드에 안전하다고 말할 수 있습니다.

 

특히, 멀티 스레드를 사용할때 스레드 안전성을 잘 고려하여 설계해야합니다. 객체 지향 프로그램에서 멀티 스레드를 잘 활용하면 훨신 좋은 성능의 프로그램을 설계할 수 있지만, 멀티 스레드를 활용할떄는 주의할 점이 있습니다.

여러 스레드가 변경할 수 있는 하나의 상태 변수를 적절한 동기화 없이 접근하면 그 프로그램은 잘못된 것입니다.

이렇게 잘못된 프로그램을 고치는 데는 세가지 방법이 있습니다.

  • 해당 상태 변수를 스레드 간에 공유하지 않거나
  • 해당 상태 변수를 변경할 수 없도록 만들거나
  • 해당 상태 변수에 접근할 땐 언제나 동기화를 사용해야 합니다. 

클래스를 설계하면서 애당초 동시 접근을 염두에 두지 않았다면, 뒤늦게 위 세 가지 방법 중 일부를 적용하고자 할 때 설계를 상당히 많이 고쳐야할 가능성이 높고, 한 마디로 소개한 것처럼 쉬운 작업이 아닐 수도 있습니다. 프로그램의 규모가 커지면 특정 변수를 여러 스레드에서 접근하는지 파악하는 일 조차 간단치 않을 수 있기 때문이죠.

 

스레드 안정성을 확보하기 위해 나중에 클래스를 고치는 것보다는 애당초 스레드에 안전하게 설계하는 편이 훨씬 쉽습니다.

스레드 안전한 클래스를 설계할 떈, 바람직한 객체 지향 기법이 왕도입니다. 캡술화와 불변 객체를 잘 활용하고, 불변 조건을 명확하게 기술해야 합니다.

제일 기초적이고 핵심이 되는 것은 캡슐화를 잘 사용하는 것입니다. 객체 지향 프로그래밍 기법에서 사용하는 캡슐화는 스레드에 안전한 클래스를 작성하는데 도움이 될 수 있습니다. 

 

특정 변수에 접근하는 코드가 적을수록 적절히 동기화가 사용됐는지 확인하기 쉬우며 어떤 조건에서 특정 변수에 접근하는지도 판단하기 쉽습니다. 하지만 꼭, 자바 언어에서는 상태를 반드시 캡슐화 해야하는 것은 아닙니다. 상태를 public 필드나 심지어 public static 필드에 저장해도 되고 내부 객체의 참조 값을 밖을 넘겨도 됩니다.

 

하지만 프로그램 상태를 잘 캡술화활수록 프로그램을 스레드에 안전하게 만들기 쉽고 유지 보수팀에서도 역시 해당 프로그램이 계속해서 스레드에 안전하도록 유지하기 쉽습니다.

 

캡슐화를 적절히 잘한 클래스는 스레드 안전성을 갖습니다. 스레드에 안전한 클래스 인스턴스에 대해서는 순차적이든 동시든 어떤 작업들을 행해도 해당 인스턴스를 잘못된 상태로 만들 수 없습니다. 스레드에 안전한 클래스는 클라이언트 쪼에서 별도로 동기화활 필요가 없도록 동기화 기능도 캡슐화 합니다.

만약 어떤 프로젝트에 투입이 됬는데, 코드들이 모두 객체지향적이지 않아서 코드 리팩토링을 통해 성능 최적화를 진행하면 좋겠지만, 그럴 시간이 없는 경우에는 어떡하는 것이 좋을까요?

때론 바람직한 객체 지향 설계 기법이 실세계의 요구사항과 배치되는 경우도 있습니다. 이런 경우에는 성능이나 기존 코드와의 호환성 때문에 바람직한 설계 원칙을 양보할 수 밖에 없습니다. 많은 개발자가 그렇게 생각하지는 않겠지만 추상화와 캡슐화 기법이 성능과 배치되기도 합니다. 

 

하지만 이런 경우 항상 코드를 올바르게 작성하는 일이 먼저이고, 그 다음 필요한 성능을 개선해야 합니다. 또 최적화는 성능측정을 해본 이후에 요구 사항에 미달될 때만 하는 편이 좋고, 실제와 동일한 상황을 구현해 성능을 측정하고, 예상되는 수치가 목표 수치와 차이가 있을 때만 적용하는 것이 좋은 판단일 것입니다. 뭐 물론, 시간이 많다면 성능 최적화하는데 시간을 많이 투자하는 것이 최고의 방법이겠죠?

보통 스레드 안전성을 보장하는 코드를 작성하기 위해 싱글톤 패턴을 사용하곤 합니다. 하지만 싱글톤 패턴은 객체가 단 1개만 생성되는 것을 보장할까요? 

자바에서 싱글톤을 구현하는 대표적인 몇 가지 패턴들에 관해 멀티스레드 환경의 관점에서 분석해 보았습니다. 

 

싱글톤 패턴의 기본적인 구현 방법입니다.

public class Singleton {

    private static Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {

      if (instance == null){
          instance = new Singleton();
      }
      return instance; 
    } 
}

getInstance() 를 여러 번 호출 해도 단 하나의 동일한 instance 만을 반환해 줍니다.

 

싱글톤 패턴의 중요한 두 가지는,

  1. 생성 패턴은 시스템이 어떤 Concrete Class를 사용하는지에 대한 정보를 캡슐화합니다.
  2. 생성 패턴은 이들 클래스의 인스턴스들이 어떻게 만들고 어떻게 결합하는지에 대한 부분을 완전히 가려줍니다.
  3. 동일한 instance를 반환해 줍니다.

쉽게 말해, 생성자를 private하게 만들어 클래스 외부에서는 인스턴스를 생성하지 못하게 차단하고, 내부에서 단 하나의 인스턴스를 생성하여 외부에는 그 인스턴스에 대한 접근 방법을 제공할 수 있습니다.

 

하지만 위의 코드는 멀티쓰레드 환경에서 Thread-safe하지 않습니다. 

instance == null

조건문에서 위와 같은 조건 때문에 멀티스레드 환경에서 동시에 실행됬을 때, instance를 두 번 생성될 수도 있습니다. 그래서 동일하지 않은 instance가 반환될 수 있습니다. 어떻게 해결해야 할까요?

Synchronized 사용

public class Singleton {
    private static Singleton instance;

    private Singleton() {}

    public static synchronzied Singleton getInstance() {
      if(instance  == null) {
         instance  = new Singleton();
      }
      return instance;
    }
}

여기서 우리는 Synchronzied를 적용하여 멀티 스레드 환경에서 getInstance() 함수 호출 시에 발생할 수 있는 동시성 문제를 해결 할 수 있습니다.

 

하지만 문제점이 있습니다.

 

둘 이상의 클라이언트 요청이 있을 시에, getInstance 메서드에 Synchronzied를 적용했기 때문에 쓰레드에 Lock가 걸립니다. 그럼 다른 쓰레드는 Lock이 풀릴때까지 기다려야 합니다.

 

이것은 성능 저하를 불러옵니다. 조금 더 나은 성능을 보장 할 수 있는 방법이 있습니다.

dclp (double checked locking pattrn)

double-checked locking 은 null check 와 같은 부분을 synchronized 밖으로 빼서synchronized 를 기다리지 않고 처리하게 만들어 줍니다.

public class Singleton {
    private static DoubleCheckedSingleton instance;

    private DoubleCheckedSingleton() {}
    
    public DoubleCheckedSingleton getInstance() {

     if(instance == null) {
         synchronized(Singleton.class) {
            if(instance == null) {
               instance = new DoubleCheckedSingleton(); 
            }
         }
      }
      return instance;
    }
}

getInstace 메서드에 Synchronzied를 사용하지 않고 instance가 null인지 확인한 후, null이라면 그때 Synchronzied를 적용하여 스레드를 동기화해서 instance가 null인지 다시 체크하는 방법법입니다.

 

밖에서 하는 체크는 이미 인스턴스가 생성된 경우 빠르게 인스턴스를 리턴하기 위함이고, 안에서 하는 체크는 인스턴스가 생성되지 않은 경우 단 한개의 인스턴스만 생성되도록 보장하기 위함입니다.

 

lazy하다는 생각이들고 getInstace 메서드에 Synchronzied를 사용한 것보다는 성능 저하를 어느정도 개선할 수 있다고 생각합니다.

 

하지만 이것도 다른 인스턴스를 생성할 수 있는 문제점이 있다.

 

우리가 volatile를 쓰지 않았을떄의 문제점을 생각해보면 됩니다. volatile에 관해 모른다면 여기를 참고해주시면 됩니다.

 

멀티쓰레드 어플리케이션에서의 non-volatile 변수에 대한 작업은 성능상의 이유로 CPU 캐시를 이용합니다. 둘 이상의 CPU가 탑제된 컴퓨터에서 어플리케이션을 실행한다면, 각 쓰레드는 변수를 각 CPU의 캐시로 복사하여 읽어들입니다. 

non-volatile 변수에 대한 작업은 JVM 이 메인 메모리로부터 CPU 캐시로 변수를 읽어들이거나, CPU 캐시로부터 메인 메모리에 데이터를 쓰거나 할 때에 대한 어떠한 보장도 하지 않습니다.

  • volatile 변수를 사용하고 있지 않는 MultiThread 어플리케이션에서는 Task를 수행하는 동안 성능 향상을 위해 Main Memory에서 읽은 변수 값을 CPU Cache에 저장하게 됩니다.
  • 만약에 Multi Thread환경에서 Thread가 변수 값을 읽어올 때 각각의 CPU Cache에 저장된 값이 다르기 때문에 변수 값 불일치 문제가 발생하게 됩니다.
public class SharedObject {

    public int counter = 0;

}

둘 이상의 쓰레드가 다음과 같은 공유 객체로 접근하는 경우를 생각해보도록 하겠습니다.

 

Thread1 은 counter 변수를 증가시키고, Thread1 과 Thread2 가 때에 따라서 counter 변수를 읽습니다.

 

만일 counter 변수에 volatile 키워드가 없다면, counter 변수가 언제 CPU 캐시에서 메인 메모리로 쓰일지(written) 보장할 수 없습니다. CPU 캐시의 counter 변수와 메인 메모리의 counter 변수가 다른 값을 가질 수 있다는 것입니다.

쓰레드가 변경한 값이 메인 메모리에 저장되지 않아서 다른 쓰레드가 이 값을 볼 수 없는 상황을 '가시성' 문제라고 합니다. 한 쓰레드의 변경(update)이 다른 쓰레드에게 보이지 않습니다.

 

counter 변수에 volatile 키워드를 선언한다면 이 변수에 대한 쓰기 작업은 즉각 메인 메모리로 이루어질 것이고, 읽기 작업 또한 메인 메모리로부터 다이렉트로 이루어질 것입니다.

 

이러한 문제는 volatile 키워드를 추가함으로써 해결할 수 있습니다.

public class SharedObject {
    public volatile int counter = 0;
}

volatile 키워드를 추가하게 되면 Main Memory에 저장하고 읽어오기 때문에 변수 값 불일치 문제를 해결 할 수 있습니다.

 

여기서 Main Memroy를 제외한 CPU Cache가 있는 부분을 Working Memory고 합니다.

 

이렇게 메인메모리와 스레드의 Working 메모리 간에 데이터의 이동이 있기 때문에 메인메모리와 Working 메모리간에 동기화가 진행되는 동안 빈틈이 생기게 됩니다.

 

volatile 을 사용하지 않는 Double Checked Locking 방법에서는 아래와 같은 문제들이 발생할 수 있습니다.

  • 첫번째 스레드가 instance 를 생성하고 synchronized 블록을 벗어남.
  • 두번째 스레드가 synchronized 블록에 들어와서 null 체크를 하는 시점에서,
  • 첫번째 스레드에서 생성한 instance 가 working memory 에만 존재하고 main memory 에는 존재하지 않을 경우
  • 또는, main memory 에 존재하지만 두번째 스레드의 working memory 에 존재하지 않을 경우
  • 즉, 메모리간 동기화가 완벽히 이루어지지 않은 상태라면
  • 두번째 스레드는 인스턴스를 또 생성하게 된다.

따라서, Double Checked Locking 으로 싱글톤 패턴 구현시 인스턴스를 레퍼런스하는 변수에 volatile 을 사용해줘야 합니다.

 

volatile 로 선언된 변수는 아래와 같은 기능을 하기 때문입니다.

  • 각 스레드가 해당 변수의 값을 메인 메모리에서 직접 읽어옵니다.
  • volatile 변수에 대한 각 write 는 즉시 메인 메모리로 플러시 됩니다.
  • 스레드가 변수를 캐시하기로 결정하면 각 read/write 시 메인 메모리와 동기화 됩니다.

그래서 DCLP에서

public class Singleton {
    private volatile static DoubleCheckedSingleton instance;

volatile 키워드를 선언해줘서 Thread-safe하게 사용할 수 있습니다.

LazyHolder 를 사용하는 싱글톤 패턴

public class Singleton {

    private Singleton() {}

    private static class LazyHolder() {
        private static final Singleton instance = new Singleton();
    }

    public static Singleton getInstance() {
        return LazyHolder.instance;
    }
    
}

private static class 인 LazyHolder 안에 instace 를 final 로 선언하는 방법입니다.

 

클래스는 Singleton 클래스가 Load 될 때에도 Load 되지 않다가 getInstance()가 호출됐을 때 비로소 JVM 메모리에 로드되고, 인스턴스를 생성하게 됩니다. 아울러 synchronized를 사용하지 않기 때문에 위에서 문제가 되었던 성능 저하 또한 해결됩니다

 

static이기 떄문에 초기화가 이루어집니다. LazyHolder 클래스가 초기화 되면서 instance 객체의 생성도 이루어지며, 이 과정에서 static이기 때문에 하나의 인스턴스만 생성되는 것을 보장해줍니다. 그리고 final로 선언했기 때문에 다시instance 가 할당되는 것 또한 막을 수 있습니다.

 

Synchronzied 를 사용하지 않아도 JVM 자체가 보장하는 원자성을 사용하여 Thread-Safe 하게 싱글톤 패턴을 구현할 수 있습니다.

 

JVM은 초기화과정이 sequential, non-concurrent 하므로, 추가적인 Synchronzied 를 사용하여 스레드 동기화를 더 할 필요가 없다는 걸 알 수 있습니다.

 

출처 : 

 

 

 

728x90
반응형
Comments