Virtuální vlákna nezabila WebFlux

Každých pár měsíců někdo publikuje benchmark ukazující, že virtuální vlákna zvládnou 100 000 souběžných requestů s jednoduchým imperativním kódem, a dojde k závěru, že WebFlux je zastaralý. Komentáře se plní větami jako „konečně žádné reaktivní špagety." Manažeři přepošlou článek svým týmům: „Můžeme to přepsat na virtuální vlákna?"
Ne. Tedy — záleží na tom, co to „to" dělá. Ale pravděpodobně ne.
Virtuální vlákna a WebFlux řeší různé problémy. Zmatek pochází z toho, že se obě technologie objevují ve větě „efektivně zpracovat mnoho souběžných requestů." To je ale jako říct, že kladivo a šroubovák jsou konkurenti, protože oba „spojují věci dohromady."
Co virtuální vlákna skutečně řeší
Před Javou 21 každý request spotřeboval jedno OS vlákno. OS vlákna jsou drahá — každé alokuje ~1 MB zásobník a OS scheduler je spravuje. Typický server zvládne maximálně několik tisíc souběžných vláken. Pokud request blokuje na I/O (databázový dotaz, HTTP volání, čtení souboru), OS vlákno jen nečinně sedí, nic nedělá, ale stále spotřebovává paměť a slot ve scheduleru.
Virtuální vlákna tohle opravují. Jsou to lehká vlákna spravovaná JVM, ne OS. Když virtuální vlákno zablokuje na I/O, JVM ho odpojí od nosného (OS) vlákna a připojí jiné virtuální vlákno. Nosné vlákno nikdy nezahálí. Můžete mít miliony virtuálních vláken s minimální paměťovou režií.
// Před: omezeno velikostí OS thread poolu
@GetMapping("/order/{id}")
public Order getOrder(@PathVariable String id) {
Order order = orderRepository.findById(id); // blokuje OS vlákno
Customer customer = customerClient.fetch(order.customerId()); // blokuje OS vlákno znovu
return enrichOrder(order, customer);
}
// Po: stejný kód, ale na virtuálních vláknech — žádné OS vlákno se neplýtvá
// Stačí nakonfigurovat: spring.threads.virtual.enabled=trueTo je ten pitch: změňte jeden konfigurační příznak a váš stávající blokující kód zvládne tisíce souběžných requestů. Žádné změny kódu. Žádné reaktivní přepsání. Žádné Mono ani Flux. Stejný imperativní styl, který jste vždy psali.
A funguje to. Pro tento konkrétní problém — vyčerpání vláken z blokujícího I/O — jsou virtuální vlákna správné řešení. Pokud je vaše aplikace typická CRUD služba, která volá databázi přes JDBC a možná jednu nebo dvě HTTP služby, virtuální vlákna jsou pravděpodobně vše, co potřebujete.
Tak kde se do toho vejde WebFlux?
Co WebFlux skutečně řeší
WebFlux není „způsob, jak zpracovat mnoho souběžných requestů." To je vedlejší efekt, ne podstata. WebFlux je reaktivní programovací model postavený na Reactive Streams — specifikaci pro asynchronní zpracování streamů s backpressure.
Klíčové koncepty, které virtuální vlákna neposkytují:
1. Backpressure
Backpressure je mechanismus řízení toku, kdy konzument sděluje producentovi, kolik dat dokáže zpracovat. To je důležité, když datové toky jsou asymetrické — rychlý producent a pomalý konzument.
// WebFlux: databáze vysílá řádky jen tak rychle, jak HTTP odpověď dokáže flushovat
@GetMapping(value = "/orders/stream", produces = MediaType.APPLICATION_NDJSON_VALUE)
public Flux<Order> streamOrders() {
return orderRepository.findAllAsStream() // R2DBC reaktivní dotaz
.delayElements(Duration.ofMillis(10)); // simulace pomalého konzumenta
// Pokud klient čte pomalu, databázový dotaz se také zpomalí.
// Žádná exploze bufferů. Žádné OOM. Backpressure se propaguje zpět.
}// Virtuální vlákna: žádné backpressure
@GetMapping("/orders/stream")
public List<Order> getAllOrders() {
return orderRepository.findAll(); // načte VŠECHNO do paměti
// 10 milionů řádků? Doufejte, že máte dost heap space.
}S virtuálními vlákny můžete provést blokující volání bez vyčerpání OS vláken — ale samotné volání stále načte všechna data do paměti před návratem. Neexistuje mechanismus pro signalizaci „zpomalni, nejsem připraven na další data." Můžete přidat stránkování, ale to je manuální implementace toho, co backpressure dává automaticky.
2. Streaming a real-time data
Některé use-casy jsou inherentně streamovací: Server-Sent Events, WebSocket spojení, real-time dashboardy, change data capture, konzumace z event busu. To nejsou request-response — jsou to dlouhodobé datové toky.
// SSE stream: push aktualizací stavu objednávky klientovi v reálném čase
@GetMapping(value = "/orders/{id}/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<OrderEvent>> orderEvents(@PathVariable String id) {
return orderEventService.subscribe(id)
.map(event -> ServerSentEvent.<OrderEvent>builder()
.data(event)
.event(event.type().name())
.build());
}Tohle byste mohli implementovat s virtuálními vlákny — spustit vlákno, smyčku, zapisovat do output streamu. Ale znovu byste vynalézali abstrakci reaktivních streamů, a špatně. Flux vám dává kompozici, error handling, timeout, retry a cancellation bez jakékoli dodatečné práce.
3. Kompozice asynchronních toků
Reaktivní kód vyniká v kompozici komplexních asynchronních pipeline — fan-out, fan-in, merge, zip, retry s backoffem, timeout, fallback:
// Načtení objednávky, obohacení o zákaznická a inventární data paralelně,
// s timeoutem a fallbackem
public Mono<EnrichedOrder> getEnrichedOrder(String orderId) {
Mono<Order> order = orderService.findById(orderId);
return order.flatMap(o -> {
Mono<Customer> customer = customerClient.fetch(o.customerId())
.timeout(Duration.ofSeconds(2))
.onErrorResume(e -> Mono.just(Customer.unknown()));
Mono<Inventory> inventory = inventoryClient.check(o.productId())
.timeout(Duration.ofSeconds(2))
.onErrorResume(e -> Mono.just(Inventory.unavailable()));
return Mono.zip(customer, inventory)
.map(tuple -> EnrichedOrder.from(o, tuple.getT1(), tuple.getT2()));
});
}„Ale to samé můžu udělat s virtuálními vlákny a StructuredTaskScope!" — Ano, můžete:
// Virtuální vlákna se structured concurrency (Java 21+ preview)
public EnrichedOrder getEnrichedOrder(String orderId) {
Order order = orderService.findById(orderId);
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<Customer> customer = scope.fork(() ->
withTimeout(() -> customerClient.fetch(order.customerId()), 2));
Subtask<Inventory> inventory = scope.fork(() ->
withTimeout(() -> inventoryClient.check(order.productId()), 2));
scope.join().throwIfFailed();
return EnrichedOrder.from(order, customer.get(), inventory.get());
}
}Pro fan-out/fan-in tohle funguje. Ale přidejte retry-with-backoff, podmíněné větvení na základě mezivýsledků, slučování více event streamů nebo řetězení fallback strategií — a imperativní kód se stane těmi špagetami. Reaktivní operátory existují proto, že tyto vzory jsou běžné v event-driven systémech, a jejich imperativní kódování pokaždé znovu je náchylné k chybám.
4. Propagace kontextu
Každý request nese kontext — user ID, trace ID, tenant, locale. V tradičním servlet kódu ho uložíte do ThreadLocal a přečtete kdekoli v call chainu. Tohle funguje, protože jedno vlákno zpracovává jeden request od začátku do konce.
Virtuální vlákna tento model zachovávají. Jedno virtuální vlákno stále zpracovává jeden request, takže ThreadLocal technicky funguje. Ale s miliony virtuálních vláken se dědění thread-local kopií stává drahým. Odpovědí Javy je ScopedValue — neměnný, strukturovaný a levný:
// ScopedValue — nativní způsob propagace kontextu pro virtuální vlákna
private static final ScopedValue<RequestContext> REQUEST_CTX = ScopedValue.newInstance();
public void handleRequest(HttpServletRequest req) {
var ctx = RequestContext.from(req); // user ID, trace ID, tenant
ScopedValue.runWhere(REQUEST_CTX, ctx, () -> {
// Každá metoda v tomto call tree — včetně forknutých virtuálních vláken
// ve StructuredTaskScope — může číst REQUEST_CTX.get()
orderService.process(req.getOrderId());
});
}
// Hluboko v call stacku, žádné předávání parametrů:
public void auditLog(String action) {
var ctx = REQUEST_CTX.get(); // implicitně dostupný
log.info("Action: {}, user: {}, trace: {}", action, ctx.userId(), ctx.traceId());
}ScopedValue je neměnný (žádná náhodná mutace), ohraničený na scope (automaticky uklizený) a zděděný child vlákny ve StructuredTaskScope. Je to čistá náhrada za ThreadLocal ve světě virtuálních vláken.
Ale ve WebFluxu neexistuje „jedno vlákno na request." Jeden request přeskakuje mezi více event-loop vlákny, jak prochází operátory. ThreadLocal je nepoužitelný — v okamžiku, kdy ho čtete, můžete být na jiném vlákně. ScopedValue nepomůže ani, protože neexistuje jedno vlákno, ke kterému by se dal scope navázat.
Odpovědí Reactoru je Context — neměnná mapa, která proudí reaktivním řetězcem, připojená k subskripci místo k vláknu:
// Reactor Context — reaktivní způsob propagace kontextu
public Mono<Order> processOrder(String orderId) {
return orderService.findById(orderId)
.flatMap(order -> auditLog("process", order).thenReturn(order))
.contextWrite(ctx -> ctx.put("userId", currentUserId())
.put("traceId", TraceId.generate()));
}
// Kdekoli v reaktivním řetězci:
public Mono<Void> auditLog(String action, Order order) {
return Mono.deferContextual(ctx -> {
log.info("Action: {}, user: {}, trace: {}",
action, ctx.get("userId"), ctx.get("traceId"));
return Mono.empty();
});
}Jiný problém, jiné řešení. ScopedValue je elegantní — ale jen když máte vlákno, ke kterému se dá scope navázat. Reactor Context je explicitnější — ale funguje přes asynchronní hranice, které žádný mechanismus založený na vláknech nedokáže překonat.
Skutečné srovnání
Buďme precizní v tom, co každá technologie nabízí:
| Schopnost | Virtuální vlákna | WebFlux / Reactor |
|---|---|---|
| Škálování blokujícího I/O bez vyčerpání vláken | Ano | N/A (nepoužívá blokující I/O) |
| Non-blocking I/O end-to-end | Ne (obaluje blokující volání) | Ano (R2DBC, WebClient atd.) |
| Backpressure | Ne | Ano (specifikace Reactive Streams) |
| Streamované odpovědi (SSE, WebSocket) | Manuálně | Nativně (Flux<T>) |
| Kompozitní async operátory (retry, zip, merge) | Omezené (StructuredTaskScope) | Bohaté (100+ operátorů) |
| Propagace kontextu | ScopedValue (implicitní, vázaný na vlákno) | Reactor Context (explicitní, vázaný na subskripci) |
| Čitelnost kódu pro jednoduchý CRUD | Imperativní (známý) | Reaktivní (křivka učení) |
| Debugování / stack traces | Plné stack traces | Fragmentované (zlepšuje se s propagací kontextu) |
| Zralost ekosystému | JDBC, RestTemplate, všechny blokující knihovny | R2DBC, WebClient, reaktivní drivery |
| Integrace s event-driven architekturami | Možná, ale manuální | Nativní fit |
Klam „prostě použijte virtuální vlákna"
Argument obvykle zní: „Virtuální vlákna nám dávají souběžnost reaktivního modelu s čitelností imperativního. Proč by si někdo vybral těžší programovací model?"
Protože souběžnost není jediná osa.
Scénář 1: Stavíte REST API, které čte z PostgreSQL přes JPA a volá dvě downstream služby.
Použijte virtuální vlákna. Získáte masivní souběžnost bez jakýchkoli změn kódu. WebFlux by vás nutil přepsat JPA repozitáře na R2DBC, nahradit RestTemplate WebClientem a naučit se nový programovací model — to vše pro stejný výsledek: zpracování více souběžných requestů.
Scénář 2: Stavíte event-driven pipeline pro zpracování objednávek, která konzumuje z Kafky, obohacuje eventy z více zdrojů s různými latencemi, zapisuje do reaktivní databáze a pushuje real-time aktualizace připojeným klientům přes SSE.
Použijte WebFlux. Celý datový tok je stream. Backpressure z SSE spojení se propaguje přes obohacení až zpět ke Kafka konzumentovi. Pokud se klient odpojí, pipeline přestane konzumovat. Virtuální vlákna by zvládla souběžnost, ale museli byste si sami postavit streaming, backpressure a kompozici — což je jen špatná reimplementace Reactoru.
Scénář 3: Máte Spring WebFlux aplikaci v produkci, která dobře funguje.
Nechte ji. „Virtuální vlákna existují" není důvod k přepisu. Reaktivní kód je už napsaný, otestovaný a odladěný. Údajný přínos čitelnosti imperativního kódu neplatí — vyměnili byste odladěný reaktivní kód za nový imperativní kód, který je potřeba testovat od nuly.
Co manažeři chápou špatně
Narativ „WebFlux je mrtvý" přitahuje manažery, protože slibuje zjednodušení: jeden model místo dvou, žádná reaktivní křivka učení, jednodušší nábor. Chápu to. Reaktivní kód je těžší na psaní, těžší na debugování a těžší na nábor.
Ale rozhodování není „reaktivní nebo virtuální vlákna." Je to „jaký problém řešíte?"
Pokud je odpověď „náš thread pool se vyčerpává pod zátěží" — virtuální vlákna. Hotovo.
Pokud je odpověď „potřebujeme streaming, backpressure nebo event-driven kompozici" — WebFlux. Virtuální vlákna tohle neřeší.
Pokud je odpověď „slyšeli jsme, že virtuální vlákna jsou budoucnost" — to není popis problému. Vraťte se a definujte skutečný technický požadavek.
Nejhorší výsledek je přepsat fungující WebFlux aplikaci na virtuální vlákna, protože na konferenci někdo řekl, že reaktivní je mrtvé, a pak o šest měsíců později zjistit, že potřebujete SSE streaming nebo backpressure-aware datové pipeline — a reimplementovat reaktivní vzory, které jste právě smazali.
Koexistují
Spring Boot 3.2+ podporuje oba modely ve stejné aplikaci. Můžete mít:
- MVC controllery na virtuálních vláknech pro tradiční request-response endpointy
- WebFlux controllery pro streamovací a reaktivní endpointy
# application.yml
spring:
threads:
virtual:
enabled: true # MVC controllery používají virtuální vlákna
# WebFlux endpointy nadále používají reaktivní event loopTohle není kompromis — je to použití každého nástroje tam, kde sedí. Vaše CRUD endpointy získají čitelnost imperativního kódu se škálovatelností virtuálních vláken. Vaše streamovací endpointy získají nativní reaktivní kompozici s backpressure. Stejná aplikace, stejný deployment.
Rozhodovací framework
Před výběrem si položte tyto otázky:
Potřebujete backpressure? Pokud jsou datové toky asymetrické (rychlý producent, pomalý konzument), potřebujete reaktivní model. Virtuální vlákna neposkytují řízení toku.
Potřebujete streaming? SSE, WebSocket, real-time feedy — ty jsou ze své podstaty reaktivní. Můžete je implementovat imperativně, ale budete bojovat s abstrakcí.
Je váš přístup k datům blokující? Pokud jste na JDBC/JPA a neplánujete změnu, virtuální vlákna vám dají souběžnost bez přepisu. R2DBC není nutný.
Je váš stávající kód reaktivní? Nechte ho. Přepis na virtuální vlákna je přepis — se vším rizikem, které to obnáší. Není to zjednodušení; je to náhrada.
Stavíte event-driven / CQRS systémy? Reaktivní model přirozeně sedí. Commandy produkují eventy, projekce konzumují streamy a celá pipeline těží z backpressure a kompozičních operátorů.
Pokud jste na všechny otázky 1, 2 a 5 odpověděli „ne" — virtuální vlákna jsou pravděpodobně jednodušší volba. Pokud jste na kteroukoli odpověděli „ano" — WebFlux si svou složitost zaslouží.
Shrnutí
Virtuální vlákna jsou fantastický přírůstek do Javy. Eliminují nahodilou komplexitu správy thread poolů u blokujícího I/O. Pro velkou kategorii aplikací — CRUD API, batch procesory, tradiční request-response služby — jsou správnou výchozí volbou.
Ale neučinila reaktivní programování zastaralým. Učinila zastaralým argument souběžnosti pro reaktivní model. Ostatní argumenty — backpressure, streaming, event-driven kompozice — jsou přesně tak platné, jako byly před Javou 21.
Až vám příště někdo řekne „WebFlux je mrtvý," zeptejte se ho, jak by implementoval backpressure s virtuálními vlákny. Ticho, které následuje, je vaše odpověď.


