Làm thế nào là fork / tham gia khung tốt hơn một nhóm luồng?


134

Những lợi ích của việc sử dụng khung fork / tham gia mới chỉ đơn giản là phân chia nhiệm vụ lớn thành N nhiệm vụ ban đầu, gửi chúng đến nhóm luồng được lưu trong bộ nhớ cache (từ Executors ) và chờ đợi mỗi tác vụ hoàn thành? Tôi không thấy cách sử dụng trừu tượng ngã ba / tham gia đơn giản hóa vấn đề hoặc làm cho giải pháp hiệu quả hơn so với những gì chúng ta đã có trong nhiều năm nay.

Ví dụ, thuật toán làm mờ song song trong ví dụ hướng dẫn có thể được triển khai như sau:

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
    }
}

Tách vào đầu và gửi các tác vụ đến một nhóm luồng:

// 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!

Các tác vụ đi đến hàng đợi của nhóm luồng, từ đó chúng được thực thi khi các luồng công nhân trở nên khả dụng. Miễn là việc phân tách đủ chi tiết (để tránh phải chờ đợi nhiệm vụ cuối cùng) và nhóm luồng có đủ (ít nhất N bộ xử lý), tất cả các bộ xử lý đều hoạt động ở tốc độ tối đa cho đến khi toàn bộ tính toán được thực hiện.

Tui bỏ lỡ điều gì vậy? Giá trị gia tăng của việc sử dụng khung fork / tham gia là gì?

Câu trả lời:


136

Tôi nghĩ rằng sự hiểu lầm cơ bản là, các ví dụ Fork / Tham gia KHÔNG hiển thị việc ăn cắp công việc mà chỉ có một số loại phân chia và chinh phục tiêu chuẩn.

Ăn cắp công việc sẽ như thế này: Công nhân B đã hoàn thành công việc của mình. Anh ấy là một người tốt bụng, vì vậy anh ấy nhìn xung quanh và thấy Công nhân A vẫn làm việc rất chăm chỉ. Anh đi dạo và hỏi: "Này chàng trai, tôi có thể giúp bạn một tay." Một câu trả lời. "Thật tuyệt, tôi có nhiệm vụ 1000 đơn vị này B nói "OK, chúng ta hãy bắt đầu để chúng ta có thể đến quán rượu sớm hơn."

Bạn thấy đấy - các công nhân phải giao tiếp với nhau ngay cả khi họ bắt đầu công việc thực sự. Đây là phần còn thiếu trong các ví dụ.

Mặt khác, các ví dụ chỉ hiển thị một cái gì đó như "sử dụng các nhà thầu phụ":

Công nhân A: "Dang, tôi có 1000 đơn vị công việc. Quá nhiều cho tôi. Tôi sẽ tự mình làm 500 và giao phó 500 cho người khác." Điều này diễn ra cho đến khi nhiệm vụ lớn được chia thành các gói nhỏ gồm 10 đơn vị mỗi đơn vị. Chúng sẽ được thực hiện bởi các công nhân có sẵn. Nhưng nếu một gói là một loại thuốc độc và mất nhiều thời gian hơn các gói khác - thật không may, giai đoạn phân chia đã kết thúc.

Sự khác biệt duy nhất còn lại giữa Fork / Tham gia và chia tách nhiệm vụ trả trước là thế này: Khi chia tách trả trước, bạn có hàng đợi công việc đầy đủ ngay từ đầu. Ví dụ: 1000 đơn vị, ngưỡng là 10, vì vậy hàng đợi có 100 mục. Các gói này được phân phối cho các thành viên threadpool.

