Virtual Threads и Project Loom
Запустил 100 000 виртуальных потоков, каждый ждёт 100 миллисекунд. Весь прогон занял 1,55 секунды. Тот же объём работы на пуле из двухсот обычных потоков занял бы 100 000 разделить на 200 умножить на 100 миллисекунд, то есть около пятидесяти секунд.
Java 21 вернула самую простую модель - поток на задачу, сделав потоки дешёвыми. На собеседованиях это горячая тема, и ловят обычно на хайпе: «виртуальные потоки ускоряют всё» - неправда. Они про масштаб ожидания, а не про скорость вычислений.
// Формулировки: «зачем виртуальные потоки, если есть пулы?», «что такое пиннинг?», «нужен ли пул виртуальных потоков?»
Механика: парковка вместо блокировки
Виртуальный поток - это объект внутри виртуальной машины, а не поток операционной системы. Несколько платформенных потоков, их называют несущими, исполняют тысячи виртуальных. Как только виртуальный поток блокируется на сетевом вызове, запросе к базе или чтении из очереди, машина паркует его и отдаёт несущий поток следующему.
Отсюда возврат модели «поток на запрос»: исполнитель, создающий виртуальный поток на каждую задачу, - штатный способ работы. Пулить их НЕ нужно: пул придуман, чтобы экономить дорогие потоки, а виртуальные дёшевы и одноразовы. Нужен предел на внешний ресурс - ставят семафор, а не размер пула.
// И важная поправка на ожидания: латентность одного вызова не меняется. Запрос в базу как шёл 50 миллисекунд, так и идёт. Меняется количество ТАКИХ ожиданий, которые можно держать одновременно - сотни тысяч вместо сотен.
- несущий поток
- платформенный поток, на котором исполняются виртуальные
- парковка
- виртуальный поток снят с несущего на время блокировки
Три границы применимости
Граница первая - вычисления. Числодробилке виртуальные потоки не дают ничего: ядер больше не стало, а мультиплексирование помогает только тем, кто ждёт.
Граница вторая - пиннинг, и её я мерил. Сто виртуальных потоков, каждый со СВОИМ отдельным замком, каждый спит 50 миллисекунд. Под synchronized прогон занял 2,51 секунды, под ReentrantLock - 0,05. Разница в пятьдесят раз, и она равна числу ядер: блокировка внутри synchronized прибивает виртуальный поток к несущему, и одновременно работают только два. Это поведение версии 21, в 24-й его починили, но код обычно живёт на долгих версиях.
// Граница третья - ThreadLocal. Формально работает, но миллион потоков означает миллион копий контекста. Замена - ScopedValue: неизменяемый контекст с чёткой областью видимости, который передаётся дочерним задачам без копирования.
- пиннинг
- виртуальный поток прибит к несущему и не отцепляется
- ScopedValue
- неизменяемый контекст задачи вместо ThreadLocal
Структурная конкурентность и вечная модель памяти
StructuredTaskScope даёт конкурентности структуру: дочерние задачи живут строго внутри области, и выход из неё гарантирует, что все они либо завершены, либо отменены. Упала одна - остальные отменяются совместно, потерянных задач-сирот не остаётся. Для логики «разошлись и собрались» это читаемая замена ручной оркестрации будущих значений.
Чего виртуальные потоки НЕ отменяют - модель памяти. Гонки, видимость, happens-before остаются ровно теми же: миллион виртуальных потоков вокруг общей HashMap ломается так же, как сотня платформенных. Инструменты те же - неизменяемость, атомарные типы, конкурентные коллекции.
// Выбор на сегодня такой. Конкурентность ввода-вывода - виртуальные потоки. Конвейеры преобразований - CompletableFuture. Параллельные вычисления - ForkJoin и параллельные стримы. И миграция ничего не даёт, если ограничение осталось ниже по течению: пул соединений к базе на десять штук просто перевёл узкое место туда.
- StructuredTaskScope
- область дочерних задач с совместной отменой
- узкое место ниже по течению
- пул соединений, лимит внешнего сервиса, диск
Как отвечать: «Зачем виртуальные потоки, если есть пулы?»
Пул решает проблему дороговизны платформенных потоков и сам же ограничивает конкурентность своим размером: тысяча одновременных запросов при пуле на двести означает очередь. Виртуальные потоки убирают саму дороговизну. Машина мультиплексирует их поверх немногих несущих потоков, и на блокировке виртуальный поток паркуется, а несущий уходит обслуживать других. Я мерил: сто тысяч виртуальных потоков, каждый ждёт сто миллисекунд, укладываются в полторы секунды, а на пуле из двухсот та же работа заняла бы около пятидесяти. Это возвращает простую модель «поток на запрос» с обычным блокирующим кодом и тем масштабом, ради которого раньше приходилось идти в реактивщину. Границы называю сразу: вычислительным задачам это ничего не даёт, на версиях до 24-й блокировка внутри synchronized прибивает виртуальный поток к несущему - у меня из-за этого сто потоков вместо параллельной работы отработали за 2,51 секунды вместо 0,05, и лимиты на внешние ресурсы теперь задаются семафором, а не размером пула.
Сильный ответ: показана смена парадигмы - пул лечил симптом, а виртуальные потоки убирают причину. Границы применимости названы до того, как о них спросили, и подкреплены собственными замерами.
На чём валят
- −Заводят пул виртуальных потоков по привычке. Пулить дешёвое незачем, лимиты ставят семафором.
- −Ждут ускорения вычислений. Ядер больше не стало, выигрыш только в ожидании.
- −Оставляют горячий synchronized с блокировкой внутри. У меня это превратило 0,05 секунды в 2,51.
- −Думают, что гонки исчезнут сами. Модель памяти никто не отменял, общее изменяемое состояние ломается так же.
- −Мигрируют на виртуальные потоки, а пул соединений к базе оставляют на десять. Узкое место просто переехало.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 13, остальные разбираются в тренажёре.
- Для какой нагрузки виртуальные потоки дают наибольший выигрыш?A)Тяжёлые вычисления, загружающие все ядра процессора без единого ожидания или блокировкиB)Задачи, которым нужно выполниться строго последовательно в одном потоке без всякого параллелизмаC)Массовая конкурентность с блокирующим вводом-выводом (запросы, БД, сеть)D)Долгие блокировки на synchronized-мониторах, где виртуальные потоки снимают накладные
показать ответ и разбор
+C)Массовая конкурентность с блокирующим вводом-выводом (запросы, БД, сеть)// разбор: Виртуальные потоки сияют там, где много одновременных задач ПРОСТАИВАЮТ на блокирующем I/O: тысячи HTTP-запросов, обращений к БД, вызовов сервисов. Раз блокировка виртуального потока освобождает носитель, можно писать простой блокирующий код (thread-per-request), но держать миллионы соединений без раздувания пула. Для CPU-bound они не помогают (упор в ядра), а внутри synchronized (в Java 21) блокировка может «приколоть» их к носителю — там берут ReentrantLock.
- Как правильно использовать виртуальные потоки для множества задач?A)Складывать их в фиксированный пул из нескольких штук и переиспользовать, как платформенные потокиB)Создавать ровно столько виртуальных потоков, сколько ядер у процессора, — по одному на ядроC)Использовать один виртуальный поток на всё приложение, отправляя ему задачи через общую очередьD)Создавать по одному на задачу (newVirtualThreadPerTaskExecutor), не пулить
показать ответ и разбор
+D)Создавать по одному на задачу (newVirtualThreadPerTaskExecutor), не пулить// разбор: Виртуальные потоки НЕ пулят: они настолько дёшевы, что создаются по одному на задачу и выбрасываются. Пул платформенных потоков нужен был, чтобы переиспользовать дорогой ресурс — у виртуальных этой проблемы нет, а пул лишь ограничил бы параллелизм. Идиома — Executors.newVirtualThreadPerTaskExecutor() (даёт по виртуальному потоку на каждую задачу) или Thread.ofVirtual().start(...). Ограничивать конкурентность к дефицитному ресурсу — семафором, а не пулом.
- Почему при работе с виртуальными потоками (Java 21) советуют ReentrantLock вместо synchronized для долгих блокировок?A)Блокировка внутри synchronized может «приколоть» (pin) виртуальный поток к носителюB)Synchronized в виртуальных потоках вызывает ошибку компиляции, поэтому его заменяют на ReentrantLockC)ReentrantLock работает на порядки быстрее synchronized, и только он совместим с планировщиком LoomD)Synchronized запрещён внутри виртуальных потоков спецификацией и игнорируется JVM молча
показать ответ и разбор
+A)Блокировка внутри synchronized может «приколоть» (pin) виртуальный поток к носителю// разбор: В Java 21, если виртуальный поток блокируется, УДЕРЖИВАЯ монитор (внутри synchronized) или в нативном коде, он «пиннится» — не может отмонтироваться от потока-носителя, занимая его на время ожидания. При массовом пиннинге носители кончаются и выигрыш Loom теряется. Поэтому для секций, где поток может долго блокироваться, советуют java.util.concurrent-локи (ReentrantLock), которые пиннинга не вызывают. (В более новых версиях Java пиннинг на synchronized убирают.)
- Что решает структурированная многопоточность (structured concurrency, StructuredTaskScope)?A)Убирает необходимость в потоках: задачи выполняются последовательно в одном потоке по очередиB)Связывает подзадачи с областью: их отмена/ошибки/ожидание управляются как одно целоеC)Автоматически распараллеливает обычный цикл for, раскидывая его итерации по ядрам процессораD)Обеспечивает, что подзадачи не смогут выполняться параллельно, устраняя гонки данных
показать ответ и разбор
+B)Связывает подзадачи с областью: их отмена/ошибки/ожидание управляются как одно целое// разбор: Структурированная многопоточность (StructuredTaskScope, новее Java 21) привязывает жизненный цикл параллельных подзадач к лексической ОБЛАСТИ: они запускаются, а scope ждёт их все, при ошибке одной — отменяет остальные, и ничего не «утекает» за границу блока. Это как try-with-resources для конкурентности: устраняет висящие/забытые задачи и делает поток управления понятным. Хорошо ложится на виртуальные потоки (форк множества подзадач).
- Чем ScopedValue привлекательнее ThreadLocal в связке с виртуальными потоками?A)Это ровно то же самое, что ThreadLocal, только переименованное в новых версиях Java без отличийB)Он позволяет менять значение из соседнего потока в произвольный момент, чего ThreadLocal делать не умеетC)Неизменяемое значение, привязанное к области видимости, без утечек и с дешёвым наследованиемD)Он хранит отдельное изменяемое значение для каждого ядра процессора, а не для каждого потока
показать ответ и разбор
+C)Неизменяемое значение, привязанное к области видимости, без утечек и с дешёвым наследованием// разбор: ThreadLocal — изменяемое значение на поток, которое легко забыть очистить (утечки), а при миллионах виртуальных потоков — ещё и накладно. ScopedValue (новее Java 21) хранит НЕИЗМЕНЯЕМОЕ значение, действующее только внутри ограниченной области (ScopedValue.where(V, x).run(...)) — по выходе оно автоматически исчезает, наследуется в дочерние (structured) задачи дёшево и без копирования. Это безопаснее и легче для Loom-стиля.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.