티스토리 뷰
주류 언어 중, 동시성 프로그래밍 측면에서 자바는 항상 앞서갔습니다.
처음 릴리스된 1996년부터 스레드, 동기화, wait/notify를 지원했습니다.
자바 5부터는 동시성 컬렉션인 java.util.concurrent 라이브러리와 Executor 프레임워크를 지원했습니다.
자바 7부터는 고성능 병렬 분해 프레임워크인 fork-join 패키지를 추가했습니다.
그리고 자바 8부터는 parallel 메서드만 한 번 호출하면 파이프라인을 병렬 실행할 수 있는 스트림을 지원했습니다.
이처럼 자바로 동시성 프로그램을 작성하기가 점점 쉬워지고는 있지만, 이를 올바르고 빠르게 작성하는 일은 여전히 어려운 작업입니다.
동시성 프로그래밍을 할 때에는 안전성(safety)과 응답 가능(liveness) 상태를 유지하기 위해 애써야 하는데, 병렬 스트림 파이프라인 프로그래밍에서도 다를 바 없습니다.
아이템 45에서 다루었던 메르센 소수를 생성하는 프로그램을 다시 살펴봅시다.
public static void main(String[] args) {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(System.out::println);
}
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
이 프로그램을 제 컴퓨터 기준 즉각 소수를 찍기 시작해서 12.5초 만에 완료됩니다.
속도를 높이고 싶어 스트림 파이프라인의 parallel()을 호출하겠다는 순진한 생각을 했다고 칩시다.
🤔
이렇게 하면 성능은 어떻게 변할까요?
안타깝게도 이 프로그램은 아무것도 출력하지 못하면서 CPU는 90%나 잡아먹는 상태가 무한히 계속됩니다.
🤔
무슨 일이 벌어진 걸까요?
프로그램이 이렇게 느려진 원인은 사실 어이없게도, 스트림 라이브러리가 이 파이프라인을 병렬화하는 방법을 찾아내지 못했기 때문입니다.
환경이 아무리 좋더라도 데이터 소스가 Stream.iterate거나 중간 연산으로 limit를 쓰면 파이프라인 병렬화로는 성능 개선을 기대할 수 없습니다.
파이프라인 병렬화는 limit을 다룰 때 CPU 코어가 남는다면 원소를 몇 개 더 처리한 후 제한된 개수 이후의 결과를 버려도 아무런 해가 없다고 가정합니다.
그런데 이 코드의 경우 새롭게 메르센 소수를 찾을 때마다 그 전 소수를 찾을 때보다 두 배 정도 더 오래 걸립니다.
무슨 말인고 하니, 원소 하나를 계산하는 비용이 대략 그 이전까지의 원소 전부를 계산한 비용을 합친 것만큼 든다는 뜻입니다.
그래서 이 순진무결해 보이는 파이프라인은 자동 병렬화 알고리즘이 제 기능을 못하게 마비시킵니다.
이 이야기의 교훈은 간단합니다.
스트림 파이프라인을 마구잡이로 병렬화하면 안 됩니다.
성능이 오히려 끔찍하게 나빠질 수도 있습니다.
대체로 스트림의 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap의 인스턴스거나 배열, int 범위, long 범위일 때 병렬화의 효과가 가장 좋습니다.
이 자료구조들은 모두 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어서 일을 다수의 스레드에 분배하기에 좋다는 특징이 있습니다.
나누는 작업은 Spliterator가 담당하며, Spliterator 객체는 Stream이나 Iterable의 spliterator 메서드도 얻어올 수 있습니다.
이 자료구조들의 또 다른 중요한 공통점은 원소들을 순차적으로 실행할 때의 참조 지역성이 뛰어나다는 것입니다.
이웃한 원소의 참조들이 메모리에 연속해서 저장되어 있다는 뜻입니다.
하지만 참조들이 가리키는 실제 객체가 메모리에서 서로 떨어져 있을 수 있는데, 그러면 참조 지역성이 나빠집니다.
참조 지역성이 낮으면 스레드는 데이터가 주 메모리에서 캐시 메모리로 전송되어 오기를 기다리며 대부분 시간을 멍하게 보내게 됩니다.
따라서 참조 지역성은 다량의 데이터를 처리하는 벌크 연산을 병렬화할 때 아주 중요한 요소로 작용합니다.
참조 지역성이 가장 뛰어난 자료구조는 기본 타입의 배열입니다.
기본 타입 배열에서는 (참조가 아닌) 데이터 자체가 메모리에 연속해서 저장되기 때문입니다.
스트림 파이프라인의 종단 연산의 동작 방식 역시 병렬 수행 효울에 영향을 줍니다.
종단 연산에서 수행하는 작업량이 파이프라인 전체 작업에서 상당 비중을 차지하면서 순차적인 연산이라면 파이프라인 병렬 수행의 효과는 제한될 수밖에 없습니다.
종단 연산 중 병렬화에 가장 적합한 것은 축소입니다.
축소는 파이프라인에서 만들어진 모든 원소를 하나를 합치는 작업으로, Stream의 reduce 메서드 중 하나, 혹은 min, max, count, sum 같이 완성된 형태로 제공되는 메서드 중 하나를 선택해 수행합니다.
anyMatch, allMatch, noneMatch처럼 조건에 맞으면 바로 반환되는 메서드도 병렬화에 적합합니다.
반면, 가변 축소를 수행하는 Stream의 collect 메서드는 병렬화에 적합하지 않습니다.
컬렉션들을 합치는 부담이 크기 때문입니다.
직접 구현한 STream, Iterable, Collection이 병렬화의 이점을 제대로 누리게 하고 싶다면 spliterator 메서드를 반드시 재정의하고 결과 스트림의 병렬화 성능을 강도 높게 테스트합시다.
고효율 spliterator를 작성하기란 상당한 난이도의 일이고, 아쉽지만 이 책에서는 다루지 않습니다.
스트림을 잘못 병렬화하면 (응답 불가를 포함해) 성능이 나빠질 뿐만 아니라 결과 자체가 잘못되거나 예상 못한 동작이 발생할 수 있습니다.
결과가 잘못되거나 오동작하는 것은 안전 실패(safety failure)라 합니다.
안전 실패는 병렬화한 파이프라인이 사용하는 mappers, filters, 혹은 프로그래머가 제공한 다른 함수 객체가 명세대로 동작하지 않을 때 벌어질 수 있습니다.
Stream 명세는 이때 사용되는 함수 객체에 관한 엄중한 규약을 정의해놨습니다.
예컨대 Stream의 reduce 연산에 건네지는 accmulator와 combiner 함수는 반드시 결합법칙을 만족하고 간섭받지 않고 상태를 갖지 않아야 합니다.
이상의 요구사항을 지키지 못하는 상태라도 파이프라인을 순차적으로만 수행한다면야 올바른 결과를 얻을 수도 있습니다.
하지만 병렬로 수행하면 참혹한 실패로 이어지기 십상입니다.
따라서 앞서의 병렬화한 메르센 소수 프로그램은 설혹 완료되더라도 출력된 소수의 순서가 올바르지 않을 수 있습니다.
출력 순서를 순차 버전처럼 정렬하고 싶다면 종단 연산 forEach를 forEachOrdered로 바꿔주면 됩니다.
이 연산은 병렬 스트림들을 순회하며 소수를 발견한 순서대로 출력되도록 보장해줄 것입니다.
심지어 데이터 소스 스트림이 효율적으로 나눠지고, 병렬화하거나 빨리 끝나는 종단 연산을 사용하고, 함수 객체들도 간섭하지 않더라도, 파이프라인이 수행하는 진짜 작업이 병렬화에 드는 추가 비용을 상쇄하지 못한다면 성능 향상은 미미할 수 있습니다.
실제로 성능이 향상될지를 추정해보는 간단한 방법이 있습니다.
스트림 안의 원소 수와 원소당 수행되는 코드 줄 수를 곱해봅시다.
이 값이 최소 수십만은 되어야 성능 향상을 맛볼 수 있습니다.
스트림 병렬화는 오직 성능 최적화 수단임을 기억해야 합니다.
다른 최적화와 마찬가지로 변경 전후로 반드시 성능을 테스트하여 병렬화를 사용할 가치가 있는지 확인해야 합니다. (아이템 67)
이상적으로는 운영 시스템과 흡사한 환경에서 테스트하는 것이 좋습니다.
보통은 병렬 스트림 파이프라인도 공통의 포크 조인 풀에서 수행되므로, 잘못된 파이프라인 하나가 시스템의 다른 부분의 성능에까지 악영향을 줄 수 있음을 유념합시다.
앞으로 여러분이 스트림 파이프라인을 병렬화할 일이 적어질 것처럼 느껴졌다면, 그건 진짜 그렇기 때문입니다.
스트림을 많이 사용하는 수백만 줄짜리 코드를 여러 개 관리하는 프로그래머는 그중 스트림 병렬화가 효과를 보는 경우가 많지 않음을 알게 됩니다.
그렇다고 스트림을 병렬화하지 말라는 뜻은 아닙니다.
조건을 잘 갖춰지면 parallel 메서드 호출 하나로 거의 프로세서 코어 수에 비례하는 성능 향상을 만끽할 수 있습니다.
머신러닝과 데이터 처리 같은 특정 분야에서는 이 성능 개선의 유혹을 뿌리치기 어려울 것입니다.
스트림 파이프라인 병렬화가 효과를 제대로 발휘하는 간단한 예를 봅시다.
다음은 pi, 즉 n보다 작거나 같은 소수의 개수를 계산하는 함수입니다.
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProablePrime(50))
.count();
}
위 코드로 pi(10의 8승)를 계산하는 데 필자 컴퓨터에서는 31초가 걸렸다고 합니다.
아래 코드는 여기에 parallel() 호출을 하나 추가한 것인데, 시간이 9.2초로 단축됐다고 합니다.
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.parallel()
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProablePrime(50))
.count();
}
하지만 n이 크다면 pi(n)을 이 방식으로 계산하는 건 좋지 않습니다.
레머의 공식이라는 훨씬 효율적인 알고리즘이 있기 때문입니다.
무작위 수들로 이뤄진 스트림을 병렬화하려거든 ThreadLocalRandom(or Random)보다는 SplittableRandom 인스턴스를 이용합시다.
SplittableRandom은 정확히 이럴 때 쓰고자 설계된 거싱라 병렬화하면 성능이 선형으로 증가합니다.
한편 ThreadLocalRandom은 단일 쓰레드에서 쓰고자 만들어졌습니다.
병렬 스트림용 데이터 소스로도 사용할 수는 있지만 SplittableRandom만큼 빠르지는 않을 것입니다.
마지막으로 그냥 Random은 모든 연산을 동기화하기 때문에 병렬 처리하면 최악의 성능을 보일 것입니다.
정리
- 계산도 올바로 수행하고 성능도 빨라질 거라는 확신 없이는 스트림 파이프라인 병렬화는 시도조차 하지말라.
- 스트림을 잘못 병렬화하면 프로그램 오작동은 물론 성능까지 떨어뜨린다.
- 병렬화하는 편이 낫다고 믿더라도, 수정 후의 코드가 여전히 정확한지 확인하고 운영 환경과 유사한 조건에서 수행해보며 성능지표를 유심히 관찰하라.
- 그래서 계산도 정확하고 좋아졌음이 확실해졌을 때, 오직 그럴 때만 병렬화 버전 코드를 운영 코드에 반영하라.
'JAVA > 이펙티브 자바' 카테고리의 다른 글
아이템[50]. 적시에 방어적 복사본을 만들라 (0) | 2022.05.02 |
---|---|
아이템[49]. 매개변수가 유효한지 검사하라 (0) | 2022.05.01 |
아이템[47]. 반환 타입으로는 스트림보다 컬렉션이 낫다 (0) | 2022.04.28 |
아이템[46]. 스트림에서는 부작용 없는 함수를 사용하라 (0) | 2022.04.27 |
아이템[45]. 스트림은 주의해서 사용하라 (0) | 2022.04.27 |
- Total
- Today
- Yesterday
- Java
- AWS
- kotest
- 알고리즘
- 프로그래머스
- Spring Boot
- Olympiad
- kkoon9
- node.js
- 이펙티브 자바
- MSA
- programmers
- 디자인패턴
- Effective Java
- 테라폼
- BOJ
- 클린 아키텍처
- Algorithm
- 객체지향
- C++
- 디자인 패턴
- 코테
- Kotlin
- 백준
- BAEKJOON
- JPA
- 정규표현식
- Spring
- 이팩티브 자바
- 클린 코드
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |