이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/thread-safety.html


 경합 조건은 다수의 쓰레드가 같은 자원에 접근하고 이 자원에 쓰기 작업을 시도할 때 발생한다. 만일 쓰레드들이 같은 자원에 접근하더라도 읽기 작업을 시도할 때는 경합 조건이 발생하지 않는다.


우리는 쓰레드들이 어떤 객체의 상태를 변경(update)할 수 없도록 함스로써 이 객체에게 불변성(immutability)을 부여할 수 있다. 그리고 이렇게 할 때 이 객체는 쓰레드 세이프하게 된다.


public class ImmutableValue{

  private int value = 0;

  public ImmutableValue(int value){
    this.value = value;
  }

  public int getValue(){
    return this.value;
  }
}


여기서 value가 어떤 방식으로 ImmutableValue 클래스의 생성자로 넘겨지는지 보고, 이 클래스에 setter 메소드가 없다는 점에 주목하자. 한 번 만들어진 ImmutableValue 인스턴스의 value 값은 변경될 수 없다. value 는 불변(immutable)한다. getValue() 메소드를 호출해서 이 값을 읽을 수는 있지만, 바꿀 수는 없다.


ImmutableValue 인스턴스의 value 에 다른 값을 주고자 한다면, 새로운 인스턴스를 생성하여 반환하는 방법으로 구현할 수는 있다.


public class ImmutableValue{

  private int value = 0;

  public ImmutableValue(int value){
    this.value = value;
  }

  public int getValue(){
    return this.value;
  }

  
      public ImmutableValue add(int valueToAdd){
      return new ImmutableValue(this.value + valueToAdd);
      }
  
}


add() 메소드는 기존 value 값에 다른 값을 더하며 새로운 ImmutableValue 인스턴스를 반환한다. 기존 인스턴스의 value 값은 변하지 않는다.



참조는 쓰레드 세이프하지 않다!


한 객체가 불변하고 쓰레드 세이프하다 하더라도, 이 객체로의 참조는 쓰레드 세이프하지 않을 수 있다는 점을 기억해야 한다. 예제를 보자.


public class Calculator{
  private ImmutableValue currentValue = null;

  public ImmutableValue getValue(){
    return currentValue;
  }

  public void setValue(ImmutableValue newValue){
    this.currentValue = newValue;
  }

  public void add(int newValue){
    this.currentValue = this.currentValue.add(newValue);
  }
}


Calculator 클래스는 ImmutableValue 인스턴스의 참조를 지닌다. 이 클래스가 setValue()와 add() 메소드를 통해 어떻게 ImmutableValue 인스턴스의 값을 변경하는지 보자. 이렇게 Calculator 클래스가 내부적으로 불변하는 객체를 사용한다 하더라도, 여기에 불변성은 존재하지 않으며, 쓰레드 세이프하지 않다. 


다시 말해서, ImmutableValue 클래스는 쓰레드 세이프하다. 하지만 이 클래스의 사용은 쓰레드 세이프하지 않다. 이것은 불변성을 이용하여 쓰레드의 안정성을 구현하려 할 때 기억해야 할 사실이다. 


여기서 Calculator 클래스를 쓰레드 세이프하게 만들기 위해서는 getValue(), setValue(), add() 메소드에 synchronized 를 사용하는 방법이 있다.



이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/thread-safety.html


 다수의 쓰레드에 의한 동시 호출에서 안정성이 보장되는 코드를 쓰레드 세이프(thread safe)하다고 한다. 쓰레드 세이프한 코드에는 경합 조건이 없다. 경합 조건은 다수의 쓰레드가 공유 자원에 쓰기 작업을 시도할 때 발생하기 때문에, 자바 쓰레드가 실행될 때 어떤 자원을 공유하게 되는지 아는 것이 중요하다.



지역변수


 지역변수는 각 쓰레드의 스택에 저장된다. 이는 지역변수는 쓰레드간에 절대로 공유되지 않는다는 것을 의미한다. 즉, 모든 기본형 지역변수는 쓰레드 세이프하다.


public void someMethod(){

  long threadSafeInt = 0;

  threadSafeInt++;
}



지역 객체 참조


 지역에서의 객체 참조의 경우는 약간 다르다. 객체 참조는 각 쓰레드의 스택에 저장되지 않는다. 모든 객체는 공유된 메모리인 힙(heap)에 저장된다.


지역적으로 생성된 객체가 자신이 생성된 메소드에서 벗어나지 않는다면, 이 객체는 쓰레드 세이프하다. 이 객체는 다른 메소드로 넘겨질 수 있지만 그 어떤 메소드나 객체도 이 넘겨진 객체를 다른 쓰레드로 하여금 접근하도록 할 수는 없다.


다음은 쓰레드 세이프한 지역 객체의 예제이다.


public void someMethod(){

  LocalObject localObject = new LocalObject();

  localObject.callMethod();
  method2(localObject);
}

public void method2(LocalObject localObject){
  localObject.setValue("value");
}


예제의 LocalObject 인스턴스는 someMethond() 메소드에서 반환되지 않았고, someMethod() 메소드 밖에서 접근 가능한 다른 객체에게 넘겨지지도 않았다. someMethod() 메소드를 실행하는 각 쓰레드는 자신만의 LocalObject 인스턴스를 가지며, localObject 에 이 인스턴스의 참조를 할당한다. 때문에 이 코드에서의 LocalObject는 쓰레드 세이프하다.


사실, someMethod() 메소드 전체가 쓰레드 세이프하다. LocalObject 인스턴스가 같은 클래스 혹은 다른 클래스 안의 다른 메소드의 파라미터로 넘겨진다 해도 이 메소드의 안정성은 보장된다.


여기서 있을 수 있는 단 하나의 예외는, LocalObject를 파라미터로 넘겨받은 메소드가 LocalObject 인스턴스를 다른 쓰레드로부터 접근 가능하도록 저장하게 되는 경우이다.



객체 멤버변수


 객체 멤버변수(필드)는 그 객체(클래스의 인스턴스)를 따라 힙에 저장된다. 때문에, 만약 두 쓰레드가 같은 객체 인스턴스의 메소드를 호출하고 이 메소드가 객체 멤버변수를 update한다면 이 메소드는 쓰레드 세이프하지 않다.


여기 그 예제가 있다.


public class NotThreadSafe{
    StringBuilder builder = new StringBuilder();

    public add(String text){
        this.builder.append(text);
    }
}


두 쓰레드가 동시에 같은 NotThreadSafe의 인스턴스의 add() 메소드를 호출한다면, 이는 경합 조건을 일으킨다. 예를 들어,


NotThreadSafe sharedInstance = new NotThreadSafe();

new Thread(new MyRunnable(sharedInstance)).start();
new Thread(new MyRunnable(sharedInstance)).start();

public class MyRunnable implements Runnable{
  NotThreadSafe instance = null;

  public MyRunnable(NotThreadSafe instance){
    this.instance = instance;
  }

  public void run(){
    this.instance.add("some text");
  }
}


두 MyRunnable 인스턴스가 한 NotThreadSate 인스턴스를 어떻게 공유하는지 주목하자. 이 때문에 NotThreadSafe의 add() 메소드는 경합 조건을 일이키는 것이다.


하지만 만약 두 쓰레드가 동시에 다른 인스턴스의 add() 메소드를 호출한다면 이야기가 달라진다. 이 때는 경합 조건이 발생하지 않는다. 약간 수정된 코드를 보자.


new Thread(new MyRunnable(new NotThreadSafe())).start();
new Thread(new MyRunnable(new NotThreadSafe())).start();


이제 두 쓰레드는 각자의 NotThreadSate 인스턴스를 가지며, 이 쓰레드들의 add() 메소드 호출은 더이상 서로 간섭하지 않는다. 이제 이 코드에서 경합 조건은 사라졌다. 여기서 알 수 있는 점은, 어떤 객체가 경합 조건을 일으키는 코드를 포함하고 있다고 해도 이 객체를 사용하는 방식에 따라서 경합 조건은 발생하지 않을 수 있다는 것이다.



쓰레드 컨트롤 이스케이프 룰


 코드의 자원 접근이 쓰레드 세이프한지 판단하기 위한 방법으로, 쓰레드 컨트롤 이스케이프 룰이 있다.


한 자원의 생성과 사용, 소멸이 동일한 쓰레드 안에서 이루어지고, 



이 쓰레드에서 절대 벗어나지 않는다면 이 자원의 사용은 쓰레드 세이프하다.


자원은 객체, 배열, 파일, 데이터베이스 커넥션, 소켓 기타 등등 어떤 방식으로든 공유 자원이 될 수 있다. 자바에서 객체들은 반드시 정확하게 소멸되지 않아도 된다. 여기서 '소멸'이란 객체로의 참조를 잃거나 nulling 되는 것을 의미한다.


