자바 병렬 프로그래밍이라는 책으로 병렬 프로그래밍을 위한 기본 사항들에 대해서 알아보았다. 

 

 

싱글 스레드 ( Single Thread ) : 순차적으로 작업을 진행하는 것 

 

멀티 스레드 ( Multi Thread ) : 여러 작업을 동시에 진행할 수 있는 것 

 


기본사항에 대해서 알아보자 [더보기] 클릭

더보기

스레드 와 프로세스의 차이 

 아래의 이미지를 보면 프로세스는 211개 , 스레드는 2675개가 존재한다. 


프로세스와 스레드 

 프로세스는 컴퓨터에서 실행되고 있는 컴퓨터 프로그램으로 운영 체제로 부터 시스템 자원을 할당 받는 단위이다. 

 

 스레드는 프로세스 내에서 실행되는 여러 흐름의 단위를 이야기하고, 프로세스가 할당받은 자원을 실행하는 단위이다.

 

 

Context Switching


우리가 사용하는 프로그램들은 각기 프로세스를 가지고 있다. 한 프로세스가 CPU를 사용중일 때, 다른 프로세스가 CPU를 사용하기 위해, 이전의 프로세스 상태를 보관하고 새로운 프로세스 상태를 CPU에 적재하는 것을 Context Switching 이라고 하는데, 프로세스 단위의 Context Switching은 Data, Heap, Stack의 모든 것이 그대로 추가 된다. Process 간에는 데이터 공유가 불가능하다. 

 

Thread Context Switching 


Thread 간에도 지금 실행되고 있는 Thread에서 다른 Thread가 실행될 수 잇게 스케줄러가 현재 실행 중인 스레드를 잠시 멈추고, 이전 스레드의 Context를 저장하고 작업을 진행할 스레드의 Context를 저장하고 작업을 진행할 Thread의 Context를 읽어오는 작업을 한다. 프로세스와는 달리 Data와 Heap은 그대로 사용되어 공유하게 되고, Stack 만 추가되어 새로운 class 호출이 용이하다. 

 

- 자바 병렬 프로그래밍 책 중, 

 

   잘 설계된 병렬 프로그램은 스레드를 사용해서 궁극적으로 성능을 향상시킬 수 있다. 하지만 스레드를 사용하면 실행 중에 어느정도 부하가 생기는 것도 사실이다.  실행 중인 컨텍스트를 저장하고 다시 읽어 들여야 하며, 메모리를 읽고 쓰는 데 있어 지역성이 손실되고, 스레드를 실행하기도 버거운 CPU 시간을 스케쥴링하는데 소모해야한다. 또 스레드가 데이터를 공유할 때는 동기화 수단도 사용해야한다. 이런 동기화는 컴파일러 최적화를 방해하고, 메모리 캐시를 지우거나 무효화 하기도 한다.

  이를 우리는 멀티스레드 상에서 동작시에 발생할 수 있는 성능 위험이라고 이야기할 수 있다. 

 


멀티스레드와 관련된 사항 


  • 경쟁 조건 

경쟁조건은 상대적인 시점이나 또는 JVM이 여러 스레드를 교차해서 실행하는 상황에 따라 계산의 정확성이 달라질 때 나타난다. 다시 말하자면 타이밍이 딱 맞았을 때만 정답을 얻는 경우를 말한다.

 