Fork / Tham gia phức tạp hơn và cố gắng giữ số lượng gói trong hàng đợi nhỏ hơn:

  • Bước 1: Đặt một gói chứa (1 ... 1000) vào hàng đợi
  • Bước 2: Một công nhân bật gói tin (1 ... 1000) và thay thế nó bằng hai gói: (1 ... 500) và (501 ... 1000).
  • Bước 3: Một công nhân bật gói (500 ... 1000) và đẩy (500 ... 750) và (751 ... 1000).
  • Bước n: Ngăn xếp chứa các gói này: (1..500), (500 ... 750), (750 ... 875) ... (991..1000)
  • Bước n + 1: Gói (991..1000) được bật và thực thi
  • Bước n + 2: Gói (981..990) được bật và thực thi
  • Bước n + 3: Gói (961..980) được bật và chia thành (961 ... 970) và (971..980). ....

Bạn thấy: trong Fork / Tham gia hàng đợi nhỏ hơn (6 trong ví dụ) và các giai đoạn "tách" và "công việc" được xen kẽ.

Khi nhiều công nhân xuất hiện và đẩy đồng thời, các tương tác không rõ ràng lắm.


Tôi nghĩ rằng đây thực sự là câu trả lời. Tôi tự hỏi nếu có các ví dụ Fork / Tham gia thực tế ở bất cứ nơi nào có thể chứng minh khả năng ăn cắp công việc của nó? Với các ví dụ cơ bản, khối lượng công việc hoàn toàn có thể dự đoán được từ kích thước của đơn vị (ví dụ: chiều dài mảng), do đó việc phân chia trả trước rất dễ dàng. Ăn cắp chắc chắn sẽ tạo ra sự khác biệt trong các vấn đề trong đó khối lượng công việc trên mỗi đơn vị không thể dự đoán tốt từ kích thước của đơn vị.
Joonas Pulakka

AH Nếu câu trả lời của bạn là đúng, nó không giải thích làm thế nào. Ví dụ được đưa ra bởi Oracle không dẫn đến việc ăn cắp công việc. Làm thế nào ngã ba và tham gia công việc như trong ví dụ bạn đang mô tả ở đây? Bạn có thể hiển thị một số mã Java để tạo fork và tham gia ăn cắp công việc theo cách bạn mô tả không? cảm ơn
Marc

@Marc: Tôi xin lỗi, nhưng tôi không có ví dụ nào.
AH

6
Vấn đề với ví dụ của Oracle, IMO, không phải là nó không thể hiện hành vi ăn cắp (nó được mô tả bởi AH) mà là dễ dàng mã hóa một thuật toán cho một ThreadPool đơn giản cũng như (như Joonas đã làm). FJ hữu ích nhất khi công việc không thể được phân chia trước thành đủ các nhiệm vụ độc lập nhưng có thể được phân chia đệ quy thành các nhiệm vụ độc lập với nhau. Xem câu trả lời của tôi để biết ví dụ
ashirley

2
Một số ví dụ về việc ăn cắp công việc có thể có ích: h-online.com/developer/features/ cảm
bóng chuyền

27

Nếu bạn có n luồng bận rộn, tất cả đều hoạt động độc lập 100%, điều đó sẽ tốt hơn n luồng trong nhóm Fork-Join (FJ). Nhưng nó không bao giờ hoạt động theo cách đó.

Có thể không thể phân chia chính xác vấn đề thành n phần bằng nhau. Ngay cả khi bạn làm, lập lịch trình luồng là một số cách công bằng. Bạn sẽ kết thúc chờ đợi cho chủ đề chậm nhất. Nếu bạn có nhiều tác vụ thì mỗi tác vụ có thể chạy với song song ít hơn n-way (thường hiệu quả hơn), nhưng vẫn đi lên n-way khi các tác vụ khác đã kết thúc.