어떤 어플리케이션에서 한 객체의 사용이 쓰레드 세이프하더라도, 그 객체가 파일이나 데이터베이스처럼 공유된 자원을 가리킨다면, 전체적으로 봤을 때 이 어플리케이션은 쓰레드 세이프하지 않을 수 있다. 예를 들어, 쓰레드1과 쓰레드2이 자신들의 데이터베이스 커넥션인 커넥션1, 커넥션2를 각각 갖는다면, 이들 커넥션의 사용을 쓰레드 세이프하다. 하지만 이 커넥션들이 가리키는 데이터베이스의 사용은 쓰레드 세이프하지 않을 수 있다. 

두 쓰레드가 다음과 같은 코드를 실행한다고 생각해보자.


check if record X exists
if not, insert record X


두 쓰레드가 이 코드를 동시에 실행한다면, 그리하여 record X 체크가 두 번 이루어진다면, 이 두 쓰레드의 코드 실행은 다음과 같이 처리될 위험이 있다.


Thread 1 checks if record X exists. Result = no
Thread 2 checks if record X exists. Result = no
Thread 1 inserts record X
Thread 2 inserts record X


이런 결과는 다수의 쓰레드가 파일이나 기타 다른 공유 자원을 처리할 때 얼마든지 발생할 수 있다. 때문에 쓰레드에 의해 컨트롤되는 객체가 자원 그 자체인지, 혹은 그저 자원으로의 참조인 것인지 구분해야 한다(데이터베이스 커넥션처럼).



이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/race-conditions-and-critical-sections.html



 경합 조건은 임계 영역 안에서 발생할 수 있는 특별한 현상이다. 임계 영역이란, 멀티쓰레드 환경에서 다수의 쓰레드들이 동시에 어떤 코드를 실행할 때, 각 쓰레드들이 서로 다른 결과를 내놓는, 동시 접근 문제가 발생할 수 있는 코드의 한 부분(영역)이다.


다수의 쓰레드가 코드의 임계 영역을 실행하게 되면 그 결과는 쓰레드의 실행 시퀀스에 따라 다르게 나올 수 있다. 임계 영역의 의미는 경합 조건을 포함하는데, 경합 조건이라는 용어는 쓰레드들이 임계 영역에서 서로 경쟁하게 되는 현상에 대한 비유로써 생겨났다. 그리고 이 경쟁의 결과는 임계 영역의 실행 결과에 영향을 미친다.


이 설명은 다소 복잡하게 느껴질 수 있는데, 때문에 이 글에서는 경합 조건과 임계 영역에 대해 보다 상세히 설명하기로 한다.


 

임계 영역


 한 어플리케이션에서 다수의 쓰레드를 실행하는 일이 스스로 문제를 유발하는 것은 아니다. 문제는 다수의 쓰레드가 같은 자원에 접근할 때 발생한다. 여기서 같은 자원이란, 같은 메모리(변수, 배열, 혹은 객체), 시스템(데이터베이스, 웹서비스 기타등등), 또는 파일을 의미한다.


하나 이상의 쓰레드가 이들 자원에 접근하여 쓰기 작업을 수행할 때 문제는 발생한다. 다수의 쓰레드가 같은 자원에 접근하더라도, 자원에 변경(수정)을 시도하지 않는 한 문제는 발생하지 않는다.


아래는 자바 코드의 임계 영역 예제이다. 이 코드는 멀티쓰레드로 동시에 실행될 때 문제가 발생할 수 있다.


  public class Counter {

     protected long count = 0;

     public void add(long value){
         this.count = this.count + value;
     }
  }


두 쓰레드 A, B 가 있고, 이 쓰레드들이 Counter 클래스의 add() 메소드를 호출하는 상황을 생각해보자. 명령 시스템이 두 쓰레드 중 어느 쪽을 언제 실행할지 알 수 있는 방법은 없다. 이 코드에서 add() 메소드는 원자적인 명령으로 실행되지 않는다. 정확히 말하자면 여기서 이 코드는 세 개의 작은 명령들로 실행되는데, 다음과 비슷하다.


  1. count를 메모리에서 레지스터로 읽는다.
  2. 레지스터에 값을 더한다.
  3. 레지스터의 값을 메모리에 쓴다(writing).


쓰레드 A와 B의 실행이 섞여서 무슨 일이 일어나는지 보자.


       this.count = 0;

   A:  Reads this.count into a register (0)
   B:  Reads this.count into a register (0)
   B:  Adds value 2 to register
   B:  Writes register value (2) back to memory. this.count now equals 2
   A:  Adds value 3 to register
   A:  Writes register value (3) back to memory. this.count now equals 3


이 두 쓰레드는 값 2와 3을 count에 더하려고 하였고, 쓰레드의 실행이 끝난 뒤의 최종 값은 5가 되어야 했다. 그러나 두 쓰레드의 실행이 끼워넣어지면서 전혀 다른 결과값이 나온다.


위에 나열된 실행 순서에서, 두 쓰레드는 메모리로부터 값 0을 읽어들이고 각자의 값 2와 3을 더한다. 그리고 다시 메모리로 이 값을 쓴다. this.count의 값은 5 대신 마지막에 실행이 완료된 쓰레드가 더한 값으로 쓰여진다. 위의 케이스에서 이 쓰레드는 A였지만, 다음 실행에서는 B가 될 수도 있는 일이다.


 

임계 영역에서의 경합 조건


위 예제 코드의 add() 메소드는 임계 영역을 가진다. 다수의 쓰레드가 이 영역을 실행할 때, 경합 조건이 발생한다.


더 형식적으로 말하자면, 두 쓰레드가 같은 자원으로 접근하기 위해 경쟁하고, 이 자원으로 접근되는 순서가 중요한 의미를 지니는 이러한 상황을 경합 조건이라 한다. 그리고 경합 조건을 불러오는 코드는 임계 영역이라 부른다.


 

경합 조건 예방


 경합 조건의 발생을 예방하기 위해서는 임계 영역 실행의 원자성을 보장해야 한다. 이는 한 쓰레드가 코드를 실행하면 이 쓰레드가 작업을 마치고 임계 영역에서 손을 놓을 때까지 때까지 다른 쓰레드는 이 코드를 실행할 수 없어야 함을 의미한다.


경합 조건은 임계 영역에 대한 적절한 쓰레드 동기화로 예방될 수 있다. 자바에서의 쓰레드 동기화는 기본적으로 synchronized 블록을 사용하여 이루어지고, locks 생성자나 java.util.concurrent.atomic.AtomicInteger 같은 원자성 변수와 같은 동기화 생성자/변수를 사용하여 이루어지기도 한다.



임계 영역 처리량


 코드에 임계 영역들이 작게 분포되어 있는 경우, 코드의 임계 영역 전체를 synchronized 블록으로 감싸는 것이 경합 조건 예방을 위한 방법이 될 수 있다.하지만 임계 영역의 범위가 큰 경우에는 이를 다수의 작은 임계 영역으로 분리하는 편이 좋을 수도 있다. 이렇게 하면 쓰레드들이 나뉘어진 임계 영역을 각각 실행할 수 있고, 이는 공유 자원으로의 경합을 줄일 수 있게 된다. 결과적으로 임계 영역의 처리량을 향상시킬 수 있는 것이다.


이게 무슨 의미인지 간단한 자바 코드로 살펴보자.


public class TwoSums {
    
    private int sum1 = 0;
    private int sum2 = 0;
    
    public void add(int val1, int val2){
        synchronized(this){
            this.sum1 += val1;   
            this.sum2 += val2;
        }
    }
}


add() 메소드가 두 개의 멤버변수에 어떻게 값을 더하는지 보자. 경합 조건을 예방하기 위해 값을 더하는 작업은 synchronized 블록 안에서 이루어진다. 이렇게 하여 한 시점에 오직 하나의 쓰레드만이 이 코드를 실행할 수 있다.


아니면, 두 변수가 서로 독립적인 관계에 있으니 이 둘에 값을 더하는 작업을 분리할 수도 있다.


public class TwoSums {
    
    private int sum1 = 0;
    private int sum2 = 0;
    
    public void add(int val1, int val2){
        synchronized(this){
            this.sum1 += val1;   
        }
        synchronized(this){
            this.sum2 += val2;
        }
    }
}


이제 add() 메소드는 한 시점에 두 개의 쓰레드가 실행 가능하게 됐다. 한 쓰레드는 첫 번째 synchronized 블록을 실행하고, 다른 하나는 두 번째 블록을 실행한다. add() 메소드 전체를 synchronized 블록으로 감쌌을 때 쓰레드들은 먼저 블록에 진입한 한 쓰레드가 메소드 전체를 실행할 동안 기다려야 했지만, 이렇게 블록을 나눔으로써 쓰레드의 기다리는 시간을 줄인 것이다.