경쟁 조건 vs 데이터 경쟁

 

 데이터 경쟁은 공유된 final이 아닌 필드에 대한 접근을 동기화로 보호하지 않았을 때 발생한다. 스레드가 다음에 다른 스레드가 읽을 수 있는 변수에 값을 쓰거나 다른 스레드가 마지막에 수정했을 수 있는 변수를 읽을 때 두 스레드 모두 동기화하지 않으면 데이터 경쟁이 생길 위험이 있다. 데이터 경쟁이 있는 코드는 자바 메모리 모델 하에선 유용한 정의된 의미가 없다. 모든 경쟁조건이 데이터 경쟁인 것 아니고, 모든 데이터 경쟁이 경쟁 조건인 것도 아니다. 하지만 경쟁조건이든 데이터 경쟁이든 병렬 프로그램을 예측할 수 없이 실패하게 만든다.

 

  • 가시성 ( 메모리 가시성 ) 

 메모리 상의 공유된 변수를 여러 스레드에서 서로 사용할 수 있게 하려먼 반드시 동기화 기능을 구현해야 한다. 공유 자원(변수)에 대해서는 반드시 적절한 동기화 작업이 필요하다.

 

 동기화 기능을 지정하지 않으면 컴파일러나 프로세서, JVM(자바 가상 머신) 등이 프로그램 코드가 실행되는 순서를 임의로 바꿔 실행하는 이상한 경우가 발생하기도 한다. 다시 말하자면, 동기화 되지 않은 상황에서 메모리 상의 변수를 대상으로 작성해둔 코드가 '반드시 이런 순서로 동작할 것이다' 라고 단정 지을 수 없다.

 

Thread와 CPU Cache / Main Memory


내장된 락을 적절히 활용하면 특정 스레드가 특정 변수를 사용하려 할 때 이전에 동작한 스레드가 해당 변수를 사용하고 난 결과를 상식적으로 예측할 수 있는 상태에서 사용할 수 있다. 락은 상호 배제 뿐만 아니라 정상적인 메모리 가시성을 확보하기 위해서도 사용된다. 변경 가능하면서 여러 스레드가 공유해 사용하는 변수를 각 스레드에서 각자 최신의 정상적인 값으로 활용하려면 동일한 락을 사용해 모두 동기화 시켜야 한다.

 

자바 병렬 프로그래밍 책 이미지 참조

 여러 스레드에서 접근할 수 있고 변경 가능한 모든 변수를 대상으로 해당 변수에 접근할 때는 항상 동일한 락을 먼저 확보한 상태여야 한다. 이 경우 해당 변수는 확보된 락에 의해 보호된다고 말한다. 모든 변경할 수 있는 공유 변수는 정확하게 단 하나의 락으로 보호해야한다. 유지보수 하는 사람이 알 수 있게 어느 락으로 보호하고 있는지를 명확하게 표시하라. 락을 활용함에 있어 일반적인 사용 예는 먼저 모든 변경 가능한 변수를 객체 안에 캡슐화 하고, 해당 객체의 암묵적인 락을 사용해 캡슐화한 변수에 접근하는 모든 코드 경로를 동기화 함으로써 여러스레드가 동시에 접근하는 상태에서 내부 변수를 보호하는 방법이다

public class TestLockProtect {
  private Lock lockForProtect = new Lock();
  private String targetValue = "";

  public String getTargetValue(){
    synchronized(this){
      targetValue = "Value";
      return targetValue;
    }
  }
}

public class TestLockProtect {
  private Lock lockForProtect = new Lock();
  private String targetValue = "";

  public String getTargetValue(){
    lockForProtect.lock();

    targetValue = "Value";

    lockForProtect.unlock();
    return targetValue;
  }
}

public class Lock {
  private boolean isLocked = false;

  public synchronized void lock() {
    throws InterruptedException{
      while(isLocked){
        wait();
      }
      isLocked = true;
    }
  
    public synchronized void unlock(){
      isLocked = false;
      notify();
    }
  }    
}            

// synchronized의 경우 내부 함수의  synchronized에 진입이 가능하다. 그 이유는 'this' 에 대한 동기화를 의미하기 때문이다. 
// 하지만 Lock에 대해서는 내부 함수 내의 lock에 대해서는 재진입이 불가능하다. 

 

  • 단일 동작과 복합동작

a = b ++; 를 봤을 때 동기화가 안되어 있다면 b를 증가하는 연산(a++)과 a에 대입하는 연산(a=...)이 두 개가 있으므로    단일 연산으로 볼 수 없다.
해당 연산이 동기화 되었다면 외부에서 성공과 실패로 나뉘므로 단일 연산이다.

 

