본문 바로가기

Fork/Join 문제 분할

단일 스레드, 직접 스레드, 스레드 풀을 비교하고 Fork·Execute·Join이 결과를 안전하게 완성하는 조건을 실행으로 확인합니다.

병렬 처리는 parallel()이라는 한 단어가 아니라 작업을 독립 단위로 나누고, 실행 자원을 배정하고, 모든 부분 결과를 합치는 설계입니다.

먼저 순차 실행의 기준값을 확보한 뒤 직접 스레드와 풀을 사용해 보면 Fork/Join이 해결하는 문제가 보입니다.

CPU 코어가 여러 개여도 작업 사이의 의존성이나 합산 규칙이 틀리면 빠르기는커녕 오답을 더 빨리 만듭니다.


작업자 완료 전 결과 오판

아래 예제는 두 작업자가 시작됐다는 사실만 확인하고 결과를 합칩니다.

release를 열기 전에는 작업자가 값을 쓸 수 없으므로 이 원문의 정상 진행 경로에서 출력은 wrong-sum=0으로 정해집니다. 이번 설명은 소스의 동기화 순서에 근거합니다.

start()는 완료를 보장하지 않으며, 다른 스레드가 쓴 값을 안전하게 읽게 하는 완료 경계도 아닙니다.

lab/UnjoinedWorkerResultBug.java
import java.util.concurrent.CountDownLatch;

public final class UnjoinedWorkerResultBug {
    private static final class Task implements Runnable {
        private final int from;
        private final int to;
        private final CountDownLatch started;
        private final CountDownLatch release;
        private int result;

        private Task(int from, int to, CountDownLatch started, CountDownLatch release) {
            this.from = from;
            this.to = to;
            this.started = started;
            this.release = release;
        }

        @Override
        public void run() {
            started.countDown();
            try {
                release.await();
            } catch (InterruptedException exception) {
                Thread.currentThread().interrupt();
                return;
            }
            for (int value = from; value <= to; value++) {
                result += value * 10;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch started = new CountDownLatch(2);
        CountDownLatch release = new CountDownLatch(1);
        Task left = new Task(1, 4, started, release);
        Task right = new Task(5, 8, started, release);
        Thread first = new Thread(left, "left-worker");
        Thread second = new Thread(right, "right-worker");
        first.start();
        second.start();
        started.await();

        System.out.println("wrong-sum=" + (left.result + right.result));
        release.countDown();
        first.join();
        second.join();
    }
}

문제는 result에 volatile을 붙이지 않은 데 있지 않습니다.

값 하나의 가시성을 높여도 계산이 끝났다는 시점을 모르면 완성되지 않은 중간 상태를 읽습니다.

원칙은 완료를 기다린 다음 결과를 모으는 것입니다.

예시 버그 예제의 started.await는 작업자 둘이 시작 지점에 도착했다는 사실만 확인합니다. 각 작업자는 release.await 뒤에야 계산할 수 있어 main의 출력 시점에는 두 result가 초깃값 0입니다. release를 해제한 뒤 join을 하는 순서는 프로그램을 끝내지만 이미 찍은 wrong-sum을 고치지 않습니다. 정상 결과를 읽는 위치를 두 join 뒤에 두어야 합니다.

Thread.join(), Future.get(), ForkJoinTask.join()은 완료를 기다리는 경계를 제공하지만 실패 전달 방식은 다릅니다. Thread.join()은 작업자의 예외를 호출자에게 다시 던지지 않으며, Future.get()은 작업 실패를 ExecutionException으로, ForkJoinTask.join()은 비검사 예외나 오류로 전달합니다.


순차 기준선 실행

병렬 후보를 평가하기 전 동일한 입력과 계산으로 순차 기준을 만듭니다.

총합, 입력 순서, 예외 정책을 이 단계에서 고정해야 병렬 구현의 정확성을 비교할 수 있습니다.

src/SequentialHeavyJobBaseline.java
import java.util.stream.IntStream;

public final class SequentialHeavyJobBaseline {
    private static int calculate(int value) {
        return value * 10;
    }

    public static void main(String[] args) {
        int sum = IntStream.rangeClosed(1, 8).map(SequentialHeavyJobBaseline::calculate).sum();
        System.out.println("expected-sum=" + sum);
    }
}

출력은 360입니다.

실제 고비용 작업이 1초의 sleep을 포함한다고 해도 이 교재의 검증 예제는 불필요하게 기다리지 않습니다.

성능 실험에서는 계산량을 별도 매개변수로 두고, 정확성 예제에서는 결과가 결정적인 작은 입력을 사용합니다.


직접 스레드의 분할·join 책임

범위 1~8을 1~4와 5~8로 나눠 서로 독립적으로 계산합니다.

각 작업은 자기 필드에만 쓰므로 공유 가변 누산기가 없습니다.

main은 두 스레드를 join한 뒤 결과를 더합니다.

src/JoinedRangeWorkers.java
public final class JoinedRangeWorkers {
    private static final class RangeTask implements Runnable {
        private final int from;
        private final int to;
        private int result;

        private RangeTask(int from, int to) {
            this.from = from;
            this.to = to;
        }

        @Override
        public void run() {
            int local = 0;
            for (int value = from; value <= to; value++) {
                local += value * 10;
            }
            result = local;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        RangeTask left = new RangeTask(1, 4);
        RangeTask right = new RangeTask(5, 8);
        Thread first = new Thread(left, "range-1-4");
        Thread second = new Thread(right, "range-5-8");

        first.start();
        second.start();
        first.join();
        second.join();

        int sum = left.result + right.result;
        System.out.println("joined-sum=" + sum);
    }
}

두 작업자의 종료 확인 뒤 main이 합산한다

두 작업자의 부분합 저장과 각각의 join 정상 반환을 거쳐 main이 100+260을 읽습니다. 작업자 간 종료 순서를 고정하지 않고 결과 읽기의 필수 선행 조건을 단계로 드러낼 수 있습니다.

두 작업자의 종료 확인 뒤 main이 합산한다

작업 종료가 결과 읽기보다 먼저

두 작업자의 종료 확인 뒤 main이 합산한다

각 작업자가 1~4와 5~8을 계산해 100과 260을 저장합니다. main은 두 join이 모두 돌아온 뒤 360을 합산합니다.

각 작업자의 쓰기를 확인한 뒤 두 결과를 읽는다원JoinedRangeWorkers의각worker부분합100/260쓰기→해당join정상반환→main360읽기5노드4의존관계다. mobile은같은2경계가main에합류한다. layout배치는작업자의완료시간순서나실행겹침을정하지않는다. join실패/worker예외정책은본문과분리한다.작업자 왼쪽 쓰기부분합100 저장첫 join 정상 반환왼쪽 쓰기 확인작업자 오른쪽 쓰기부분합260 저장둘째 join 정상 반환오른쪽 쓰기 확인main 결과 읽기두 join 뒤100 + 260 = 360
각 작업자의 쓰기를 확인한 뒤 두 결과를 읽는다원JoinedRangeWorkers의각worker부분합100/260쓰기→해당join정상반환→main360읽기5노드4의존관계다. mobile은같은2경계가main에합류한다. layout배치는작업자의완료시간순서나실행겹침을정하지않는다. join실패/worker예외정책은본문과분리한다.작업자 왼쪽 쓰기부분합100 저장첫 join 정상 반환왼쪽 쓰기 확인작업자 오른쪽 쓰기부분합260 저장둘째 join 정상 반환오른쪽 쓰기 확인main 결과 읽기두 join 뒤100 + 260 = 360

두 작업자의 종료 확인 뒤 main이 합산한다

두 작업자의 종료 확인 뒤 main이 합산한다독립 구간 1부터 4의 부분합 100과 5부터 8의 부분합 260을 두 작업자의 종료를 확인한 뒤 main이 더해 360을 만듭니다.구간 1–4부분합 100구간 5–8부분합 260main두 join 뒤100 + 260= 360
두 작업자의 종료 확인 뒤 main이 합산한다독립 구간 1부터 4의 부분합 100과 5부터 8의 부분합 260을 두 작업자의 종료를 확인한 뒤 main이 더해 360을 만듭니다.구간 1–4부분합 100구간 5–8부분합 260main두 join 뒤100 + 260= 360

화살표는 각 작업의 쓰기가 종료를 확인한 main의 읽기보다 앞선다는 의존 관계입니다. 두 join이 정상 반환한 경로이며, 작업자 사이의 완료 순서나 실행 시간을 나타내지 않습니다.

이 예의 조건

  • 두 worker의 실제 완료 시간 순서·실행 겹침은 고정하지 않습니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2원 JoinedRangeWorkers의 각 worker 부분합 100/260 쓰기→해당 join 정상 반환→main의 360 읽기를 5개 노드와 4개 의존 관계로 나타낸다. mobile에서도 같은 2개 경계가 main에 합류한다. layout 배치는 작업자의 완료 시간 순서나 실행의 겹침을 정하지 않는다. join 실패/worker 예외 정책은 본문과 분리한다.
4부분합 100 저장
8부분합 260 저장
원 도표의 설명과 주석
구분내용
설명원 JoinedRangeWorkers의 각 worker 부분합 100/260 쓰기→해당 join 정상 반환→main의 360 읽기를 5개 노드와 4개 의존 관계로 나타낸다. mobile에서도 같은 2개 경계가 main에 합류한다. layout 배치는 작업자의 완료 시간 순서나 실행의 겹침을 정하지 않는다. join 실패/worker 예외 정책은 본문과 분리한다.
전체 읽기 정보두 작업자의 종료 확인 뒤 main이 합산한다 두 작업자의 종료 확인 뒤 main이 합산한다 작업 종료가 결과 읽기보다 먼저 두 작업자의 종료 확인 뒤 main이 합산한다 각 작업자가 1~4와 5~8을 계산해 100과 260을 저장합니다. main은 두 join이 모두 돌아온 뒤 360을 합산합니다. 두 작업자의 종료 확인 뒤 main이 합산한다 화살표는 각 작업의 쓰기가 종료를 확인한 main의 읽기보다 앞선다는 의존 관계입니다. 두 join이 정상 반환한 경로이며, 작업자 사이의 완료 순서나 실행 시간을 나타내지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
부분합 기록

각 worker가 자기 부분합을 저장합니다.

  • 왼쪽: 100
  • 오른쪽: 260
  • 각 쓰기에는 각 worker의 종료 확인 경계가 필요합니다.
각 종료 확인

main이 각 join의 정상 반환을 받습니다.

  • 첫 join: 왼쪽 쓰기 확인
  • 둘째 join: 오른쪽 쓰기 확인
  • 한 join이 두 worker 모두의 완료를 확인하지 않습니다.
두 결과 읽기

두 join 뒤에 합계를 읽습니다.