이 예제는 매우 단순한 것이다. 당연하게도 실제 프로그램에서 임계 영역을 나누는 작업은 훨씬 복잡하고, 실행 순서의 면밀한 분석이 요구된다.



 

이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/creating-and-starting-threads.html



자바 쓰레드는 자바의 다른 오브젝트들과 다르지 않다. 쓰레드는 java.lang.Thread 클래스의 인스턴스이거나, 이 클래스의 서브클래스의 인스턴스이다. 그리고 쓰레드는 오브젝트이면서 코드를 실행하는 일도 가능하다.


 

자바 쓰레드의 생성과 시작


자바에서 쓰레드의 생성을 다음과 같은 코드로 이루어진다.


  Thread thread = new Thread();


자바 쓰레드를 실행하기 위해 start() 메소드를 호출한다.


  thread.start();


이 예제에 쓰레드의 실행을 위한 구체적인 코드는 없다. 쓰레드는 실행된 다음 곧바로 종료될 것이다.


쓰레드를 실행하는 코드에는 두 가지 방법이 있다. 하나는 Thread의 서브클래스를 만들어 run() 메소드를 오버라이딩 하는 것이고, 다른 하나는 java.lang.Runnable 인터페이스를 구현한 오브젝트를 Thead의 생성자의 파라미터로 넘기는 방법이다. 두 방법을 아래에서 소개한다.


 

Thread의 서브클래스


쓰레드를 실행하는 첫 번째 방법은 Thread의 서브클래스를 만들어 run() 메소드를 오버라이딩 하는 것인데, run() 메소드는 Thread의 start() 메소드가 호출됐을 때 실행되는 메소드이다. Thread의 서브클래스를 만드는 방법은 다음과 같다.


  public class MyThread extends Thread {

    public void run(){
       System.out.println("MyThread running");
    }
  }


그리고 다음은 위 쓰레드를 실행하기 위한 코드이다.


  MyThread myThread = new MyThread();
  myTread.start();


start() 메소드는 호출이 이루어지면 곧바로 종료되며, run() 메소드가 끝나기를 기다리지 않는다. run() 메소드는 마치 다른 CPU에 의해 실행되는 것처럼 실행된다. run() 메소드가 실행될 때, "MyThread running" 문구가 출력될 것이다.


Thread의 익명 클래스를 생성할 수도 있다.


  Thread thread = new Thread(){
    public void run(){
      System.out.println("Thread Running");
    }
  }

  thread.start();


이 예제는 새로운 쓰레드에 의해 "Thread running" 문구를 출력할 것이다.


 

Runnable 인터페이스 구현


쓰레드를 실행하는 두 번째 방법은 java.lang.Runnable 인터페이스를 구현하는 것이다. Runnable 객체는 Thread에 의해 실행된다.

여기 그 예제가 있다.


  public class MyRunnable implements Runnable {

    public void run(){
       System.out.println("MyRunnable running");
    }
  }


run() 메소드가 호출되도록 하기 위해 MyRunnable 클래스의 인스턴스를 Thread의 생성자로 전달한다.


   Thread thread = new Thread(new MyRunnable());
   thread.start();


이렇게 하면 쓰레드가 시작될 때 Thread 자신의 run() 메소드 대신 생성자로 전달된 MyRunnable 인스턴스의 run() 메소드를 호출한다.위의 예제는 "MyRunnable running" 문구를 출력한다.


여기서도 역시 익명 클래스를 생성할 수 있다.


   Runnable myRunnable = new Runnable(){

     public void run(){
        System.out.println("Runnable running");
     }
   }

   Thread thread = new Thread(myRunnable);
   thread.start();

 


서브클래스와 java.lang.Runnable


두 방법 모두 잘 작동하며, 이들 중 어느쪽이 더 나은가에 대한 정답은 없다. 개인적으로는 Runnable 인터페이스를 구현하는 쪽을 선호하는데, 쓰레드 풀(thread pool)에게 Runnable의 인스턴스를 실행하게 하는 쪽이 Runnable 인스턴스를 Thread가 대기할 때까지 줄세워두기(queue up) 쉽다. 이 작업은 Thread의 서브클래스로는 약간 더 어려운 일이다.


종종 Runnable 인터페이스의 구현과 함께 Thread의 서브클래스를 만들어야 할 때가 있다. 예를 들자면 하나 이상의 Runnable 인스턴스를 실행하는 경우이다. 이런 상황이 쓰레드 풀을 구현할 때의 전형적인 케이스이다.


 

위험요소: start() 메소드가 아닌 run() 메소드의 호출


쓰레드를 생성하고 실행하는 데에 있어서 흔히 발생하는 실수는 start() 메소드 대신 run() 메소드를 실행하는 일이다.


  Thread newThread = new Thread(MyRunnable());
  newThread.run();  //should be start();


예상대로 Runnable의 run() 메소드가 실행되기 때문에 여기서 무엇이 문제인지 모를 수도 있다. 하지만 이 코드는 새로 생성된 쓰레드에 의해 실행된 것이 아니다. 여기서 실행된 run() 메소드는 이 코드를 실행하는 현재 쓰레드(current thread)에 의해 작동되는데, MyRunnable 인스턴스의 run() 메소드를 새로 생성된 쓰레드 - 코드를 실행하는 쓰레드(current thread)가 아닌 - 가 실행하도록 하기 위해서는 newThread.start() 메소드를 호출해야만 한다.


 

쓰레드의 이름


자바 쓰레드를 생성할 때 쓰레드에게 이름을 부여할 수 있다. 쓰레드의 이름은 멀티쓰레드 환경에서 각 쓰레드를 쉽게 구분할 수 있게 해준다. 예를 들어, 다수의 쓰레드가 System.out 을 사용한다고 하면, 콘솔에 찍힌 문구가 어떤 쓰레드에 의해 출력된 것인지 식별하는 데에 유용하다.


   Thread thread = new Thread("New Thread") {
      public void run(){
        System.out.println("run by: " + getName());
      }
   };


   thread.start();
   System.out.println(thread.getName());


Thread의 생성자에 전달된 "New Thead" 문구를 보자. 이 문자열이 쓰레드의 이름이다. 쓰레드는 Thread의 getName() 메소드를 통해 이 이름을 획득한다. Runnable 인스턴스를 이용한 쓰레드 생성에서도 쓰레드 이름을 지정할 수 있다.


   MyRunnable runnable = new MyRunnable();
   Thread thread = new Thread(runnable, "New Thread");

   thread.start();
   System.out.println(thread.getName());


하지만 MyRunnable 클래스는 Thread의 서브클래스가 아니기 때문에, 여기서 getName() 을 호출할 수는 없다.


 

Thread.currentThread()


Thread.currentThread() 메소드는 currentThread() 를 실행하는 Thread 인스턴스의 레퍼런스를 반환한다. 이 방법으로 자바 코드블럭을 실행하는 쓰레드의 객체에 접근할 수 있다.


Thread thread = Thread.currentThread();


Thread 객체를 가지게 되면 이 객체의 메소드를 호출할 수 있는다. 예를 들어 현재 코드를 실행중인 쓰레드의 이름은 다음과 같이 획득할 수 있다.


 

자바 쓰레드 예제


아래 작은 예제가 하나 있다. 이 코드는 먼저 main() 메소드를 실행하는 쓰레드의 이름을 출력한다. 이 쓰레드는 JVM에 의해 할당된 것이다. 그리고 다음으로는 10개의 쓰레드를 실행하고 이 쓰레드들에게 숫자로 된 이름("" + i)을 부여한다. 각 쓰레드는 자신의 이름을 출력하고는 동작이 종료된다.


public class ThreadExample {

  public static void main(String[] args){
    System.out.println(Thread.currentThread().getName());
    for(int i=0; i<10; i++){
      new Thread("" + i){
        public void run(){
          System.out.println("Thread: " + getName() + " running");
        }
      }.start();
    }
  }
}


쓰레드를 순차적으로(1, 2, 3, ...) 시작했지만 이들이 이 순서대로 실행되지 않는다는 점에 주목하자. 처음 시작된 1번 쓰레드는 콘솔에 자신의 이름을 출력하는 첫 번째 쓰레드가 되지 않을 수도 있다. 이는 쓰레드의 실행이 순차적이 아닌, 병렬적으로 이루어지기 때문이다. JVM과 명령 시스템, 혹은 JVM이나 명령 시스템(and/or)은 쓰레드의 실행 순서를 결정하고, 이 순서는 실행할 때마다 달라질 수 있다.

 

이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/concurrency-vs-parallelism.html



 용어 '컨커런시' 와 '페러럴리즘' 은 멀티쓰레드 프로그램 관계에서 흔히 사용된다. 하지만 이 두 용어의 정확한 의미가 무엇일까?

