자바병렬프로그래밍-Chapter06-작업실행-task

자바병렬프로그래밍 06. 작업 실행 (Task, Thread, Executor, Future, Callable, ThreadPool)

  • 대부분의 병렬 애플리케이션은 [작업 task]을 실행하는 구조가 효율적으로 구성되어 있다.
  • 여기서 작업 task란 추상적이면서 명확하게 구분된 업무의 단위를 말한다.
    • 애플리케이션이 해야할 일을 작업이라는 단위로 분할하면 프로그램의 구조를 간결하게 잡을 수 있고, 트랜잭션의 범위를 지정함으로써 오류에 효과적으로 대응할 수 있고, 작업 실행 부분의 병렬성을 자연스럽게 극대화할 수 있다.

6.1 스레드에서 작업 실행

  • 프로그램에서 일어나는 일을 작업 task의 단위로 재구성하고자 한다면, 가장 먼저 해야할 일은 작업의 범위를 정하는 일이 최우선이다.
  • 작업 task는 다른 작업 task의 상태, 결과, 부수 효과 등에 영향을 받지 않는 동립적인 동작이 갖추어 져야 한다.
    • 이런 독립성이 갖추어 져야 병렬성을 보장할 수 있다.
    • 독립적인 작업이어야 적절한 자원이 확보된 상태에서 병렬로 실행이 가능하다.
    • 작업을 스케줄링하거나 부하 분산 load balancing을 하고자 할 때 폭넓은 유연성을 얻으려면 각 작업이 애플리케이션의 전체적인 업무 내용 가운데 충분히 작은 부분을 담당하도록 구성되어 있어야 한다.

  • 서버 애플리케이션

    • 부하가 걸리지 않은 일반적인 상황에서 항상 충분한 속도와 빠른 속도를 보여줘야 한다.
    • 사용자는 원하는 서비스를 즉각적으로 받을 수 있기를 원한다.
    • 애플리케이션에 부하가 가해지는 상황에서 그냥 죽어버려서는 안된다.
    • 부하에 따라 성능이 점진적으로 떨어지도록 설계돼 있어야 한다.
  • 서버 애플리케이션이 위와 같은 특성을 갖게 하려면 작업의 단위를 적절하게 설정해야한다.

  • 작업 실행 정책 task execution policy을 면밀히 구성해야 한다.

  • 서버 애플리케이션의 작업 단위 예제 - 웹 서버, 메일 서버, 파일 서버, EJB 컨테이너, 데이터베이스 서버

6.1.1 작업을 순차적으로 실행

  • 작업을 실행하는 가장 간단한 방법은 단일 스레드에서 작업 목록을 순차적으로 실행하는 방법이다.
public class SingleThreadWebServer {
   public static void main(String[] args) throws IOException {
       ServerSocket socket = new ServerSocket(80);
       while (true) {
           Socket connection = socket.accept();
           handleRequest(connection);
       }
   }

   private static void handleRequest(Socket connection) {
       // request-handling logic here
   }
}
  • 80 포트에 접속하는 클라이언트 요청을 순차적으로 처리한다.
  • SingleThreadWebServer는 한 번에 하나의 요청만 처리할 수 있기 떄문에 실제 상황에서 성능이 엄청나게 떨어진다.
  • 웹 서버에 대한 클라이언트의 요청을 처리하는 과정에는 대부분 약간의 연산과 I/O 작업이 대부분을 차지한다.

6.1.2 작업마다 스레드를 직접 생성

  • 반응 속도를 훨씬 높일 수 있는 방법 가운데 하나는 요청이 들어올 때마다 새로운 스레드를 하나씩 만들어 실행시키는 방법이다.
public class ThreadPerTaskWebServer {
   public static void main(String[] args) throws IOException {
       ServerSocket socket = new ServerSocket(80);
       while (true) {
           final Socket connection = socket.accept();
           Runnable task = new Runnable() {
               public void run() {
                   handleRequest(connection);
               }
           };
           new Thread(task).start();
       }
   }

   private static void handleRequest(Socket connection) {
       // request-handling logic here
   }
}
  • ThreadPerTaskWebServer는 구조만 본다면 단일 스레드 구조와 크게 다르지 않다.

  • 클라이언트가 접속할 때마다 반복문에서 해당 클라이언트의 요청 처리를 담당하는 새로운 스레드를 매번 생성한다는 차이점이 있다.

  • 이렇게 변경하면 크게 세 가지 결과를 얻을 수 있따.

