병렬 데이터 처리와 성능

병렬 스트림

컬렉션에 parallelStream을 호출하면 병렬 스트림이 생성된다.
병렬 스트림이란 각각의 스레드에서 처리할 수 있도록 스트림 요소를 여러 청크로 분할한 스트림이다.
병렬 스트림을 이용하면 모든 멀티코어 프로세서가 각각의 청크를 처리하도록 할당할 수 있다.

예시
만약 n을 입력받아 1부터 n까지 합계를 구하는 메서드를 구현한다고 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
public long sequentialSum(long n){
return Stream.iterate(1L, i->i+1)
.limit(n)
.reduce(0L, Long::sum);
}
//이걸 병렬로 처리하려면? 아주 간단하다!!!!!

public long parrallelSum(long n){
return Stream.iterate(1L, i -> i+1)
.limit(n)
.parallel() //병렬 스트림!!
.reduce(0L, Long::sum);
}

parallel메서드는 순차 스트림을 병렬 스트림으로 만들고,
sequential메서드는 병렬 스트림을 순차 스트림으로 만든다.

근데 이 두 메서드는 마지막으로 호출된 메서드가 전체 파이프라인에 영향을 미친다.
파이프라인에 parallel, sequential parallel 이렇게 마구잡이로 써도, 결국 가장 마지막에 나온 메서드가 병렬/순차를 결정한다.

하지만 병렬 스트림을 사용한다고 무조건 성능이 좋을까?

병렬 스트림이 오히려 순차 스트림보다 성능이 별로 좋아지지 않는 경우가 있다.
위 예시도 실제로 돌려보면, 일반적인 반복문 버전, 순차적 스트림 버전, 병렬 스트림 버전 이렇게 세가지를 비교하면,
반복문이 제일 빠르고, 순차적 스트림, 병렬 스트림 순이다.

왜 그럴까?
일반적 반복문은 저수준으로 동작하고, 기본값을 박싱언박싱을 할 필요가 없어서 더 빠르다.
그렇다면 같은 스트림이더라도 왜 병렬이 더 느렸는가?

이는 두가지로 요약된다.

  • 반복 결과로 박싱된 객체가 만들어지는데, 숫자를 더하려면 매번 언박싱을 해야함.
  • 반복 잡업은 병렬로 수행할 수 있는 독립 단위로 나누기 어렵다.

즉 해당 연산을 할 스트림 요소가 얼마나 되는지 컴퓨터는 모르니, 이를 청크로 나눌 수 없다는 의미다.

더 특화된 메서드 사용

예를 들어 LongStream.rangeClosed 메서드를 사용하면 iterate 메서드에 비해 두가지 장점을 가진다.

  • 기본형 long을 사용하므로 박싱언박싱 오버헤드가 사라짐.
  • rangeClosed는 쉽게 청크로 분할할 수 있는 숫자 범위 제공한다.

즉 이런 예시를 보면 알 수 있듯이,

병렬화는 공짜가 아니다!

효과적인 병렬화를 이뤄낼려면,

  • 스트림을 재귀적으로 분할해야 하고,
  • 각 서브 스트림을 서로 다른 스레드 리듀싱 연산으로 할당하고,
  • 이 결과를 하나의 값으로 합쳐야 한다.
  • 게다가 코어 간 데이터 전송 시간보다 훨씬 오래 걸리는 작업만 병렬로 설계해야 한다.

병렬 스트림의 올바른 사용법

자 일단 기존의 방식대로 명령형 프로그래밍 방식으로, n까지 자연수를 더하면서 공유된 누적자를 바꾸는 프로그램이다.

1
2
3
4
5
6
7
8
9
10
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 변수에 다수의 스레드가 동시에 데이터에 접근하는 데이터 레이스 문제가 발생할 수 있다.
즉 병렬 스트림과 병렬 계산을 사용할 때는 공유된 가변 상태를 피해야 한다.

올바른 병렬 스트림을 위한 지침

  1. 박싱을 주의하라(자바 8부터 제공하는 IntStream, LongStream, DoubleStream을 애용하자.)
  2. 요소의 순서에 의존하는 연산은 병렬 스트림이 효과적이지 않다.(limit, findFirst 등.. 요소가 상관없다면 unordered로 비정렬 스트림으로 하자)
  3. 만약 한 요소 당 연산 비용이 높아지면, 병렬 스트림이 효과적이다.
  4. 소량 데이터는 병렬 스트림이 효과보기 힘들다.
  5. 스트림을 구성하는 자료구조가 적잘한지 확인하라.(모든 요소를 손쉽게 파악할 수 있는 ArrayList가 LinkedList보다 유리하다.)
  6. 스트림의 특성과 중간 연산에 따라 분해 과정 성능이 달라진다.(중간연산에서 스트림을 분해하는 경우, 병렬처리가 더 손쉬워진다.)
  7. 최종 연산의 병합 과정 비용이 높으면, 병렬 스트림에서 얻은 이익이 각 서브스트림의 최종 연산 병합에서 상쇄된다.

포크/조인 프레임워크

포크/조인 프레임워크는 병렬화할 수 있는 작업을 재귀적으로 작은 작업으로 분할한 다음,
서브 태스크 결과를 합쳐서 전체 결과를 만들도록 설계됐다.
서브태스크를 스레드 풀의 작업자 스레드에 분산 할당하는 ExecutorService 인터페이스를 구현한다.

Share