  • 계산: 100+260
  • 결과: 360
  • 각 완료 의존이 main의 전체 결과에 합류합니다.

두 작업자의 종료 확인 뒤 main이 합산한다

두 작업자의 부분합 저장과 각각의 join 정상 반환을 거쳐 main이 100+260을 읽습니다. 작업자 간 종료 순서를 고정하지 않고 결과 읽기의 필수 선행 조건을 단계로 드러낼 수 있습니다.

두 작업자의 종료 확인 뒤 main이 합산한다

작업 종료가 결과 읽기보다 먼저

두 작업자의 종료 확인 뒤 main이 합산한다

각 작업자가 1~4와 5~8을 계산해 100과 260을 저장합니다. main은 두 join이 모두 돌아온 뒤 360을 합산합니다.

각 작업자의 쓰기를 확인한 뒤 두 결과를 읽는다원JoinedRangeWorkers의각worker부분합100/260쓰기→해당join정상반환→main360읽기5노드4의존관계다. mobile은같은2경계가main에합류한다. layout배치는작업자의완료시간순서나실행겹침을정하지않는다. join실패/worker예외정책은본문과분리한다.작업자 왼쪽 쓰기부분합100 저장첫 join 정상 반환왼쪽 쓰기 확인작업자 오른쪽 쓰기부분합260 저장둘째 join 정상 반환오른쪽 쓰기 확인main 결과 읽기두 join 뒤100 + 260 = 360
각 작업자의 쓰기를 확인한 뒤 두 결과를 읽는다원JoinedRangeWorkers의각worker부분합100/260쓰기→해당join정상반환→main360읽기5노드4의존관계다. mobile은같은2경계가main에합류한다. layout배치는작업자의완료시간순서나실행겹침을정하지않는다. join실패/worker예외정책은본문과분리한다.작업자 왼쪽 쓰기부분합100 저장첫 join 정상 반환왼쪽 쓰기 확인작업자 오른쪽 쓰기부분합260 저장둘째 join 정상 반환오른쪽 쓰기 확인main 결과 읽기두 join 뒤100 + 260 = 360

두 작업자의 종료 확인 뒤 main이 합산한다

두 작업자의 종료 확인 뒤 main이 합산한다독립 구간 1부터 4의 부분합 100과 5부터 8의 부분합 260을 두 작업자의 종료를 확인한 뒤 main이 더해 360을 만듭니다.구간 1–4부분합 100구간 5–8부분합 260main두 join 뒤100 + 260= 360
두 작업자의 종료 확인 뒤 main이 합산한다독립 구간 1부터 4의 부분합 100과 5부터 8의 부분합 260을 두 작업자의 종료를 확인한 뒤 main이 더해 360을 만듭니다.구간 1–4부분합 100구간 5–8부분합 260main두 join 뒤100 + 260= 360

화살표는 각 작업의 쓰기가 종료를 확인한 main의 읽기보다 앞선다는 의존 관계입니다. 두 join이 정상 반환한 경로이며, 작업자 사이의 완료 순서나 실행 시간을 나타내지 않습니다.

이 예의 조건

  • 두 worker의 실제 완료 시간 순서·실행 겹침은 고정하지 않습니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2원 JoinedRangeWorkers의 각 worker 부분합 100/260 쓰기→해당 join 정상 반환→main의 360 읽기를 5개 노드와 4개 의존 관계로 나타낸다. mobile에서도 같은 2개 경계가 main에 합류한다. layout 배치는 작업자의 완료 시간 순서나 실행의 겹침을 정하지 않는다. join 실패/worker 예외 정책은 본문과 분리한다.
4부분합 100 저장
8부분합 260 저장
원 도표의 설명과 주석
구분내용
설명원 JoinedRangeWorkers의 각 worker 부분합 100/260 쓰기→해당 join 정상 반환→main의 360 읽기를 5개 노드와 4개 의존 관계로 나타낸다. mobile에서도 같은 2개 경계가 main에 합류한다. layout 배치는 작업자의 완료 시간 순서나 실행의 겹침을 정하지 않는다. join 실패/worker 예외 정책은 본문과 분리한다.
전체 읽기 정보두 작업자의 종료 확인 뒤 main이 합산한다 두 작업자의 종료 확인 뒤 main이 합산한다 작업 종료가 결과 읽기보다 먼저 두 작업자의 종료 확인 뒤 main이 합산한다 각 작업자가 1~4와 5~8을 계산해 100과 260을 저장합니다. main은 두 join이 모두 돌아온 뒤 360을 합산합니다. 두 작업자의 종료 확인 뒤 main이 합산한다 화살표는 각 작업의 쓰기가 종료를 확인한 main의 읽기보다 앞선다는 의존 관계입니다. 두 join이 정상 반환한 경로이며, 작업자 사이의 완료 순서나 실행 시간을 나타내지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
부분합 기록

각 worker가 자기 부분합을 저장합니다.