이에 대한 간단한 대답은, "아니오" 이다. 이 둘은 상당히 비슷해 보이긴 하지만 다른 용어이다. 이 둘의 차이를 이해하는 데에 시간이 조금 필요했고, 때문에 이 주제를 튜토리얼에 추가하기로 마음먹었다. '컨커런시 vs '페러럴리즘'.


 

컨커런시


 컨커런시는 한 어플리케이션이 하나 이상의 task를 가지고, 동시에 실행된다. 만일 컴퓨터의 CPU가 하나라면, 어플리케이션은 '동시에' 다수의 task를 가질 수 없을 수 있다. 하지만 하나 이상의 task는 어플리케이션 내부에서 작동한다. 한 task가 시작되기 전, 이전의 task는 완전히 종료되지 않는다.

 

 


페러럴리즘


 페러럴리즘은 한 어플리케이션이 다수의 subtasks로 나뉘고 이 subtasks들이 병렬로 작동하는 것이다. 정확히 같은 시간에 작동하는 다수의 CPU를 예로 들 수 있다.


 


컨커런시 vs. 페러럴리즘 In Detail


 보다시피, 컨커런시는 한 어플리케이션이 다수의 task를 어떻게 다루는가와 관련이 있다. 어플리케이션은 한번에 한 task를 순차적으로 가지거나, 다수의 task를 동시에 가질 수 있다. 반면 페러럴리즘은 한 어플리케이션이 각각의 task를 어떻게 다루는가와 관련이 있다. 어플리케이션은 task를 처음부터 끝까지 연속적으로 가지거나, 한 task를 다수의 subtask 로 나누고 이들이 병렬로 작동하게끔 한다.


한 어플리케이션은 동시성(concurrency)을 가질 수 있고, 병행성(parallelism)은 가지지 않을 수 있다. 이것은 하나 이상의 task를 동시에 가진다는 말이고, 이 task들은 subtask로 나뉘지 않는다. 또한, 한 어플리케이션은 병행성을 가지고 동시성은 가지지 않을 수 있다. 이것은 한번에 하나의 task만을 가진다는 말이고, 이 task가 다수의 subtask로 나뉘어 병렬로 작동한다.


이에 더하여, 어플리케이션은 동시성과 병행성 둘 다 가지지 않을 수도 있다. 이는 오직 한번에 한 task만을 가지고 이 task는 절대 subtask로 나뉘지 않는다는 말이다.


마지막으로, 어플리케이션은 동시성과 병행성 모두를 가질 수도 있다. 이 두 형태는 동시에 다수의 task로 작동하며, 각 task는 subtask로 나뉘어 병렬로 작동하기도 한다. 하지만, 이 경우에 동시성과 병행성의 이점을 잃을 수도 있다. 컴퓨터의 CPU가 이미 다른 작업을 처리하느라 바쁘게 돌아가고 있다면, 동시성과 병행성 모두 의미가 없다. 이 둘을 결합하는 일은 그저 약간의 성능 향상 혹은 오히려 성능의 저하를 야기하기도 한다. 맹목적으로 이 모델을 사용하기 전에 정확한 분석과 측정이 요구되는 이유이다.

 

'Java > Concurrency' 카테고리의 다른 글

경합 조건과 임계 영역(Race Conditions and Critical Section)  (1) 2017.04.09
자바 쓰레드 시작하기  (0) 2017.04.09
컨커런시 모델  (0) 2017.04.09
멀티쓰레딩의 단점  (0) 2017.04.09
멀티쓰레딩의 장점  (0) 2017.04.09

이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/concurrency-models.html



 컨커런트 시스템들의 구현에는 각각 다른 컨커런시 모델이 사용될 수 있다. 컨커런시 모델은 다수의 쓰레드가 주어진 작업을 완료하기 위해 어떤 식으로 협력하는지를 명시한다. 컨커런시 모델들은 처리할 작업들을 각각 다른 방식으로 분배하고, 쓰레드들도 마찬가지로 다른 방식으로 소통하고 협력한다. 이 컨커런시 모델 튜토리얼은 이 시점에(2015년) 가장 널리 사용되는 컨커런시 모델들에 대해 다소 더 깊게 다룰 것이다.



컨커런시 모델과 분산 시스템의 유사성


 여기에 기술된 컨커런시 모델들은 분산 시스템에서 사용되는 아키텍처들과 유사하다. 컨커런트 시스템에서 각 쓰레드들은 서로 소통한다. 분산 시스템에서 각 프로세스들은 서로 소통한다(각각 다른 컴퓨터에서). 쓰레드와 프로세스는 사실상 상당히 닮아있고, 때문에 서로 다른 컨커런시 모델들이 분산 시스템 아키텍처들과 비슷하게 보이는 것이다.


물론 분산 시스템은 네트워크 장애나 원격 컴퓨터 혹은 프로세스의 다운 혹은 기타 여러가지 문제들로 인한 추가적인 부담을 가지고 있다. 하지만 큰 규모의 서버에서 돌아가는 컨커런트 시스템은 CPU의 장애, 네트워크 카드의 문제, 디스크의 문제나 기타 등등의 상황으로 비슷한 문제점을 경험하기도 한다. 이런 문제가 발생할 확률은 더 낮을 수도 있지만, 이론적으로 여전히 발생할 수 있다.


컨커런시 모델과 분산 시스템 아키텍처에는 유사한 점이 있기에, 이들은 종종 서로에게서 어떤 발상이나 방안을 차용할 수 있다. 예를 들자면, 워커 쓰레드들 사이의 작업 작업 분산에 대한 모델은 분산 시스템의 로드밸런싱 모델과 비슷하다. 로깅, 페일오버, 작업의 멱등성(idempotency) 혹은 기타 등등과 같은 에러 핸들링 기술에서도 마찬가지이다.



페러럴 워커


 첫번째 컨커런시 모델은 내가 페러럴 워커라 칭하는 모델이다. 요청 작업(incoming jobs)은 각각 다른 워커에 할당된다. 페러럴 워커 컨커런시 모델의 다이어그램은 다음과 같다.

페러럴 워커 컨커런시 모델에서 델리게이터는 요청 작업을 워커들에게 분배한다. 각 워커는 완전한 한 작업을 맡아서 완수한다. 워커들은 병행 처리로 수행되며, 각각 다른 쓰레드에서, 가능한대로 다른 CPU에서 작동된다.


만일 자동차 공장에서 페러럴 워커 모델이 구현된다면, 각 자동차 한 대에 하나의 워커가 할당되어 생산될 것이다. 워커는 자동차 생산을 위한 스펙을 가지며, 한 생산의 처음부터 끝까지 모두 처리하게 된다. 페러럴 워커 컨커런시 모델은 컨커런시 모델 자바 어플리케이션에서 가장 널리 사용된다(변화하고 있지만). 자바 패키지 java.util.concurrent 의 많은 컨커런시 유틸리티들이 이 모델로 구성되어 있다. 자바 엔터프라이즈 에디션 어플리케이션 서버의 디자인에서 이 모델의 구조를 추적해 볼 수 있다.

 


페러럴 워커의 강점


페러럴 워커 컨커런시 모델의 강점은 쉽다는 것이다. 병행 처리를 늘리기 위해 당신은 그저 워커를 추가하기만 하면 된다.


예를 들어, 당신이 웹 크롤러를 구현한다고 하면 당신은 다수의 워커를 이용해 페이지를 크롤링 할 수 있고, 어떤 워커의 크롤링 시간이 짧은지(성능이 뛰어난지) 알 수 있다. 데이터를 다운로드하는 시간동안 대기하는 시간이 길고, 때문에 CPU 당 하나의 쓰레드는 너무 적다. 웹 크롤링은 IO에 치중한 작업이기 때문에 다수의 쓰레드를 필요로 하며, 페러럴 워커 컨커런시 모델을 활용한다면 당신은 CPU / core 당 수 개의 쓰레드로 작업하게 될 것이다.

 


페러럴 워커의 약점


 페러럴 워커 컨커런시 모델에는 그 단순한 구조 아래 몇 가지 약점이 숨어있다. 여기서 가장 분명한 약점에 대해 설명한다.



공유 상태(shared state)는 복잡해진다.


현실적으로, 페러럴 워커 컨커런시 모델은 위의 일러스트보다는 다소 복잡하다. 공유된(shared) 워커는 공유된 데이터 - 메모리나 데이터베이스의 - 로 자주 접근할 필요가 있는데, 이것이 어떻게 페러럴 워커 컨커런시 모델을 복잡하게 만드는지 다음의 다이어그램이 보여준다. 

