#java #project-loom #structured-concurrency #architecture #backend

StructuredTaskScope poza zabawkowymi przykładami: bootstrap kernela oparty o zależności w nowoczesnej Javie

Dlaczego StructuredTaskScope lepiej pasował mi do orkiestracji startu subsystemów niż futures czy pule wątków — i dlaczego część bootstrapu nadal zostawiłem ściśle sekwencyjną.

Nie zacząłem tego dlatego, że chciałem napisać artykuł o StructuredTaskScope.

Doszedłem do tego z bardziej irytującej strony: bootstrap przestał być prostym skryptem startowym.

W momencie, w którym kernel dostał realny graf subsystemów — config, memory, persistence, graph, events, flow, transport — stary model myślenia przestał działać. Pytanie nie brzmiało już „jak uruchomić moduły?”. Zmieniło się w „co wolno uruchomić teraz, co musi już być gotowe i co dzieje się, jeśli jeden element padnie w połowie startu?”

To jest zupełnie inny problem niż klasyczny request fan-out.

Ten artykuł jest follow-upem do mojego wcześniejszego tekstu o DOP, ScopedValue i Loom. Tam używałem StructuredTaskScope jako czystego przykładu natywnego modelu fail-fast. Tutaj chcę pokazać bardziej użyteczny przypadek: co się stało, gdy próbowałem wpasować ten prymityw w rzeczywisty model lifecycle.

Ograniczenie od razu: to podejście ma sens tylko wtedy, gdy ścieżka wykonania nadal pozostaje pod moją kontrolą. Jeśli budujesz otwarty system pluginów albo bardzo szeroką powierzchnię rozszerzeń, część tego modelu zaczyna się szybko rozjeżdżać.


Bootstrap przestał być liniowy

Duża część kodu startowego nadal zakłada, że system jest w gruncie rzeczy listą:

  1. zbuduj kilka obiektów
  2. wywołaj start()
  3. może chwilę poczekaj
  4. miej nadzieję, że shutdown będzie odwróceniem tej kolejności

To działa tylko do momentu, w którym graf zależności staje się realny.

W Exeris bootstrap jest ograniczony relacjami między subsystemami, a nie kolejnością, którą akurat lubię w main(). Niektóre subsystemy są fundamentalne. Niektóre są opcjonalne. Niektóre mogą wystartować dopiero wtedy, gdy kilka innych już działa. Niektóre błędy da się zdegradować. Innych nie.

W tym momencie startup staje się problemem grafu, niezależnie od tego, czy ktoś chce to tak nazwać.

Ze starego modelu zostawiłem deterministyczność. Porzuciłem za to pomysł, że wszystko sensowne powinno się wydarzyć w jednej generycznej fazie „start all modules”.

Kształt grafu jest ważniejszy niż sam odruch, żeby wszystko równoleglić.

Schemat 1: Graf bootstrapu kernela zależny od relacji między subsystemami. Współbieżność jest dozwolona tylko tam, gdzie pozwala na to graf.

Schemat 1: Graf bootstrapu kernela w Exeris. Istotne nie jest samo to, że subsystemów jest kilka. Istotne jest to, że współbieżność jest legalna tylko tam, gdzie pozwala na to graf.

Dość szybko zauważyłem, że w momencie, gdy graf stał się jawny, odpowiedź „to po prostu zrównoleglij bootstrap” przestała być poważna. Graf sam pokazuje, gdzie współbieżność ma sens, a gdzie jest po prostu za wcześnie.

Dokumentacja bootstrapu w Exeris opisuje to samo z perspektywy subsystemów: L0 pozostaje warstwą fundamentalną, wyższe warstwy mogą ruszyć dopiero wtedy, gdy podłoże jest gotowe, a shutdown zachowuje tę strukturę w odwrotnej kolejności.


Podział, który naprawdę miał znaczenie

Najważniejszą decyzją projektową nie było samo użycie StructuredTaskScope.

Najważniejsze było to, gdzie świadomie go nie użyłem.

Na początku naturalna pokusa była prosta: skoro JVM daje virtual threads i structured concurrency, to aż chce się szukać miejsc, w których można to zastosować szerzej.