가능하면 클래스 상태를 관리하기 위해 AtomicLog 처럼 스레드에 안전하게 이미 만들어져 있는 객체를 사용하는 편이 좋다. 스레드 안전하지 않는 상태 변수를 선언해두고 사용하는 것보다 이미 스레드 안전하게 만들어진 클래스가 가질 수 있는 가능한 상태의 변화를 파악하는 편이 휠씬 쉽고, 스레드 안전성을 더 쉽게 유지하고 검증할 수 있다.

 

 

  • 재진입성

스레드가 다른 스레드가 가진 락을 요청하면 해당 스레드는 대기 상태에 들어간다. 하지만 암묵적인 락은 재진입 가능하기 때문에 특정 스레드가 자기가 이미 획득한 락을 다시 확보할 수 있다. 재진입성은 확보 요청의 단위가 아닌 스레드 단위로 락을 얻는다는 것을 의미한다.

 

public class Reentrancy {
  public synchronized void getA(){
    System.out.println("a");
    // b 가 synchronized 로 선언되어 있지만 a 진입시 이미 락을 획득하였으므로, b를 호출할 수 있다. 
    b();
  }

  public synchronized void b(){
    System.out.println("b");
  }

  public static void main(String[] args){
    new Reentrancy().a();
  }
}

 

컴퓨터 프로그램 또는 서브 루틴에 재진입성이 있으면, 이 서브 루틴은 동시에(병렬) 안전하게 실행 가능하다. 즉 재진입이 가능한 루틴은 동시에 접근해도 언제나 같은 실행결과를 보장한다. 재진입이 가능하려면 함수는 다음 조건을 만족해야 한다.

  • 정적 (전역) 변수를 사용하면 안된다.
  • 정적 (전역) 변수의 주소를 반환하면 안된다.
  • 호출자가 호출시 제공한 매개변수만으로 동작해야한다.
  • 싱글턴 객체의 잠금에 의존하면 안된다.
  • 다른 비-재진입 함수를 호출하면 안된다.

 

  • 원자성

 CPU가 처리하는 하나의 단일 연산을 의미한다. 하나의 Thread에서 읽기와 쓰기, 다른 Thread에서는 읽기만 한다면, 원자성을 고려한 변수를 선언하고 싶은 경우, volatile 변수를 활용한다. ( 메모리 문제 고려해야함 )

 

 Volatile 변수는 약간 다른 형태의 좀더 약한 동기화 기능을 제공하는데, 다시 말해 volatile로 선언된 변수의 값을 바꿨을 때 다른 스레드에서 항상 최신 값을 읽어갈 수 있도록 해준다. 특정 변수를 선언할 때 volatile 키워드를 지정하면 컴파일러와 런타임 모두 '이 변수는 공유해 사용하고, 따라서 실행 순서를 재배치해서는 안된다'라고 이해한다. volatile로 지정된 변수는 프로세스의 레지스터에 캐시 되지도 않고, 프로세서의 외부 캐시에도 들어가지 않기 때문에 volatile 변수의 값을 읽으면 항상 다른 스레드가 보관해둔 최신의 값을 읽어갈 수 있다.

 

Volatile를 사용하기 적합할 때

 

 MultiThread 환경에서 하나의 Thread만 Read&Write를 하고 나머지 Thread가 read 하는 상황에서 가장 최신의 값을 보장합니다.
하지만 하나의 Thread에서만 write하더라도, 그 변수(메모리)에 대한 접근과 수정이 잦다면 다른 스레드에서 read할때 원자성이 보장되지 않을 것 같습니다.

 

Volatile를 사용하기 부적합할 때

 

 하나의 Thread가 아닌 여러 Thread에서 write를 하는 상황에서는 적합하지 않습니다.
이와 같은 경우에는 synchronized를 통해서 read & write의 원자성을 보장해야 합니다.

 

 

  • 스레드 한정