  • 왼쪽: 100
  • 오른쪽: 260
  • 각 쓰기에는 각 worker의 종료 확인 경계가 필요합니다.
각 종료 확인

main이 각 join의 정상 반환을 받습니다.

  • 첫 join: 왼쪽 쓰기 확인
  • 둘째 join: 오른쪽 쓰기 확인
  • 한 join이 두 worker 모두의 완료를 확인하지 않습니다.
두 결과 읽기

두 join 뒤에 합계를 읽습니다.

  • 계산: 100+260
  • 결과: 360
  • 각 완료 의존이 main의 전체 결과에 합류합니다.

첫 부분합은 (1+2+3+4)×10=100, 둘째는 (5+6+7+8)×10=260입니다. 어느 작업자가 먼저 끝나도 두 join이 정상 반환한 뒤 100+260=360을 읽습니다. JLS의 Thread.join 완료 관계는 작업자의 쓰기와 join 뒤 읽기의 관계를 정하며, 출력 순서가 작업자 완료 순서라는 뜻은 아닙니다.

반대로 여러 작업자가 같은 int sum에 +=를 수행하면 읽기·수정·쓰기 과정이 겹쳐 갱신이 손실될 수 있습니다.

부분 결과를 격리하고 마지막에 합치는 구조가 동기화 범위를 작게 유지합니다.


스레드 풀: 작업과 작업자 생명 주기 분리

다음 고정 스레드 풀에 Callable<Integer>를 제출하면 플랫폼 작업자의 재사용과 대기 큐를 풀이 관리합니다. newFixedThreadPool의 대기 큐는 크기 제한이 없으므로 이것만으로 제출량에 역압이 걸리지는 않습니다.

호출자는 Future에서 결과를 가져와 join 단계로 사용합니다.

src/PooledRangeResults.java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public final class PooledRangeResults {
    private static Callable<Integer> range(int from, int to) {
        return () -> {
            int subtotal = 0;
            for (int value = from; value <= to; value++) {
                subtotal += value * 10;
            }
            return subtotal;
        };
    }

    public static void main(String[] args) throws InterruptedException, ExecutionException {
        try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
            List<Future<Integer>> futures = new ArrayList<>();
            futures.add(pool.submit(range(1, 4)));
            futures.add(pool.submit(range(5, 8)));

            int sum = 0;
            for (Future<Integer> future : futures) {
                sum += future.get();
            }
            System.out.println("pooled-sum=" + sum);
        }
    }
}

Future.get()은 완료까지 기다리고 작업 예외를 ExecutionException으로 전달합니다.

운영 코드에서는 인터럽트를 삼키지 않고 상위 취소 정책으로 연결해야 합니다.

풀 크기는 작업 개수와 같게 고정하는 공식이 아니며 CPU 중심 여부, 블로킹 비율, 동시 요청 수를 함께 봐야 합니다.


Fork·Execute·Join 패턴 해석

전형적인 Fork/Join 알고리즘은 큰 작업을 임계값 이하가 될 때까지 재귀적으로 분할합니다. 프레임워크가 임계값과 분할 코드를 자동으로 정하는 것은 아닙니다.

리프는 직접 계산하고 부모는 두 결과를 결합합니다.

분할 가능한가, 리프 계산이 독립적인가, 결합 연산이 올바른가가 핵심 규칙입니다.

  1. 분할(fork): 범위를 겹치지 않는 하위 범위로 나눈다.
  2. 실행: 임계값 이하의 리프를 현재 작업자가 계산한다.
  3. 결합(join): 하위 결과가 완료될 때까지 기다린 뒤 결합한다.

한쪽을 fork()한 뒤 다른 쪽을 현재 스레드가 compute()하고 마지막에 join하면 호출자도 일을 합니다.

한쪽을 직접 계산하는 패턴은 불필요한 제출을 줄일 수 있습니다. 두 작업을 모두 fork한 경우에도 join 대기 중 작업자가 일을 도울 수 있으므로 어느 방식이 더 빠른지는 계산과 분할 조건에 따라 측정해야 합니다.

임계값이 너무 크면 병렬성이 부족하고 너무 작으면 작업 생성·스케줄링·join 비용이 계산보다 커집니다.


선택 기준