Skończyłem robiąc mniej, nie więcej.

Zostawiłem initialize() jako fazę sekwencyjną i topologiczną. Strukturalną równoległość dopuściłem dopiero w start().

To nie była decyzja ideologiczna. To była decyzja praktyczna.

initialize() buduje strukturę:

  • provider bindings
  • rejestrację health
  • aktywną kolejność subsystemów
  • lifecycle state bez naruszenia zależności
  • hooki telemetryczne bootstrapu

Ta faza bardziej potrzebuje deterministyczności niż prędkości. Nie chciałem, żeby budowanie grafu, składanie providerów i wykonanie lifecycle zlały się w jeden współbieżny rozmyty etap.

Ze start() jest inaczej. Kiedy graf jest już rozwiązany, a aktywny zbiór subsystemów znany, współbieżność zaczyna mieć sens — ale tylko wtedy, gdy nadal mieści się w granicach lifecycle wyznaczonych wcześniej przez graf.

To doprowadziło mnie do prostszej zasady:

inicjalizacja pozostaje uporządkowana, startup może stać się równoległy.

W tym miejscu stary model przestał mieć dla mnie sens. Nie próbowałem już „przyspieszyć bootstrapu” w abstrakcji. Próbowałem utrzymać czytelne ownership lifecycle.

for (BootstrapPhase phase : BootstrapPhase.values()) {
    List<Subsystem> forPhase = orderedSubsystems.stream()
            .filter(s -> s.phase() == phase)
            .toList();
    if (forPhase.isEmpty()) {
        continue;
    }

    if (phase == BootstrapPhase.FOUNDATION) {
        startSequential(forPhase, phase, profileName, startedNames);
    } else {
        startParallel(forPhase, phase, profileName, startedNames);
    }
}

W praktyce FOUNDATION zostawiłem sekwencyjne celowo. Chodzi o te fragmenty bootstrapu, które decydują, czy reszta kernela w ogóle może być interpretowana poprawnie: korzenie konfiguracji, bazowy runtime substrate, granice wyjątków i podstawowi providerzy.

Mogłem to zrównoleglić mocniej. Nie zrobiłem tego.

Ten trade-off jest świadomy:

  • oddaję trochę równoległości na początku startu
  • w zamian dostaję czystszy substrate
  • i mniej niejednoznaczności, gdy wyższe warstwy zaczynają się poruszać

To nie jest uniwersalne. Jeśli Twój graf startupu jest płytki, a warstwy naprawdę niezależne, możesz iść agresywniej. U mnie realnym kosztem złego foundation nie było kilka dodatkowych milisekund. Problemem był mniej czytelny model lifecycle i trudniejsze do sklasyfikowania awarie później.


ScopedValue nadal miało znaczenie na granicy

Ten artykuł jest o StructuredTaskScope, ale po drodze wróciłem do tej samej lekcji co poprzednio: propagacja kontekstu pozostaje czysta tylko wtedy, gdy granica jest jawna.

W Exeris bootstrap rozwiązuje konfigurację raz, a potem wiąże ją na granicy kernela, zanim ruszy reszta lifecycle. Wszystko, co powstaje pod tą granicą, dziedziczy ten sam niemutowalny kontekst.

try {
    ScopedValue.where(KernelProviders.CURRENT_CONFIG, config)
            .call(() -> {
                runBootInsideScope(orchestrator, config, configRegistry, configWatcher, kernelMain);
                return null;
            });
} catch (SubsystemCircularDependencyException ex) {
    throw ex;
} catch (SubsystemOrchestrator.BootstrapException ex) {
    throw new BootstrapException("Subsystem bootstrap failed: " + ex.getMessage(), ex);
}

Ta decyzja miała dla mnie większe znaczenie niż kolejna warstwa constructor wiring.

Nie chciałem przepychać konfiguracji przez argumenty do każdego subsystemu, handlera czy virtual thread tylko dlatego, że bootstrap potrzebuje lifecycle scope. Nie chciałem też wracać do ThreadLocal i odziedziczyć tych samych problemów z mutowalnością i dziedziczeniem, które wcześniej już odrzuciłem.

