Search
Write a publication
Pull to refresh
РСХБ.цифра (Россельхозбанк)
Меняем банк и сельское хозяйство

Новая фича в Java 21: Виртуальные потоки: новые возможности для I/O bound микросервисов

Reading time7 min
Views6K

Привет, Хабр! Я Иван Попов, ведущий инженер ЦК платформенных и интеграционных решений РСХБ-Интех. Java — мой самый любимый язык программирования, я всю жизнь работал только на нём. Сейчас я работаю в банке и хочу разрушить стереотип  о том, что в банках все работают на Vegas. На java мы очень много работаем, тем более если видим, что новая технология позволяет нам оптимизировать процессы разработки (а количество интеграций огромное). 

Расскажу о новой фиче виртуальных потоков в Java 21, которая призвана повысить эффективность многопоточного кода.

Почему виртуальные потоки так важны для разработки микросервисов и почему современные веб-фреймворки начали под капотом использовать виртуальные потоки?

Давайте для начала начнем с самого простого примера — посмотрим на синтаксис, когда мы создаем потоки, при этом у нас есть некий Runnable через executor-сервиc, и в коде у нас использовался fixed threadpool или cache threadpool или различные другие пулы, которые уже давно есть в java. Появилась альтернатива, реализующая тот же самый интерфейс, но под капотом, когда мы вызываем Submit и передаем наш Runnable, у нас это все дело будет выполняться в виртуальных потоках. Видно, что разработчики проделали большую работу: способы создания платформенных потоков и виртуальных очень похожи. Они стремились к минимуму переписывания кода.

Посмотрим, как это все работает под капотом на трех разных уровнях — JVM, операционная система и железо. Раньше, когда мы создавали объект Thread Jail, через него мы посылали команду на старт операционной системе, и операционная система имела свой планировщик и планировала выполнение этого потока на одном из наших ядер. Так же у нас происходило резервирование места в стеке. Была такая картина: обычный платформенный поток был по сути такой тонкой оберткой над потоком операционной системы. То есть практически это было одно и то же, можно было поставить знак равенства.

Но вот появились виртуальные потоки. В чем их отличие? В первую очередь, в том, что они напрямую не связаны с потоком операционной системы и со стеком. Они гораздо более легкие. Их можно создать в гигантских количествах, здесь нет каких-то жестких ограничений. Если бы мы создавали обычный Thread, то столкнулись бы с ограничением операционной системы, сотню тысяч мы бы уже не создали, потому что появились бы ограничения со стороны операционной системы из-за количества памяти. А виртуальные потоки — это по сути обычные Java-объекты,  которые разработчики Java сделали так, чтобы эффективно распределять по платформенным потокам.

Таким образом мы создаем гигантское количество виртуальных потоков.  И Java за счет своего внутреннего пула потоков эффективно распределяет эти виртуальные потоки по платформам. Давайте посмотрим детальнее.   Круги — это потоки, круги В — это виртуальные потоки, которые добавились в java 21, а P — это старые потоки.

В терминах новой Java старые называют Carrier потоки, потому что они обеспечивают выполнение виртуальных потоков, также их еще называют воркеры. Получается, что мы создаем в коде большое количество виртуальных потоков, здесь нас java не ограничивает. Они распределяются по очередям наших обычных потоков. Распределение идёт равномерное, по сути распределение обеспечивается fork join pool, который у нас был еще с 8 версии java. Здесь они ничего нового не изобрели. 

Наши виртуальные потоки оседают в очередях платформенных потоков или воркеров, и в процессе этого у нас происходит такая операция как маунд. Это значит,  что наш воркер берет виртуальный поток — виртуальную задачу — и начинает исполнять. 

Остальные воркеры тоже не простаивают. Разработчики Java обеспечили работу так, чтобы наши воркеры P1, P2, P3 постоянно были заняты работой. Когда виртуальная задача выполнилась, воркер берет следующую задачу, все работает эффективно.

В определенный момент у нас вызывается блокирующая операция в одном из виртуальных потоков.  Раньше в реактивном стеке это было проблемой. Был такой отдельный инструмент Block Hound, который позволял обнаруживать такие моменты.

В виртуальных потоках мы можем блокировать себя безопасно, потому что используется такой трюк, который называется park and mount: в этот момент у нас виртуальная задача с блокирующей операцией снимается с воркера. И наш воркер спокойно продолжает заниматься другими задачами. У нас также есть фоновый процесс Unparker: он ждет сигнала о том, что блокирующая операция завершилась, и что можно эту задачу продолжать выполнять с того места, на котором мы остановились. 

Кстати, это обеспечивается за счет новой классной штуки, которую добавили в Java,  — Continuation. Она позволяет приостановить выполнение нашего кода в любом месте, снять данные со стека и затем продолжить с этого места позднее, после того как уже получили данные.  Получается,  наши воркеры никогда не простаивают, продолжают работать. И самое главное, в виртуальных потоках мы можем сколь угодно много делать блокирующих операций. При этом они могут быть весьма долгими. Это эффективно, ведь наши воркеры не будут спать, они все время будут заниматься работой. 

Проблемы Java

Но есть с новой Java некоторые нюансы. Когда Unparker получил сигнал о том, что блокирующая операция завершилась и её можно вернуть обратно в наш Fork Joint Pool, задача может попасть в очередь уже к другому воркеру. Происходит кража работы, и с этим связан ряд ограничений. У нас поменялись воркер и стек, и с этим есть одна проблема. Обсудим, что может пойти не так.