  • 작은 컬렉션의 값싼 변환은 순차 반복문이나 순차 Stream을 기준으로 삼습니다.
  • 서로 성격이 다른 소수의 독립 작업은 ExecutorService와 Future로 표현하는 편이 명확합니다.
  • 균일하고 재귀 분할 가능한 CPU 계산은 Fork/Join 후보입니다.
  • 요청 처리 안의 블로킹 I/O를 공용 풀에 섞는 것은 별도 자원 정책이 필요합니다.
  • 어느 방식이든 공유 가변 상태 대신 작업별 지역 결과와 명시적 결합을 우선합니다.
직접 만든 스레드 수를 CPU 코어 수에 맞추면 항상 가장 빠른가요?

아닙니다.

CPU 중심 계산은 코어 수를 출발점으로 삼을 수 있지만 분할 비용, 메모리 대역폭, 캐시 지역성, 다른 프로세스와 애플리케이션 작업의 경쟁이 결과를 바꿉니다.

블로킹 작업은 대기 비율을 고려하되 무제한 스레드가 답은 아닙니다.

동일한 운영 조건에서 예열과 반복 측정을 거쳐야 합니다.


연습 문제

1~12를 네 구간으로 나눠 각 구간이 제곱합을 반환하게 하세요.

공유 누산기를 쓰지 말고 모든 Future가 끝난 뒤 650을 출력해야 합니다.

해설 보기
exercise/PooledSquareSumSolution.java
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public final class PooledSquareSumSolution {
    private static Callable<Integer> squares(int from, int to) {
        return () -> {
            int subtotal = 0;
            for (int value = from; value <= to; value++) {
                subtotal += value * value;
            }
            return subtotal;
        };
    }

    public static void main(String[] args) throws Exception {
        try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
            List<Future<Integer>> parts =
                    List.of(
                            pool.submit(squares(1, 3)),
                            pool.submit(squares(4, 6)),
                            pool.submit(squares(7, 9)),
                            pool.submit(squares(10, 12)));
            int total = 0;
            for (Future<Integer> part : parts) {
                total += part.get();
            }
            System.out.println("square-sum=" + total);
        }
    }
}

종료 기준은 출력값만이 아닙니다.

원문은 각 작업의 지역 부분합을 Future 목록 순서대로 get()하여 더하고, try-with-resources로 풀을 닫습니다. 결과 650과 별개로 이 수집 순서는 작업자의 시작·완료 순서를 정하지 않습니다.

네 부분합은 [1,3]의 14, [4,6]의 77, [7,9]의 194, [10,12]의 365로 합계 650입니다. 첫 Future의 get이 기다리는 동안 뒤의 작업자가 먼저 완성돼도 결과를 담아 두고 마지막 결합에서 더할 수 있습니다.

독립 확인: 제곱합 예제 연습의 네 구간 중 마지막 시작만 9로 바꿉니다. 합계가 650보다 얼마나 커지나요? 별도로 세 번째 끝을 8로 바꾸고 마지막 시작은 원래 10으로 유지하면 얼마가 빠지나요? 출력이 맞는지만 검사하는 것 외에 어떤 구간 조건을 확인해야 하나요?

풀이 확인

첫 변형은 9가 두 구간에 들어가 81을 중복 계산해 731입니다. 별도 변형은 9가 어느 구간에도 없어 81이 빠져 569입니다. 전체 입력의 모든 인덱스가 정확히 한 구간에 들어가는지 확인해야 합니다. 공유 누산기가 없더라도 분할의 중복·누락은 남을 수 있으며, 일부 합계가 우연히 상쇄되는 입력까지 합계 검사 하나로 증명할 수는 없습니다.