스택 한정 기법은 특정 객체를 로컬 변수를 통해서만 사용할 수 있는 특별한 경우의 스레드 한정 기법이라고 할 수 있다. 변수를 클래스 내부에 숨겨두면 변경상태를 관리하기가 쉬운데, 또한 클래스 내부에 숨겨둔 변수는 특정 스레드에 쉽게 한정시킬 수도 있다. 로컬 변수는 모두 암묵적으로 현재 실행 중인 스레드에 한정되어 있다고 볼 수 있다.

 

public int loadTheArk(Collection<Animal> candidates) {
  SortedSet<Animal> animals;
  int numPairs = 0;
  Animal candidate = null;

  // animals 변수는 메소드에 한정되어 있으며, 유출돼서는 안된다. 
  animals = new TreeSet<Animal> ( new SpeciesGenderComparator());
  animals.addAll(candidates)
  for ( Animal a : animals){
    if(candidate == null || !candidate.isPotentialMate(a))
      candidate = a;
    else {
      ark.load(new AnimalPair(candidate, a));
      ++numPairs;
      candidate = null;
    }
  }

  return numPairs;
}

 

  • Final에 대하여

final을 지정한 변수의 값을 변경할 수 없다. 물론 변수가 가리키는 객체가 불변 객체가 아니라면 해당 객체에 들어있는 값은 변경할 수 있다. final 키워드를 적절하게 사용하면 초기화 안정성을 보장하기 때문에 별다른 동기화 작업 없이 불변 객체를 자유롭게 사용하고 공유할 수 있다.

 

@Immutable 
class OneValueCache {
  private final BigInteger lastNumber;
  private final BigInteger[] lastFactors;

  public OneValueCache(BigInteger i, BigInteger[] factors){
    lastNumber = i;
    lastFactors = Arrays.copyOf(factors, factors.length);
  }

  public BigInteger[] getFactors(BigInteger i) {
    if ( lastNumber == null || !lastNumber.equals(i))
      return null;
    else 
      return Arrays.copyOf(lastFactors, lastFactors.leghth);
  }
}

불변 객체의 요구조건

 

  • 상태를 변경할 수 없어야 하고,
  • 모든 필드의 값이 final로 선언되어야 하며,
  • 적절한 방법으로 생성되어야 한다.

 

 

멀티스레드에 대한 스레드 안정성 


동기화를 처리할 수 있는 3가지 방식

만약 여러 스레드가 변경할 수 있는 하나의 상태 변수를 적절한 동기화 없이 접근하면 그 프로그램은 잘못된 것이다.
이렇게 잘못된 프로그램을 고치는 데는 세가지 방법이 있다.

 

  • 해당 상태 변수를 스레드 간에 공유되지 않거나
  • 해당 상태 변수를 변경할 수 없도록 만들거나
  • 해당 상태 변수에 접근할 때는 언제나 동기화

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

 

 여러 스레드가 클래스에 접근할 때, 실행 환경이 해당 스레드들의 실행을 어떻게 스케쥴하든 어디에 끼워 넣든, 호출하는 쪽에서 추가적인 동기화나 다른 조율 없이도 정확하게 동작하면 해당 클래스는 스레드 안전하다고 말한다.

 

 애당초 단일 스레드 환경에서도 제대로 동작하지 않으면 스레드 안전할 수 없다. 객체가 제대로 구현됬으면 어떤 일련의 작업도 해당 객체의 불변 조건이나 후조건에 위배될 수 없다.

 

 스레드 안전한 클래스는 클라이언트 쪽에서 별도로 동기화할 필요가 없도록 동기화 기능도 캡슐화 한다.

// 상태가 없는 항상 안전한 객체 
// 상태 없는 객체에 접근하는 스레드가 어떤 일을 하든 다른 스레드가 수행하는 동작의 정확성에 영향을 끼칠 수 없기 때문에 객체는 항상 스레드에 안전하다. 
@ThreadSafe
public class StatelessFactorizer implements Servlet {
  public void service(ServletRequest req, SevletRespones resp){
    BigInteger i = extractFromRequest(req);
    BigInteger[] factors = factor(i);
    encodeIntoResponse(resp, factors);
  }
}

 