Granica została więc prosta:

  • config jest rozwiązywany raz
  • bindowany raz
  • dziedziczony w dół
  • i zwijany, gdy boot się kończy

To utrzymało model lifecycle w czystszej formie. A później, kiedy otwierałem strukturalne rundy startowe, każda z nich dziedziczyła ten sam runtime context bez dodatkowej ceremonii.


Użyteczna część nie polegała na samym STS

Naprawdę użyteczne okazało się obliczenie bezpiecznej rundy zanim scope zostanie w ogóle otwarty.

Nie chcę wygładzać tego do zbyt generycznego opisu, bo to właśnie tutaj leży środek ciężkości całego projektu.

Orchestrator nie robi po prostu fork() dla wszystkich oczekujących subsystemów w danej fazie i nie czeka, co się stanie.

Najpierw oblicza, które subsystemy naprawdę wolno uruchomić teraz.

Set<String> pendingNames = pending.stream()
        .map(Subsystem::name)
        .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));

List<Subsystem> ready = pending.stream()
        .filter(subsystem -> dependenciesReadyForRound(subsystem, pendingNames, startedNames))
        .toList();

if (ready.isEmpty()) {
    throw new BootstrapException(
            "Phase " + phase + " cannot make progress: unresolved dependencies among pending subsystems "
            + pendingNames);
}

To całkowicie zmieniło rolę StructuredTaskScope.

Przestał odpowiadać za odkrywanie kolejności. Zaczął odpowiadać za wykonanie jednej, bezpiecznej rundy wewnątrz kolejności, którą orchestrator już wcześniej jawnie ustalił.

Dlatego cały czas wracam do tego samego zdania: to jest najpierw problem grafu, a dopiero potem problem współbieżności.

Schemat 2: Runda startowa bezpieczna względem zależności. StructuredTaskScope otwierany jest dopiero po obliczeniu ready set przez orchestrator.

Schemat 2: Runda startowa bezpieczna względem zależności. Orchestrator najpierw wyznacza eligibility, a dopiero potem przekazuje StructuredTaskScope ograniczoną jednostkę pracy.

To był moment, w którym stary model „odpal wszystko i koordynuj później” przestał mieć dla mnie sens. Nie chciałem, żeby kolejność startupu stawała się emergentną własnością czasu wykonania, przyszłych completion stage’ów albo tego, co akurat zakończyło się szybciej.

Chciałem, żeby współbieżność pojawiała się dopiero po tym, jak graf potwierdził bezpieczeństwo wykonania.


To tutaj StructuredTaskScope naprawdę zaczęło pasować

Kiedy gotowy ready set już istnieje, rola StructuredTaskScope staje się bardzo wąska i bardzo czysta.

Posiada jedną rundę startową.

I tyle.

try (var scope = StructuredTaskScope.open()) {
    List<StructuredTaskScope.Subtask<Object>> tasks = ready.stream()
            .<StructuredTaskScope.Subtask<Object>>map(
                    subsystem -> scope.fork(() -> {
                        doStart(subsystem, phase, profile);
                        return null;
                    }))
            .toList();

    scope.join();

    List<Throwable> failures = tasks.stream()
            .filter(task -> task.state() == StructuredTaskScope.Subtask.State.FAILED)
            .map(StructuredTaskScope.Subtask::exception)
            .toList();

    if (!failures.isEmpty()) {
        Throwable first = failures.getFirst();
        throw new BootstrapException(
                failures.size() + " subsystem(s) failed in phase " + phase
                + ". First failure: " + first.getMessage(), first);
    }
}

To jest ta część, którą naprawdę lubię.

Nie dlatego, że jest sprytna. Raczej dlatego, że jest nudna w dobry sposób.

Taka runda ma:

  • właściciela
  • jawny czas życia
  • jawne zakończenie
  • jawne zbieranie awarii

Żadne zadanie nie trafia do jakiegoś mglistego executora, który żyje dłużej niż lifecycle moment, który je stworzył. Żadna praca startowa nie ucieka w stan „może jeszcze gdzieś działa w tle”. Granica współbieżności w końcu pokrywa się z granicą lifecycle.

