Hình dung bạn thuê thêm người làm. Cả cánh đồng lúa chín thì càng nhiều tay gặt càng nhanh — thuê người là đáng. Nhưng nếu việc chỉ là nhặt ba quả táo trên bàn, thì thời gian gọi người tới, chia việc, rồi gom họ lại nghe báo cáo còn lâu hơn tự tay nhặt. Tệ hơn nữa: nếu mùa màng nằm trong một đường hầm hẹp chỉ đi lọt một người, thì thuê mười người cũng vô ích vì không ai dàn hàng ra được. parallelStream là thuê thêm người: tuyệt khi cánh đồng đủ lớn và trải rộng, vô dụng — thậm chí tai hại — khi việc nhỏ hoặc dữ liệu không chia được. Hôm nay tôi đo đúng ba ca đó.
Đổi stream() thành parallelStream() là thay đổi rẻ nhất trong Java: một chữ, và mã chạy trên mọi nhân CPU.
Đó cũng chính là vấn đề. Vì nó rẻ tới mức người ta gõ vào mà không hỏi, và trong nhiều trường hợp nó làm mã chậm đi.
Tôi đo bốn tình huống trên máy 16 nhân.
Bốn kết quả
SongSong.mangLon_tuanTu 2458.906 us/op
SongSong.mangLon_songSong 348.493 us/op <- nhanh gấp 7
SongSong.nang_tuanTu 1941.818 us/op
SongSong.nang_songSong 295.718 us/op <- nhanh gấp 6,6
SongSong.lienKet_tuanTu 1639.822 us/op
SongSong.lienKet_songSong 2201.035 us/op <- CHẬM hơn 1,3 lần
SongSong.nho_tuanTu 0.046 us/op
SongSong.nho_songSong 97.150 us/op <- CHẬM hơn 2112 lần
Hai trường hợp đầu là thứ song song sinh ra để làm. Hai trường hợp sau là lý do đừng gõ parallel theo phản xạ.
Vì sao 100 phần tử lại chậm hơn hai nghìn lần
listNho.parallelStream().mapToLong(Integer::longValue).sum(); // 100 phần tử
Cộng 100 số là việc mất 46 nano giây khi chạy tuần tự. Còn chạy song song thì phải: dựng spliterator, chia nhỏ dữ liệu, đẩy công việc vào ForkJoinPool, chờ các luồng xong, gộp kết quả.
Toàn bộ bộ máy đó tốn khoảng 97 micro giây — gấp hai nghìn lần công việc thật.
Đây là bài học chung của mọi cơ chế song song: có một chi phí cố định để khởi động, và nó chỉ đáng khi phần việc thật đủ lớn để bù lại.
Với stream, ngưỡng thô thường được nhắc là khoảng 10.000 phần tử cho phép tính rẻ. Nhưng con số đó chỉ là điểm khởi đầu để đo, không phải quy tắc.
Vì sao LinkedList chạy song song lại chậm hơn
lienKet_tuanTu 1639.822 us/op
lienKet_songSong 2201.035 us/op
Cùng một triệu phần tử, cùng phép cộng — mà song song lại thua.
Nguyên nhân là tách dữ liệu. Để chia việc cho nhiều luồng, Stream cần cắt nguồn thành các khúc. Với mảng hay ArrayList, cắt là một phép chia chỉ số — tức thời, và mỗi khúc bằng nhau.
Với LinkedList thì không có cách nào biết "phần tử thứ 500.000 ở đâu" ngoài việc đi từ đầu. Nên spliterator phải duyệt tuần tự để tách, và toàn bộ lợi thế biến mất — chỉ còn chi phí.
Xếp hạng nguồn theo mức độ dễ tách:
| Nguồn | Tách |
|---|---|
mảng, ArrayList, IntStream.range |
rất tốt — chia đôi chỉ số |
HashMap, HashSet |
tốt — chia theo ngăn |
TreeMap, TreeSet |
khá — chia theo nhánh cây |
LinkedList |
tệ |
Stream.iterate, BufferedReader.lines |
rất tệ — bản chất tuần tự |
Nói cách khác: nguồn quan trọng hơn số phần tử.
Khi phép tính đắt thì ngưỡng hạ xuống rất nhiều
listLon.parallelStream().limit(20_000).mapToLong(x -> nang(x)).sum();
nang là một vòng lặp 200 phép căn bậc hai cho mỗi phần tử.
nang_tuanTu 1941.818 us/op
nang_songSong 295.718 us/op <- nhanh gấp 6,6
Chỉ 20.000 phần tử — ít hơn nhiều so với 10 triệu ở phép cộng — mà song song vẫn thắng đậm.
Vì tổng công việc mới là thứ quyết định, không phải số phần tử. Ngưỡng thật là số phần tử × chi phí mỗi phần tử.
Nên câu hỏi đúng không phải "danh sách của tôi có lớn không" mà là "tổng thời gian xử lý có đủ lớn để bù chi phí khởi động không".
Ba câu hỏi trước khi gõ parallel
Một: nguồn có tách được rẻ không? Mảng và ArrayList thì có. LinkedList, Stream.iterate, hay đọc file theo dòng thì không.
Hai: tổng công việc có đủ lớn không? Ước lượng thô: dưới một mili giây thì gần như chắc chắn không đáng.
Ba: phép xử lý có độc lập không? Mỗi phần tử phải xử lý được mà không phụ thuộc phần tử khác, và không đụng vào trạng thái dùng chung.
Nếu một trong ba câu trả lời là "không", đừng dùng song song.
Ba cái bẫy khác
forEach không giữ thứ tự khi song song. Cần thứ tự thì dùng forEachOrdered — nhưng nó ép đồng bộ và ăn mất phần lớn lợi ích. Thường tốt hơn là gom vào List rồi duyệt.
Tác dụng phụ vào biến ngoài là hỏng. Đoạn này chạy sai âm thầm:
List<String> kq = new ArrayList<>();
ds.parallelStream().forEach(kq::add); // ArrayList KHÔNG an toàn luồng
Kết quả có thể thiếu phần tử, hoặc ném ArrayIndexOutOfBoundsException ở một chỗ chẳng liên quan. Dùng collect — nó được thiết kế để an toàn khi song song.
Tất cả dùng chung một pool. Mặc định mọi parallelStream trong JVM chạy trên ForkJoinPool.commonPool(), có số luồng bằng số nhân trừ một. Nghĩa là một tác vụ song song chậm sẽ chặn mọi tác vụ song song khác trong cùng ứng dụng.
Đặc biệt nguy hiểm: đừng bao giờ đặt thao tác I/O — gọi API, truy vấn CSDL — trong parallelStream. Nó sẽ chiếm luồng của pool chung để ngồi chờ, và mọi thứ khác đứng theo. Cần song song cho I/O thì dùng ExecutorService riêng, hoặc virtual thread — chuyện của chặng đồng thời.
Muốn dùng pool riêng
Có một mẹo được dùng khá rộng rãi: chạy stream bên trong một ForkJoinPool khác:
ForkJoinPool poolRieng = new ForkJoinPool(4);
try {
poolRieng.submit(() -> ds.parallelStream().map(...).toList()).get();
} finally {
poolRieng.shutdown();
}
Nó hoạt động vì parallelStream dùng pool đang chạy nếu có. Nhưng đây là hành vi không được ghi trong tài liệu — nó đúng với bản cài đặt hiện tại, không phải một bảo đảm. Dùng thì nên ghi chú lại.
Đo, và đo đúng cách
Điều tôi muốn nhấn mạnh nhất: mọi con số trong bài này là của máy tôi, với dữ liệu của tôi.
16 nhân cho tăng tốc 7 lần ở phép cộng mảng — không phải 16, vì còn chi phí gộp và băng thông bộ nhớ. Máy 4 nhân sẽ ra con số khác hẳn. Máy ảo bị giới hạn CPU lại khác nữa, và đây là chỗ rất hay bị bỏ qua: trong container có --cpus=2, parallelStream vẫn tưởng mình có đủ nhân của máy chủ nếu JVM không đọc đúng giới hạn cgroup.
Nên quy trình đúng là: viết tuần tự trước, đo, thấy chậm thì thử song song, đo lại, và giữ bản nhanh hơn.
Muốn tự soi dự án mình thì thử ngay: lấy một parallelStream() bất kỳ, đếm xem nguồn của nó thực tế có bao nhiêu phần tử. Dưới một nghìn thì gần như chắc chắn bản tuần tự nhanh hơn; và nếu bên trong có gọi mạng hay truy vấn CSDL, bạn vừa tìm ra một chỗ đang chiếm pool chung của cả ứng dụng.
Mẫu số chung
Cái bài học "song song không miễn phí" không phải của riêng Java — nó là định luật của mọi cách chạy song song trên dữ liệu, và mỗi ngôn ngữ lặp lại đúng ba câu hỏi (tách rẻ không / việc đủ lớn không / độc lập không) dưới một cú pháp khác.
- Rust có
rayon: đổi.iter()thành.par_iter()— đúng một chữ nhưparallelcủa Java, cũng chạy trên work-stealing pool, cũng cần dữ liệu chia được và phép xử lý độc lập. Khác biệt lớn là Rust bắt tính độc lập ở mức biên dịch (không được đụng trạng thái chung nếu chưa đồng bộ), nên cái bẫy "forEach ghi vào ArrayList" của Java đơn giản không biên dịch nổi ở Rust. - .NET có PLINQ:
.AsParallel()trên một truy vấn LINQ, và tài liệu của nó cảnh báo đúng cái ta vừa đo — với tập nhỏ hoặc phép rẻ, tuần tự nhanh hơn vì chi phí điều phối. - Go đi hướng tường minh: không có "parallel" một chữ, mà bạn tự chia việc ra goroutine và điều phối bằng channel/
WaitGroup. Đổi lại sự rườm rà là bạn kiểm soát độ hạt — quyết định chia bao nhiêu khúc, thay vì phó mặc cho thư viện; vàGOMAXPROCSđóng vai số luồng của pool chung. - C/C++ có OpenMP:
#pragma omp parallel forđặt trên một vòng lặp — cũng cùng điều kiện "vòng lặp phải chia được và các vòng độc lập".
Sợi chỉ chung đáng mang theo là định luật Amdahl mặc áo thực dụng: tăng tốc không bao giờ bằng số nhân (ta đo 16 nhân chỉ cho 7 lần), vì luôn có phần tuần tự — chia việc, gộp kết quả, tranh băng thông bộ nhớ — không song song hoá được. Nên ở mọi ngôn ngữ, song song chỉ là một phép tối ưu có điều kiện, không phải nút "làm nhanh hơn". Viết tuần tự cho đúng trước, đo, rồi mới cân nhắc thuê thêm người — và nhớ rằng với ba quả táo trên bàn thì tự nhặt bao giờ cũng nhanh nhất.
Ngày mai: Optional trong chuỗi Stream — findFirst, max, reduce đều trả về Optional, và cách nối chúng vào chuỗi mà không biến thành một câu if trá hình.