[모던 자바 인 액션] Chapter07. 병렬 데이터 처리와 성능

SunYerim·2024년 2월 18일
0

언어

목록 보기
7/11

외부 반복을 내부 반복으로 바꾸면 네이티브 자바 라이브러리가 스트림 요소의 처리를 제어할 수 있다.

자바 7은 쉽게 병렬화를 수행하면서 에러를 최소화할 수 있도록 포크/조인 프레임워크기능을 제공한다.

7장에서는 스트림으로 데이터 컬렉션 관련 동작을 얼마나 쉽게 병렬로 실행할 수 있는지 다룬다.


7.1 병렬 스트림

스트림 인터페이스를 이용하면 간단하게 요소를 병렬로 처리할 수 있는데, 컬렉션에 parallelStream을 호출하면 병렬 스트림이 생성된다.

병렬 스트림은 각각의 스레드에서 처리할 수 있도록 스트림 요소를 여러 청크로 분할할 스트림이다.

다음은 숫자 n을 인수로 받아서 1 ~ n까지 모든 숫자의 합계를 반환해주는 메서드를 구현한 것이다.

7.1.1 순차 스트림을 병렬 스트림으로 변환하기

순차 스트림에 parallel 메서드를 호출하면 기존의 함수형 리듀싱 연산이 병렬로 처리된다.

public long parallelSum(long n) {
	return Stream.iterate(1L, i -> i + 1)
							 .limit(n)
							 .parallel() // 스트림을 병렬 스트림으로 변환
							 .reduce(0L, Long::Sum);
}

반대로 sequential로 병렬 스트림을 순차 스트림으로 바꿀 수 있다.

7.1.2 스트림 성능 측정

성능 최적화를 할 때는 측정이 가장 중요한데, 이를 도와주는 라이브러리들이 있다.

이는 책을 참고하도록 하자.

7.1.3 병렬 스트림의 올바른 방법

병렬 스트림을 잘못 사용하면서 발생하는 문제는 공유된 상태를 바꾸는 알고리즘을 사용하기 때문에 일어난다.

// n까지의 자연수를 더하면서 공유된 누적자를 바꾸는 프로그램을 구현함
public long sideEffectSum(long n) {
	Accumulator accumulator = new Accumulator();
	LongStream.rangeClosed(1, n).forEach(accumulator::add);
	return accumulator.total;
}

public class Accumulator {
	public long total = 0;
	public void add(long value) { total += value; }
}

위 코드는 순차 실행할 수 있도록 구현되어 있기때문에 병렬로 일어나면 문제가 발생한다.

특히 total을 접근할때마다 데이터 레이스 문제가 일어난다.

병렬 스트림과 병렬 계산에서는 공유된 가변 상태를 피해야한다는 사실을 확인할 수 있었는데, 추후에 자세히 다룬다.

7.1.4 병렬 스트림 효과적으로 사용하기

소스분해성
ArrayList훌륭함
LinkList나쁨
IntStream.range훌륭함
Stream.iterate나쁨
HashSet좋음
TreeSet좋음
  • 확신이 서지 않으면 직접 측정하라. 순차 스트림과 병렬 스트림 중 어떤 것이 좋을지 모르겠다면 적절한 벤치마크로 직접 성능을 측정하는 것이 바람직하다.
  • 박싱을 주의하라. 자동 박싱과 언박싱은 성능을 크게 저하시킬 수 있는 요소다. 되도록이면 기본형 특화 스트림을 사용하는 것이 좋다.
  • 순차 스트림보다 병렬 스트림에서 성능이 떨어지는 연산이 있다. 특히 limit이나 findFirst처럼 요소의 순서에 의존하는 연산을 병렬 스트림에서 수행하려면 비싼 비용을 치러야 한다.
  • 스트림에서 수행하는 전체 파이프라인 연산 비용을 고려하라.
  • 소량의 데이터에서는 병렬 스트림이 도움 되지 않는다.
  • 스트림을 구성하는 자료구조가 적절한지 확인하라.
  • 스트림의 특성과 파이프라인의 중간 연산이 스트림의 특성을 어떻게 바꾸는지에 따라 분해 과정의 성능이 달라질 수 있다.
  • 최종 연산의 병합 과정 비용을 살펴보라.

7.2 포크/조인 프레임워크

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

7.2.1 RecursiveTask 활용

스레드 풀을 이용하려면 RecursiveTask의 서브클래스를 만들어야 한다.

protected abstract R compute();

compute 메서드는 태스크를 서브태스크로 분할하는 로직과 더 이상 분할할 수 없을 때 개별 서브태스크의 결과를 생산할 알고리즘을 정의한다.

해당 알고리즘은 분할 후 정복알고리즘의 병렬화 버전이라고 생각하면 된다.

일반적으로 애플리케이션에서는 둘 이상의 ForkJoinPool을 사용하지 않는다. 즉, 소프트웨어의 필요한 곳에서 언제든 가져다 쓸 수 있도록 ForkJoinPool을 한 번만 인스턴스화해서 정적필드에 싱글턴으로 저장한다.

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

  • join 메서드를 태스크에 호출하면 태스크가 생산하는 결과가 준비될 때까지 호출자를 블록시킨다. 두 서브태스크가 모두 시작된 다음에 join을 호출해야 한다.
  • RecursiveTask내에서는 ForkJoinPool의 invoke 메서드를 사용하지 말아야 한다. 대신 compute나 fork 메서드를 직 호출할 수 있다. 순차 코드에서 병렬 계산을 시작할 때만 invoke를 사용한다.
  • 서브태스크에 fork 메서드를 호출해서 ForkJoinPool의 일정을 조절할 수 있다.
  • 포크/조인 프레임워크를 이용하는 병렬 계산은 디버깅하기 어렵다.
  • 멀티코어에 포크/조인 프레임워크를 사용하는 것이 순차 처리보다 무조건 빠를 거라는 생각을 버려야한다. 병렬 처리로 성능을 개선하려면 태스크를 여러 독립적인 서브태스크로 분할할 수 있어야 한다. 각 서브태스크의 실행시간은 새로운 태스크를 포킹하는 데 드는 시간보다 길어야 한다.

7.2.3 작업 훔치기

작업 훔치기 기법에서는 ForkJoinPool의 모든 스레드를 거의 공정하게 분할한다.

각각의 스레드는 자신에게 할당된 태스크를 포함하는 이중 연결 리스트를 참조하면서 작업이 끝날 때마다 큐의 헤드에서 다른 태스크를 가져와서 작업을 처리한다. 할 일이 없어진 스레드는 유휴 상태로 바뀌는 것이 아니라 다른 스레드의 큐의 꼬리에서 작업을 훔쳐온다. 모든 큐가 빌 때까지 해당 과정을 반복한다.

태스크의 크기를 작게 나누어야 작업자 스레드 간의 작업부하를 비슷한 수준으로 유지할 수 있다.

풀에 있는 작업자 스레드의 태스크를 재분배하고 균형을 맞출 때 작업 훔치기 알고리즘을 사용한다.

스트림을 자동으로 분할해주는 기능이 있는데 이는 자동으로 스트림을 분할하는 Spliterator에서 설명한다.


7.3 Spliterator 인터페이스

Spliterator는 분할할 수 있는 반복자라는 의미이다.

Iterator처럼 Spliterator는 소스의 요소 탐색 기능을 제공한다는 점은 같지만 Spliterator는 병렬 작업에 특화되어 있다.

// Spliteraotr 인터페이스
public interface Spliterator<T> {
	boolean tryAdvance(Consumer<? super t> action);
	Spliterator<T> trySplit();
	long estimateSize();
	int characteristics();
}
  • T는 Spliterator에서 탐색하는 요소의 형식을 가리킨다.
  • tryAdvance메서드 : Spliterator의 요소를 하나씩 순차적으로 소비하면서 탐색해야 할 요소가 남아있으면 참을 반환
  • trySplit메서드: Spliterator의 일부 요소를 분할해서 두 번째 Spliterator를 생성하는 메서드.
  • Spliterator에서는 estimateSize 메서드로 탐색해야 할 요소 수 정보를 제공할 수 있다.

7.3.1 분할 과정

스트림을 여러 스트림으로 분할하는 과정은 재귀적으로 일어난다.

Spliterator는 characteristics라는 추상 메서드도 정의한다. 해당 메서드는 Spliterator 자체의 특성 집합을 포함하는 int를 반환한다.

  • ORDERED: 리스트처럼 요소에 정해진 순서가 있으므로 Spliterator는 요소를 탐색하고 분할할 때 이 순서에 유의해야 한다.
  • DISTINCT: x, y 두 요소를 방문했을 때 x.equals(y)는 항상 false를 반환한다.
  • SORTED: 탐색된 요소는 미리 정의된 정렬 순서를 따른다.
  • SIZED: 크기가 알려진 소스로 Spliterator를 생성했으므로 estimatedSize()는 정확한 값을 반환한다.
  • NON-NULL: 탐색하는 모든 요소는 null이 아니다.
  • IMMUTABLE: 이 Spliterator의 소스는 불변이다. 즉, 요소를 탐색하는 동안 요소를 추가하거나, 삭제하거나, 고칠 수 없다.
  • CONCURRENT: 동기화 없이 Spliterator의 소스를 여러 스레드에서 동시에 고칠 수 있다.
  • SUBSIZED: 이 Spliterator 그리고 분할되는 모든 Spliterator는 SIZED 특성을 갖는다.

7.4 정리

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

0개의 댓글