[java] 포크 / 조인 프레임 워크가 스레드 풀보다 나은 점은 무엇입니까?

처음에는 큰 작업을 N 개의 하위 작업으로 나누고 ( Executors 의 캐시 된 스레드 풀로 ) 각 작업이 완료되기를 기다리는 것보다 새로운 fork / join 프레임 워크 를 사용하면 어떤 이점이 있습니까? 포크 / 조인 추상화를 사용하여 문제를 단순화하거나 현재 몇 년 동안 솔루션을보다 효율적으로 만드는 방법을 알지 못합니다.

예를 들어, 튜토리얼 예제 의 병렬화 된 흐림 알고리즘은 다음 과 같이 구현 될 수 있습니다.

public class Blur implements Runnable {
    private int[] mSource;
    private int mStart;
    private int mLength;
    private int[] mDestination;

    private int mBlurWidth = 15; // Processing window size, should be odd.

    public ForkBlur(int[] src, int start, int length, int[] dst) {
        mSource = src;
        mStart = start;
        mLength = length;
        mDestination = dst;
    }

    public void run() {
        computeDirectly();
    }

    protected void computeDirectly() {
        // As in the example, omitted for brevity
    }
}

처음에 분할하여 작업을 스레드 풀로 보냅니다.

// source image pixels are in src
// destination image pixels are in dst
// threadPool is a (cached) thread pool

int maxSize = 100000; // analogous to F-J's "sThreshold"
List<Future> futures = new ArrayList<Future>();

// Send stuff to thread pool:
for (int i = 0; i < src.length; i+= maxSize) {
    int size = Math.min(maxSize, src.length - i);
    ForkBlur task = new ForkBlur(src, i, size, dst);
    Future f = threadPool.submit(task);
    futures.add(f);
}

// Wait for all sent tasks to complete:
for (Future future : futures) {
    future.get();
}

// Done!

작업은 스레드 풀의 대기열로 이동하여 작업자 스레드가 사용 가능 해지면 실행됩니다. 분할이 충분히 세분화되고 (특히 마지막 작업을 기다릴 필요가 없도록) 스레드 풀에 충분한 (최소 N 개의 프로세서) 스레드가 있으면 모든 프로세서는 전체 계산이 완료 될 때까지 최고 속도로 작동합니다.

뭔가 빠졌습니까? 포크 / 조인 프레임 워크를 사용하면 어떤 부가 가치가 있습니까?



답변

기본적인 오해는 포크 / 조인 예제가 업무 도용을 보여 주지 않고 일종의 표준 나누기와 정복 만 보여주는 것이라고 생각합니다 .

작업 도용은 다음과 같습니다. 작업자 B가 작업을 완료했습니다. 그는 친절한 사람이므로 주위를 둘러보고 작업자 A가 여전히 열심히 일하는 것을 봅니다. 그는 걸어 다니며 물었다. “이봐, 내가 손을 줄 수있어.” 답글입니다. “쿨, 나는 1000 단위 의이 작업을했습니다. 지금까지 나는 345 떠나 655를 완료했습니다. 당신은 번호 673에서 1000에 대해 작업하십시오, 346에서 672를 할 것입니다.” B는 “좋아요, 먼저 술집에 갈 수 있도록 시작하겠습니다”라고 말합니다.

알다시피-노동자는 실제 작업을 시작할 때도 서로 의사 소통해야합니다. 이것은 예제에서 빠진 부분입니다.

반면에 예는 “하청 업체 사용”과 같은 것만 보여줍니다.

Worker A : “Dang, 나는 1000 단위의 일을하고있다. 나에게 너무 많은 일이다. 나는 500을 스스로하고 500을 하도급 할 것이다.” 이는 큰 작업이 각각 10 개 단위의 작은 패킷으로 분류 될 때까지 계속됩니다. 이들은 가능한 노동자들에 의해 처형 될 것입니다. 그러나 하나의 패킷이 일종의 독약이고 다른 패킷보다 상당히 오래 걸리면 (불운) 분할 단계는 끝납니다.

Fork / Join과 작업을 미리 분할하는 것의 유일한 차이점은 다음과 같습니다. 미리 분할 할 때 작업 큐가 시작부터 바로 가득 찼습니다. 예 : 1000 단위, 임계 값은 10이므로 큐에 100 개의 항목이 있습니다. 이 패킷은 스레드 풀 멤버에 분배됩니다.

포크 / 조인은 더 복잡하며 큐의 패킷 수를 더 작게 유지하려고합니다.

  • 1 단계 : (1 … 1000)을 포함하는 하나의 패킷을 대기열에 넣습니다.
  • 2 단계 : 한 작업자가 패킷을 팝 (1 … 1000)하여 두 개의 패킷 (1 … 500) 및 (501 … 1000)으로 바꿉니다.
  • 3 단계 : 한 근로자가 패킷 (500 … 1000)을 팝하고 (500 … 750) 및 (751 … 1000)을 푸시합니다.
  • n 단계 : 스택에는 (1..500), (500 … 750), (750 … 875) … (991..1000) 패킷이 포함됩니다.
  • n + 1 단계 : 패킷 (991..1000)이 팝되어 실행됩니다.
  • 단계 n + 2 : 패킷 (981..990)이 팝되어 실행됩니다
  • 단계 n + 3 : 패킷 (961..980)이 팝되어 (961 … 970) 및 (971..980)으로 분할됩니다. ….

다음을 참조하십시오. 포크 / 조인에서 큐가 더 작고 (예에서 6) “분할”및 “작업”단계가 인터리브됩니다.

여러 근로자가 동시에 튀어 나오면서 밀릴 때 상호 작용은 분명하지 않습니다.


답변

사용중인 스레드가 모두 100 %로 독립적으로 작동하는 경우에는 포크 조인 (FJ) 풀의 n 스레드보다 낫습니다. 그러나 결코 그런 식으로 작동하지 않습니다.

문제를 n 개의 동일한 조각으로 정확하게 분할하지 못할 수 있습니다. 그럼에도 불구하고 스레드 스케줄링은 공정하지 않은 방법입니다. 가장 느린 스레드를 기다리게됩니다. 여러 작업이있는 경우 각각 n-way 병렬 처리 (일반적으로 더 효율적)로 실행할 수 있지만 다른 작업이 완료되면 n-way로 올라갈 수 있습니다.

그렇다면 문제를 FJ 크기로 잘라서 스레드 풀 작업을 해보는 것이 어떻습니까? 일반적인 FJ 사용법은 문제를 작은 조각으로 줄입니다. 이를 무작위 순서로 수행하려면 하드웨어 수준에서 많은 조정이 필요합니다. 오버 헤드는 살인자 일 것입니다. FJ에서 태스크는 스레드가 LIFO / 스택 (Last In First Out) 순서로 읽는 큐에 배치되며, 작업 도용 (핵심 작업의 경우)은 선입 선출 (FIFO / “대기열)입니다. 결과적으로 긴 배열 처리는 작은 덩어리로 나눠 지더라도 크게 순차적으로 수행 될 수 있습니다. (한 빅뱅에서 작은 크기의 덩어리로 문제를 나누는 것이 사소한 일이 아닌 경우도 있습니다. 균형없이 어떤 형태의 계층 구조를 다루는 것을 말합니다.)

결론 : FJ를 사용하면 고르지 않은 상황에서 하드웨어 스레드를보다 효율적으로 사용할 수 있습니다. 스레드가 두 개 이상인 경우 항상 그렇습니다.


답변

스레드 풀과 Fork / Join의 궁극적 인 목표는 모두 같습니다. 둘 다 처리량을 최대화하기 위해 최대한 사용 가능한 CPU 성능을 활용하려고합니다. 최대 처리량은 가능한 많은 작업을 장기간 완료해야 함을 의미합니다. 그렇게하려면 무엇이 필요합니까? (다음은 계산 작업이 부족하지 않다고 가정합니다. 100 % CPU 사용에는 항상 충분한 양이 있습니다. 또한 하이퍼 스레딩의 경우 코어 또는 가상 코어에 대해 “CPU”를 동일하게 사용합니다).

  1. 최소한의 스레드를 실행하면 코어가 사용되지 않기 때문에 사용 가능한 CPU 수만큼 스레드를 실행해야합니다.
  2. 더 많은 스레드를 실행하면 다른 스레드에 CPU를 할당하는 스케줄러에 추가로드가 발생하여 일부 CPU 시간이 계산 작업이 아닌 스케줄러로 이동하기 때문에 최대한 많은 스레드가 실행 중이어야합니다.

