Gdy CompletableFuture zadebiutował w Java 8, jego architekci zoptymalizowali zero-konfiguracyjny równoległość, wiążąc domyślne operacje asynchroniczne z ForkJoinPool.commonPool(). Ten wykonawca singleton dopasowuje się do Runtime.getRuntime().availableProcessors() - 1, co jest obliczeniem dostosowanym do zadań intensywnych w CPU i krótkoterminowych, a nie do operacji ograniczonych przez latencję.
Degradacja objawia się, gdy deweloperzy wysyłają zadania związane z I/O — takie jak żądania HTTP — za pomocą supplyAsync() lub thenApplyAsync() bez określenia własnego Executor. Ponieważ wspólny pul znajduje się w całej JVM, blokowanie jego ograniczonych wątków sprawia, że występuje systemowy głód; gdy wszystkie wątki czekają na gniazda sieciowe, żadne zadania intensywne w CPU (w tym równoległe potoki Stream) nie mogą postępować, skutecznie zamrażając przepustowość aplikacji.
Rozwiązanie wymaga wyraźnej izolacji wykonawcy. Kod produkcyjny musi dostarczyć dedykowaną ExecutorService — najlepiej taką, która jest wspierana przez wirtualne wątki lub puli wątków pamięci podręcznej dla I/O — za pomocą przeciążenia akceptującego argument wykonawcy. Ta granica architektoniczna zapewnia, że blokujące oczekiwania wykorzystują zasoby z izolowanej przestrzeni nazw, pozostawiając wspólny pul niezakłócony przez pracę obliczeniową.
// Niebezpieczne: Implicitnie używa ForkJoinPool.commonPool() CompletableFuture<String> risky = CompletableFuture.supplyAsync(() -> { // Zamyka wątek wspólnego puli! return httpClient.send(request, BodyHandlers.ofString()).body(); }); // Bezpieczne: Izolowany wykonawca dla blokującego I/O try (ExecutorService ioExecutor = Executors.newVirtualThreadPerTaskExecutor()) { CompletableFuture<String> safe = CompletableFuture.supplyAsync( () -> httpClient.send(request, BodyHandlers.ofString()).body(), ioExecutor ); }
Rozważmy platformę analityczną handlu wysokiej częstotliwości, która wzbogaca dane rynkowe poprzez asynchroniczne pobieranie ocen kredytowych z zewnętrznych API REST. Oryginalna implementacja wykorzystywała CompletableFuture.supplyAsync(() -> fetchRating(ticker)) łańcuchowo na tysiącach tickers, polegając na domyślnym wspólnym pulu. Podczas zmienności rynkowej, latencja wzrosła katastrofalnie, ponieważ piętnaście wspólnych wątków (na serwerze szesnastordzeniowym) wszystkie zablokowały się na czasie oczekiwania HTTP, zamrażając wszystkie równoległe potoki danych aplikacji i powodując utraconą transakcję.
Rozwiązanie rozważane: Zwiększenie równoległości wspólnego puli
Deweloperzy początkowo zaproponowali ustawienie -Djava.util.concurrent.ForkJoinPool.common.parallelism=200, aby pomieścić blokujące oczekiwania. Zaleta była natychmiastowa ulga bez zmian w kodzie. Jednak to podejście zdecydowanie wpłynęło na pamięć podręczną CPU dla legalnej pracy obliczeniowej i marnowało pamięć, utrzymując nadmiernie bezczynne wątki. Jest to zasadniczo niewykonalne, ponieważ myli profile zasobów CPU i I/O w jednym pulie, ostatecznie nasycając harmonogram OS.
Rozwiązanie rozważane: Synchronous blocking with get()
Alternatywną opcją było wywołanie .get() bezpośrednio po każdym utworzeniu przyszłości, co w efekcie sprawziało, że operacja stała się synchroniczna. To wyeliminowało problem głodu wspólnego puli, ale zniweczyło wszystkie zalety asynchroniczne. Kod stał się sekwencyjny, niewykorzystując zasoby serwera i zwiększając czas przetwarzania end-to-end o rząd wielkości podczas szczytowych obciążeń, co bezpośrednio naruszało SLA niskiej latencji.
Rozwiązanie rozważane: Dedykowany elastyczny wykonawca dla I/O
Przyjęta strategia wprowadziła osobny ExecutorService wykorzystujący wirtualne wątki (lub pulę wątków pamięci podręcznej w wcześniejszych wersjach Java przed Loom) niezależnych od liczby procesorów. Każdy etap asynchroniczny jawnie odnosił się do tego wykonawcy za pomocą thenApplyAsync(transform, ioExecutor). Zalety obejmowały pełną izolację latencji I/O od przepustowości obliczeniowej i precyzyjny nadzór. Jedynym minusem była umiarkowana ilość szablonów do zarządzania cyklem życia wykonawcy i hakami zamykającymi.
Wybór rozwiązania i wynik
Zespół zaimplementował podejście z dedykowanym wykonawcą za pomocą Executors.newVirtualThreadPerTaskExecutor() w Java 21. To natychmiast oddzieliło blokującą latencję HTTP od analityki intensywnej w CPU. Przepustowość systemu ustabilizowała się na pięćdziesięciu tysiącach żądań na sekundę podczas testów stresowych, podczas gdy wariant wspólnego puli spadł poniżej tysiąca. Percentyle latencji spadły o dziewięćdziesiąt pięć procent, co pokazuje, jak istotna jest izolacja wykonawcy.
Dlaczego rozmiar ForkJoinPool domyślnie wynosi availableProcessors() - 1, zamiast odpowiadać liczbie rdzeni fizycznych?
Odejmowanie rezerwuje jeden rdzeń fizyczny wyłącznie dla zbieracza śmieci i wątków systemowych, zapobiegając pauzom GC w konkurowaniu z zadaniami obliczeniowymi. Kandydaci często zakładają, że więcej wątków uniwersalnie poprawia wydajność, ale to konkretne obliczenie optymalizuje obecność pamięci podręcznej CPU i minimalizuje przełączanie kontekstu. Przekroczenie tej liczby dla pracy intensywnej w CPU rzeczywiście degraduje przepustowość z powodu trzaskania pamięci podręcznej i dotyczącej konkurencji w harmonogramie.
Jeśli utworzę CompletableFuture wewnątrz własnego ForkJoinPool, dlaczego nie używa tego niestandardowego puli zamiast wspólnego?
CompletableFuture wyraźnie twardo koduje swój domyślny wykonawca jako singleton wspólnego puli podczas konstrukcji obiektu; nie sprawdza kontekstu wykonania bieżącego wątku. Oznacza to, że asynchroniczne transformacje zawsze wracają do wspólnego puli, chyba że jawnie przekażesz argument wykonawcy. Deweloperzy błędnie wierzą, że lokalność wątku jest zachowywana, co prowadzi do niewidocznej konkurencji między pulami i odbicia linii pamięci podręcznej, która niszczy wydajność równoległą.
Jak blokująca operacja wewnątrz CompletableFuture może niespodziewanie zablokować wątek nosiciela, nawet gdy używa wirtualnych wątków w Java 21?
Gdy działają na wirtualnych wątkach, blokujące operacje generalnie demontują wirtualny wątek z jego nosiciela. Jednak jeśli blokujący kod zawiera blok synchronized lub metodę natywną (JNI), zablokowuje to podstawowy wątek nosiciela platformy dla wirtualnego wątku. Jeśli ForkJoinPool dostarcza tych nosicieli i wszyscy są zablokowani, pula głoduje podobnie jak w epoce przed Loom. Kandydaci nie zauważają, że słowa kluczowe synchronized muszą zostać zastąpione ReentrantLock, aby umożliwić demontaż i zapobiec katastrofalnemu wyczerpaniu nosiciela.