Bài này khép lại chặng đồng thời, và nó nói về thứ mà mười bốn bài trước đã ngầm phàn nàn: các tác vụ song song không có ranh giới rõ ràng.

Bạn nộp việc vào một ExecutorService, nhận về vài Future, rồi tự lo phần còn lại — ai huỷ ai, lỗi lan truyền thế nào, chờ tới bao giờ. Không có gì trong ngôn ngữ ràng buộc những việc đó lại với nhau.

Vấn đề, đo bằng con số

Một hàm cần hai thứ: hồ sơ người dùng và danh sách đơn hàng. Dịch vụ hồ sơ hỏng sau 100 ms; dịch vụ đơn hàng cần 3 giây.

Future<HoSo> f1 = es.submit(() -> layHoSo());       // hỏng sau 100ms
Future<DonHang> f2 = es.submit(() -> layDonHang());  // cần 3 giây
try { f1.get(); f2.get(); } catch (ExecutionException e) { ... }
  bắt lỗi sau 104 ms: dịch vụ hồ sơ hỏng
  ngay lúc này còn 1 tác vụ ĐANG CHẠY tiếp
  sau 3 giây: 1 tác vụ đã chạy tới cùng dù kết quả bị vứt đi

Bạn biết mình đã hỏng sau 104 mili giây. Nhưng tác vụ kia chạy tiếp thêm gần 3 giây, giữ một luồng, giữ một kết nối cơ sở dữ liệu, gọi một dịch vụ bên ngoài — để tạo ra một kết quả sẽ bị vứt đi.

Trên một máy chủ có tải, đây là công phí nhân với số request. Và nó hoàn toàn im lặng: không log, không cảnh báo, không có gì trong mã gợi ý rằng chuyện đó đang xảy ra.

Có thể sửa bằng tay — f2.cancel(true) trong catch, cộng finally để dọn. Nhưng với ba tác vụ và hai nhánh lỗi thì đoạn mã ấy dài hơn phần nghiệp vụ, và rất dễ sót một nhánh.

Cách của StructuredTaskScope

try (var pham = new StructuredTaskScope.ShutdownOnFailure()) {
    var s1 = pham.fork(() -> layHoSo());
    var s2 = pham.fork(() -> layDonHang());
    pham.join();
    pham.throwIfFailed();
    return gop(s1.get(), s2.get());
}
  bắt lỗi sau 102 ms: dịch vụ hồ sơ hỏng
  ngay lúc này còn 0 tác vụ đang chạy   <- tự huỷ
  sau đó: 0 tác vụ chạy tới cùng

Không tác vụ nào sống sót. Một cái hỏng, cả phạm vi đóng lại.

Và đường chạy thuận lợi vẫn song song đầy đủ:

  HoSo[ten=Minh] + DonHang[ds=[DH-1, DH-2]] trong 301 ms

Hai lời gọi 300 ms xong trong 301 ms.

Ý tưởng nằm ở cái tên: có cấu trúc. Giống như một khối { } bảo đảm mọi biến cục bộ biến mất khi ra khỏi khối, StructuredTaskScope bảo đảm mọi tác vụ con kết thúc trước khi ra khỏi try. Không có tác vụ nào sống lâu hơn phạm vi đã sinh ra nó.

Ba luật giữ cho điều đó đúng:

fork() chỉ gọi được bởi luồng sở hữu phạm vi.

join() bắt buộc phải gọi trước khi đọc kết quả — quên thì get() ném IllegalStateException chứ không trả về dữ liệu dở dang.

close() (do try-với-tài-nguyên gọi) chờ mọi tác vụ con xong.

Hai chính sách dựng sẵn

ShutdownOnFailure — một cái hỏng thì huỷ hết. Dùng khi bạn cần tất cả kết quả: gọi ba dịch vụ rồi gộp lại thành một phản hồi.

ShutdownOnSuccess — cái đầu tiên thành công thì huỷ phần còn lại:

  'máy chủ nhanh' sau 102 ms | còn 0 tác vụ chạy

Ba máy chủ, cái nhanh nhất trả lời sau 100 ms, hai cái kia bị huỷ ngay. Dùng khi bạn hỏi nhiều nguồn tương đương và chỉ cần một câu trả lời — bản sao dự phòng, nhiều CDN, nhiều máy chủ DNS.

So với CompletableFuture.anyOf ở bài 75: anyOf cũng trả về cái nhanh nhất, nhưng không huỷ các cái còn lại. Chúng chạy tới cùng.

Hạn chờ cho cả nhóm

pham.joinUntil(Instant.now().plusMillis(300));
  hết hạn sau 301 ms -> mọi tác vụ con bị huỷ
  còn 0 tác vụ đang chạy

Một hạn chờ cho toàn bộ nhóm, không phải mỗi tác vụ một cái. Đây là thứ rất khó viết đúng bằng tay: với ba Future, bạn phải tự tính thời gian còn lại sau mỗi lần get(timeout), và tự huỷ những cái chưa xong.

Phạm vi tự viết