따라서 우리는 최대 처리량을 위해 CPU와 정확히 같은 수의 스레드가 필요하다는 것을 알았습니다. Oracle의 모호한 예에서 사용 가능한 CPU 수와 동일한 스레드 수로 고정 크기 스레드 풀을 사용하거나 스레드 풀을 사용할 수 있습니다. 차이가 없습니다, 당신 말이 맞아요!

그렇다면 언제 스레드 풀에 문제가 생길까요? 스레드가 다른 작업이 완료되기를 기다리고 있기 때문에 스레드가 차단되는 경우 입니다. 다음 예제를 가정하십시오.

class AbcAlgorithm implements Runnable {
    public void run() {
        Future<StepAResult> aFuture = threadPool.submit(new ATask());
        StepBResult bResult = stepB();
        StepAResult aResult = aFuture.get();
        stepC(aResult, bResult);
    }
}

여기서 볼 수있는 것은 3 단계 A, B 및 C로 구성된 알고리즘입니다. A와 B는 서로 독립적으로 수행 될 수 있지만 C 단계는 단계 A와 B의 결과가 필요합니다.이 알고리즘이하는 일은 작업 A를 제출하는 것입니다 스레드 풀과 태스크 b를 직접 수행하십시오. 그런 다음 스레드는 작업 A도 완료 될 때까지 기다렸다가 단계 C를 계속합니다. A와 B가 동시에 완료되면 모든 것이 정상입니다. 그러나 A가 B보다 오래 걸리면 어떻게 될까요? 작업 A의 특성이이를 지시하기 때문일 수 있지만, 처음에 사용 가능한 작업 A에 대한 스레드가없고 작업 A가 대기해야하기 때문일 수도 있습니다. (사용 가능한 단일 CPU가 있고 스레드 풀에 단일 스레드 만있는 경우 교착 상태가 발생할 수 있지만 현재로서는 문제가 아닙니다.) 요점은 작업 B를 방금 실행 한 스레드가전체 스레드를 차단합니다 . CPU와 동일한 수의 스레드가 있고 하나의 스레드가 차단되므로 하나의 CPU가 유휴 상태 임을 의미합니다 .

포크 / 조인이이 문제를 해결합니다. 포크 / 조인 프레임 워크에서 다음과 같은 알고리즘을 작성합니다.

class AbcAlgorithm implements Runnable {
    public void run() {
        ATask aTask = new ATask());
        aTask.fork();
        StepBResult bResult = stepB();
        StepAResult aResult = aTask.join();
        stepC(aResult, bResult);
    }
}

동일하게 보이지 않습니까? 그러나 단서는 aTask.join 차단되지 않습니다 . 대신, 여기서는 작업 스털링 이 시작됩니다. 스레드는 과거에 포크 된 다른 작업을 둘러보고 계속 진행할 것입니다. 먼저 분기 된 작업이 처리를 시작했는지 확인합니다. 따라서 A가 다른 스레드에 의해 아직 시작되지 않은 경우 다음에 A를 수행하고 그렇지 않으면 다른 스레드의 큐를 확인하고 작업을 도용합니다. 다른 스레드의 다른 작업이 완료되면 A가 지금 완료되었는지 확인합니다. 위의 알고리즘이라면를 호출 할 수 있습니다 stepC. 그렇지 않으면 훔칠 또 다른 작업을 찾습니다. 따라서 포크 / 조인 풀은 차단 작업에도 불구하고 100 % CPU 사용률을 달성 할 수 있습니다 .

그러나 함정이 있습니다. 작업 도청은 s 의 join호출 에만 가능합니다 ForkJoinTask. 다른 스레드 대기 또는 I / O 조치 대기와 같은 외부 차단 조치에는 수행 할 수 없습니다. 그렇다면 I / O가 완료되기를 기다리는 것은 일반적인 작업입니까? 이 경우 차단 작업이 완료 되 자마자 다시 중지되는 추가 스레드를 포크 / 조인 풀에 추가 할 수 있다면 두 번째로 가장 좋은 방법입니다. 그리고 ForkJoinPool우리가 ManagedBlockers를 사용한다면 실제로 그렇게 할 수 있습니다 .

피보나치

에서 RecursiveTask 용의 JavaDoc 포크 / 가입하여 피보나치 수를 산출하기위한 일례이다. 클래식 재귀 솔루션은 다음을 참조하십시오.

public static int fib(int n) {
    if (n <= 1) {
        return n;
    }
    return fib(n - 1) + fib(n - 2);
}

