분할(Fork), 처리(Execute), 모음(Join)
스레드는 한 번에 하나의 작업을 처리할 수 있다. 따라서 하나의 큰 작업을 여러 스레드가 처리할 수 있는 작은 단위의 작업으로 분할(Fork)해야 한다.
그리고 이렇게 분할한 작업을 각각의 스레드가 처리(Execute)하는 것이다.
각 스레드의 분할된 작업 처리가 끝나면 분할된 결과를 하나로 모아야(Join) 한다.
이렇게 분할(Fork) 처리(Execute) 모음(Join)의 단계로 이루어진 멀티스레딩 패턴을 Fork/Join 패턴이라고 부른다.
이 패턴은 병렬 프로그래밍에서 매우 효율적인 방식으로, 복잡한 작업을 병렬적으로 처리할 수 있게 해준다.
자바의 Fork/Join 프레임워크는 자바 7부터 도입된 java.util.concurrent
패키지의 일부로, 멀티코어 프로세서를 효율적으로 활용하기 위한 병렬 처리 프레임워크이다.
주요 개념은 다음과 같다.
ExecutorService
스레드 풀 작업 스케줄링 및 스레드 관리를 담당ForkJoinTask
는 Fork/Join 작업의 기본 추상 클래스이고 Future
를 구현했다.
개발자는 주로 다음 두 하위 클래스를 구현해서 사용한다.
RecursiveTask<V>
: 결과를 반환하는 작업
RecursiveAction
: 결과를 반환하지 않는 작업(void
)
compute()
메서드를 재정의해서 필요한 작업 로직을 작성한다.
일반적으로 일정 기준(임계값)을 두고, 작업 범위가 작으면 직접 처리하고, 크면 작업을 둘로 분할하여 각각 병렬로 처리하도록 구현한다.
fork()
/ join()
메서드fork()
: 현재 스레드에서 다른 스레드로 작업을 분할하여 보내는 동작(비동기 실행)
join()
: 분할된 작업이 끝날 때까지 기다린 후 결과를 가져오는 동작
자바 8에서는 공용 풀(Common Pool)이라는 개념이 도입되었는데, 이는 Fork/Join 작업을 위한 자바가 제공하는 기
본 스레드 풀이다.
ForkJoinPool.commonPool()
을 통해 접근할 수 있다.RecursiveTask
/RecursiveAction
을 사용할 때 기본적으로 이 공용 풀이 사용된다.(CPU 코어 수 - 1)
만큼 스레드를 생성하는 이유기본적으로 자바의 Fork/Join 공용 풀은 시스템의 가용 CPU 코어 수에서 1을 뺀 값을 병렬 수준(parallelism)으로 사용한다.
Fork/Join 작업은 공용 풀의 워커 스레드뿐만 아니라 메인 스레드도 연산에 참여할 수 있다. 메인 스레드가 단순히 대기하지 않고 직접 작업을 도와주기 때문
애플리케이션이 실행되는 환경에서는 OS나 다른 애플리케이션, 혹은 GC 같은 내부 작업들도 CPU를 사용해야 한다.
모든 코어를 최대치로 점유하도록 설정하면 다른 중요한 작업이 지연되거나, 컨텍스트 스위칭 비용이 증가할 수 있다.
따라서 하나의 코어를 여유분으로 남겨 두어 전체 시스템 성능을 보다 안정적으로 유지하려는 목적이 있다.
일반적으로는 CPU 코어 수와 동일하게 스레드를 만들더라도 성능상 큰 문제는 없지만, 공용 풀에서 CPU 코어 수 - 1
을 기본값으로 설정함으로써, 다른 작업 스레드나 OS 레벨 작업에서도 병목을 일으키지 않는 선에서 효율적으로 CPU를 활용할 수 있다.
Fork/Join 프레임워크는 주로 CPU 바운드 작업(계산 집약적인 작업)을 처리하기 위해 설계되었다.
이러한 작업은 CPU 사용률이 높고 I/O 대기 시간이 적다.
CPU 바운드 작업의 경우, 물리적인 CPU 코어와 비슷한 수의 스레드를 사용하는 것이 최적의 성능을 발휘할 수 있다.
스레드 수가 코어 수보다 많아지면 컨텍스트 스위칭 비용이 증가하고, 스레드 간 경쟁으로 인해 오히려 성능이 저하될 수 있기 때문이다.
스레드 블로킹에 따른 CPU 낭비
ForkJoinPool
은 CPU 코어 수에 맞춰 제한된 개수의 스레드를 사용한다.
(특히 공용 풀) I/O 작업으로 스레드가 블로킹되면 CPU가 놀게 되어, 전체 병렬 처리 효율이 크게 떨어진다.
컨텍스트 스위칭 오버헤드 증가
I/O 작업 때문에 스레드를 늘리면, 실제 연산보다 대기 시간이 길어지는 상황이 발생할 수 있다.
스레드가 많아질수록 컨텍스트 스위칭 비용도 증가하여 오히려 성능이 떨어질 수 있다.
작업 훔치기 기법 무력화
ForkJoinPool
이 제공하는 작업 훔치기 알고리즘은, CPU 바운드 작업에서 빠르게 작업 단위를 계속 처
리하도록 설계되었다. (작업을 훔쳐서 쉬는 스레드 없이 계속 작업)
I/O 대기 시간이 많은 작업은 스래드가 I/O로 인해 대기하고 있는 경우가 많아, 작업 훔치기가 빛을 발휘하
기 어렵고, 결과적으로 병렬 처리의 장점을 살리기 어렵다.
분할-정복(작업 분할) 이점 감소
Fork/Join 방식을 통해 작업을 잘게 나누어도, I/O 병목이 발생하면 CPU 병렬화 이점이 크게 줄어든다.
오히려 분할된 작업들이 각기 I/O 대기를 반복하면서, fork()
, join()
에 따른 오버헤드만 증가할 수
있다.
공용 풀(Common Pool)은 Fork/Join 프레임워크의 편리한 기능으로, 별도의 풀 생성 없이도 효율적인 병렬 처리를 가능하게 한다. 하지만 블로킹 작업이나 특수한 설정이 필요한 경우에는 커스텀 풀을 고려해야 한다.
CPU 바운드 작업이라면 ForkJoinPool
을 통해 병렬 계산을 극대화할 수 있지만, I/O 바운드 작업은 별도의 전용 스레드 풀을 사용하자!
예) Executors.newFixedThreadPool()
등등
Reference :
- 김영한의 실전 자바 - 고급 3편, 람다, 스트림, 함수형 프로그래밍