Vậy tại sao chúng ta không cắt vấn đề thành các mảnh có kích thước FJ và có một nhóm luồng xử lý vấn đề đó. Việc sử dụng FJ điển hình cắt vấn đề thành các mảnh nhỏ. Làm những việc này theo thứ tự ngẫu nhiên đòi hỏi nhiều sự phối hợp ở cấp độ phần cứng. Các chi phí sẽ là một kẻ giết người. Trong FJ, các tác vụ được đưa vào hàng đợi mà luồng đọc theo thứ tự Last In First Out (LIFO / stack) và việc đánh cắp công việc (nói chung là trong công việc cốt lõi) được thực hiện First In First Out (FIFO / "queue"). Kết quả là việc xử lý mảng dài có thể được thực hiện phần lớn theo tuần tự, mặc dù nó được chia thành các phần nhỏ. (Đây cũng là trường hợp có thể không tầm thường khi chia vấn đề thành các phần nhỏ có kích thước bằng nhau trong một vụ nổ lớn. Nói cách xử lý một dạng phân cấp nào đó mà không cân bằng.)

Kết luận: FJ cho phép sử dụng hiệu quả hơn các luồng phần cứng trong các tình huống không đồng đều, sẽ luôn luôn nếu bạn có nhiều hơn một luồng.


Nhưng tại sao FJ cuối cùng cũng chờ đợi chuỗi chậm nhất? Có một số nhiệm vụ được xác định trước, và tất nhiên một số trong số chúng sẽ luôn là nhiệm vụ cuối cùng hoàn thành. Điều chỉnh maxSizetham số trong ví dụ của tôi sẽ tạo ra phép chia phụ gần như tương tự như "phân tách nhị phân" trong ví dụ FJ (được thực hiện trong compute()phương thức, tính toán một cái gì đó hoặc gửi nhiệm vụ đến invokeAll()).
Joonas Pulakka

Bởi vì chúng nhỏ hơn nhiều - tôi sẽ thêm vào câu trả lời của tôi.
Tom Hawtin - tackline

Ok, nếu số lượng nhiệm vụ là thứ tự cường độ lớn hơn so với những gì có thể được xử lý song song (điều này hợp lý, để tránh phải chờ đến lần cuối cùng), thì tôi có thể thấy các vấn đề phối hợp. Ví dụ FJ có thể gây hiểu nhầm nếu phân chia được coi là dạng hạt đó: nó sử dụng ngưỡng 100000, đối với hình ảnh 1000x1000 sẽ tạo ra 16 nhiệm vụ thực tế, mỗi phần tử xử lý 62500. Đối với một hình ảnh 10000x10000 sẽ có 1024 nhiệm vụ, đó là một cái gì đó.
Joonas Pulakka

19

Mục tiêu cuối cùng của nhóm luồng và Fork / Tham gia là như nhau: Cả hai đều muốn sử dụng sức mạnh CPU có sẵn tốt nhất có thể để thông lượng tối đa. Thông lượng tối đa có nghĩa là càng nhiều nhiệm vụ càng tốt nên được hoàn thành trong một khoảng thời gian dài. Điều gì là cần thiết để làm điều đó? (Đối với những điều sau đây, chúng tôi sẽ cho rằng không thiếu các tác vụ tính toán: Luôn có đủ để thực hiện việc sử dụng CPU 100%. Ngoài ra, tôi sử dụng "CPU" tương đương cho lõi hoặc lõi ảo trong trường hợp siêu phân luồng).

  1. Ít nhất cần phải có nhiều luồng chạy như có sẵn CPU, bởi vì chạy ít luồng hơn sẽ khiến lõi không được sử dụng.
  2. Tối đa phải có nhiều luồng chạy như có sẵn CPU, bởi vì chạy nhiều luồng hơn sẽ tạo ra tải bổ sung cho Bộ lập lịch, người gán CPU cho các luồng khác nhau, khiến cho một số thời gian CPU đi đến trình lập lịch thay vì nhiệm vụ tính toán của chúng tôi.

Do đó, chúng tôi đã tìm ra rằng để có thông lượng tối đa, chúng tôi cần phải có cùng số lượng luồng chính xác so với CPU. Trong ví dụ làm mờ của Oracle, bạn có thể vừa lấy một nhóm luồng có kích thước cố định với số lượng luồng bằng số lượng CPU có sẵn hoặc sử dụng nhóm luồng. Nó sẽ không làm cho một sự khác biệt, bạn đã đúng!

Vì vậy, khi nào bạn sẽ gặp rắc rối với một nhóm chủ đề? Đó là nếu một khối luồng , bởi vì luồng của bạn đang chờ một tác vụ khác hoàn thành. Giả sử ví dụ sau:

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

Những gì chúng ta thấy ở đây là một thuật toán bao gồm ba bước A, B và C. A và B có thể được thực hiện độc lập với nhau, nhưng bước C cần kết quả của bước A VÀ B. Thuật toán này làm gì để gửi nhiệm vụ A đến các threadpool và thực hiện nhiệm vụ b trực tiếp. Sau đó, luồng sẽ chờ nhiệm vụ A cũng được thực hiện và tiếp tục với bước C. Nếu A và B được hoàn thành cùng một lúc, thì mọi thứ đều ổn. Nhưng nếu A mất nhiều thời gian hơn B thì sao? Đó có thể là do bản chất của nhiệm vụ A ra lệnh cho nó, nhưng nó cũng có thể là trường hợp vì không có luồng cho nhiệm vụ A có sẵn trong đầu và nhiệm vụ A cần phải chờ. (Nếu chỉ có một CPU duy nhất và do đó luồng của bạn chỉ có một luồng duy nhất, điều này thậm chí sẽ gây ra bế tắc, nhưng bây giờ điều đó nằm ngoài vấn đề). Vấn đề là luồng chỉ thực hiện tác vụ Bchặn toàn bộ chủ đề . Vì chúng ta có cùng số luồng như CPU ​​và một luồng bị chặn, điều đó có nghĩa là một CPU không hoạt động .

Fork / Tham gia giải quyết vấn đề này: Trong khung fork / tham gia, bạn viết thuật toán tương tự như sau:

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

Trông giống nhau, phải không? Tuy nhiên đầu mối là aTask.join sẽ không chặn . Thay vào đó là nơi ăn cắp công việc phát huy tác dụng: Chủ đề sẽ xem xét xung quanh các nhiệm vụ khác đã được rẽ nhánh trong quá khứ và sẽ tiếp tục với những công việc đó. Đầu tiên, nó kiểm tra xem các tác vụ mà nó đã rẽ nhánh đã bắt đầu xử lý chưa. Vì vậy, nếu A chưa được bắt đầu bởi một chủ đề khác, nó sẽ làm A tiếp theo, nếu không nó sẽ kiểm tra hàng đợi của các chủ đề khác và đánh cắp công việc của họ. Khi nhiệm vụ khác của luồng khác đã hoàn thành, nó sẽ kiểm tra xem A đã hoàn thành chưa. Nếu đó là thuật toán trên có thể gọi stepC. Nếu không, nó sẽ tìm kiếm một nhiệm vụ khác để đánh cắp. Do đó, các nhóm fork / tham gia có thể đạt được mức sử dụng CPU 100%, ngay cả khi đối mặt với các hành động chặn .

Tuy nhiên, có một cái bẫy: Ăn cắp công việc chỉ có thể cho joincuộc gọi của ForkJoinTasks. Không thể thực hiện các hành động chặn bên ngoài như chờ đợi một luồng khác hoặc chờ hành động I / O. Vì vậy, những gì về điều đó, chờ đợi I / O hoàn thành là một nhiệm vụ phổ biến? Trong trường hợp này, nếu chúng ta có thể thêm một luồng bổ sung vào nhóm Fork / Tham gia sẽ bị dừng lại ngay sau khi hành động chặn hoàn thành sẽ là điều tốt nhất thứ hai cần làm. Và ForkJoinPoolthực sự có thể làm điều đó nếu chúng ta đang sử dụng ManagedBlockers.

Trong JavaDoc cho RecursiveTask là một ví dụ để tính toán các số Fibonacci bằng cách sử dụng Fork / Tham gia. Đối với một giải pháp đệ quy cổ điển, xem:

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

Như được giải thích trong JavaDocs, đây là một cách khá hay để tính toán các số Wikipedia, vì thuật toán này có độ phức tạp O (2 ^ n) trong khi các cách đơn giản hơn là có thể. Tuy nhiên thuật toán này rất đơn giản và dễ hiểu, vì vậy chúng tôi gắn bó với nó. Giả sử chúng tôi muốn tăng tốc điều này với Fork / Tham gia. Một triển khai ngây thơ sẽ như thế này:

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();
   }
}

Các bước mà Nhiệm vụ này được chia thành quá ngắn và do đó điều này sẽ thực hiện khủng khiếp, nhưng bạn có thể thấy khung công tác thường hoạt động rất tốt: Hai triệu hồi có thể được tính toán độc lập, nhưng sau đó chúng ta cần cả hai để xây dựng trận chung kết kết quả. Vì vậy, một nửa được thực hiện trong một chủ đề khác. Hãy vui vẻ làm điều tương tự với các nhóm luồng mà không gặp bế tắc (có thể, nhưng gần như không đơn giản).

Chỉ để hoàn thiện: Nếu bạn thực sự muốn tính toán các số Fibonacci bằng cách sử dụng phương pháp đệ quy này thì đây là một phiên bản được tối ưu hóa:

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);
        }
    }
}

Điều này giữ cho các nhiệm vụ nhỏ hơn nhiều vì chúng chỉ được phân chia khi n > 10 && getSurplusQueuedTaskCount() < 2đúng, điều đó có nghĩa là có hơn 100 lệnh gọi phương thức cần thực hiện ( n > 10) và không có các tác vụ rất đàn ông đang chờ ( getSurplusQueuedTaskCount() < 2).

Trên máy tính của tôi (4 lõi (8 khi đếm siêu phân luồng), CPU Intel (R) Core (TM) i7-2720QM @ 2.20GHz), fib(50)mất 64 giây với cách tiếp cận cổ điển và chỉ 18 giây với cách tiếp cận Fork / Tham gia là một mức tăng khá đáng chú ý, mặc dù không nhiều như lý thuyết có thể.

Tóm lược

  • Có, trong ví dụ của bạn Fork / Tham gia không có lợi thế so với nhóm luồng cổ điển.
  • Fork / Tham gia có thể cải thiện đáng kể hiệu suất khi tham gia chặn
  • Ngã ba / Tham gia cắt ngang một số vấn đề bế tắc

17

Fork / tham gia khác với một nhóm luồng vì nó thực hiện công việc ăn cắp. Từ Ngã ba / Tham gia

Như với bất kỳ ExecutorService nào, khung fork / tham gia phân phối các tác vụ cho các luồng công nhân trong một nhóm luồng. Khung fork / tham gia là khác biệt bởi vì nó sử dụng thuật toán đánh cắp công việc. Các luồng công nhân hết việc cần làm có thể đánh cắp các nhiệm vụ từ các luồng khác vẫn đang bận.

Giả sử bạn có hai luồng và 4 tác vụ a, b, c, d lần lượt mất 1, 1, 5 và 6 giây. Ban đầu, a và b được gán cho luồng 1 và c và d cho luồng 2. Trong nhóm luồng, việc này sẽ mất 11 giây. Với fork / tham gia, luồng 1 kết thúc và có thể đánh cắp công việc từ luồng 2, do đó, nhiệm vụ d cuối cùng sẽ được thực hiện bởi luồng 1. Chuỗi 1 thực hiện a, b và d, luồng 2 chỉ c. Thời gian tổng thể: 8 giây, không phải 11.

EDIT: Như Joonas chỉ ra, các nhiệm vụ không nhất thiết phải được phân bổ trước cho một luồng. Ý tưởng của fork / tham gia là một luồng có thể chọn chia một tác vụ thành nhiều phần phụ. Vì vậy, để giới thiệu lại ở trên:

