【2023년】멀티 스레드 포크/조인 프레임워크

1. 포크/조인이란 무엇입니까?

Fork/Join 프레임워크는 ExecutorService 인터페이스를 구현하는 멀티 스레드 프로세서로, 재귀를 통해 더 작은 작업으로 분해될 수 있는 작업을 위해 설계되었으며, 멀티 코어 프로세서의 사용을 극대화하여 애플리케이션 성능을 향상시킵니다.

다른 ExecutorService 관련 구현과 마찬가지로 Fork/Join 프레임워크는 스레드 풀의 스레드에 작업을 할당합니다. 차이점은 Fork/Join 프레임워크는 작업을 실행할 때 작업 도용 알고리즘을 사용한다는 것입니다.

Fork는 영어로 포크(fork)를 의미하고, Join은 영어로 연결, 결합을 의미합니다. 이름에서 알 수 있듯이 포크는 큰 작업을 여러 개의 작은 작업으로 분해하는 것이고, 조인은 각 작은 작업의 결과를 최종적으로 결합하여 큰 작업의 결과를 얻는 것입니다.

Fork/Join의 실행 프로세스는 대략 다음과 같습니다.
여기에 이미지 설명을 삽입하세요.
하위 작업이 충분히 작아질 때까지 그림의 보조 하위 작업을 나눌 수 있다는 점에 유의해야 합니다. 의사 코드로 표현하면 다음과 같습니다.

solve(任务):
    if(任务已经划分到足够小):
        顺序执行任务
    else:
        for(划分任务得到子任务)
            solve(子任务)
        结合所有子任务的结果到上一层循环
        return 最终结合的结果

위 의사코드에서 볼 수 있듯이, 분할 정복이라는 알고리즘 아이디어를 구현한 재귀적 중첩 계산을 통해 최종 결과를 얻습니다.

2. 업무 훔치기 알고리즘

작업 훔치기 알고리즘은 여러 스레드가 서로 다른 작업 대기열을 실행하는 프로세스를 말하며, 스레드가 자신의 대기열에 있는 작업 실행을 마친 후 다른 스레드의 작업 대기열에서 작업을 훔쳐 실행합니다.

작업 훔치기 과정은 아래 그림과 같습니다.
여기에 이미지 설명을 삽입하세요.

하나의 스레드가 다른 스레드를 훔칠 때 두 작업 스레드 간의 경쟁을 줄이기 위해 일반적으로 작업을 저장하기 위해 양방향 대기열을 사용한다는 점은 주목할 가치가 있습니다. 도난당한 작업 스레드는 이중 종료 대기열의 헤드 에서 작업을 실행하는 반면, 다른 작업을 훔치는 스레드는 이중 종료 대기열의 꼬리에서 작업을 실행합니다.

또한 스레드가 작업을 훔치고 사용 가능한 다른 작업이 없으면 스레드는 다시 "작업"을 기다리며 차단된 상태 로 들어갑니다.

Fork/Join의 구체적인 구현

앞서 Fork/Join 프레임워크는 단순히 작업을 분할하고 하위 작업을 병합하는 것이라고 말했으므로 이 프레임워크를 구현하려면 먼저 작업이 있어야 합니다. 추상 클래스 ForkJoinTask는 작업을 구현하기 위해 Fork/Join 프레임워크에 제공됩니다.

1、ForkJoinTask

ForkJoinTask는 일반 스레드와 유사하지만 일반 스레드보다 훨씬 가벼운 엔터티입니다.

fork() 메서드: 스레드 풀의 유휴 스레드를 사용하여 비동기적으로 작업을 제출합니다.

// 本文所有代码都引自Java 8
public final ForkJoinTask<V> fork() {
    
    
    Thread t;
    // ForkJoinWorkerThread是执行ForkJoinTask的专有线程,由ForkJoinPool管理
    // 先判断当前线程是否是ForkJoin专有线程,如果是,则将任务push到当前线程所负责的队列里去
    if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread)
        ((ForkJoinWorkerThread)t).workQueue.push(this);
    else
         // 如果不是则将线程加入队列
        // 没有显式创建ForkJoinPool的时候走这里,提交任务到默认的common线程池中
        ForkJoinPool.common.externalPush(this);
    return this;
}