공유 상태는 작업 큐(queues)와 같은 통신 메카니즘 안에 있다. 공유 상태는 비지니스 데이터, 데이터 캐시, 데이터베이스 커넥션 풀이나 기타등등 이 될 수 있다. 페러럴 워커 컨커런시 모델 안에서 이 공유 상태는 복잡해지기 시작한다. 쓰레드들은 공유된 데이터에 접근할 때, 한 쓰레드가 데이터를 변경하면 다른 쓰레드들이 이 사실을 알 수 있도록 해야한다(데이터의 변경은 쓰레드를 실행하는 CPU 캐시에 묶여있지 않고 메인 메모리에 저장되어야 한다). 쓰레드는 경합 조건(race condition), 데드락 상태, 그리고 다른 많은 공유 상태 컨커런시의 문제점을 피해야 한다.


이에 더하여, 쓰레드가 공유 데이터 구조에 접근하기 위해 다른 쓰레드의 작업이 끝나기를 기다리는 동안 병행화 상태를 잃어버리게 된다. 하나 혹은 다수의 쓰레드 집단든 주어진 시간에만 데이터에 접근할 수 있는데, 이로 인해 많은 컨커런트 데이터 구조는 블록 상태에 놓인다. 이 현상은 공유 데이터 구조에 대한 경합을 불러온다.  높은 경합은 본질적으로 공유 데이터 구조에 접근하는 코드 실행의 serialization으로 이어진다.


요즘의 non-blocking 컨커런시 알고리즘은 경합을 줄이고 성능을 향상시키지만, non-blocking 알고리즘의 구현은 쉽지 않다. 이에 대한 또다른 대안은 지속형 데이터 구조이다. 지속형 데이터 구조는 데이터의 수정에 있어서 항상 자신의 이전 버전을 보존한다. 때문에 다수의 쓰레드가 같은 (지속형)데이터 구조를 가리키고 여기에 한 쓰레드가 데이터를 변경하는 상황이 되면 데이터를 변경하는 쓰레드는 변경된 새로운 데이터 구조를 참조하게 되고 나머지 쓰레드들은 변경 이전의 데이터 구조에의 참조를 유지하게 된다. 스칼라 프로그래밍은 여러가지 지속형 데이터 구조를 지니고 있다. 지속형 데이터 구조는 공유 데이터 구조에의 동시 접근에 대한 명쾌한 해결책이 되지만, 이 데이터 구조는 뜻대로 작동되지 않는 경향이 있다. 가령, 지속형 데이터 구조의 한 리스트 데이터에 새 데이터가 추가되면, 새 데이터는 리스트의 상단(head)에 오게 되고 이에 대한 레퍼런스를 반환한다(그리고 이 데이터는 나머지 리스트 데이터를 참조한다). 여기서 이 새 데이터에 대한 레퍼런스를 가진 쓰레드는 리스트에 데이터를 추가한 쓰레드이며, 나머지 쓰레드들은 앞서 설명했듯 데이터가 추가되기 이전의 리스트에서 첫번째 데이터를 참조한다. 이 쓰레드들은 리스트 데이터의 변경을 알 수 없으며 새로운 데이터를 볼 수 없다.


이런 지속형 데이터 리스트는 링크드 리스트로 구현된다. 유감스럽게도 링크드 리스트는 현대의 컴퓨터에서 제대로 작동하지 않는다. 데이터 리스트의 각 데이터는 각각의 객체이며, 이들은 컴퓨터 메모리 각지에 위치할 수 있다. 현대의 CPU는 순차적인 데이터 접근에 훨씬 빠르게 작동하는데, 때문에 현대의 하드웨어에서는 배열로 구현된 리스트에서 훨씬 나은 성능을 보인다. CPU 캐시는 더 큰 배열 덩어리들을 적재할 수 있으며, 캐시가 한 번 로드되면 이 데이터에 다이렉트로 접근할 수 있다. 메인 메모리에 산발적으로 적재되는 링크드 리스트로는 불가능한 방식이다.



비상태(stateless) 워커


 공유 상태는 시스템의 다른 쓰레드들에 의해 변경될 수 있다. 때문에 워커는 이에 접근할 때 최신 상태를 유지하기 위해 반드시 상태를 다시 읽는 작업이 필요하다. 워커는 이 상태를 자신의 내부에 유지하지 않기 때문에, 공유 상태가 메모리에 있는지 외부 데이터베이스에 있는지는 문제가 되지 않는다. 이를 비상태(stateless)라 부른다.


특히 외부 데이터베이스 환경에서, 필요할 때마다 데이터를 다시 읽는 작업은 성능 저하의 요인이 될 수 있다.



작업 순서의 비 결정성


 페러럴 워커 모델의 또다른 약점은 작업 실행 순서가 정해지지 않는다는 것이다. 어떤 작업이 먼저 실행될지 혹은 마지막에 실행될지 보장할 수 있는 방법은 없다. 작업 A 는 작업 B 보다 먼저 할당될 수 있고, 작업 B 는 작업 A 보다 먼저 실행될 수도 있다.


 

어셈블리 라인


 두 번째 컨커런시 모델은 어셈블리 라인 컨커런시 모델이다. 내가 이 이름을 택한 이유는 이 이름이 이전부터 '페러럴 워커' 의 비유에 어울렸기 때문이다. 혹자는 플렛폼이나 커뮤니티에 따라 이 모델을 반응형 시스템, 혹은 드리븐 시스템이라 부르기도 한다. 어셈블리 라인 컨커런시 모델의 다이어그램은 다음과 같다.



워커들은 공장의 조립 라인과 같이 놓인다. 이 모델에서 각 워커는 한 작업의 일정 부분만을 담당한다. 한 워커의 작업이 끝나면 워커는 작업을 다음 워커에게 전달한다.


각 워커는 각자 자신들의 쓰레드에서 작동하며, 다른 워커들과의 상태 공유는 발생하지 않는다. 이 모델은 '비공유' 컨커런시 모델으로 참조되기도 한다.


어셈블리 라인 컨커런시 모델을 이용하는 시스템은 주로 non-blocking IO 모델을 사용하기 위해 구현된다. non-blocking IO란 한 워커가 파일을 읽는다거나 네트워커 커넥션에서 데이터를 읽는 등과 같은 작업을 시작할 때 이 워커는 IO 호출이 끝나기를 기다리지 않는다는 것을 의미한다. IO 작업의 처리는 느리기 때문에 IO 작업이 끝나기를 기다리는 것은 CPU 시간을 낭비하는 일이다. CPU는 IO 작업이 처리될 동안 다른 작업을 수행할 수 있다. IO 작업이 끝나면, IO 작업 결과 - 읽어들인 데이터나 쓰여진 데이터 상태와 같은 - 를 다른 워커에게 전달한다.


non-blocking IO 에서 IO 작업은  워커들간의 작업 경계를 결정한다. 한 워커는 IO 작업이 시작될 때까지 가능한 모든 시간을 다른 작업에 쓰고, IO 작업을 시작해야 할 때가 되면 작업을 내려놓고 IO 작업을 수행한다. IO 작업이 끝나면 어셈블리 라인의 다음 순서에 있는 워커가 나머지 작업을 이전 워커와 같은 방식으로 처리하기 시작한다. 그리고 또 다음 IO 작업을 수행할 때가 되면 작업을 내려놓고 IO 작업을 수행한다.



이론적으로는 워커는 이런 방식으로 작동하는데, 실제로는 작업들은 단 하나의 어셈블리 라인에 놓이지는 않는다. 대부분의 시스템이 하나 이상의 작업을 수행하는 일이 가능하기 때문에, 작업들은 그 필요성에 따라 워커에서 워커로 흘러간다. 실제로 하나 이상의 가상 어셈블리 라인이 동시에 발생할 수 있다. 다음 다이어그램은 그 모습을 나타낸 것이다.



작업들은 동시 수행을 위해 하나 이상의 워커로 전달될 수도 있다. 예를 들어, 한 작업은 작업 실행자(executor)와 작업 로거(logger) 둘에게 전달되는 일이 가능한다. 다음 다이어그램이 이를 보여주는데, 세 어셈블리 라인이 하나의 같은 워커에게 작업을 전달한다.



어셈블리 라인은 이보다 복잡해질 수도 있다.



반응형, 이벤트 주도 시스템


어셈블리 라인 컨커런시 모델을 사용하는 시스템은 반응형 시스템, 혹은 이벤트 주도 시스템이라 불리기도 한다. 이 시스템의 워커들은 시스템에 발생하는 이벤트에 반응하는데, 시스템 밖에서부터 발생해 들어온 이벤트나 다른 워커로부터 발생한 이벤트에도 반응한다. 이벤트는 HTTP 요청이나 메모리로 로딩된 어떤 파일 또는 기타 등등일 수도 있다.


이 글을 쓰는동안, 몇가지 흥미로운 반응형/이벤트 주도 플렛폼이 등장하였다. 향후에는 더 많은 것들이 출현할 것이다. 유명한 몇가지는 다음과 같다.

  • Vert.x

  • Akka

  • Node JS(Javascript)

개인적으로 Vert.x 가 매우 흥미롭다


액터 vs 체널


액터와 체널은 어셈블리 라인(혹은 반응형/이벤트 주도) 모델의 두 가지 비슷한 예이다.