Chúng tôi có hai nhiệm vụ (ab) và (cd) lần lượt mất 2 và 11 giây. Chủ đề 1 bắt đầu thực thi ab và chia nó thành hai nhiệm vụ phụ a & b. Tương tự với luồng 2, nó chia thành hai nhiệm vụ phụ c & d. Khi luồng 1 đã hoàn thành a & b, nó có thể đánh cắp d từ luồng 2.


5
Nhóm chủ đề thường là các trường hợp ThreadPoolExecutor . Trong đó, các tác vụ đi theo hàng đợi ( BlockingQueue trong thực tế), từ đó các luồng công nhân nhận nhiệm vụ ngay khi chúng hoàn thành nhiệm vụ trước đó. Nhiệm vụ không được gán trước cho các chủ đề cụ thể, theo như tôi hiểu. Mỗi luồng có (nhiều nhất) 1 nhiệm vụ tại một thời điểm.
Joonas Pulakka

4
AFAIK có một Hàng đợi cho một ThreadPoolExecutor, lần lượt điều khiển một số Chủ đề. Điều này có nghĩa là việc gán các tác vụ hoặc Runnables (không phải Chủ đề!) Cho người thi hành, các tác vụ cũng không được phân bổ cho một Chủ đề cụ thể. Chính xác cách FJ cũng làm điều đó. Cho đến nay không có lợi ích cho việc sử dụng FJ.
AH

1
@AH Có, nhưng fork / tham gia cho phép bạn phân chia nhiệm vụ hiện tại. Các luồng đang thực hiện nhiệm vụ có thể chia nó thành hai nhiệm vụ khác nhau. Vì vậy, với ThreadPoolExecutor, bạn có một danh sách các nhiệm vụ cố định. Với fork / tham gia, tác vụ thực thi có thể chia nhiệm vụ riêng của nó thành hai, sau đó có thể được chọn bởi các luồng khác khi chúng hoàn thành công việc. Hoặc bạn nếu bạn hoàn thành đầu tiên.
Matthew Farwell

1
@Matthew Farwell: Trong ví dụ FJ , trong mỗi tác vụ, compute()sẽ tính toán tác vụ hoặc chia nó thành hai nhiệm vụ. Tùy chọn nào nó chọn chỉ phụ thuộc vào kích thước của tác vụ ( if (mLength < sThreshold)...), vì vậy đây chỉ là một cách ưa thích để tạo một số lượng nhiệm vụ cố định. Đối với hình ảnh 1000x1000, sẽ có chính xác 16 nhiệm vụ thực sự tính toán một cái gì đó. Ngoài ra, sẽ có 15 (= 16 - 1) tác vụ "trung gian" chỉ tạo và gọi các nhiệm vụ phụ và không tự tính toán bất cứ điều gì.
Joonas Pulakka

2
@Matthew Farwell: Có thể tôi không hiểu tất cả về FJ, nhưng nếu một nhiệm vụ con đã quyết định thực hiện computeDirectly()phương pháp của nó , không có cách nào để đánh cắp bất cứ điều gì nữa. Toàn bộ việc phân tách được thực hiện một tiên nghiệm , ít nhất là trong ví dụ.
Joonas Pulakka

14

Tất cả mọi người ở trên là chính xác những lợi ích đạt được bằng cách ăn cắp công việc, nhưng để mở rộng lý do tại sao điều này là.

Lợi ích chính là sự phối hợp hiệu quả giữa các luồng công nhân. Công việc phải được chia ra và tập hợp lại, đòi hỏi sự phối hợp. Như bạn có thể thấy trong câu trả lời của AH ở trên, mỗi luồng có danh sách công việc riêng. Một thuộc tính quan trọng của danh sách này là nó được sắp xếp (các nhiệm vụ lớn ở trên cùng và các nhiệm vụ nhỏ ở phía dưới). Mỗi luồng thực thi các tác vụ ở cuối danh sách của nó và đánh cắp các tác vụ từ đầu danh sách các luồng khác.