I dokładnie o to chodziło.

Dlatego uważam, że StructuredTaskScope jest ciekawsze tutaj niż w klasycznych przykładach typu „pobierz dwie rzeczy równolegle”. Takie przykłady pokazują, że API działa. Orchestrator tego typu pokazuje, kiedy ten prymityw zaczyna naprawdę pasować do kształtu systemu.


Mógłbym to zrobić futuresami. Nie chciałem.

Nie ma nic niemożliwego w zbudowaniu tego przy pomocy:

  • ExecutorService
  • CompletableFuture
  • latchy
  • własnego trackingu workerów
  • ręcznie składanej agregacji błędów

Gdyby celem było tylko „uruchom kilka rzeczy równolegle”, to wszystkie te narzędzia by wystarczyły.

Ale realny problem nigdy nie polegał wyłącznie na równoległości.

Obchodziło mnie raczej:

  • kto jest właścicielem tej pracy
  • kiedy dokładnie kończy się ta runda
  • co należy do tej fazy, a co nie
  • jak awaria ma zostać ujawniona
  • jak zachować czyste rozumowanie o shutdownie

Bootstrap to jedno z najgorszych miejsc na tolerowanie rozmytego ownership współbieżności. Kiedy startup się sypie, nie chcę zgadywać, czy jakiś task nadal żyje w tle ani czy chain futuresów nie oderwał się już od lifecycle momentu, który go stworzył.

Na tym polega realna różnica.

Sednem nie jest to, że StructuredTaskScope potrafi uruchamiać zadania. Sednem jest to, że nadaje tej rundzie właściw�� granicę.

Wciąż użyłbym bardziej klasycznych narzędzi współbieżności tam, gdzie lifecycle jest płytszy, ownership z definicji luźniejszy, albo architektura po prostu nie zyskuje aż tyle na tak rygorystycznym modelu granic. To nie jest historia o uniwersalnym zastąpieniu wszystkiego przez STS.


Failure policy miało co najmniej takie samo znaczenie jak sam prymityw współbieżności

Nie sądzę, żeby ten model był spójny bez jawnej polityki awarii.

Exeris wspiera oba tryby:

  • FAIL_FAST
  • DEGRADE

Ale nie symetrycznie.

Subsystemy fundamentalne nadal są obowiązkowe. Nie dostają prawa do degradacji tylko dlatego, że wyższe warstwy mogą sobie na nią pozwolić. Ta granica ma znaczenie.

W orchestratorze ta asymetria jest jawna. Opcjonalne subsystemy mogą zostać usunięte w trybie DEGRADE, ale awaria elementu obowiązkowego nadal przerywa boot.

boolean isMandatory =
        (subsystem.phase() == BootstrapPhase.FOUNDATION) || !subsystem.isOptional();

if (failurePolicy == FailurePolicy.DEGRADE && !isMandatory) {
    removeSubsystemAndTransitiveDependents(subsystem.name());
} else {
    healthMonitor.markKernelState(KernelHealthMonitor.KernelState.FAILED);
    throw new BootstrapException(
            "Subsystem '" + subsystem.name() + "' failed: "
            + failure.getMessage(), failure);
}

Zostawiłem tę asymetrię, bo nie wszystkie awarie znaczą to samo. Awaria opcjonalnej zdolności wyższej warstwy może być do przeżycia. Awaria foundation zwykle oznacza, że system nie ma już sensownego substrate do dalszego działania.

To był kolejny moment, w którym opierałem się pokusie wygładzenia modelu do bardziej równomiernej, „eleganckiej” wersji. Na papierze wyglądałoby to ładniej. W praktyce lifecycle semantics stałyby się mniej prawdziwe.

Dlatego pytanie, które było dla mnie użyteczne, nie brzmiało:

czy te taski mogą działać równolegle?

Tylko:

co dla kernela oznacza awaria tego elementu właśnie teraz?

To pytanie zmusza architekturę do uczciwości.


Startup ma sens tylko wtedy, gdy shutdown zachowuje ten sam kształt

Nie chciałem stracić symetrii lifecycle.

Model startupu oparty o graf zależności nie powinien się rozpadać przy shutdownie na improwizowaną ścieżkę kończenia pracy. Jeśli kolejność startu wynika ze struktury zależności, shutdown powinien zachować tę strukturę w odwrotną stronę.

W praktyce oznacza to, że reverse topological shutdown jest dla mnie tak samo ważny jak rundy startowe.

List<Subsystem> reversed = new ArrayList<>(orderedSubsystems);
Collections.reverse(reversed);

for (Subsystem subsystem : reversed) {
    if (subsystem.isRunning()) {
        subsystem.stop();
    }
}

To brzmi oczywiście, ale po wejściu współbieżności robi się ważniejsze. Ustrukturyzowany startup łatwiej obdarzyć zaufaniem, jeśli reszta lifecycle nadal zachowuje się jak jeden spójny model, a nie zbiór niepowiązanych hooków.

Schemat 3: Symetria lifecycle w Exeris. Kolejność startupu i shutdownu to dwie strony tego samego modelu zależności.

Schemat 3: Symetria lifecycle w Exeris. Startup buduje możliwość działania zgodnie z porządkiem zależności, a shutdown zachowuje ten porządek w odwrotną stronę.

Nie robiłbym z reverse shutdown głównego nagłówka artykułu, ale też nie traktowałbym go jak przypisu. To część tego samego argumentu: structured concurrency pomaga najbardziej wtedy, gdy otaczający ją lifecycle już wcześniej jest uporządkowany.


Co mierzyłem, a czego nie twierdzę

Ten artykuł jest przede wszystkim architektoniczny, ale nie chciałem zostawić go na poziomie „to po prostu wygląda czyściej”.

Interesuje mnie tu nie generyczny throughput, tylko evidence związane z lifecycle.

W tym modelu sensowne sygnały to na przykład:

  • startup sekwencyjny vs startup fazowy ze structured parallelism
  • całkowity czas cold boot do momentu boot-ready
  • timing per phase
  • timing rund w fazach równoległych
  • wariancja między kolejnymi cold startami
  • czas bootu w trybie degraded po usunięciu subsystemów opcjonalnych
  • zdarzenia jdk.VirtualThreadPinned podczas startupu
  • końcowa liczba aktywnych subsystemów zapisana przy boot-ready

Dlatego traktuję JFR jako część architektury, a nie tylko narzędzie wydajnościowe. Jeśli model startupu jest realny, powinien zostawiać po sobie czytelny ślad lifecycle.

Dokumentacja bootstrapu w Exeris też tak to traktuje: boot-ready, shutdown completion, detekcja cykli zależności i stan lifecycle są sygnałami pierwszej klasy, a nie przypadkowymi logami.

Mam też pierwsze eksploracyjne pomiary startupu, ale celowo traktuję je jako supporting evidence, a nie główną tezę artykułu.

MetrykaExeris community h1Quarkus JVM VT tuned
Startup → health-ready1132 ms2182 ms
Startup → first request1205 ms2432 ms
Health-ready → first request73 ms250 ms

Te pomiary zostały wykonane na sprzęcie deweloperskim i nadal są wrażliwe na lokalne warunki uruchomieniowe, w tym stan maszyny oraz różnice typu GUI / no-GUI. Dlatego nie używam ich jeszcze do szerokich claimów o przewadze startupowej.

Już teraz pokazują jednak coś węższego, ale nadal użytecznego: ten model bootstrapu jest mierzalny operacyjnie. To nie jest tylko architektonicznie czystszy pomysł na papierze.

Szczególnie interesująca jest dla mnie mniejsza luka między health-ready a first request, bo sugeruje, że granica lifecycle nie jest tylko krótka na papierze, ale też bliższa realnie użytecznej pracy.

Zweryfikowałem też ten runtime w eksploracyjnym, ograniczonym profilu wykonania bez błędów requestów, ale to należy już do trochę innej rozmowy niż ten artykuł. Tutaj teza jest węższa: model lifecycle da się obserwować i nie rozpada się przy kontakcie z pomiarem.