JavaDocs에서 설명했듯이 피보나치 수를 계산하는 덤프 방법입니다.이 알고리즘은 복잡도가 O (2 ^ n)이며 간단한 방법이 가능하기 때문입니다. 그러나이 알고리즘은 매우 간단하고 이해하기 쉽기 때문에이 알고리즘을 고수합니다. 포크 / 조인으로 속도를 높이고 싶다고 가정 해 봅시다. 순진한 구현은 다음과 같습니다.

class Fibonacci extends RecursiveTask<Long> {
    private final long n;

    Fibonacci(long n) {
        this.n = n;
    }

    public Long compute() {
        if (n <= 1) {
            return n;
        }
        Fibonacci f1 = new Fibonacci(n - 1);
        f1.fork();
        Fibonacci f2 = new Fibonacci(n - 2);
        return f2.compute() + f1.join();
   }
}

이 작업이 분리되는 단계는 너무 짧아서 너무 끔찍하게 수행되지만 프레임 워크가 일반적으로 어떻게 잘 작동하는지 볼 수 있습니다. 두 개의 summand는 독립적으로 계산할 수 있지만 최종 구성하려면 두 가지가 필요합니다. 결과. 따라서 절반은 다른 스레드에서 수행됩니다. 교착 상태를 갖지 않고도 스레드 풀에서 동일한 작업을 수행 할 수 있습니다 (단순하지는 않지만).

완전성을 위해 :이 재귀 접근법을 사용하여 피보나치 수를 실제로 계산하려면 여기에 최적화 된 버전이 있습니다.

class FibonacciBigSubtasks extends RecursiveTask<Long> {
    private final long n;

    FibonacciBigSubtasks(long n) {
        this.n = n;
    }

    public Long compute() {
        return fib(n);
    }

    private long fib(long n) {
        if (n <= 1) {
            return 1;
        }
        if (n > 10 && getSurplusQueuedTaskCount() < 2) {
            final FibonacciBigSubtasks f1 = new FibonacciBigSubtasks(n - 1);
            final FibonacciBigSubtasks f2 = new FibonacciBigSubtasks(n - 2);
            f1.fork();
            return f2.compute() + f1.join();
        } else {
            return fib(n - 1) + fib(n - 2);
        }
    }
}

이것은 서브 태스크 n > 10 && getSurplusQueuedTaskCount() < 2가 참일 때만 분할되기 때문에 서브 태스크를 훨씬 더 작게 유지합니다. 즉, 수행해야 할 메소드 호출이 100 개를 훨씬 초과 n > 10하고 ( ) 이미 대기중인 수동 태스크가 없습니다 ( getSurplusQueuedTaskCount() < 2).