Kết quả của việc này là:

  • Đầu và đuôi của danh sách nhiệm vụ có thể được đồng bộ hóa độc lập, giảm sự tranh chấp trong danh sách.
  • Các cây con đáng kể của công việc được chia ra và ghép lại bởi cùng một luồng, do đó không cần phối hợp giữa các luồng cho các cây con này.
  • Khi một luồng đánh cắp hoạt động, nó sẽ lấy một mảnh lớn và sau đó nó sẽ chia thành danh sách riêng của nó
  • Việc gia công thép có nghĩa là các luồng được sử dụng gần như hoàn toàn cho đến khi kết thúc quá trình.

Hầu hết các sơ đồ phân chia và chinh phục khác sử dụng nhóm luồng yêu cầu giao tiếp và phối hợp giữa các luồng nhiều hơn.


13

Trong ví dụ này, Fork / Join không thêm giá trị nào vì không cần thiết và việc chia khối lượng công việc được chia đều cho các luồng công nhân. Fork / Tham gia chỉ thêm chi phí.

Đây là một bài viết tốt về chủ đề này. Trích dẫn:

Nhìn chung, chúng ta có thể nói rằng ThreadPoolExecutor sẽ được ưu tiên khi khối lượng công việc được chia đều cho các luồng công nhân. Để có thể đảm bảo điều này, bạn cần phải biết chính xác dữ liệu đầu vào trông như thế nào. Ngược lại, ForkJoinPool cung cấp hiệu suất tốt bất kể dữ liệu đầu vào và do đó là một giải pháp mạnh mẽ hơn đáng kể.


8

Một sự khác biệt quan trọng khác dường như là với FJ, bạn có thể thực hiện nhiều giai đoạn "Tham gia" phức tạp. Hãy xem xét sắp xếp hợp nhất từ http://facemony.ycp.edu/~dhovemey/spring2011/cs365/lecture/lecture18.html , sẽ có quá nhiều sự phối hợp cần thiết để phân chia trước tác phẩm này. ví dụ: Bạn cần làm những việc sau:

  • sắp xếp quý đầu tiên
  • sắp xếp quý thứ hai
  • hợp nhất 2 quý đầu
  • sắp xếp quý thứ ba
  • sắp xếp quý thứ tư
  • hợp nhất 2 quý vừa qua
  • hợp nhất 2 nửa

Làm thế nào để bạn xác định rằng bạn phải thực hiện các loại trước khi hợp nhất liên quan đến họ, vv

Tôi đã xem xét cách tốt nhất để làm một việc nhất định cho mỗi danh sách các mục. Tôi nghĩ rằng tôi sẽ chỉ phân chia trước danh sách và sử dụng một ThreadPool tiêu chuẩn. FJ có vẻ hữu ích nhất khi công việc không thể phân chia trước thành đủ các nhiệm vụ độc lập nhưng có thể được phân chia đệ quy thành các nhiệm vụ độc lập với nhau (ví dụ: sắp xếp các nửa là độc lập nhưng không hợp nhất hai nửa được sắp xếp thành một tổng thể được sắp xếp thì không).


6

F / J cũng có một lợi thế khác biệt khi bạn có các hoạt động hợp nhất đắt tiền. Bởi vì nó phân tách thành một cấu trúc cây, bạn chỉ hợp nhất log2 (n) trái ngược với n hợp nhất với phân tách luồng tuyến tính. (Điều này đưa ra giả định về mặt lý thuyết rằng bạn có nhiều bộ xử lý như các luồng, nhưng vẫn là một lợi thế) Để thực hiện bài tập về nhà, chúng tôi phải hợp nhất hàng nghìn mảng 2D (tất cả các kích thước giống nhau) bằng cách tính tổng các giá trị ở mỗi chỉ mục. Với bộ nối fork và bộ xử lý P, thời gian tiếp cận log2 (n) khi P tiến đến vô cùng.