1. 작업을 처리하는 기능이 메인 스레드에서 떨어져 나온다. 따라서 메인 반복문은 다음 클라이언트의 접속을 기다리는 부분으로 굉장히 빨리 넘어갈 수 있다. 서버가 이렇게 구성되어 있으면 클라이언트는 이전 작업이 끝나기 이전에라도 언제든지 서버에 접속해 요청을 전송할 수 있기 때문에 서버의 응답 속도를 높여 준다.
2. 동시에 여러 작업을 병렬로 처리할 수 있기 떄문에 두 개 이상의 요청을 받아 동시에 처리할 수 있다. 만약 서버의 하드웨어에 여러 개의 CPU가 장착되어 있다면 전반적으로 처리 속도를 향상시킬 수 있고, 각 작업에서 I/O 기능이 실행되기를 기다리는 부분이 있거나 락을 확보하기 위해 대기하는 부분 또는 기타 특정 자원을 사용하기 위해 대기하는 부분이 있는 경우에 서버의 처리 속도를 높여줄 수 있다.
3. 실제 작업을 처리하는 스레드의 프로그램은 여러 클라이언트가 접속하는 경우 동시에 동작할 가능성이 매우 높기 때문에 스레드 안전성을 확보해야 한다.
  • 순차 처리 방법보다 속도가 크게 향상되며 웬만한 부하까지는 견딜 수 있다.
  • 다만 클라이언트가 접속해 요청을 전송하는 속도에 비해 요청을 처리해 응답을 넘겨주는 속도가 빨라야 한다는 제약이 있다.

6.1.3 스레드를 많이 생성할 때의 문제점

  • 특정 상황에서 엄청나게 많은 대량의 스레드가 생성될 수 있다.

  • 이떄 아래와 같은 단점이 발생한다.

  • 스레드 라이프 사이클 문제 : 스레드를 생성하는 과정에 일정량의 시간이 필요하다. 기본적인 딜레이가 발생하고, JVM과 운영체제는 몇가지 기초적인 작업을 수행한다. 간단한 작업이라면 매번 새로운 스레드를 생성하는 일이 작업에서 많은 부분을 차지할 수 있다.

  • 자원 낭비 : 실행 중인 스레드는 시스템 자원, 특히 메모리를 소모한다. 하드웨어에 실제로 장착되어 있는 프로세서보다 많은 수의 스레드가 만들어져 동작 중이라면, 실제로는 대부분의 스레드가 대기(idle) 상태에 머무른다. 이렇게 대기 상태에 머무르는 스레드가 많아지면 많아질수록 많은 메모리를 필요로 하며, JVM 가비지 콜렉터에 가해지는 부하가 늘어날 뿐만 아니라 CPU를 사용하기 위해 여러 스레드가 경쟁하는 모양새가 되기 떄문에 메모리 이외의 많은 자원을 소모한다.

    • 시스템에 꽂혀 있는 CPU 개수에 해당하는 스레드가 동작 중이라면, 스레드를 더 만들어 낸다 해도 성능이 직접적으로 개선되지 않을 수 있으며 오히려 악영향을 미칠 가능성도 있다.
  • 안정성 문제 : 모든 시스템에는 생성할 수 있는 스레드의 개수가 제한되어 있다. 플랫폼과 운영체제마다 다르고, JVM을 실행할 때 지정하는 인자나 Thread 클래스에 필요한 스택의 크기에 따라서 달랒기도 한다.

  • 일정 수준까지는 스레드를 추가로 만들어 사용해서 성능상의 이점을 얻을 수 있지만, 특정 수준을 넘어간다면 성능이 떨어지게 된다.

6.2 Executor 프레임워크

  • 작업 task는 논리적인 업무의 단위이며, 스레드는 특정 작업을 비동기적으로 동작시킬 수 있는 방법을 제공한다.

  • 앞서 하나의 스레드에서 여러 작업을 순차적으로 실행시키는 방법과 각 작업을 각각의 스레드에 실행시키는 방법을 살펴 봤다.

  • 순차 방법은 응답 속도와 전체적인 성능이 문제였고, 두번째 방법은 자원 관리 측면에서 허점이 있다.

  • 크기가 제한된 큐 bounded queue를 사용하면 부하가 크게 걸리는 애플리케이션의 메모리를 모두 소모해 버리지 않도록 통제하는 방법이 있다.

  • 스레드 풀 thread pool 또한 스레드 관리 측면에서 이와 같은 통제력을 갖출 수 있도록 해주며, Executor 프레임워크의 일부분으로 유연하게 사용할 수 있는 스레드 풀이 있다.

  • 작업을 실행하고자 할때는 Thread보다 Executor가 훨씬 추상화가 잘되어 있으며 사용하기 편하다.