액터 모델에서 각 워커는 액터(actor)라 불린다. 액터는 다른 액터들에게 직접 메세지를 보낼 수 있다. 메세지는 비동기적으로 처리된다. 액터들은 하나 혹은 하니 이상의 어셈블리 라인 작업 공정을 구현하기 위해 사용된다. 다음이 그 다이어그램이다.



채널 모델이서 워커는 다른 워커들과 직접 통신하지 않는다. 대신 워커는 메세지(이벤트)를 다른 채널으로 보낸다(publish). 다른 워커들은 채널을 통해 메세지를 수신하며, 메세지를 보낸 워커는 이 사실을 모른다. 이 모델의 다이어그램은 다음과 같다.



지금의(이 글을 쓰는 시점의)  채널 모델은 이보다 더 유연해졌다. 한 워커는 어셈블리 라인의 다른 워커들 중 어떤 워커가 작업을 수행할지 알 필요가 없다. 워커가 알아야 할 것은 단지 작업(혹은 메세지 또는 기타등등)을 어떤 채널로 보낼 것인가이다. 채널의 작업을 수신하는 워커들(listeners)은 채널로 작업을 보내는 워커에게 아무런 영향을 주지 않으면서 작업을 받거나 받지 않거나 할 수 있다. 이는 워커들간의 좀 더 약한 결합(a somewhat looser coupling)을 가능하게 한다.


 

어셈블리 라인의 강점

 

페러럴 워커 모델과 비교하여 어셈블리 라인 컨커런시 모델은 몇가지 강점이 있다. 여기 이 강점들을 소개한다.


공유 상태가 없다


 워커들간의 공유 상태가 없다는 사실은 공유 상태로의 동시 접근이 야기하는 동시성 문제점들을 생각할 필요가 없다는 것을 의미한다. 이것은 워커를 구현하기 훨씬 용이하게 한다. 당신은 마치 한 쓰레드로 작업하는 듯이 워커를 구현할 수 있다. - 기본적으로 싱글쓰레드 구현이다.



스테이트풀 워커


 워커는 다른 워커들이 자신의 데이터에 접근하지 않는다는 것을 알기에, 자신의 상태를 지속적으로 유지할 수 있다(stateful). 여기서 자신의 상태란, 워커가 자신의 작업을 위해 접근해야 하는 메모리상의 데이터를 의미한다. 오직 자신의 쓰기(writing) 작업만이 외부 저장소 시스템으로 데이터를 돌려보낼 수 있다. 때문에 스테이트풀 워커는 흔히 스테이트리스 워커보다 신속하게 작업을 처리할 수 있다.



더 나은 하드웨어 적합성


 싱글쓰레드 코드는 컴퓨터 내부의 하드웨어 워커들에 보다 적합하다는 이점이 있다. 첫째로, 당신은 보통 싱글쓰레드에서 코드를 실행할 때 더 최적화된 데이터 구조와 알고리즘을 만들 수 있다. 둘째로, 싱글쓰레드 스테이트풀 워커는 앞서 언급한대로 메모리에 데이터를 캐싱할 수 있다. 데이터가 메모리에 캐시될 때, 데이터는 쓰레드를 실행하는 CPU에 캐시될 확률이 더 높아진다. 이것은 캐시된 데이터로의 접근을 보다 빠르게 만든다.


나는 어떤 코드가 컴퓨터 내부의 하드웨어의 동작 방식으로부터 자연스럽게 이점을 얻을 때, 이를 하드웨어 적합성(hardware conformity)이라 부른다. 어떤 개발자들은 이를 mechanical sympathy 라 부르기도 한다. 나는 '하드웨어 적합성'이라는 말을 더 좋아하는데, 왜냐하면 컴퓨터에서 기계적인 부분은 매우 적고, 'sympathy' 라는 말은 이 문맥에서 'matching better'의 비유로 사용되기 때문이다. 'conform' 이라는 말이 의미를 더 잘 전달한다고 생각한다. 어쨌든, 이건 사소한 부분이고, 당신이 선호하는 용어를 쓰면 된다.



작업 순서를 정의할 수 있다.


 어셈블리 라인 컨커런시 모델에 따르면 작업 순서를 보장하는 컨커런트 시스템을 구현하는 일이 가능하다. 작업 순서를 정의하면 언제든지 지금 시스템의 작업 상태를 논리적으로 추정하는 일이 훨씬 쉬워진다. 뿐만 아니라, 워커로 들어오는 작업을 모두 기록할 수도 있다(logging). 이 기록은 어떤 케이스의 시스템 장애(system fails)에서든 시스템의 상태를 재건하는 일에 쓰일 수 있다. 작업들은 정확한 순서로 기록되며, 이 순서는 작업 순서의 보장으로 이어진다. 다음 다이어그램을 보자. 



보장된 작업 순서를 구현하는 일이 쉬운 것은 아니지만 가능한 일이다. 당신이 이를 구현한다면 백업, 데이터 재저장, 데이터 복재, 기타등등의 작업들이 놀라울 정도로 단순해진다. 이 모든 것은 로그 파일로 이루어질 수 있다.


 

어셈플리 라인의 약점


 어셈블리 라인 컨커런시 모델의 주요 약점은 작업 실행이 자주 다수의 워커에게로, 게다가 프로젝트의 클래스 파일들에게 분산된다는 점이다. 이 현상은 주어진 작업이 정확히 어떤 코드에서 실행되는지 보기 어렵게 만든다.


코드를 작성하기도 더 어려워질 수 있다. 워커 코드는 자주 콜백 핸들러로 작성되는데, 많은 중첩 콜백 핸들러가 작성된 코드는 개발자를 콜백 지옥에 빠뜨린다. 콜백 지옥은 쉽게 말해 수많은 콜백 중 어떤 코드가 실행되는지 추적하기 어려운 상황을 의미하는데, 이는 또한 각 콜백이 필요한 데이터로 확실히 접근하는지도 알기 어렵게 만든다.


이런 점들에서는 페러럴 워커 컨커런시 모델이 더 쉬운 경향이 있다. 당신은 워커 코드를 열고 실행되는 코드를 처음부터 끝까지 완전히 읽을 수 있다. 물론 페러럴 워커 코드 또한 많은 클래스들을 뒤덮을 수는 있지만, 그 실행 순서를 읽어내기는 더 쉽다.

 


함수형 병렬성


 함수형 병렬성은 요 근래(2015) 많이 언급되는 세 번째 컨커런시 모델이다.

항수형 병렬성의 기본적인 발상은 함수 호출을 이용해 프로그램을 구현하는 것이다. 마치 어셈블리 라인 컨커런시 모델에서와 같이, 각 함수들은 서로에게 메세지를 보내는 '대리인' 이나 '액터' 로 보일 수 있다. 한 함수가 다른 함수를 호출할 때, 이 동작은 메세지를 보내는 것과 유사하다.


함수로 전달된 모든 파라미터들은 복사되고, 때문에 파라미터를 전달받는 함수 밖에서 데이터를 접근할 수 있는 것은 없다. 이 이 복사는 본질적으로 공유 데이터에 대한 경합 조건을 피하기 위함이다. 이는 함수 실행을 원자적인 작업(atomic operation)과 유사하게 만든다. 각 함수 호출은 다른 함수 호출로부터 독립적으로 실행된다. 함수 호출이 독립적으로 이루어질 때, 각 함수는 독립적인 CPU 에서 실행된다. 이것은 함수형으로 구현된 알고리즘이 다수의 CPU에서 병렬적으로 실행될 수 있음을 의미한다.


자바 7 에서 우리는 java.util.concurrent 패키지를 얻었다. 이 패키지에는 ForkAndJoinPool 이 포함되어 있는데, 이것은 당신이 함수형 병렬성과 유사한 것을 구현하도록 해준다. 자바 8 에는 페러럴 스트림(streams)이 있는데, 이것은 당신이 거대한 컬렉션의 반복의 병렬 처리를 도와줄 수 있다.


ForkAndJoinPool 에 비판적인 개발자들이 있다는 사실을 기억하라. 함수형 병렬성의 어려운 부분은 어떤 함수가 병렬로 처리되는지 알기 어렵다는 점이다. 다수의 CPU에 걸친 조직화된 함수 호출은 오버헤드를 야기한다. 이 오버헤드를 위해 함수에 의해 수행되는 작업의 단위는 정확한 크기여야 한다. 매우 작은 함수 호출을 병렬로 처리하려 한다면 이 작업은 싱글 CPU의 싱글쓰레드 작업보다 느려질 수 있다.