Celowo zawężam też zakres tezy. Wczesne pomiary są użyteczne, ale nadal są wrażliwe na:

  • stan classloadingu
  • stan JIT
  • szum maszyny
  • warunki ładowania natywnych komponentów
  • kształt środowiska startowego

Dlatego wolę powiedzieć:

ten model jest mierzalny i operacyjnie obserwowalny

niż zbyt wcześnie ogłaszać:

ten model jest definitywnie szybszy.

Na takie stwierdzenia przyjdzie czas dopiero wtedy, gdy dane to naprawdę utrzymają.


Czego to nie rozwiązuje

Ten model nie naprawia źle wydzielonych granic subsystemów.

Nie naprawia cyklicznych grafów. Nie usuwa pracy startupowej, która w ogóle nie powinna istnieć. Nie oznacza też, że każdy subsystem nagle należy uruchamiać równolegle. I zdecydowanie nie generalizuje się na każdą architekturę runtime.

Nadal użyłbym bardziej konwencjonalnych wzorców wtedy, gdy:

  • graf jest płytki
  • lifecycle jest prostszy
  • ownership subsystemów jest z definicji rozmyty
  • otwartość pluginowa ma większe znaczenie niż deterministyczny kształt startupu

To nie jest uniwersalna recepta. To działa wtedy, gdy ścieżka wykonania nadal pozostaje pod moją kontrolą, a sam lifecycle jest częścią architektury.

Ta granica ma znaczenie.


Co zostawiłem, a co odrzuciłem

To jest chyba ten fragment, który najłatwiej ginie, gdy tekst zostanie zbyt mocno wypolerowany.

Nie skończyłem z uniwersalną zasadą „używaj STS do bootstrapu”.

Co zostawiłem:

  • topologiczny porządek lifecycle
  • deterministyczną inicjalizację
  • jawne granice faz
  • jawną politykę awarii
  • symetrię shutdownu względem startupu

Co odrzuciłem:

  • pomysł, że initialize() i start() powinny być tą samą fazą
  • pomysł, że równoległość startupu powinna być maksymalna
  • pomysł, że współbieżność powinna pojawiać się zanim bezpieczeństwo zależności zostanie potwierdzone

Rozważałem też użycie StructuredTaskScope w innych miejscach, gdzie na papierze wyglądało to modnie. W przynajmniej jednym przypadku nie dało mi to nic naprawdę wartościowego, więc po prostu to odpuściłem.

Ten kontrast okazał się użyteczny. Dzięki niemu przypadek bootstrapu zrobił się wyraźniejszy. StructuredTaskScope nie okazało się wartościowe dlatego, że jest nowe. Okazało się wartościowe dlatego, że ta część systemu już wcześniej miała naturalnego właściciela, naturalną granicę i naturalny model awarii.


Wnioski

Kilka rzeczy stało się dla mnie wyraźniejszych w trakcie budowy tego modelu:

  1. Bootstrap stał się najpierw problemem grafu, a dopiero potem problemem współbieżności. To zmieniło, która część projektu naprawdę potrzebowała struktury.

  2. StructuredTaskScope pomogło dopiero wtedy, gdy kolejność była już jawna. Użyteczny ruch nie polegał na „fork everything”, tylko na „oblicz bezpieczną rundę, a potem uruchom ją wewnątrz ograniczonego scope.”

  3. Ten trade-off jest celowy. Zostawiłem initialize() i FOUNDATION jako sekwencyjne świadomie. Oddałem część równoległości po to, żeby łatwiej było rozumować o ownership lifecycle i semantyce awarii.

To, co ten model odblokowuje dalej w Exeris, to nie tylko czystszy kod startupu. Daje mi też bardziej obserwowalny model lifecycle pod dalszą pracę nad health, telemetry, izolacją subsystemów i z czasem bardziej wymagającymi kontraktami cold-start.

Jeśli chcesz zobaczyć, jak to wygląda poza zabawkowym przykładem, kod bootstrapu i lifecycle jest dostępny w repozytorium Exeris Kernel.


Poznaj Exeris Kernel — architekturę zero-allocation w działającym kodzie: 🔗 exeris-systems/exeris-kernel