안전한 공개 방법의 특성

 

 객체를 안전하게 공개하려면 해당 객체에 대한 참조와 객체 내부의 상태를 외부의 스레드에게 동시에 볼 수 있어야 한다. 올바르게 생성 메소드가 실행되고 난 개체는 아래와 같이 처리할 수 있다.

 

  • 객체에 대한 참조를 static 메서드로 초기화 시킨다.
  • 객체에 대한 참조를 volatile 변수 또는 AtomicReference 클래스에 보관한다.
  • 객체에 대한 참조를 올바르게 생성된 클래스 내부의 final 변수에 보관한다.
  • 락을 사용해 올바르게 막혀 있는 변수에 객체에 대한 참조를 보관한다.

HashTable, ConcurrentMap, synchronizedMap을 사용해 만든 Map 객체를 사용하면 그 안에 보관하고 있는 키와 값 모두를 어느 스레드에서라도 항상 안전하게 사용할 수 있다.

 

  • 스레드 한정
  • 읽기 전용 개체를 공유
  • 스레드에 안전한 객체를 공유
  • 동기화 방법 적용

스레드 안전한 클래스 설계

 

 클래스가 스레드 안정성을 확보하도록 설계하고자 할 때에는 아래를 고려해야한다.

  • 객체의 상태를 보관하는 변수가 어떤 것인가?
  • 객체의 상태를 보관하는 변수가 가질 수 있는 값이 어떤 종류, 어떤 범위에 해당하는가?
  • 객체 내부의 값을 동시에 사용하고자 할 때, 그 과정을 관리할 수 잇는 정책
/*
  primitive type을 사용할 경우 아래와 같이 객체의 상태를 완벽하기 동기화 처리 가능하다.                        
*/
@ThreadSafe 
public final class Counter {
  @GuardedBy("this") private long value = 0;

  public synchronized long getValue() {
    return value;
  }

  public synchronized long increment() {
    if ( value == Long.MAX_VALUE ) 
      throw new IllegalStateException("counter overflow");
      return ++value;
  }
}
      

인스턴스 한정

 

객체를 적절하게 캡슐화하는 것으로도 스레드 안정성을 확보할 수 있는데, 이런 경우 흔히 '한정' 이라고 단순하게 부르기도 하는 '인스턴스 한정' 기법을 활용하는 셈이다.

@ThreadSafe
public class PersonSet {
  @GuardedBy("this")
  private final Set<Person> mySet = new HashSet<Person>();

  public synchronized void addPerson(Person p) {
    mySet.add(p);
  }

  public synchronized boolean containsPerson(Person p) {
    return mySet.contains(p);
  }
}                          
  

스레드의 안정성을 확보하는 방법으로 대부분 데코레이터(장식자) 패턴을 활용한다.

 


자바 모니터 패턴

 

자바 모니터 패턴을 따르는 객체는 변경 가능한 데이터를 모두 객체 내부에 숨긴 다음 객체의 암묵적인 락으로 데이터에 대한 동시 접근을 막는다. 자바 모니터 패턴은 단순한 관례에 불과하며 일정한 형태로 스레드 안전성을 확보할 수 있다면 어떤 형태의 락을 사용해도 무방하다.

 

public class PrivateLock {
  private final Object myLock = new Object();
  @GuardedBy("myLock") Widget Widget;

  void someMethod() {
    synchronized (myLock){
      
    }
  }
}

 

'알아보기' 카테고리의 다른 글

0004 Reactive Programming 2  (0) 2019.12.05
0004 Reactive Programming 1  (0) 2019.12.04
0002 Realm 활용하기 2  (0) 2019.11.22
0002 Realm 활용하기 3  (0) 2019.11.22
0002 Realm 활용하기 1  (0) 2019.11.20

+ Recent posts