public interface Executor {
  void execute(Runnable command);
}
  • 굉장히 단순한 인터페이스 이지만, 아주 다양한 여러 가지 종류의 작업 실행 정책을 지원하는 유연하면서도 강력한 비동기적 작업 실행 프레임워크의 근간을 이루는 인터페이스이다.

  • Executor작업 등록 task submission작업 실행 task execution을 분리하는 표준적인 방법이며, 각 작업은 Runnable 형태로 정의한다. Executor인터페이스를 구현한 클래스는 작업의 라이프 사이클을 관리하는 기능도 갖고 있고, 몇가지 통계값을 뽑아내거나 애플리케이션의 작업 실행 과정을 관리하고 모니터하는 기능도 갖고 있다.

  • Executor는 프로듀서-컨슈머 패턴에 기반하고 있다.

6.2.1 예제: Executor를 사용한 웹서버

public class TaskExecutionWebServer {
    private static final int NTHREADS = 100;
    private static final Executor exec
            = Executors.newFixedThreadPool(NTHREADS);

    public static void main(String[] args) throws IOException {
        ServerSocket socket = new ServerSocket(80);
        while (true) {
            final Socket connection = socket.accept();
            Runnable task = new Runnable() {
                public void run() {
                    handleRequest(connection);
                }
            };
            exec.execute(task);
        }
    }

    private static void handleRequest(Socket connection) {
        // request-handling logic here
    }
}
  • 몇 가지 표준 Executor 가운데 100개의 고정된 스레드를 확보하는 풀을 사용했다.
  • Executor를 구현한 클래스를 다른 방법으로 사용하면 비슷한 기능에 다른 특성으로 동작하도록 손쉽게 동작할 수 있다.
  • 스레드를 직접 생성하면 동작 특성을 쉽게 변경할 수 없지면, Executor는 가능하다.

6.2.2 실행 정책

  • 작업을 등록하는 부분과 실행하는 부분을 서로 분리시켜두면 특정 작업을 실행하고자 할 때 코드를 많이 변경하거나 기타 여러 가지 어려운 상황에 맞닥뜨리지 않으면서도 실행 정책 execution policy를 언제든지 쉽게 변경할 수 있다.
- 작업을 어느 스레드에서 실행할 것인가?
- 작업을 어떤 순서로 실행할 것인가? ( FIFO, LIFO, 기타 다양한 우선순위 정책 )
- 동시에 몇 개 의 작업을 병렬로 실행할 것인가?
- 최대 몇 개까지의 작업이 큐에서 실행을 대기할 수 있게 할 것인가?
- 시스템에 부하가 많이 걸려서 작업을 거절해야 하는 경우, 어떤 작업을 희생양으로 삼아야 할 것이며, 작업을 요청한 프로그램에 어떻게 알려야 할 것인가?
- 작업을 실행하기 직전이나 실행한 직후에 어떤 동작이 있어야 하는가?
  • 실행 정책은 일종의 자원 관리 도구이다.
  • 가장 최적화된 실행 정책을 찾으려면 하드웨어나 소프트웨어적인 자원을 얼마나 확보할 수 있는지 확인해야 한다.
  • 애플리케이션의 성능과 반응속도가 요구사항에 얼마만큼 명시되어 있는지도 알아야 한다.
  • 병렬로 실행되는 스레드의 수를 제한한다면 아마도 애플리케이션이 경쟁하느라 애플리케이션의 성능이 떨어지는 일은 별로 보기 어려울 것이다.
  • 실행 정책과 작업 등록 부분을 명확하게 분리시켜두면 애플리케이션을 실제 상황에 적용하려 할 때 설치할 하드웨어와 기타 자원의 양에 따라 적절한 실행 정책을 임의로 지정할 수 있다.
프로그램 어디에서든 간에
`new Thread(runnable).start();`
와 같은 코드가 남아 있다면 조만간 이런 부분에 유연한 실행 정책을 적용할 준비를 해야할 것이며, 나중을 위해서 Executor를 사용해 구현하는 방안을 심각하게 고려해봐야 한다.

출처 :브라이언 괴츠, 더그 리, 팀 피얼스, 조셉 보우비어, 데이빗 홈즈, 조슈아 블로쉬 공저, 『 멀티코어를 100% 활용하는 자바 병렬 프로그래밍』, 에이콘(2008.7.30), 6장 인용.