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).
- Í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.
- 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 join
cuộc gọi của ForkJoinTask
s. 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à ForkJoinPool
thực sự có thể làm điều đó nếu chúng ta đang sử dụng ManagedBlocker
s.
Xơ
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