7. 병렬 데이터 처리와 성능
자바 개발자는 컬렉션 데이터 처리 속도를 높이려고 따로 고민할 필요가 없음.
무엇보다 컴퓨터의 멀티코어를 활용해서 파이프라인 연산을 실행할 수 있음이 가장 중요한 특징.
자바7 이후 더 쉽게 병렬화를 수행하면서 에러를 최소화할 수 있도록 포크/조인 프레임워크 기능 제공.
1. 병렬 스트림
- 컬렉션에 parallelStream을 호출하면 병렬 스트림이 생성.
- 각각의 스레드에서 처리할 수 있도록 스트림 요소를 여러 청크로 분할한 스트림.
- 병렬 스트림을 이용하면 모든 멀티코어 프로세서가 각각의 청크를 처리하도록 할당 가능.
1.1 순차 스트림을 병렬 스트림으로 변환.
- 순차 스트림에 parallel 메서드를 호출하면 기존의 함수형 리듀싱 연산이 병렬 처리.
- 순차 스트림에 parallel을 호출해도 스트림 자체에는 아무 변화가 없음.
- 내부적으로는 parallel을 호출하면 이후 연산이 병렬로 수행해야 함을 의미하는 Boolean 플레그가 설정.
- 반대로 sequential로 병렬 스트림을 순차 스트림으로 변환 가능.
1.2 스트림 성능 측정.
- 성능을 최적화할때 세 가지 황금 규칙.
> 1. 측정
> 2. 측정
> 3. 측정...
- 올바른 자료구조를 선택해야 병렬 실행도 최적의 성능을 발휘 가능.
- 병렬화는 완전 꽁짜가 아님.
- 병렬화를 이용하려면 스트림을 재귀적으로 분할, 각 서브스트림을 서로 다른 스레드의 리듀싱 연산으로 할당, 이 결과를 하나의 값으로 합침.
- 멀티코어 간의 데이터 이동은 우리 생각보다 비쌈.
- 코어 간에 데이터 전송 시간보다 훨씬 오래 걸리는 작업만 병렬로 다른 코어에서 수행하는 것이 바람직.
1.3 병렬 스트림의 올바른 사용법.
- 병렬 스트림을 잘못 사용하면서 발생하는 많은 문제는 공유된 상태를 바꾸는 알고리즘을 사용하기 때문.
- 병렬 스트림과 병렬 계산에서는 공유된 가변 상태를 피해야 함.
1.4 병렬 스트림 효과적으로 사용하기.
- 확신이 서지 않는다면 직접 측정.
> 순차 스트림을 병렬 스트림으로 쉽게 변환 가능.
> 무조건 병렬 스트림으로 바꾸는 것이 능사가 아님.
> 따라서, 순차 스트림과 병렬 스트림 중 어떤 것이 좋을지 모르겠다면 적절한 벤치마크로 직접 성능 측정하는 것이 바람직.
- 박싱 주의.
> 자동 박싱과 언박싱은 성능을 크게 저하시킬 수 있는 요소.
> 자바8은 박싱 동작을 피할 수 있도록 기본형 특화 스트림 제공(IntStream, LongStream, DoubleStream)
> 되도록 기본형 특화 스트림을 사용하는 것이 좋음.
- 순차 스트림보다 병렬 스트림에서 성능이 떨어지는 연산 존재.
> limit, findFirst처럼 요소의 순서에 의존하는 연산을 병렬 스트림에서 수행하려면 비싼 비용을 치워야 함.
> findAny는 요소의 순서와 상관없이 연산하므로 findFirst보다 성능이 좋다.
> 정렬된 스트림에 unordered를 호출하면 비정렬된 스트림 획득 가능.
- 스트림에서 수행하는 전체 파이프라인 연산 비용 고려.
- 소량의 데이터에서는 병렬 스트림이 도움되지 않음.
> 병렬화 과정에서 생기는 부가 비용을 상쇄할 수 있을 만큼의 이득을 얻지 못하기 때문.
- 스트림을 구성하는 자료구조가 적절한지 확인.
- 스트림의 특성과 파이프라인의 중간 연산이 스트림의 특성을 어떻게 바꾸는지에 따라 분해 과정의 성능이 달라질 수 있음.
- 최종 연산의 병합 과정 비용 확인.
> 병합 과정의 비용이 비싸다면 병렬 스트림으로 얻은 성능의 이익이 서브스트림의 부분결과를 합치는 과정에서 상쇄될 수 있음.
표 7-1 스트림 소스와 분해성
소스 |
분해성 |
ArrayList |
훌륭함 |
LinkedList |
나쁨 |
IntStream.range |
훌륭함 |
Stream.iterate |
나쁨 |
HashSet |
좋음 |
TreeSet |
좋음 |
2. 포크/조인 프레임워크
- 병렬화할 수 있는 작업을 재귀적으로 작은 작업으로 분할한 다음에 서브태스크 각각의 결과를 합쳐서 전체 결과를 만들도록 설계.
- 서브태스크를 스레드 풀(ForkJoinPool)의 작업자 스레드에 분산 할당하는 ExecutorService 인터페이스 구현.
2.1 RecursiveTask 활용
- 스레드 풀을 이용하려면 RecursiveTask<R>의 서브클래스를 생성해야 함.
- R은 병렬화된 태스크가 생성하는 결과 형식 또는 결과가 없을 때는 RecursiveAction 형식.
> RecursiveTask를 정의하려면 추상 메서드 compute 구현 필요.
- compute 메서드는 테스크를 서브태스크로 분할하는 로직과 더 이상 분할할 수 없을 때 개별 서브태스크의 결과를 생산할 알고리즘을 정의.
- 일반적으로 어플리케잇ㄴ에서는 둘 이상의 ForkJoinPool을 사용하지 않음.
- 즉, 소프트웨어의 필요한 것에서 언제든 가져다 쓸 수 있도록 ForkJoinPool을 한 번만 인스턴스화해서 정적 필드에 싱글턴으로 저장.
ForkJoinSumCalculator 실행
- ForkJoinSumCalculator를 ForkJoinPool로 전달하면 풀의 스레드가 ForkJoinSumCalculator의 compute 메서드를 실행하면서 작업 수행.
- compute 메서드는 병렬로 실행할 수 있을만큼 태스크의 크기가 충분히 작아졌는지 확인, 아직 태스트의 크기가 크다고 판단되면숫자 배열의 반으로 분할해서 두 개의 새로운 ForkJoinSumCalculator로 할당.
> 그러면 다시 ForkJoinPool이 새로 생성된 ForkJoinSumCalculator를 실행.
> 결국 이 과정이 재귀적으로 반복되면서 주어진 조건을 만족할 떄 까지 태스크 분할을 반복.
2.2 포크/조인 프레임워크를 제대로 사용하는 방법
- join 메서드를 태스크에 호출하면 태스크가 생산하는 결과를 준비될 때까지 호출자를 블록.
> 따라서, 두 서브태스크가 모두 시작된 다음에 join을 호출 해야 함.
> 각각의 서브태스크가 다른 태스크가 끝나길 기다리는 일이 발생하며 원래 순차 알고리즘보다 느리고 복잡한 프로그램이 되어버릴 수 있음.
- RecursiveTask 내에서는 ForkJoinPool의 invoke 메서드를 사용하지 말아야 함.
> 대신 compute나 fork 메서드를 직접 호출 가능.
> 순차 코드에서 병렬 계산을 시작할 때만 invoke를 사용.
- 서브태스크에 fork 메서드를 호출해서 ForkJoinPool의 일정 조절가능.
> 왼쪽 작업과 오른쪽 작업 모두 fork 메서드를 호출하는 것이 자연스러울 것 같지만 한쪽 작업에는 fork를 호출하는 것보다 compute를 호출하는 것이 효율적.
> 두 서브태스크의 한 태스크에는 같은 스레드를 재사용할 수 있으므로 풀에서 불필요한 태스크를 할당하는 오버헤드를 피할 수 있음.
- 병렬 계산은 디버깅하기 어려움.
> 보통 IDE로 디버깅할 때 스택 프레이스로 문제가 일어난 과정을 쉽게 확인 가능하나, 포크/조인 프레임워크에서는 fork라 불리는 다른 스레드에서 compute를 호출하므로 스택 트레이스가 도움되지 않음.
- 멀티코어에 포크/조인 프레임워크를 사용하는 것이 순차 처리보다 무조건 빠를 거라는 생각은 금물.
> 병렬 처리로 성능을 개선하려면 태스크를 여러 독립적인 서브태스크로 분할 가능해야 함.
> 각 서브태스크의 실행시간은 새로운 태스크를 포킹하는 데 드는 시간보다 길어야 함.
2.3 작업 훔치기
- 코어 개수와 관계없이 적절한 크기로 분할된 많은 태스크를 포킹하는 것이 바람직.
- 이론적으로는 코어 개수만큼 병렬화된 태스크로 작업부하를 분할하며 모든 CPU 코어에서 태스크를 실행할 것이고 크기가 같은 각각의 태스크는 같은 시간에 종료될 것이라 생각할 수 있음.
- 하지만 복잡한 시나리오가 사용되는 현실에서는 각각의 서브태스크의 작업완료 시간이 크게 달라질수 있음.
- 분할 기법이 효율적이지 않았기 때문일수도 있고, 예기치 않게 디스크 접근 속도가 저하되었거나 외부 서비스와 협력하는 과정에서 지연이 발생 할 수도 있기 때문.
- 작업 훔치기라는 기법을 통해 위 문제를 해결.
> ForkJoinPool의 모든 스레드를 거의 공정하게 분할함.
> 각각의 스레드는 자신에세 할당된 태스크를 포함하는 이중 연결 리스트를 참조하면서 작업이 끝날 때 마다 큐의 헤드에서 다른 테스크를 가져와 작업을 처리.
> 이때 한 스레드는 다른 스레드 보다 자신에게 할당된 태스크를 더 빨리 처리 가능.
> 즉, 다른 스레드는 바쁘게 일하고 있는데 한 스레드는 할일이 다 떨어진 상황. 이때 할일이 없는 스레드는 유휴 상태로 바뀌는 것이 아니라 다른 스레드 큐의 꼬리에서 작업을 훔쳐옴.
> 모든 태스크가 작업을 끝낼 때 까지 모든 큐가 빌 때까지 이 과정을 반복.
> 따라서, 태스크의 크기를 작게 나누어야 작업자 스레드 간의 작업부하를 비슷한 수준으로 유지 가능.
3. Spliterator
- Spliterarot는 "분할할 수 있는 반복자" 라는 의미
- Iterator처럼 소스의 요소 탐색 기능을 제공하는 점은 같지만 Spliterator는 병렬 작업에 특화.
- Spliterator 인터페이스는 여러 메서드를 정의
> tryAdvance: Spliterator의 요소를 하나씩 순차적으로 소비하면서 탐색해야 할 요소 존재 시 true 반환.
> trySplit: Spliterator의 일보 요소(자신이 반환한 요소)를 반할해서 두 번쨰 Spliterator를 생성.
> estimateSize: 탐색해야할 요소 수 정보를 제공.
3.1 분할 과정
- 스트림을 여러 스트림으로 분할하는 과정은 재귀적으로 일어남.
- trySplit의 결과가 null이 될때까지 재귀적으로 요소를 분할.
- 이 분할과정은 characteristics 메서드로 정의하는 Spliterator의 특성에 영향을 받음.
Spliterator 특성
- characteristics라는 추상 메서드 정의.
- Characteristics 메서드는 Spliterator 자체의 특성 집합을 포함하는 int 반환.
표 7-2 Spliterator 특성
특성 |
의미 |
ORDERED |
리스트처럼 요소에 정해진 순서가 있으므로 Spliterator는 요소를 탐색하고 분할할 때 이 순서에 유의. |
DISTINCT |
x, y 두 요소를 방문했을 때 x.equals(y)는 항상 false를 반환. |
SORTED |
탐색된 요소는 미리 정의된 정렬 순서를 따름. |
SIZED |
크기가 알려진 소스로 Spliterator를 생성했으므로 estimatedSize()는 정확한 값을 반환. |
NONNULL |
탐색하는 모든 요소는 null이 아님. |
IMMUTABLE |
이 Spliterator의 소스는 불변. 즉, 요소를 탐색하는 동안 요소를 추가하거나, 삭제하거나, 고칠 수 없음. |
CONCURRENT |
동기화 없이 Spliterator의 소스를 여러 스레드에서 동시게 고칠 수 있음. |
SUBSIZED |
이 Spliterator 그리고 분할된 모든 Spliterator는 SIZED 특성을 갖음. |
4. 요약
- 내부 반복을 이용하면 명시적으로 다른 스레드를 사용하지 않고도 스트림을 병렬 처리 가능.
- 간단하게 스트림을 병렬로 처리가능. 단, 항상 병렬 처리가 빠른 것은 아님.
> 병렬 소프트웨어 동작 방법과 성능은 직관적이지 않을 때가 많으므로 병렬 처리를 사용했을 때 성능을 직접 측정해봐야 함.
- 병렬 스트림으로 데이터 집합을 병렬 실행할 때 특히 처리해야 할 데이터가 아주 많거나 각 요소를 처리하는 데 오랜 시간이 거릴 때 성능을 높일 수 있음.
- 기본형 특화 스트림을 사용하는 등 올바른 자료구조 선택이 어떤 연산을 병렬로 처리하는 것보다 성능적으로 더 큰영향을 미칠 수 있음.
- 포크/조인 프레임워크에서는 병렬화할 수 있는 태스크를 작은 태스크로 분할한 다음에 분할된 태스크를 각각의 스레드로 실행하며 서브태스크 각각의 결과를 합쳐서 최종 결과 생성.
- Spliterator는 탐색하려는 데이터를 포함하는 스트림을 어떻게 병렬화할 것인지 정의.
'Java8 > Java 8 in Action' 카테고리의 다른 글
디폴트 메서드 (0) | 2017.07.05 |
---|---|
리팩토링, 테스팅, 디버깅 (0) | 2017.07.03 |
스트림으로 데이터 수집 (0) | 2017.06.29 |
스트림 활용 예제 소스 (0) | 2017.06.26 |
스트림 활용 (0) | 2017.06.26 |