내 컴퓨터 (4 코어 (하이퍼 스레딩 계산시 8 개), 인텔 ® 코어 ™ i7-2720QM CPU (2.20GHz) fib(50)에서는 64 초, 클래식 접근 방식에서는 64 초, 포크 / 조인 방식에서는 18 초 이론적으로 가능한 한 많지는 않지만 상당히 눈에 띄는 이익입니다.

요약

  • 예, 예에서 포크 / 조인은 클래식 스레드 풀보다 이점이 없습니다.
  • 포크 / 조인은 차단과 관련하여 성능을 크게 향상시킬 수 있습니다
  • 포크 / 가입은 일부 교착 상태 문제를 피합니다

답변

포크 / 조인은 작업 도용을 구현하므로 스레드 풀과 다릅니다. 에서 포크 / 가입

다른 ExecutorService와 마찬가지로 fork / join 프레임 워크는 스레드 풀의 작업자 스레드로 작업을 배포합니다. 포크 / 조인 프레임 워크는 워크 스털링 알고리즘을 사용하므로 구별됩니다. 수행 할 작업이 부족한 작업자 스레드는 여전히 사용중인 다른 스레드에서 작업을 훔칠 수 있습니다.

두 개의 스레드와 4 개의 작업 a, b, c, d가 각각 1, 1, 5 및 6 초가 걸린다고 가정 해보십시오. 처음에는 a와 b가 스레드 1에 할당되고 c와 d가 스레드 2에 할당됩니다. 스레드 풀에서는 11 초가 걸립니다. 포크 / 조인을 사용하면 스레드 1이 완료되고 스레드 2에서 작업을 훔칠 수 있으므로 작업 d는 스레드 1에 의해 실행됩니다. 스레드 1은 a, b 및 d를 실행하고 스레드 2는 c 만 실행합니다. 전체 시간 : 11 초가 아닌 8 초

편집 : Joonas가 지적한 것처럼 작업이 스레드에 사전 할당되지는 않습니다. 포크 / 조인의 아이디어는 스레드가 작업을 여러 하위 조각으로 분할하도록 선택할 수 있다는 것입니다. 위의 내용을 다시 말하면 :

우리는 각각 2 초와 11 초가 걸리는 두 가지 작업 (ab)과 (cd)가 있습니다. 스레드 1이 ab를 실행하기 시작하고이를 두 개의 하위 작업 a & b로 나눕니다. 스레드 2와 마찬가지로 두 개의 하위 작업 c & d로 나뉩니다. 스레드 1이 a & b를 완료하면 스레드 2에서 d를 훔칠 수 있습니다.


답변

위의 모든 사람은 일 도둑질로 얻을 수있는 이점이 맞지만 이것이 왜 그런지 확장하는 것입니다.

주요 이점은 작업자 스레드 간의 효율적인 조정입니다. 작업을 분할하고 재 조립해야하며 조정이 필요합니다. 위에서 AH의 답변에서 볼 수 있듯이 각 스레드에는 자체 작업 목록이 있습니다. 이 목록의 중요한 속성은 목록이 정렬된다는 것입니다 (위에 큰 작업이 있고 아래쪽에 작은 작업이 있음). 각 스레드는 목록 맨 아래에서 작업을 실행하고 다른 스레드 목록 맨 위에서 작업을 훔칩니다.

이것의 결과는 다음과 같습니다.

  • 작업 목록의 머리와 꼬리는 독립적으로 동기화되어 목록에서 경합을 줄입니다.
  • 작업의 중요한 하위 트리는 동일한 스레드로 분할 및 재 조립되므로 이러한 하위 트리에 대해 스레드 간 조정이 필요하지 않습니다.
  • 실이 훔칠 때 큰 조각을 취한 다음 자체 목록으로 세분화합니다.
  • 가공 강은 나사산이 공정이 끝날 때까지 거의 완전히 활용됨을 의미합니다.

스레드 풀을 사용하는 대부분의 다른 분할 및 정복 체계에는 더 많은 스레드 간 통신 및 조정이 필요합니다.


답변

이 예에서 포크 / 조인은 포크가 필요하지 않고 워크로드가 작업자 스레드간에 균등하게 분할되므로 값을 추가하지 않습니다. 포크 / 조인은 오버 헤드 만 추가합니다.

여기 주제에 관한 좋은 기사 가 있습니다. 인용문:

전반적으로 워크로드가 작업자 스레드간에 균등하게 분할되는 경우 ThreadPoolExecutor가 선호된다고 말할 수 있습니다. 이를 보장하려면 입력 데이터의 모양을 정확하게 알아야합니다. 반대로 ForkJoinPool은 입력 데이터에 관계없이 우수한 성능을 제공하므로 훨씬 강력한 솔루션입니다.


답변

또 다른 중요한 차이점은 FJ를 사용하면 여러 개의 복잡한 “가입”단계를 수행 할 수 있다는 것입니다. http://faculty.ycp.edu/~dhovemey/spring2011/cs365/lecture/lecture18.html 에서 병합 정렬을 고려하면 이 작업을 사전 분할하는 데 너무 많은 오케스트레이션이 필요합니다. 예를 들어 다음을 수행해야합니다.

  • 1 분기를 정렬하다
  • 2 분기 정렬
  • 첫 2 분기 합병
  • 3 분기 정렬
  • 4 분기를 정렬하다
  • 지난 2 분기 합병
  • 두 반쪽을 병합

병합 등을 수행하기 전에 정렬을 수행하도록 지정하는 방법은 무엇입니까?

각 항목 목록에 대해 특정 작업을 수행하는 최선의 방법을 찾고 있습니다. 목록을 미리 분리하고 표준 ThreadPool을 사용한다고 생각합니다. FJ는 작업이 충분히 독립된 작업으로 사전 분할 될 수 없지만 독립적으로 독립된 작업으로 재귀 적으로 분할 될 수있는 경우에 가장 유용합니다 (예 : 절반을 정렬하는 것은 독립적이지만 2 개의 정렬 된 절반을 정렬 된 전체로 병합하는 것은 아닙니다).