После смены воркера адрес стека будет уже другой, потому что виртуальный поток, когда снимается с воркера,  он по сути забирает свои данные со стека, а потом, когда он маунтится на другой воркер, он кладет свои данные из кучи в стек другого воркера. Это небезопасно, поэтому разработчики решили эту проблему следующим образом. 

Они сделали так, что по сути наши виртуальные потоки просто перестают работать в таком кейсе: если вызвать блокирующую операцию, то у нас воркер заблокируется. Если блокирующая операция предполагает ожидание данных, то воркер будет ждать. Он не сможет выполнять следующие задачи своей очереди.

В java 21 есть еще одна проблема: если блокирующая операция выполнилась, в этом случае это ограничение называется пининг. Виртуальный поток пинится на своем воркере, они становятся единым целым, и когда у нас встречается блокирующая операция все еще внутри этого блока, воркеры временно выходят из игры — на столько, на сколько длится блокирующая операция. Конечно, другие воркеры могут из очереди уснувшего воркера забрать его задачи, но если у нас вот этот вот пининг происходит массово, то мы можем усыпить большое количество наших воркеров, и пропускная способность упадёт.

Воркеров не так много, и уснувший воркер — это плохо.  Нам обещают, что в следующей Java синхрониза блоков можно будет не бояться. Одна из ключевых метрик, которую позволит нам улучшить виртуальные потоки — это по сути пропускная способность. Улучшать процессную способность — это значит увеличивать количество потоков. Виртуальных потоков мы создаем большое количество, но под капотом потоков все так же мало. По сути выигрыш здесь происходит за счет того, что у нас сокращается среднее время обработки запросов, ведь мы не ждем завершения блокирующей операции, а наши ворки продолжают работать.

У нас, конечно, есть и альтернативные способы — переписать на React вполне себе вариант, но не все любят разбираться в огромном количестве операторов на Mono и Flux. Способ вполне себе годный, но виртуальные потоки — это как альтернатива, причем если хорошо владеть и реактивными программируемыми виртуальными потоками, то можно обнаружить, что их можно использовать вместе, они прекрасно дополняют друг друга.

Посмотрим на конкретном примере. У нас есть инстанс микросервиса, рест-контроллер, а количество платформенных потоков ограничено. Традиционно мы держали в пуле небольшое количество платформенных потоков, которые обрабатывали большие запросы. Теперь же с виртуальными потоками мы можем создавать виртуальный поток на каждый запрос. Пусть даже к нам сотни тысяч запросов в инстанс микросервиса прилетят, мы можем использовать виртуальный поток на запрос. И Java сама позаботится о том, чтобы эффективно распределить виртуальные потоки по воркерам. А мы можем делать в этих виртуальных потоках блокирующие операции, например, писать сообщения в другой микросервис, ожидать ответ от базы данных.

Можно не бояться писать простой блокирующий код, который не возвращает CompatibleFuture. А если есть CompatibleFuture, то можно делать get и join. Да, мы заблокировались, но за счёт вот этих трюков в Java это всё не страшно.

А на уровне операционной системы у нас все по-старому. Операционная система не знает про эти виртуальные потоки, она видит только обычные потоки, как обычно.  Планировщик операционной системы планирует их выполнение на том или ином ядре.  

Максимальный выигрыш от виртуальных потоков мы получаем в  I/O bound задачах, в микросервисах, которые много общаются с другими микросервисами. Идеальный пример — это какой-нибудь микросервис-агрегатор данных из других микросервисов, который делает много запросов. При этом его код можно оставить очень простым, делать блокирующие операции при условии, что мы убедились, что все в виртуальных потоках.

Для I/O bound задач придумали такие решения, как реактивное программирование и асинхронное программирование, но виртуальные потоки являются прекрасным третьим компонентом, который тоже может побороть задачу. И у нас I/O операций может быть очень много,  это обмен данными с базой данных, брокером сообщений, чтение данных с жесткого диска.

Формула виртуальных потоков очень проста. Это fork-join-pool и continuation. Continuation – это такой специальный класс,  который позволяет приостановить поток в нужном месте, снять данные и перекинуть из стека в кучу, а затем возобновить его работу спустя некоторое время. 

Мы можем использовать простые блокирующие операции, которые не возвращают нам CompatibleFuture, при этом у нас по-прежнему код останется таким же эффективным.  Виртуальные потоки – прикольная тема. Проблема в том, что вся магия спрятана, но вот в Java 21 она работает не всегда из-за опасности синхронизации с блоками. Я считаю, что все равно имеет смысл попробовать. 

Главный принцип, когда речь идет о перформансе, – это измерять и не гадать. Надо провести нагрузочное тестирование и убедиться, что действительно будет хороший рост производительности. Основные бенефиты — это высоконагруженный микросервис с сотнями тысяч запросов, которые надо обрабатывать одновременно. Чем больше эта цифра, тем больше бенефитов. 

Напишите в комментариях, стоила ли игра свеч и стоило ли разработчикам java проделывать колоссальную работу по добавлению новой фичи в продукт.  

Tags:
Hubs:
Total votes 14: ↑10 and ↓4+8
Comments20

Articles

Information

Website
www.rshbdigital.ru
Registered
Founded
Employees
over 10,000 employees
Location
Россия