실제로, fork()는 작업을 현재 작업자 스레드의 작업 대기열로 푸시하는 한 가지 작업만 수행합니다.

Join() 메서드: 작업을 처리하는 스레드가 처리를 완료하고 반환 값을 얻을 때까지 기다립니다.

Join()의 소스 코드를 살펴보겠습니다.

public final V join() {
    
    
    int s;
    // doJoin()方法来获取当前任务的执行状态
    if ((s = doJoin() & DONE_MASK) != NORMAL)
        // 任务异常,抛出异常
        reportException(s);
    // 任务正常完成,获取返回值
    return getRawResult();
}

/**
 * doJoin()方法用来返回当前任务的执行状态
 **/
private int doJoin() {
    
    
    int s; Thread t; ForkJoinWorkerThread wt; ForkJoinPool.WorkQueue w;
    // 先判断任务是否执行完毕,执行完毕直接返回结果(执行状态)
    return (s = status) < 0 ? s :
    // 如果没有执行完毕,先判断是否是ForkJoinWorkThread线程
    ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ?
        // 如果是,先判断任务是否处于工作队列顶端(意味着下一个就执行它)
        // tryUnpush()方法判断任务是否处于当前工作队列顶端,是返回true
        // doExec()方法执行任务
        (w = (wt = (ForkJoinWorkerThread)t).workQueue).
        // 如果是处于顶端并且任务执行完毕,返回结果
        tryUnpush(this) && (s = doExec()) < 0 ? s :
        // 如果不在顶端或者在顶端却没未执行完毕,那就调用awitJoin()执行任务
        // awaitJoin():使用自旋使任务执行完成,返回结果
        wt.pool.awaitJoin(w, this, 0L) :
    // 如果不是ForkJoinWorkThread线程,执行externalAwaitDone()返回任务结果
    externalAwaitDone();
}

Thread.join()이 스레드를 차단하고 ForkJoinPool.join()이 스레드의 차단을 방지한다고 앞서 소개한 바 있으며, ForkJoinPool.join()의 흐름도는 다음과 같습니다.
여기에 이미지 설명을 삽입하세요.

RecursiveAction과 RecursiveTask

일반적으로 작업을 생성할 때 일반적으로 ForkJoinTask를 직접 상속하지 않고 RecursiveActionRecursiveTask 하위 클래스를 상속합니다 .

둘 다 ForkJoinTask**의 하위 클래스입니다. RecursiveAction은 반환 값이 없는 ForkJoinTask로 간주될 수 있고 RecursiveTask는 반환 값이 있는 ForkJoinTask**로 간주될 수 있습니다.

또한, 두 하위 클래스 모두 주요 계산을 수행하기 위한 Compute() 메소드를 가지고 있는데, 물론 RecursiveAction의 Compute()는 void를 반환하고, RecursiveTask의 Compute()는 특정 반환 값을 갖습니다.

2、ForkJoinPool

ForkJoinPool은 ForkJoinTask 작업을 실행하는 데 사용되는 실행(스레드) 풀입니다.

ForkJoinPool은 실행 풀의 스레드와 작업 대기열을 관리하며, 실행 풀이 여전히 작업을 수락하고 스레드의 실행 상태를 표시하는지 여부도 여기에서 처리됩니다.

ForkJoinPool의 소스 코드를 간략하게 살펴보겠습니다.

@sun.misc.Contended
public class ForkJoinPool extends AbstractExecutorService {
    
    
    // 任务队列
    volatile WorkQueue[] workQueues;   

    // 线程的运行状态
    volatile int runState;  

    // 创建ForkJoinWorkerThread的默认工厂,可以通过构造函数重写
    public static final ForkJoinWorkerThreadFactory defaultForkJoinWorkerThreadFactory;

    // 公用的线程池,其运行状态不受shutdown()和shutdownNow()的影响
    static final ForkJoinPool common;

    // 私有构造方法,没有任何安全检查和参数校验,由makeCommonPool直接调用
    // 其他构造方法都是源自于此方法
    // parallelism: 并行度,
    // 默认调用java.lang.Runtime.availableProcessors() 方法返回可用处理器的数量
    private ForkJoinPool(int parallelism,
                         ForkJoinWorkerThreadFactory factory, // 工作线程工厂
                         UncaughtExceptionHandler handler, // 拒绝任务的handler
                         int mode, // 同步模式
                         String workerNamePrefix) {
    
     // 线程名prefix
        this.workerNamePrefix = workerNamePrefix;
        this.factory = factory;
        this.ueh = handler;
        this.config = (parallelism & SMASK) | mode;
        long np = (long)(-parallelism); // offset ctl counts
        this.ctl = ((np << AC_SHIFT) & AC_MASK) | ((np << TC_SHIFT) & TC_MASK);
    }

}

작업 대기열

이중 종료 대기열인 ForkJoinTask가 여기에 저장됩니다.

작업자 스레드가 자체 작업 대기열을 처리할 때 실행을 위해 대기열 헤드(FIFO)에서 작업을 가져옵니다. 다른 대기열에서 작업을 훔치면 훔친 작업은 자신이 속한 작업 대기열의 끝에 위치합니다( LIFO).

ForkJoinPool과 기존 스레드 풀의 가장 중요한 차이점은 작업 대기열 배열(휘발성 WorkQueue[] 작업 대기열, ForkJoinPool의 각 작업자 스레드는 작업 대기열을 유지 관리함)을 유지 관리한다는 것입니다.

실행 상태

ForkJoinPool의 실행 상태입니다. SHUTDOWN 상태는 음수로 표시되고, 나머지는 2의 거듭제곱으로 표시됩니다.

4. Fork/Join의 활용

위에서 ForkJoinPool은 스레드와 작업 관리를 담당하고 ForkJoinTask는 분기 및 조인 작업을 구현하므로 이 두 클래스는 Fork/Join 프레임워크와 분리될 수 없지만 실제 개발에서는 ForkJoinTask 대신 ForkJoinTask 하위 클래스인 RecursiveTask 및 RecursiveAction을 사용하는 경우가 많습니다.

피보나치 수열의 n번째 항을 계산하는 예를 통해 Fork/Join의 사용을 살펴보겠습니다.

피보나치 수열은 세 번째 항부터 시작하는 선형 재귀 수열입니다. 여기서 각 항의 값은 이전 두 항의 합과 같습니다.

1장, 1장, 2장, 3장, 5장, 8장, 13장, 21장, 34장, 55장, 89장······

f(n)이 시퀀스의 n번째 항목(n∈N*)이라고 가정하면 f(n) = f(n-1) + f(n-2)가 됩니다.

public class FibonacciTest {
    
    

    class Fibonacci extends RecursiveTask<Integer> {
    
    

        int n;

        public Fibonacci(int n) {
    
    
            this.n = n;
        }

        // 主要的实现逻辑都在compute()里
        @Override
        protected Integer compute() {
    
    
            // 这里先假设 n >= 0
            if (n <= 1) {
    
    
                return n;
            } else {
    
    
                // f(n-1)
                Fibonacci f1 = new Fibonacci(n - 1);
                f1.fork();
                // f(n-2)
                Fibonacci f2 = new Fibonacci(n - 2);
                f2.fork();
                // f(n) = f(n-1) + f(n-2)
                return f1.join() + f2.join();
            }
        }
    }

    @Test
    public void testFib() throws ExecutionException, InterruptedException {
    
    
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        System.out.println("CPU核数:" + Runtime.getRuntime().availableProcessors());
        long start = System.currentTimeMillis();
        Fibonacci fibonacci = new Fibonacci(40);
        Future<Integer> future = forkJoinPool.submit(fibonacci);
        System.out.println(future.get());
        long end = System.currentTimeMillis();
        System.out.println(String.format("耗时:%d millis", end - start));
    }


}