내가 아는 바에 의하면, 당신은 반응형, 이벤트 주도형 모델을 이용해 알고리즘을 구현할 수 있고, 함수형 병렬성에 의한 것과 유사한 작성의 분해를 이뤄낼 수 있다. 이벤트 주도형 모델을 이용한다면 병렬 처리를 정확하게 컨트롤 할 수 있다(내 생각에). 더하여, 오버헤드를 발생시키며 다수의 CPU에 작업을 나누는 일은 오직 현재 시스템에서 그 하나의 task(그 작업을 실행하는 프로그램)만이 실행되고 있을 때만 타당한 것이다. 하지만 만약 이렇지 않고 그 task 외에 다른 task들이 시스템에서 실행되고 있다면(웹서버, 데이터베이스 서버 그리고 기타 등등 과 같은), 한 싱글 task를 병렬 처리하는 의미가 없다. 컴퓨터의 다른 CPU들은 어쨌든 다른 task의 작업을 위해 바쁘가 돌아가고 있기 때문에, 함수형 병렬 작업으로 그들을 방해할 이유가 없는 것이다. 당신에게 주로 도움이 될만한 것은 어셈블리 라인 컨커런시 모델이다. 왜냐하면 이 모델은 오버헤드를 줄여주고(싱글쓰레드로 순차적으로 실행되기에), 하드웨어의 작업과 조화롭게 작동하기 때문이다.


 

어떤 컨커런시 모델이 가장 좋을까?


그래서, 최고의 컨커런시 모델은 어떤 것일까?


흔히 있는 일이지만, 정답은 당신의 시스템이 어떤 일을 하느냐에 달렸다. 당신의 작업이 자연히 병렬 처리, 독립되고 공유 상태가 없는 쪽을 요구한다면, 당신은 페러럴 워커 모델을 이용할 수 있다. 많은 작업들이 자연스럽게 병렬 처리를 요구하지는 않고 또 독립적이지 않은데, 이런 종류의 시스템들에서는 어셈블리 라인 컨커런시 모델의 강점이 크다고 믿는다. 그리고 페러럴 워커보다 더 나을 것이다. 모든 어셈블리 라인 코드를 직접 작성할 필요는 없다. Vert.x 와 같은 플렛폼에 코드의 많은 부분이 구현되어 있다. 개인적으로 Vert.x 와 같은 플렛폼에서 돌아가는 디자인을 찾을 것이다. Java EE 는 더이상 우위에 있지 않다. 내가 보기에는.

 

'Java > Concurrency' 카테고리의 다른 글

자바 쓰레드 시작하기  (0) 2017.04.09
컨커런시 vs. 페러럴리즘  (0) 2017.04.09
멀티쓰레딩의 단점  (0) 2017.04.09
멀티쓰레딩의 장점  (0) 2017.04.09
자바 컨커런시 / 멀티쓰레딩 튜토리얼  (0) 2017.04.09

 

이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/costs.html


 


 싱글쓰레드에서 멀티쓰레드로 가는 길에는 꼭 장점만 있는 것은 아니다. 여기에는 비용이 존재한다. 당신이 멀티쓰레드 구현을 할 수 있다는 이유로 어플리케이션에 멀티쓰레드를 적용해서는 안된다. 멀티쓰레드 적용에 따른 이득을 고려해야 하고, 이 이득이 그 비용보다 커야한다. 이를 판단하는 데에 의구심이 든다면, 추측에서 그치지 말고 어플리케이션의 성능이나 반응성을 실제로 테스트해보라.


더 복잡한 디자인

 멀티쓰레드 어플리케이션의 어떤 부분들은 싱글쓰레드보다 더 단순하긴 하지만, 그 외 다른 부분들은 더 복잡하다. 공유된 자원에 접근하는 멀티쓰레드로 실행되는 코드는 특별한 주의가 필요하다. 쓰레드의 상호작용은 늘, 전혀 단순하지 않다. 잘못된 쓰레드 동기화에서 발생하는 에러가 발견되고 고쳐지기는 매우 어렵다.


컨텍스트 스위칭의 간접 비용

 CPU가 한 쓰레드에서 다른 쓰레드로 전환할 때, CPU는 현재 쓰레드의 로컬 데이터, 프로그램 포인터(또는 기타등등)을 저장하고 다음에 실행될 쓰레드의 로컬 데이터, 프로그램 포인터(또는 기타등등)을 불러올 필요가 있다. 이 스위칭을 '컨텍스트 스위칭'이라 한다. CPU는 한 쓰레드의 컨텍스트에서 실행중인 상태를 다른 쓰레드의 컨텍스트에서 실행중인 것으로 전환한다.

컨텍스트 스위칭의 비용은 저렴하지 않다. 당신은 이 스위칭이 필요 이상으로 발생하기를 원하지 않을 것이다.

위키피디아에서 컨텍스트 스위칭에 관한 자료를 읽어볼 수 있다.


자원 소비의 증가

 한 쓰레드가 가동되기 위해서는 컴퓨터의 자원이 필요하다. 쓰레드는 CPU의 시간 뿐만 아니라 로컬 스택을 유지하기 위한 메모리도 필요하다. 이것은 또한 쓰레드를 관리하기 위한 오퍼레이팅 시스템 내부의 자원을 차지하기도 한다. 아무 작업도 하지 않고 기다리는 쓰레드 100개를 생성하는 프로그램을 만들어보고 이 프로그램이 돌아갈 때 메모리를 얼마나 점유하는지 지켜보라.


'Java > Concurrency' 카테고리의 다른 글

자바 쓰레드 시작하기  (0) 2017.04.09
컨커런시 vs. 페러럴리즘  (0) 2017.04.09
컨커런시 모델  (0) 2017.04.09
멀티쓰레딩의 장점  (0) 2017.04.09
자바 컨커런시 / 멀티쓰레딩 튜토리얼  (0) 2017.04.09

이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/benefits.html



멀티쓰레딩이 그 위험요소에도 불구하고 여전히 사용되는 이유는 얻을 수 있는 장점이 있기 때문이다. 그 중 몇가지는 다음과 같다.

  • 향상된 자원 활용성
  • 상황에 따른 더 간단한 프로그램 디자인
  • 프로그램의 반응성 향상

향상된 자원 활용성


로컬 파일 시스템의 파일을 읽고 처리하는 어플리케이션을 생각해보자. 디스크에서 파일을 읽는 데에 5초가 소요되고, 이를 처리하는 데에는 2초가 소요된다. 2개의 파일을 가공한다면 다음과 같은 시간이 소요된다.


  5 seconds reading file A
  2 seconds processing file A
  5 seconds reading file B
  2 seconds processing file B
-----------------------
 14 seconds total


읽기 작업에서는, 디스크가 데이터를 읽는 것을 기다리는 데에 CPU의 시간 대부분을 소모한다. 이 시간동안 CPU는 일하지 않는다. 이건 다른 것이 될 수 있다. 작업의 순서를 바꾼다면, CPU의 효율성은 더 나아질 수 있다.


  5 seconds reading file A
  5 seconds reading file B + 2 seconds processing file A
  2 seconds processing file B
-----------------------
 12 seconds total


CPU는 첫 번째 파일이 읽혀지길 기다린다음 두 번째 파일을 읽기 시작한다. 두 번쨰 파일이 읽혀지는 동안, CPU 프로세스들은 첫 번째 파일을 처리한다. 여기서, 디스크에서 파일이 읽히는 동안 CPU는 거의 놀고 있다는 점을 기억하라.

보통의 경우, CPU는 입출력을 기다리는 동안 다른 작업을 할 수 있는데, 이건 디스크 입출력 뿐만 아니라 네트워크 입출력 혹은 사용자의 입력의 경우도 마찬가지이다. 네트워크와 디스크 입출력은 CPU와 메모리 입출력보다 상당히 느리다.


상황에 따른 더 간단한 프로그램 디자인


 당신이 싱글쓰레드 어플리케이션에서 위와 같은 작업 순서로 읽기와 처리 작업을 직접 한다면, 각 파일에 대한 읽기/처리 작업 상태를 계속 파악하고 있어야 할 것이다. 한 파일에 대한 읽기/쓰기 작업을 두 개의 쓰레드에게 각각 맡긴다면, 이 쓰레드들은 디스크가 파일을 읽어올 때까지 블록 상태에 놓일 것이다. 이 디스크의 작업을 기다리는 동안, 다른 쓰레드들은 파일의 이미 읽혀진 부분들에 대한 처리 작업을 위해 CPU를 사용할 수 있다. 이 과정은 디스크가 파일을 읽어 메모리에 적재하기 위해 계속해서 가동되는(kept busy at all times) 결과를 낳는다. 이건 디스크과 CPU 모두에게 더 효율적인 운용 방법이 된다. 각 쓰레드는 한 파일에만 집중할 수 있기에, 이는 또한 프로그램에게도 더 쉬운 것이다.