1 2 3 .. 7 3 1 .... 8 5 4
4 5 6 + 2 4 3 => 6 9 9
7 8 9 .. 1 1 0 .... 8 9 9


3

Bạn sẽ ngạc nhiên về hiệu suất của ForkJoin trong ứng dụng như trình thu thập thông tin. đây là hướng dẫn tốt nhất bạn sẽ học hỏi

Logic của Fork / Tham gia rất đơn giản: (1) riêng biệt (ngã ba) mỗi nhiệm vụ lớn thành các nhiệm vụ nhỏ hơn; (2) xử lý mỗi tác vụ trong một luồng riêng biệt (tách chúng thành các tác vụ thậm chí nhỏ hơn nếu cần thiết); (3) tham gia kết quả.


3

Nếu vấn đề là chúng ta phải đợi các luồng khác hoàn thành (như trong trường hợp sắp xếp mảng hoặc tổng mảng), thì nên sử dụng kết nối fork, vì Executor (Executors.newFixedThreadPool (2)) sẽ bị nghẹt do giới hạn số của chủ đề. Nhóm forkjoin sẽ tạo ra nhiều luồng hơn trong trường hợp này để che cho luồng bị chặn để duy trì sự song song

Nguồn: http://www.oracle.com/technetwork/articles/java/fork-join-422606.html

Vấn đề với các nhà thi hành để thực hiện các thuật toán phân chia và chinh phục không liên quan đến việc tạo các nhiệm vụ phụ, bởi vì Callable có thể tự do gửi một bảng con mới cho người thực thi của nó và chờ kết quả của nó theo kiểu đồng bộ hoặc không đồng bộ. Vấn đề là sự song song: Khi một Callable chờ kết quả của một Callable khác, nó sẽ được đặt ở trạng thái chờ, do đó lãng phí một cơ hội để xử lý một Callable khác được xếp hàng để thực thi.

Khung fork / tham gia được thêm vào gói java.util.conc hiện trong Java SE 7 thông qua các nỗ lực của Doug Lea lấp đầy khoảng trống đó

Nguồn: https://docs.oracle.com/javase/7/docs/api/java/util/concản/ForkJoinPool.html

Nhóm cố gắng duy trì đủ các luồng hoạt động (hoặc khả dụng) bằng cách tự động thêm, tạm dừng hoặc tiếp tục các luồng công nhân nội bộ, ngay cả khi một số tác vụ bị đình trệ chờ tham gia các nhiệm vụ khác. Tuy nhiên, không có điều chỉnh nào được đảm bảo khi đối mặt với IO bị chặn hoặc đồng bộ hóa không được quản lý khác

public int getPoolSize () Trả về số lượng luồng công nhân đã bắt đầu nhưng chưa kết thúc. Kết quả được trả về bởi phương thức này có thể khác với getParallelism () khi các luồng được tạo để duy trì tính song song khi các luồng khác bị chặn hợp tác.


2

Tôi muốn thêm một câu trả lời ngắn cho những người không có nhiều thời gian để đọc câu trả lời dài. So sánh được lấy từ cuốn sách Các mẫu Akka ứng dụng:

Quyết định của bạn về việc nên sử dụng một người thực hiện fork-tham gia hoặc một người thực hiện nhóm luồng chủ yếu dựa trên việc các hoạt động trong bộ điều phối đó sẽ bị chặn. Trình thực thi fork-tham gia cung cấp cho bạn số lượng luồng hoạt động tối đa, trong khi đó, trình thực thi nhóm luồng cung cấp cho bạn một số luồng cố định. Nếu các luồng bị chặn, một hàm thực thi fork-tham gia sẽ tạo ra nhiều hơn, trong khi một luồng xử lý luồng-luồng sẽ không. Đối với các hoạt động chặn, bạn thường tốt hơn với trình thực thi nhóm luồng vì nó ngăn chặn số luồng của bạn phát nổ. Các hoạt động khác có tính phản ứng cao hơn là tốt hơn trong một công cụ thực hiện fork-tham gia.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.