이 머신에서 위 예제의 출력은 다음과 같습니다.

CPU 코어 수: 4
계산 결과: 102334155
시간 소비: 9490밀리초

위의 계산 시간 복잡도는 O(2^n)이며, n이 증가함에 따라 계산 효율성은 점점 낮아지므로 위 예에서 n이 너무 크지 않은 이유입니다.

또한 모든 작업이 Fork/Join 프레임워크에 적합한 것은 아닙니다. 예를 들어 위 예의 작업 분할이 너무 작아 효율성을 반영하지 못합니다. 일반적인 재귀를 사용하여 f(n)의 값을 찾아보겠습니다. Fork/Join을 사용하는 것보다 빠릅니다.

// 普通递归,复杂度为O(2^n)
public int plainRecursion(int n) {
    
    
    if (n == 1 || n == 2) {
    
    
        return 1;
    } else {
    
    
        return plainRecursion(n -1) + plainRecursion(n - 2);
    }
}

@Test
public void testPlain() {
    
    
    long start = System.currentTimeMillis();
    int result = plainRecursion(40);
    long end = System.currentTimeMillis();
    System.out.println("计算结果:" + result);
    System.out.println(String.format("耗时:%d millis",  end -start));
}

일반 재귀의 출력 예:

계산 결과: 102334155
시간 소모: 436밀리초
일반 재귀를 사용하는 경우의 효율성이 Fork/Join 프레임워크를 사용하는 것보다 훨씬 높다는 것을 출력에서 ​​분명히 알 수 있습니다.

여기서는 계산을 위해 다른 사고 방식을 사용합니다.

// 通过循环来计算,复杂度为O(n)
private int computeFibonacci(int n) {
    
    
    // 假设n >= 0
    if (n <= 1) {
    
    
        return n;
    } else {
    
    
        int first = 1;
        int second = 1;
        int third = 0;
        for (int i = 3; i <= n; i ++) {
    
    
            // 第三个数是前两个数之和
            third = first + second;
            // 前两个数右移
            first = second;
            second = third;
        }
        return third;
    }
}

@Test
public void testComputeFibonacci() {
    
    
    long start = System.currentTimeMillis();
    int result = computeFibonacci(40);
    long end = System.currentTimeMillis();
    System.out.println("计算结果:" + result);
    System.out.println(String.format("耗时:%d millis",  end -start));
}

작성자가 사용하는 컴퓨터에서 위 예제의 출력은 다음과 같습니다.

계산 결과: 102334155
소요 시간: 0 millis

여기서 시간소모가 0이라는 것은 시간소모가 없다는 뜻이 아니라 여기서 계산하는데 소요되는 시간이 거의 무시할 수 있다는 뜻이다. 자신의 컴퓨터에서 시도해 볼 수 있다. n이 데이터보다 훨씬 크다고 해도(참고 int 오버플로 문제) 시간 소모가 매우 높으며 시간도 매우 짧거나 System.nanoTime()을 사용하여 나노초 시간을 계산할 수 있습니다.

여기서 일반 재귀 또는 반복이 더 빠른 이유는 무엇입니까? Fork/Join은 여러 스레드의 협력을 통해 계산되므로 스레드 통신 및 스레드 전환에 따른 오버헤드가 발생합니다.

계산할 작업이 비교적 간단한 경우(예: 우리의 경우 피보나치 수열) 당연히 단일 스레드를 직접 사용하는 것이 더 빠릅니다. 그러나 계산할 내용이 상대적으로 복잡하고 컴퓨터에 다중 코어가 있는 경우 다중 코어 CPU를 최대한 활용하여 계산 속도를 높일 수 있습니다.

또한 Java 8 Stream의 기본 병렬 작업은 Fork/Join 프레임워크를 사용하며 다음 장에서는 소스 코드와 사례 모두에서 Java 8 Stream의 병렬 작업을 소개합니다.

추천

출처blog.csdn.net/weixin_52315708/article/details/131771243