프로그램의 반응성 향상


 싱글쓰레드를 멀티쓰레드로 바꾸는 또다른 목적은 어플리케이션의 더 나은 반응성을 위해서이다(to achieve a more responsive). 몇 포트로 사용자의 요청을 받는 서버 어플리케이션을 생각해보자. 하나의 요청이 오면 어플리케이션은 이를 처리하고 대기 상태로 돌아간다(listening). 이런 서버의 동작은 다음과 같다.


 
  while(server is active){
    listen for request
    process request
  }


한 요청이 처리되는 데에 긴 시간이 소요되면, 그동안 다른 새로운 클라이언트의 요청을 받을 수가 없다. 서버가 요청 대기상태여야만 클라이언트의 요청은 처리될 수 있다.

이를 대체할 수 있는 디자인은 요청 대기 쓰레드가 워커 쓰레드에게 요청을 맡기고 자신은 즉각 대기 상태로 돌아가는 형태가 될 수 있다. 워커 쓰레드는 요청을 처리하고 클라이언트에게 결과를 전송한다. 이 디자인은 다음과 같다.


  while(server is active){
    listen for request
    hand request to worker thread
  }


이 디자인에서 서버 쓰레드는 더 빠르게 대기 상태로 회귀할 것이고, 클라이언트들은 서버에게 요청을 보낼 수 있다. 서버의 반응성이 향상된 것이다.

데스크톱 어플리케이션의 경우도 같다. 당신의 클릭 버튼이 긴 작업 시간을 요구한다면, 그리고 이 작업의 쓰레드가 윈도우나 버튼을 업데이트하는 쓰레드라면, 어플리케이션은 이 작업 동안 반응할 수 없는 상태에 될 것이다. 여기서 이 작업이 워커 쓰레드로 전달된다면 워커 쓰레드가 작업을 처리하는 동안 윈도우 쓰레드는 유저의 요청을 받을 수 있다. 워커 쓰레드가 작업을 마치면 윈도우 쓰레드에게 신호를 보낸다. 윈도우 쓰레드는 어플리케이션 윈도우에 작업 결과를 업데이트한다. 워커 쓰레드가 존재하는 프로그램 디자인의 반응성은 보다 향상될 것이다.



'Java > Concurrency' 카테고리의 다른 글

자바 쓰레드 시작하기  (0) 2017.04.09
컨커런시 vs. 페러럴리즘  (0) 2017.04.09
컨커런시 모델  (0) 2017.04.09
멀티쓰레딩의 단점  (0) 2017.04.09
자바 컨커런시 / 멀티쓰레딩 튜토리얼  (0) 2017.04.09

이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/index.html



 오래전 컴퓨터는 단일 CPU로 가동되고 한번에 한 프로그램만 실행할 수 있었다. 후에 멀티태스킹이 가능한 컴퓨터가 출현하였고, 여러 프로그램(작업 or 프로세스)을 동시에 실행하는 일이 가능해졌다. 사실 이 멀티태스킹은 엄밀히 말하면 '동시에' 실행되는 것이 아니었다. 여러 프로그램이 하나의 CPU를 공유하였고, 오퍼레이팅 시스템이 프로그램들 사이에서 아주 짧은 시간을 두고 포커스를 전환(switching)하는 원리였다.


멀티태스킹의 출현은 소프트웨어 개발자들에게 새로운 과제를 주었다. 하나의 프로그램이 더이상 CPU의 시간과 메모리, 리소스를 독점할 수 없게 되었고, '좋은 프로그램(A "good citizen" program)'이란 다른 프로그램들이 CPU의 자원을 활용할 수 있도록, 사용한 자원을 해제할 수 있는 것이어야 한다. 


지금의 멀티태스킹은 다수의 쓰레드가 같은 프로그램 안에서 실행되는 것이다. 한 쓰레드의 실행은 한 CPU가 실행중인 하나의 프로그램이라 할 수 있다. 당신이 한 프로그램을 실행하는 멀티쓰레드를 가질 때, 이것은 마치 다수의 CPU가 하나의 프로그램을 실행하는 것과 같은 일이 된다.


 어떤 프로그램들에게 멀티쓰레딩은 성능 향상에 있어 훌륭한 방법이 된다. 그러나, 멀티쓰레딩은 멀티태스킹과 다르고, 더 어려운 일이기도 하다. 하나 이상의 쓰레드는 한 프로그램 안에서 실행될 수 있고, 이런 이유로 읽기와 쓰기 작업이 같은 메모리에 동시에 발생된다. 이 현상은 싱글쓰레드 프로그램에선 보지 못했던 에러를 뱉어낼 수 있다. 이러한 에러들 중 몇몇은 싱글 CPU 머신에서는 좀처럼 보이지 않는데, 왜냐하면 이런 경우에는 두개의 쓰레드가 '동시에' 실행되는 일은 절대로 일어나지 않기 때문이다. 요즘의 컴퓨터는 멀티코어 CPU를 가지고 나오며, 아예 CPU 자체가 두 개 이상인 것들도 존재한다. 이것이 의미하는 바는 별개의 쓰레드들이  별개의 코어 혹은 CPU에서 동시에 실행될 수 있다는 것이다.



만일 하나의 쓰레드가 어떤 하나의 메모리 영역을 읽는데 다른 쓰레드가 이 영역에 쓰기 작업을 한다면, 첫번째 쓰레드가 읽게 되는 값은 어떤 것일까? 쓰기 작업 이전의 값? 두번째 쓰레드가 쓰기 작업을 마치고 난 다음의 값? 두가지가 섞인 어떤 값? 아니면, 두 쓰레드가 동시에 한 메모리 영역에 쓰기 작업을 한다면? 이 작업 뒤에는 어떤 값이 남겨질까? 첫번째 쓰레드의 값? 두번째 쓰레드의 값? 두 값이 섞인 값?


적절한 예방책이 없다면 이런 상황은 얼마든지 가능하며, 그 결과는 예상할 수 없을 것이다. 이 결과는 때에 따라 바뀔 수 있는 것이다. 때문에 개발자가 적절한 예방책을 숙지하는 것은 중요한 일이다 - 적절한 예방책을 숙지하는 일이란, 메모리, 파일, 데이터베이스 기타 등등 과 같은 공유된 자원을 쓰레드가 어떻게 접근하는지 컨트롤하는 방법을 배우는 것이다. 이것이 이 자바 컨커런시 튜토리얼이 다루는 토픽 중 하나이다.



자바의 멀티쓰레딩과 컨커런시


 자바는 개발자가 멀티쓰레딩을 쉽게 사용할 수 있게끔 만들어진 초기의 언어들 중 하나였다. 자바는 애초부터 멀티쓰레딩 능력을 가지고 있었는데, 때문에 자바 개발자들은 위에서 설명한 문제점들을 자주 직면한다. 이것이 내가 이 자바 컨커런시 코스(trail)를 작성이는 이유이다. 내가 그랬던 것처럼, 자바 개발자라면 누구나 이 코스에서 도움을 받을 수 있다.


이 코스는 주로 자바에서의 멀티쓰레딩에 관하여 다룰 것이지만, 멀티쓰레딩에서의 문제점들 중 몇가지는 멀티태스킹이나 분산처리 시스템 환경에서 발생되는 문제점들과 비슷하다. 때문에 멀티태스킹과 분산처리 시스템에 대한 레퍼런스도 존재할 것이다. 이런 이유로 '멀티쓰레딩' 보다는 '컨커런시' 가 더 어울린다.



2015년, 그리고 이후의 자바 컨커런시


 자바 컨커런시 서적이 처음 쓰여지고 자바 5 컨커런시 유틸리티가 배포된 이래로 컨커런트 아키텍처의 세계에는 많은 일들이 있었다. Vert.x와 Play / Akka and Qbit 과 같은 새로운, 비동기적인 'shared-nothing' 플랫폼과 API들이 나왔다. 이 플랫폼들은 스탠다드 자바/JEE의 쓰레딩 컨커런시 모델과는 다른 컨커런시 모델을 사용한다. 새로운 non-blocking 컨커런시 알고리즘이 발표되었고, LMax Disrupter 와 같은 새로운 non-blocking 툴들이 우리의 툴킷에 추가되었다. Fork/Join 프레임워크와 함께 새로운 함수형 프로그래밍 병행 구조가 자바 7 과 자바 8 의 컬렉션 스트림 API에 소개되었다.


나는 이 모든 새로운 개발물들과 함께 이 컨커런시 튜토리얼을 업데이트한다. 그러니까, 이 튜토리얼은 진행중 에 있다. 새 튜토리얼은 시간이 허락될 때면 언제든 개재될 것이다.



'Java > Concurrency' 카테고리의 다른 글

자바 쓰레드 시작하기  (0) 2017.04.09
컨커런시 vs. 페러럴리즘  (0) 2017.04.09
컨커런시 모델  (0) 2017.04.09
멀티쓰레딩의 단점  (0) 2017.04.09
멀티쓰레딩의 장점  (0) 2017.04.09

+ Recent posts