Какая проблема возникает при использовании parallel stream с REST клиентом в многопоточной среде?
Что проверяют
Знаете ли вы, на каком пуле потоков работает parallelStream() и почему блокирующий I/O в нём опасен для всего приложения. Следом просят предложить, как правильно распараллелить сотню HTTP-вызовов.
Как отвечать
Главная проблема: параллельный стрим выполняется в общем ForkJoinPool.commonPool(). Он один на всю JVM, его параллелизм по умолчанию равен числу ядер минус один, плюс в работе участвует вызывающий поток. Пул рассчитан на короткие CPU-задачи, а HTTP-вызов сотни миллисекунд просто ждёт сеть.
Что из этого следует:
- голодание общего пула: пока потоки ждут ответа, остальные параллельные стримы и
CompletableFuture.supplyAsyncбез своего executor во всём приложении стоят в очереди. Один медленный внешний сервис тормозит несвязанный код; - параллелизм не управляется: на 4 ядрах одновременно пойдут 4 запроса, на 64 ядрах 64, хотя внешняя сторона выдерживает, скажем, 10;
- теряется контекст потока:
ThreadLocal, MDC для логов,SecurityContext, транзакция. В вызывающем потоке они есть, в рабочих нет, и поведение зависит от того, какой поток взял элемент; - ошибки: исключение одного элемента пробрасывается наружу, но уже запущенные запросы продолжают выполняться, и их побочные эффекты не отменить.
Правильно: отдельный ExecutorService ограниченного размера, CompletableFuture.supplyAsync(task, executor) и allOf, плюс таймауты на клиенте. На Java 21 и новее удобно взять Executors.newVirtualThreadPerTaskExecutor() и ограничить одновременные запросы через Semaphore. В реактивном стеке тот же эффект даёт Flux.flatMap с параметром concurrency поверх WebClient.
Где спотыкаются
Ищут проблему в потокобезопасности клиента и создают по экземпляру на поток. RestClient, WebClient и java.net.http.HttpClient после настройки потокобезопасны, а так ломается переиспользование соединений.
Поднимают java.util.concurrent.ForkJoinPool.common.parallelism для всего приложения. Это меняет поведение всех стримов в JVM ради одного места.
Запускают стрим внутри своего ForkJoinPool через submit. Это опирается на деталь реализации, которую документация не гарантирует.