Fork/Join: Chia để trị song song với work-stealing
Chia để trị song song với work-stealing: ForkJoinPool, RecursiveTask/RecursiveAction, ngưỡng sequential cutoff, và liên hệ với parallel streams.
TL;DR: Fork/Join là framework chuyên cho bài toán divide-and-conquer CPU-bound: task tự chẻ mình thành task con đệ quy rồi gộp kết quả. Sức mạnh nằm ở work-stealing — mỗi worker giữ một deque riêng, owner push/pop ở đầu (LIFO), thread đói việc trộm từ đuôi (task to nhất) — nên tải tự cân ra các core mà không có hàng đợi trung tâm làm cổ chai. Viết task qua RecursiveTask/RecursiveAction với sequential cutoff: ngưỡng quá nhỏ thì chi phí điều phối nuốt hết lợi ích, quá lớn thì không phủ kín core. Parallel stream chạy trên chính commonPool này. Tuyệt đối không block I/O trong compute() — khi buộc phải chặn, dùng ManagedBlocker hoặc pool riêng.
1. Bài toán chia để trị, và vì sao thread pool thường không vừa
Hãy lấy một việc cụ thể, đủ đơn giản để soi rõ cấu trúc: tính tổng một mảng long rất lớn. Tuần tự thì chỉ là một vòng lặp. Nhưng nếu mảng có hàng chục triệu phần tử và máy có tám core, để bảy core còn lại ngồi không là lãng phí.
Ý tưởng chia để trị rất tự nhiên. Tổng của cả mảng bằng tổng nửa trái cộng tổng nửa phải. Mà tổng nửa trái lại bằng tổng một phần tư đầu cộng một phần tư sau, cứ thế. Ta chẻ đôi đệ quy cho tới khi mỗi mảnh đủ nhỏ để tính trực tiếp, rồi gộp ngược các kết quả lên. Cái hay là hai nửa độc lập hoàn toàn với nhau - không nửa nào đọc hay ghi dữ liệu của nửa kia - nên chúng có thể chạy song song mà không cần một cái khóa nào.
Câu hỏi là chạy song song bằng gì. Phản xạ đầu tiên của một người vừa học xong các bài trước là: ném mỗi mảnh vào một ExecutorService rồi get kết quả về. Nhưng làm vậy có một cái bẫy chết người mà ta sẽ gặp lại ở mục 6. Một task chia để trị, sau khi fork hai task con, phải chờ hai con xong rồi mới gộp được. Nếu cha và con cùng nằm trong một thread pool kích thước cố định, các task cha sẽ chiếm hết thread để ngồi chờ, trong khi các task con không còn thread nào để chạy. Cả pool đứng hình - một dạng deadlock do cạn thread. Cây đệ quy càng sâu, vấn đề càng chắc chắn xảy ra.
Fork/Join được thiết kế để giải đúng nút thắt này. Nó biết rằng task của nó có dạng cây, biết rằng một task cha sẽ chờ con, và nó tổ chức lại cách thread tiêu thụ công việc sao cho việc "cha chờ con" không bao giờ làm chết pool.
2. ForkJoinPool và work-stealing
Một thread pool thông thường mà ta gặp ở Executors.newFixedThreadPool có một hàng đợi trung tâm duy nhất. Mọi thread đói việc đều thò tay vào cùng cái hàng đợi đó mà lấy task. Mô hình này tốt cho các task độc lập, kích thước tương đương, đến từ bên ngoài. Nhưng với cây divide-and-conquer, nó tạo ra một điểm tranh chấp nóng: hàng triệu task con bé tí sinh ra liên tục, tất cả chen nhau vào một hàng đợi chung.
ForkJoinPool làm khác. Mỗi worker thread có một hàng đợi riêng của nó, gọi là work-stealing deque - một hàng đợi hai đầu. Khi một task đang chạy trên một thread fork ra task con, task con không đi vào hàng đợi trung tâm nào cả; nó được đẩy vào đầu local của chính deque của thread đó. Khi thread cần việc tiếp theo, nó cũng lấy từ chính đầu local đó. Nói cách khác, ở điều kiện bình thường mỗi thread làm việc với riêng hàng đợi của mình, theo kiểu stack, không đụng tới ai - gần như không có tranh chấp.
Phần thú vị xảy ra khi một thread làm hết việc của mình. Thay vì ngồi không, nó trở thành kẻ trộm. Nó nhìn sang deque của một thread khác và lấy trộm một task - nhưng lấy từ đầu kia, đuôi của deque nạn nhân. Đây là dụng ý tinh tế: chủ deque lấy việc ở một đầu, kẻ trộm lấy ở đầu đối diện, nên hai bên hiếm khi giành cùng một task; và task ở đuôi deque thường là task "to" nhất, được fork sớm nhất, nên trộm một cái là vớ được cả một nhánh con bự để làm, đỡ phải đi trộm lặt vặt nhiều lần.
Hình dung bằng một analogy đời thường. Một bếp ăn có tám đầu bếp, mỗi người một thớt riêng và một chồng phiếu order úp trước mặt. Bình thường ai lo chồng của nấy, lấy phiếu trên cùng mà làm, không chạm vào thớt người khác nên không cãi nhau. Khi một đầu bếp làm xong sạch chồng của mình, anh ta không đứng chơi mà đi rút một phiếu từ đáy chồng của người đang bận nhất - phiếu đáy thường là một order lớn chưa ai đụng tới. Người chủ chồng vẫn rút từ trên xuống, kẻ giúp việc rút từ dưới lên, nên cả hai hiếm khi với cùng một tờ. Kết quả là tải tự cân: không ai ngồi không khi vẫn còn việc trong bếp.
Đó chính là work-stealing, và nó giải thích vì sao Fork/Join chịu được cây đệ quy hàng triệu node mà không nghẹt: công việc phân tán đều ra các hàng đợi cục bộ, các core tự động kéo việc về phía mình khi đói, và không có cái cổ chai trung tâm nào.
Sơ đồ dưới chụp lại đúng khoảnh khắc đó — Worker 1 đang bận với deque đầy task, Worker 2 vừa hết việc và đi trộm:
flowchart TB
subgraph dq1["Deque cua Worker 1"]
direction TB
head["DAU: task nho nhat<br/>(fork gan nhat)"] --- mid["task vua"]
mid --- tail["DUOI: task TO nhat<br/>(fork som nhat)"]
end
subgraph dq2["Deque cua Worker 2"]
empty["(rong - da lam het viec)"]
end
W1["Worker 1 (owner)"] -- "push con moi fork +<br/>pop viec ke tiep o DAU (LIFO)" --> head
W2["Worker 2 (thief)"] -- "STEAL tu DUOI:<br/>vo duoc nhanh to nhat" --> tailHai chiều mũi tên là toàn bộ linh hồn thiết kế. Owner làm việc kiểu LIFO ở đầu deque: task con vừa fork còn nóng trong cache, lấy ra làm ngay là rẻ nhất. Thief trộm kiểu FIFO ở đuôi: task nằm đó lâu nhất là task được fork sớm nhất — tức nhánh to nhất của cây — nên một lần trộm là ôm về cả một cây con đáng kể, đỡ phải quay lại trộm vặt nhiều lần. Và vì hai bên thao tác ở hai đầu đối diện, chúng gần như không bao giờ tranh nhau cùng một phần tử.
2.1 commonPool
Ta không nhất thiết phải tự tạo ForkJoinPool. JVM duy trì sẵn một pool dùng chung cho toàn ứng dụng, gọi là common pool, lấy qua ForkJoinPool.commonPool(). Đây cũng là pool mà các phương thức tiện lợi mặc định nhắm tới: parallel stream chạy trên nó, và CompletableFuture.supplyAsync không truyền executor cũng dùng nó.
Kích thước mặc định của common pool là số core trừ một (cộng với chính thread đang gọi, nên hiệu quả là bằng số core). Con số này có lý do: với CPU-bound, có nhiều thread hơn số core không làm tính nhanh hơn, chỉ thêm chi phí context switching. Trên JDK 25, ta vẫn có thể chỉnh số này qua property java.util.concurrent.ForkJoinPool.common.parallelism nếu cần, nhưng phần lớn trường hợp mặc định là đúng.
Cái tiện của common pool cũng là cái bẫy của nó, và ta sẽ quay lại ở mục 6: vì nó dùng chung cho cả ứng dụng, một task cư xử xấu - đặc biệt là task blocking - trên common pool có thể bỏ đói mọi thứ khác cũng đang dựa vào nó.
3. RecursiveTask và RecursiveAction
Để mô tả một việc cho Fork/Join, ta kế thừa một trong hai lớp. RecursiveTask<V> cho task trả về kết quả; RecursiveAction cho task chỉ làm việc mà không trả gì (ví dụ sắp xếp tại chỗ một mảng). Cả hai bắt ta cài đúng một phương thức: compute(), nơi chứa toàn bộ logic chẻ-và-gộp.
Khung sườn của compute() luôn có cùng một hình dạng. Nếu mảnh việc đã đủ nhỏ, làm trực tiếp và trả kết quả. Nếu còn to, chẻ đôi thành hai task con, fork một con để nó chạy song song, tự mình compute con còn lại, rồi join con đã fork và gộp hai kết quả.
Viết lại bài toán tính tổng mảng long ở mục 1 thành RecursiveTask:
import java.util.concurrent.RecursiveTask;
import java.util.concurrent.ForkJoinPool;
public final class SumTask extends RecursiveTask<Long> {
private static final int THRESHOLD = 10_000; // ngưỡng sequential cutoff
private final long[] data;
private final int lo, hi;
SumTask(long[] data, int lo, int hi) {
this.data = data;
this.lo = lo;
this.hi = hi;
}
@Override
protected Long compute() {
int length = hi - lo;
if (length <= THRESHOLD) { // đủ nhỏ: làm thẳng
long sum = 0;
for (int i = lo; i < hi; i++) sum += data[i];
return sum;
}
int mid = lo + length / 2; // còn to: chẻ đôi
SumTask left = new SumTask(data, lo, mid);
SumTask right = new SumTask(data, mid, hi);
left.fork(); // giao nửa trái cho pool chạy song song
long rightSum = right.compute(); // tự mình làm nửa phải
long leftSum = left.join(); // chờ và lấy kết quả nửa trái
return leftSum + rightSum;
}
public static long sum(long[] data) {
return ForkJoinPool.commonPool().invoke(new SumTask(data, 0, data.length));
}
}
Cây chia việc mà đoạn code này dựng lên trông như sau — với mảng 40 nghìn phần tử và THRESHOLD = 10_000, mỗi node trên ngưỡng chẻ đôi, mỗi lá dưới ngưỡng tính thẳng bằng vòng for:
flowchart TD
R["sum [0 .. 40k)"] --> L1["sum [0 .. 20k)"]
R --> R1["sum [20k .. 40k)"]
L1 --> L2["sum [0 .. 10k)<br/>la: tinh thang"]
L1 --> L3["sum [10k .. 20k)<br/>la: tinh thang"]
R1 --> R2["sum [20k .. 30k)<br/>la: tinh thang"]
R1 --> R3["sum [30k .. 40k)<br/>la: tinh thang"]Kết quả chảy ngược từ lá lên gốc qua các lần join: mỗi node cộng hai con, gốc trả về tổng cả mảng. Bốn lá độc lập nhau hoàn toàn nên bốn core có thể cùng tính một lúc.
Mấy dòng trông vô hại trong đoạn này lại là chỗ quyết định nhanh hay chậm, đúng hay sai.
Thứ tự fork rồi compute rồi join không phải tùy tiện. Ta fork nửa trái - tức đẩy nó vào deque cho thread khác có thể trộm - rồi tự mình chạy nửa phải bằng compute() (gọi hàm thường, không qua pool), rồi mới join nửa trái. Nếu một thread rảnh đã trộm mất nửa trái, lúc join nó đã xong, ta lấy kết quả ngay. Nếu chưa ai trộm, join sẽ tự thực thi nửa trái đó ngay trên thread hiện tại thay vì ngồi chờ suông. Bằng cách để thread hiện tại luôn bận với một nửa thay vì fork cả hai rồi ngồi join cả hai, ta giảm hẳn số task thừa và tránh để thread chết dí trong lúc chờ.
Một cái sai phổ biến là fork cả hai con rồi join cả hai: left.fork(); right.fork(); return left.join() + right.join();. Nó vẫn chạy đúng nhưng phí - thread hiện tại không tự làm gì cả mà đẩy hết việc đi rồi ngồi chờ, tạo thêm một task không cần thiết cho pool. Còn tệ hơn nữa là lỗi kinh điển join con vừa fork trước, rồi mới compute con kia: left.fork(); long l = left.join(); long r = right.compute(); — dòng join chặn cho tới khi nửa trái xong rồi mới đụng tới nửa phải, hai con bị tuần tự hóa, song song biến mất hoàn toàn mà code vẫn chạy đúng kết quả nên rất khó phát hiện. Quy tắc an toàn để nhớ: fork con cuối cùng rồi compute, hoặc fork một con rồi compute con kia; luôn join theo thứ tự ngược với thứ tự fork.
Nếu thấy bộ quy tắc thứ tự này dễ trượt tay, JDK cho sẵn một idiom gọn hơn: ForkJoinTask.invokeAll(left, right). Nó nhận các task con, tự lo đúng vũ đạo bên trên hộ bạn — fork các task sau, chạy task đầu ngay trên thread hiện tại, rồi chờ tất cả hoàn tất:
@Override
protected Long compute() {
int length = hi - lo;
if (length <= THRESHOLD) {
long sum = 0;
for (int i = lo; i < hi; i++) sum += data[i];
return sum;
}
int mid = lo + length / 2;
SumTask left = new SumTask(data, lo, mid);
SumTask right = new SumTask(data, mid, hi);
ForkJoinTask.invokeAll(left, right); // fork right, compute left, wait for both
return left.join() + right.join(); // both done here -- join returns immediately
}
Sau khi invokeAll trả về, cả hai con đều đã xong, nên hai lần join phía sau chỉ là lấy kết quả ra, không chặn nữa. Phiên bản này dài hơn vài dòng nhưng không thể mắc lỗi đảo thứ tự fork/join, và đọc rõ ý đồ hơn: "chạy song song cả hai con, chờ xong, gộp". Với cây chia nhiều hơn hai nhánh, invokeAll còn nhận varargs hoặc collection — đáng làm lựa chọn mặc định khi viết compute() mới.
Và compute() ở đây không cần một cái khóa nào. Mỗi SumTask chỉ đọc đoạn [lo, hi) của riêng nó và không ghi vào data. Đây không phải ăn may mà là điều kiện tiên quyết để Fork/Join phát huy: các task con phải độc lập, không chia sẻ mutable state. Nếu các nhánh phải tranh nhau ghi vào một biến đếm chung, ta lại rơi đúng vào shared mutable state của bài Thread Safety, và mọi lợi ích song song sẽ bị nuốt bởi contention.
4. Ngưỡng và hiệu năng
Con số THRESHOLD = 10_000 ở trên là linh hồn của cả việc. Nó là sequential cutoff - ranh giới mà dưới đó ta thôi chẻ và làm thẳng. Đặt sai nó, framework vẫn chạy đúng nhưng có thể chậm hơn cả vòng lặp tuần tự.
Lý do nằm ở chi phí. Mỗi lần chẻ là tạo hai object task mới, đẩy vào deque, có thể bị trộm sang thread khác, rồi join lại. Tất cả những thao tác đó tốn CPU và bộ nhớ. Nếu ngưỡng quá nhỏ - giả sử chẻ tới khi mỗi mảnh chỉ còn mười phần tử - ta tạo ra hàng triệu task để cộng vài chục con số mỗi cái. Lúc đó chi phí quản lý task lấn át hoàn toàn công việc thực, và chương trình bò còn chậm hơn một vòng for đơn giản. Đây là cái bẫy "tách quá nhỏ" mà ai mới dùng Fork/Join cũng từng sa vào: tưởng càng song song nhiều càng nhanh, hóa ra ngược lại.
Ở thái cực kia, ngưỡng quá lớn thì ta không chẻ đủ mảnh để phủ kín các core. Mảng mười triệu phần tử với ngưỡng năm triệu chỉ tạo được hai mảnh - chạy được trên hai core, sáu core còn lại đứng nhìn. Tệ hơn, nếu hai mảnh đó không cân nhau về thời gian, core làm xong trước cũng chẳng có gì để trộm vì không còn task nào trong deque.
Điểm cân bằng nằm ở chỗ tạo nhiều mảnh hơn số core một chút - thường gấp vài lần. Nhiều mảnh nhỏ vừa đủ cho work-stealing có thứ để san: khi một core gặp mảnh nặng bất thường, các core khác đã có sẵn một rổ task chờ bị trộm để khỏi ngồi không. Nhưng "nhỏ vừa đủ" phải đủ to để mỗi mảnh làm một lượng việc đáng kể so với chi phí một lần fork/join. Không có con số vàng cho mọi bài toán; nó phụ thuộc việc mỗi phần tử tốn bao nhiêu tính toán. Cách duy nhất chắc chắn là đo: thử vài ngưỡng, benchmark trên dữ liệu thật và phần cứng thật, rồi chọn. Một quy tắc ngón tay cái khởi đầu là chọn ngưỡng sao cho tổng số task rơi vào khoảng vài lần tới vài chục lần số core, rồi tinh chỉnh từ đó.
Và đừng quên phép thử quan trọng nhất: so với phiên bản tuần tự. Song song hóa chỉ đáng khi dữ liệu đủ lớn và phép tính đủ nặng để bù lại chi phí điều phối. Trên một mảng vài nghìn phần tử, một vòng for thường thắng Fork/Join, và cả parallel stream nữa - bài học này dẫn ta thẳng sang mục tiếp theo.
5. Fork/Join là nền của parallel streams
Phần lớn lập trình viên Java thực ra dùng Fork/Join mỗi ngày mà không gọi tới tên nó. Khi bạn viết stream.parallel() hay list.parallelStream(), cái máy chạy bên dưới chính là ForkJoinPool.commonPool(). Stream framework tự lo phần ta vừa viết tay ở mục 3: nó tách nguồn dữ liệu thành các mảnh qua một cơ chế gọi là Spliterator, gói chúng thành task Fork/Join, và gộp kết quả lại.
Nghĩa là viết lại bài tính tổng bằng parallel stream gọn còn một dòng:
long total = java.util.stream.LongStream.of(data).parallel().sum();
hoặc với mảng:
long total = java.util.Arrays.stream(data).parallel().sum();
Bên dưới, cùng một cơ chế chẻ-fork-trộm-gộp đang chạy. Đây gần như luôn là cách nên dùng trước: ngắn, ít chỗ sai, và đội ngũ JDK đã chỉnh sequential cutoff khá hợp lý qua Spliterator. Tự tay viết RecursiveTask chỉ đáng làm khi cấu trúc chẻ của bài toán không khớp với mô hình stream - ví dụ duyệt một cây bất đối xứng, hay cần kiểm soát ngưỡng và chiến lược gộp tinh vi hơn mức stream cho phép.
Một điều cần tỉnh táo: hiểu parallel stream chạy trên common pool cũng là hiểu giới hạn của nó. Vì mọi parallel stream trong ứng dụng chia nhau đúng một common pool, nhét một thao tác blocking - chẳng hạn gọi network bên trong một map của parallel stream - sẽ ghim thread của common pool và làm chậm mọi parallel stream lẫn CompletableFuture khác đang dùng chung pool đó. Parallel stream sinh ra cho phép tính CPU-bound thuần trên dữ liệu đã nằm sẵn trong bộ nhớ, không phải để chạy I/O. Đây đúng là điểm khởi đầu cho mục cạm bẫy.
6. Cạm bẫy: blocking trong Fork/Join và chuyện chia sẻ common pool
Cạm bẫy lớn nhất của Fork/Join lặp lại cảnh báo từ bài Future & CompletableFuture, nhưng ở đây nó nghiêm trọng hơn vì cơ chế của pool. ForkJoinPool được kích thước theo số core với một giả định ngầm: mỗi worker thread gần như lúc nào cũng đang tính, không ngồi chờ. Nhét một thao tác blocking - đọc file, gọi HTTP, chờ một khóa, sleep - vào trong compute() là phá vỡ chính giả định đó. Một thread đang chờ I/O không tính toán gì nhưng vẫn chiếm một trong số ít ỏi các slot của pool. Vài task blocking là đủ để bỏ đói toàn bộ pool: không còn thread nào để chạy các task thực, dù CPU rảnh.
Khi bắt buộc phải chặn bên trong một task Fork/Join, framework cho một lối thoát có kiểm soát: ForkJoinPool.ManagedBlocker. Ta gói thao tác blocking vào một ManagedBlocker và chờ qua ForkJoinPool.managedBlock(...). Lúc đó pool nhận biết rằng một thread sắp chặn và có thể tạm thời bù thêm một worker để duy trì mức song song, tránh chết pool. Đây là van an toàn, không phải lời mời chạy I/O ồ ạt trên Fork/Join - nó chỉ làm cho một thao tác chặn không thể tránh trở nên ít độc hại hơn.
import java.util.concurrent.ForkJoinPool;
// Gói một thao tác blocking để ForkJoinPool có thể bù thread trong lúc chờ.
ForkJoinPool.managedBlock(new ForkJoinPool.ManagedBlocker() {
private Object result;
private boolean done;
@Override
public boolean block() throws InterruptedException {
result = slowBlockingCall(); // thao tác chặn không tránh được
done = true;
return true; // true = đã xong, không cần chặn nữa
}
@Override
public boolean isReleasable() {
return done;
}
});
Cạm bẫy thứ hai là chuyện dùng chung common pool. Vì parallel stream, CompletableFuture.supplyAsync mặc định, và mọi commonPool().invoke đều cùng đổ vào một pool, một tác vụ tham lam hay blocking ở một góc ứng dụng có thể âm thầm làm chậm một góc hoàn toàn khác. Trong một service có nhiều thành phần, đây là loại lỗi rất khó truy: một báo cáo nặng chạy parallel stream làm tăng độ trễ của một luồng CompletableFuture chẳng liên quan, chỉ vì hai bên vô tình chia nhau common pool.
Cách phòng là cô lập. Khi một workload đáng kể cần Fork/Join - nhất là nếu nó có nguy cơ chặn hoặc chạy lâu - hãy tạo một ForkJoinPool riêng cho nó và invoke/submit vào pool đó, thay vì mượn common pool:
import java.util.concurrent.ForkJoinPool;
try (ForkJoinPool pool = new ForkJoinPool(4)) { // pool riêng, cô lập khỏi common pool
long total = pool.invoke(new SumTask(data, 0, data.length));
// ... dùng total
} // try-with-resources tự shutdown pool
Từ Java 19, ForkJoinPool cài AutoCloseable, nên trên JDK 25 ta có thể đặt nó trong try-with-resources và để nó tự đóng. Một pool riêng cho phép tách bạch sự cố: nếu nó bị bỏ đói hay chạy chậm, thiệt hại khoanh lại trong workload đó, không lan sang phần còn lại của ứng dụng.
7. Tie-in capstone: đối soát báo cáo bán vé theo divide-and-conquer
TicketFlow ở phiên bản v3 chủ yếu sống bằng Executor và CompletableFuture - đó vẫn là xương sống của luồng xác nhận và thông báo. Fork/Join xuất hiện như một mảnh độc lập, đúng chỗ nó thuộc về: một tác vụ tổng hợp CPU-bound chạy ngoài đường đi của request.
Hình dung cuối ngày hệ thống cần đối soát: quét qua một mảng rất lớn các bản ghi giao dịch trong bộ nhớ để cộng tổng doanh thu và đếm vé đã bán theo từng sự kiện. Đây là divide-and-conquer thuần khiết - các bản ghi độc lập, phép gộp có tính kết hợp, không I/O - nên nó hợp với Fork/Join hơn hẳn việc trải lên CompletableFuture. Cấu trúc giống hệt SumTask: chẻ đôi khoảng bản ghi tới một ngưỡng, mỗi lá cộng cục bộ một map nhỏ doanh thu-theo-sự-kiện, rồi mỗi lần join gộp hai map con. Vì mỗi task chỉ đọc đoạn của riêng nó và trả về một kết quả mới thay vì ghi vào một accumulator chung, không có shared mutable state, không cần khóa - đúng tinh thần mục 3.
Quan trọng không kém là biết khi nào không lôi Fork/Join vào. Việc đối soát nếu cần đọc từng dòng từ database thì không còn là CPU-bound nữa, và nhét truy vấn blocking vào compute() sẽ vấp đúng cạm bẫy mục 6. Trong capstone, ranh giới được giữ rạch ròi: I/O và điều phối luồng nghiệp vụ thuộc về Executor và CompletableFuture; chỉ riêng phần tính toán thuần trên dữ liệu đã nằm sẵn trong bộ nhớ mới giao cho Fork/Join, và chạy trên một pool riêng để khỏi tranh chấp common pool với phần còn lại của hệ thống.
8. 📚 Deep Dive Oracle
Spec / reference chính thức:
- Doug Lea — A Java Fork/Join Framework (2000) — paper gốc của tác giả framework; mô tả thiết kế work-stealing deque và các phép đo hiệu năng đầu tiên, ngắn và rất dễ đọc.
ForkJoinPooljavadoc (Java 21) — quy định kích thước mặc định của commonPool, property chỉnh parallelism, và contract củaManagedBlocker.ForkJoinTaskjavadoc — ghi rõ ngữ nghĩafork/join/invokeAllvà cảnh báo chính thức về task blocking.
Ghi chú: Fork/Join vào JDK 7 qua JSR 166y; cùng một dòng công trình của Doug Lea sau này thành nền cho parallel stream (JDK 8) và là một nhánh tham chiếu khi đọc về scheduler của virtual thread. Đọc paper trước, javadoc sau — paper cho bức tranh, javadoc cho contract.
9. Tổng kết
Fork/Join là công cụ chuyên dụng cho một lớp bài toán hẹp nhưng quan trọng: divide-and-conquer trên dữ liệu CPU-bound đã có sẵn trong bộ nhớ. Sức mạnh của nó đến từ work-stealing - mỗi worker giữ một deque riêng để gần như không tranh chấp, và tự đi trộm việc từ đuôi deque của kẻ khác khi đói, nhờ đó tải tự cân ra các core mà không cần một hàng đợi trung tâm làm cổ chai.
RecursiveTask và RecursiveAction mô tả việc qua compute() với cùng một hình dạng: nhỏ thì làm thẳng, to thì chẻ; fork một con, tự compute con kia, rồi join. Thứ tự fork-compute-join và việc join ngược thứ tự fork là chỗ quyết định hiệu năng.
Sequential cutoff là tham số sống còn. Quá nhỏ thì chi phí fork/join nuốt hết lợi ích; quá lớn thì không đủ mảnh để phủ kín core. Hãy tạo nhiều mảnh hơn số core một chút, và luôn benchmark so với phiên bản tuần tự.
Parallel stream chạy trên ForkJoinPool.commonPool(), nên nó là cách dùng Fork/Join nên thử trước; tự viết RecursiveTask chỉ đáng làm khi cấu trúc bài toán không khớp mô hình stream.
Và đừng chặn trong Fork/Join. Pool kích thước theo số core với giả định mọi thread đều đang tính, nên vài task blocking là đủ bỏ đói cả pool. Khi buộc phải chặn, dùng ManagedBlocker; với workload nặng hoặc rủi ro, tạo một ForkJoinPool riêng thay vì mượn common pool.
Đến đây Phần B - task execution - khép lại. Từ Executor tách task khỏi thread, qua Future/CompletableFuture lấy và ghép nối kết quả async, tới Fork/Join cân tải tính toán song song, cả ba bài đều đứng trên một giả định nền tảng giống nhau: mỗi Thread của Java là một tài nguyên đắt, gắn với một OS thread suốt vòng đời, nên ta phải gói chúng vào pool, đếm chúng cẩn thận, và tránh để chúng ngồi chờ. Chính vì giả định đó mà ta phải né blocking trên Fork/Join, phải giới hạn kích thước pool, phải dựng cả một pipeline async để khỏi chặn thread.
Phần C lật ngược chính giả định ấy. Nếu một Thread rẻ tới mức tạo hàng triệu cái cũng không sao, và một thread đang chờ I/O không còn chiếm giữ một OS thread nữa, thì rất nhiều quy tắc ta vừa học sẽ phải viết lại. Đó là virtual thread - và là bài tiếp theo.
10. Tự kiểm tra
Q1Vì sao trong work-stealing deque, owner lấy việc từ đầu (LIFO) còn thief trộm từ đuôi (FIFO)?▸
Owner pop ở đầu vì task con vừa fork là task mới nhất — dữ liệu của nó còn nóng trong CPU cache của chính thread đó, lấy ra làm ngay là rẻ nhất; kiểu LIFO này cũng khớp tự nhiên với thứ tự đệ quy.
Thief trộm ở đuôi vì task nằm đó lâu nhất là task được fork sớm nhất — tức nhánh to nhất của cây chia việc. Trộm một lần là ôm về cả một cây con đáng kể, đỡ phải quay lại trộm vặt nhiều lần. Và vì hai bên thao tác ở hai đầu đối diện của deque, chúng gần như không bao giờ tranh nhau cùng một phần tử — contention tiến về không.
Q2Sequential cutoff đặt quá nhỏ thì chuyện gì xảy ra? Quá lớn thì sao?▸
Quá nhỏ: mỗi lần chẻ tốn tạo hai object task, push vào deque, có thể bị trộm, rồi join — chẻ tới khi mỗi mảnh chỉ còn vài chục phần tử nghĩa là tạo hàng triệu task để làm vài phép cộng mỗi cái. Chi phí điều phối lấn át công việc thực, chương trình chạy chậm hơn cả vòng lặp tuần tự.
Quá lớn: không đủ mảnh để phủ kín core (ngưỡng năm triệu trên mảng mười triệu chỉ tạo hai mảnh cho tám core), và work-stealing không có gì để san khi một mảnh nặng bất thường. Điểm cân bằng: tổng số task khoảng vài lần tới vài chục lần số core, rồi benchmark trên dữ liệu thật để tinh chỉnh.
Q3Vì sao không được block I/O bên trong compute()? ManagedBlocker giúp gì khi buộc phải chặn?▸
ForkJoinPool sizing theo số core với giả định ngầm: mỗi worker gần như lúc nào cũng đang tính, không ngồi chờ. Một thread chặn trên I/O không tính gì nhưng vẫn chiếm một slot trong số ít ỏi worker — vài task blocking là đủ bỏ đói cả pool, dù CPU rảnh. Trên commonPool còn tệ hơn: parallel stream và CompletableFuture mặc định của cả ứng dụng bị vạ lây.
ForkJoinPool.managedBlock(...) là van an toàn: nó báo trước cho pool rằng một thread sắp chặn, để pool có thể tạm bù thêm một worker duy trì mức song song. Nó làm một thao tác chặn không tránh được trở nên ít độc hại hơn — không phải lời mời chạy I/O ồ ạt trên Fork/Join.
Q4So sánh ba cách viết: (a) fork cả hai con rồi join cả hai; (b) fork một con, compute con kia, rồi join; (c) fork một con, join nó ngay, rồi compute con kia. Cách nào đúng, cách nào sai, vì sao?▸
(b) là idiom chuẩn: thread hiện tại tự làm một nửa thay vì ngồi chờ, nửa kia nằm trong deque cho thread khác trộm; nếu chưa ai trộm, join sẽ tự chạy nó luôn. Ít task thừa nhất, thread không bao giờ rảnh vô ích.
(a) chạy đúng nhưng phí: thread đẩy hết việc đi rồi đứng chờ, tạo thêm một task không cần thiết. (c) là lỗi kinh điển: join chặn cho tới khi con đã fork xong rồi mới đụng con kia — hai con bị tuần tự hóa, mất sạch parallelism, mà kết quả vẫn đúng nên rất khó phát hiện. Cách an toàn khỏi nghĩ: ForkJoinTask.invokeAll(left, right) tự lo đúng vũ đạo.
Q5Vì sao ném task chia-để-trị vào một fixed thread pool thường gây deadlock cạn thread, còn ForkJoinPool thì không?▸
Trong fixed pool, task cha sau khi submit hai con phải get chờ chúng — nhưng nó vẫn chiếm giữ thread trong lúc chờ. Cây đệ quy đủ sâu là các task cha chiếm hết thread của pool để ngồi chờ, trong khi các task con nằm trong queue không còn thread nào chạy: pool đứng hình vĩnh viễn dù không có khóa nào.
ForkJoinPool hiểu quan hệ cha-con: khi một task gọi join mà con chưa xong, worker không ngồi chờ suông — nó có thể tự thực thi chính task con đó hoặc lấy task khác trong deque ra làm. Thread luôn bận với việc có ích, nên "cha chờ con" không bao giờ làm chết pool.
Q6Parallel stream chạy trên pool nào? Hệ quả khi nhét một lời gọi mạng vào map của parallel stream là gì?▸
stream.parallel() chạy trên ForkJoinPool.commonPool() — stream framework tự chẻ nguồn dữ liệu qua Spliterator, gói thành task Fork/Join và gộp kết quả.
Lời gọi mạng trong map ghim worker của commonPool vào trạng thái chờ, mà commonPool chỉ có khoảng số-core thread và dùng chung toàn JVM. Hệ quả lan xa: mọi parallel stream khác và mọi CompletableFuture mặc định trong ứng dụng cùng chậm theo — loại lỗi rất khó truy vì hai nơi tưởng chừng chẳng liên quan. Parallel stream sinh ra cho tính toán CPU-bound trên dữ liệu đã trong bộ nhớ; I/O thuộc về executor riêng.
Bài tiếp theo: Virtual Threads: Thread-per-request trở lại
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên