본문 바로가기
Book/모던 자바 인 액션

[모던 자바 인 액션] Chapter 07. 병렬 데이터 처리와 성능 (2)

by ghan2 2024. 11. 1.

7.2 포크/조인 프레임워크

포크/조인 프레임워크는 병렬화할 수 있는 작업을 재귀적으로 작은 작업으로 분할한 다음에 서브태스크 각각의 결과를 합쳐서 전체 결과를 만들도록 설계되었다. 

  • ExecutorService 인터페이스를 구현한다. (서브태스크를 스레드 풀의 작업자 스레드에 분산 할당하는)

7.2.1) RecursiveTask 활용

스레드 풀을 이용하려면 RecursiveTask<R> 의 서브클래스를 만들어야 한다. 이 클래스는 추상메서드 compute를 가진다.

if (태스크가 충분히 작거나 더 이상 분할할 수 없으면) {
	순차적으로 태스크 계산
} else {
	태스크를 두 서브태스크로 분할
    태스크가 다시 서브태스크로 분할되도록 이 메서드를 재귀적으로 호출함
    모든 서브태스크의 연산이 완료될 때까지 기다림
    각 서브태스크 결과를 합침
}

 

포크/조인 프레임워크를 이용해서 병렬 합계를 수행할 수 있다. 

public class ForkJoinSumCalculator extends RecursiveTask<Long> {
    private final long[] numbers;
    private final int start;
    private final int end;
    private static final long THRESHOLD = 10_000;

    public ForkJoinSumCalculator(long[] numbers) {
        this(numbers, 0, numbers.length);
    }

    public ForkJoinSumCalculator(long[] numbers, int start, int end) {
        this.numbers = numbers;
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        int length = end - start;

        if (length < THRESHOLD) {
            return computeSequentially();
        }
        
        ForkJoinSumCalculator leftTask = new ForkJoinSumCalculator(numbers, start, start + length/2);
        leftTask.compute();
        
        ForkJoinSumCalculator rightTask = new ForkJoinSumCalculator(numbers, start + length/2, end);
        Long rightResult = rightTask.compute();
        Long leftResult = leftTask.join();
        
        return leftResult + rightResult;
    }
    
    private Long computeSequentially() {
        long sum = 0;
        
        for (int i = start; i < end; i++) {
            sum += numbers[i];
        }
        
        return sum;
    }
}

 

7.2.2) 포크/조인 프레임워크를 제대로 사용하는 방법

  • join 메서드를 태스크에 호출하면 태스크가 생산하는 결과가 준비될 때까지 호출자를 블록시킨다. 따라서 두 서브태스크가 모두 시작된 다음에 join을 호출해야 한다. 
  • RecursiveTask 내에서는 ForkJoinPool의 invoke 메서드를 사용하지 말아야 한다. 대신 compute나 fork 메서드를 직접 호출할 수 있다. 순차코드에서 병렬 계산을 시작할 때만 invoke를 사용한다.
  • 서브태스크에 fork 메서드를 호출해서 ForkJoinPool의 일정을 조절할 수 있다. 
  • 포크/조인 프레임워크를 이용하는 병렬 계산은 디버깅 하기 어렵다.
  • 병렬 스트림에서 살펴본 것처럼 멀티코어에 포크/조인 프레임워크를 사용하는 것이 순차 처리보다 무조건 빠를 거라는 생각은 버려야 한다. 

 

7.2.3) 작업 훔치기

앞선 예제에서는 덧셈을 수행할 숫자가 만 개 이하면 서브태스크 분할을 중단했다. 기준값을 바꿔가면서 실험해보는 방법 외에는 좋은 기준을 찾을 뾰족한 방법이 없다. 하지만 실제로는 코어 개수와 관계없이 적절한 크기로 많은 태스크를 포킹하는 것이 바람직하다. 

 

포크/조인 프레임워크에서는 작업 훔치기(Work Stealing)라는 기법으로 이 문제를 해결한다. 작업 훔치기 기법에서는 ForkJoinPool의 모든 스레드를 거의 공정하게 분할한다. 

이 그림을 보면 이해가 쉬운데, 각각의 스레드는 자신에게 할당된 태스크를 포함하는 이중 연결 리스트를 참조하면서 작업이 끝날 때 마다 큐의 헤드에서 다른 태스크를 가져와서 작업을 처리한다. 모든 태스크가 작업을 끝낼 때 까지, 즉 모든 큐가 빌 때까지 이 과정을 반복한다. 따라서 태스크의 크기를 작게 나누어야 작업자 스레드 간의 작업부하를 비슷한 수준으로 유지할 수 있다. 

 

이번 절에서는 숫자 배열을 여러 태스크로 분할하는 로직을 개발하며 예제를 살펴봤다. 그러나 분할 로직을 개발하지 않고도 병렬 스트림을 이용할 수 있었다. 즉, 스트림을 자동으로 분할해주는 기능이 있다는 사실을 이미 확인했다. 다음 절에서는 자동으로 스트림을 분할하는 기법인 Spliterator를 설명한다. 

 

7.3 Spliterator 인터페이스

(내용이 익숙하지 않아서 생략, 필요한 경우 다시 찾아보는게 나을듯)

 

7.4 마치며

  • 내부 반복을 이용하면 명시적으로 다른 스레드를 사용하지 않고도 스트림을 병렬로 처리할 수 있다.
  • 간단하게 스트림을 병렬로 처리할 수 있지만 항상 병렬 처리가 빠른 것은 아니다. 병렬 소프트웨어 동작 방법과 성능은 직관적이지 않을 때가 많으므로 병렬 처리를 사용했을 때 성능을 직접 측정해봐야 한다.
  • 병렬 스트림으로 데이터 집합을 병렬 실행할 때 특히 처리해야 할 데이터가 아주 많거나 각 요소를 처리하는 데 오랜 시간이 걸릴 때 성능을 높일 수 있다. 
  • 가능하면 기본형 특화 스트림을 사용하는 등 올바른 자료구조 선택이 어떤 연산을 병렬로 처리하는 것보다 성능적으로 더 큰 영향을 미칠 수 있다.
  • 포크/조인 프레임워크에서는 병렬화할 수 있는 태스크를 작은 태스크로 분할한 다음에 분할된 태스크를 각각의 스레드로 실행하며 서브태스크 각각의 결과를 합쳐서 최종 결과를 생산한다. 
  • Spliterator는 탐색하려는 데이터를 포함하는 스트림을 어떻게 병렬화할 것인지 정의한다.