Hai chính sách dựng sẵn không phủ hết. Trường hợp rất hay gặp là "lấy những nguồn nào trả lời được, bỏ qua nguồn hỏng":

static class GomHet<T> extends StructuredTaskScope<T> {
    private final Queue<Subtask<? extends T>> xong = new ConcurrentLinkedQueue<>();

    @Override protected void handleComplete(Subtask<? extends T> t) { xong.add(t); }

    List<T> thanhCong() {
        super.ensureOwnerAndJoined();
        List<T> r = new ArrayList<>();
        for (var s : xong) if (s.state() == Subtask.State.SUCCESS) r.add(s.get());
        return r;
    }
}
  thành công: [nguồn A ổn, nguồn C ổn]
  thất bại  : [nguồn B hỏng]

handleComplete được gọi cho mỗi tác vụ con khi nó kết thúc, và ensureOwnerAndJoined() chặn việc đọc kết quả trước khi join() — đúng kỷ luật mà lớp cha áp đặt.

Cây phạm vi hiện ra trong ảnh chụp luồng

Đây là lợi ích tôi thấy ít được nhắc nhưng rất đáng giá khi xử lý sự cố.

Tôi dựng một phạm vi lồng trong một phạm vi, rồi chụp:

jcmd <pid> Thread.dump_to_file -format=json /tmp/d.json
  phạm vi ShutdownOnFailure@635eaaf1
    cha : <root>
    chủ : luồng 1
    con : 2 luồng ảo

  phạm vi ShutdownOnFailure@282b9061
    cha : ShutdownOnFailure@635eaaf1      <- quan hệ cha-con được giữ
    chủ : luồng 33
    con : 2 luồng ảo

Ảnh chụp giữ nguyên quan hệ cha-con. Bạn nhìn thấy phạm vi nào sinh ra phạm vi nào, luồng nào sở hữu cái nào.

Với ExecutorService thì không có gì tương đương: các luồng của pool nằm phẳng cạnh nhau, và không có cách nào biết tác vụ nào được nộp bởi ai. Đây là lý do định dạng ảnh chụp luồng dạng JSON mới được thêm vào cùng lúc với luồng ảo.

Lợi ích tương tự với dấu vết ngăn xếp: ngoại lệ từ tác vụ con mang theo ngữ cảnh của phạm vi cha, thay vì kết thúc ở một khung ThreadPoolExecutor.runWorker vô danh như ở bài 74.

Vẫn là bản xem trước

Java 21 đưa nó vào dạng xem trước, cần --enable-preview. Vì vậy chưa dùng cho mã sản xuất được, và API còn đổi giữa các phiên bản — nếu bạn đọc ví dụ trên mạng, hãy kiểm lại chữ ký so với JDK đang dùng.

Nhưng nó đáng biết ngay bây giờ vì hai lý do. Thứ nhất, nó là mảnh cuối của bộ ba mà Java 21 xây quanh luồng ảo: luồng ảo cho phép tạo hàng triệu tác vụ, ScopedValue truyền ngữ cảnh xuống chúng, structured concurrency quản vòng đời của chúng. Thứ hai, ngay cả khi chưa dùng được, tư duy "mọi tác vụ con phải chết trước khi hàm cha trả về" áp dụng được ngay cho mã ExecutorService hôm nay.

Nhìn lại chặng đồng thời

Mười bốn bài, và tôi thấy chúng dồn về ba điều:

Trạng thái chia sẻ là gốc của mọi rắc rối. Điều kiện đua, deadlock, rò rỉ ThreadLocal, hỏng dữ liệu đều bắt nguồn từ đó. Bất biến và truyền thông điệp — BlockingQueue, record bất biến — tránh được cả họ vấn đề thay vì chữa từng cái.

Chạy thử không chứng minh được gì. Bài 68 có lần chạy đúng hai triệu; bài 73 có cấu hình mà notify() sai vẫn chạy hoàn hảo. Mã đa luồng đúng phải lập luận ra được, không phải thử ra được.

Đo, đừng tin lời khuyên. Bài 71 cho thấy ReadWriteLock có lúc chậm hơn khoá thường; bài 70 cho thấy AtomicLong thua synchronized ở tranh chấp cao; bài 79 cho thấy luồng ảo không chậm hơn ở tải tốn CPU dù ai cũng nói vậy. Mỗi lần tôi đo, ít nhất một điều "ai cũng biết" hoá ra không đúng trong hoàn cảnh cụ thể.

Thử ba mươi giây

Tìm trong mã của bạn một chỗ nộp nhiều tác vụ rồi chờ tất cả.

Hỏi: nếu tác vụ đầu tiên ném ngoại lệ, những tác vụ kia có bị dừng không? Với ExecutorService thuần, câu trả lời gần như luôn là không — chúng chạy tới cùng rồi kết quả bị vứt đi, đúng ba giây lãng phí ở đầu bài.

Ngày mai bắt đầu chặng JVM: class loader và cây uỷ quyền — bootstrap, platform, application, và vì sao ClassNotFoundException với NoClassDefFoundError là hai chuyện hoàn toàn khác nhau.