Это руководство поможет вам понять, что представляет собой Project Loom в Java и как его виртуальные потоки (также называемые «fibers») работают «под капотом».
Виртуальные потоки Project Loom
Пытаясь освоить Project Loom для Java 19, я посмотрел выступление Николая Парлога и прочитал несколько постов в блоге.
Все они показывали, как virtual threads
(или fibers
) могут существенно масштабироваться до сотен тысяч или миллионов, тогда как старые добрые Java-потоки, поддерживаемые ОС, могли масштабироваться только до пары тысяч (TBD: проверьте гипотезу о потоках ОС в реальных сценариях).
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i -> executor.submit(() -> { // (1)
Thread.sleep(Duration.ofSeconds(1));
System.out.println(i);
return i;
}));
}
Пример, использованный в сообщениях блога, позволяет спать 100 000 виртуальных потоков.
Сотни тысяч спящих виртуальных потоков - это отлично. Но могу ли я теперь легко выполнить 100 000 HTTP-вызовов параллельно с помощью виртуальных потоков?
// какая разница?
for (int i = 0; i < 1000000; i++) {
// good, old Java Threads
new Thread( getURL("https://www.marcobehler.com"))
.start();
}
for (int i = 0; i < 1000000; i++) {
// Виртуальные потоки Java 19 спешат на помощь?
Thread.startVirtualThread(() -> getURL("https://www.marcobehler.com"))
.start();
}
Давай выясним.
Почему некоторые Java вызовы блокируются?
Здесь приведен код нашего метода getURL
, который открывает URL-адрес и возвращает его содержимое в виде строки.
static String getURL(String url) {
try (InputStream in = new URL(url).openStream()) {
byte[] bytes = in.readAllBytes(); // ALERT, ALERT!
return new String(bytes);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
Когда вы открываете JavaDoc про inputStream.readAllBytes()
или вам повезло вспомнить свой курс Java 101, вам вдалбливают, что вызов блокируется и не вернется, пока не будут прочитаны все байты, т.е. ваш текущий поток заблокирован до этого момента.
Почему же теперь я могу якобы выполнять этот вызов миллион раз параллельно, при работе внутри виртуальных потоков, но не при работе внутри обычных потоков?
Части головоломки — темы, о которых вы никогда не знали, о которых вы хотели бы узнать больше после CS 101: Sockets & Syscalls.
Сокеты
Когда вы хотите сделать HTTP-вызов или, скорее, отправить какие-либо данные на другой сервер, вы (или, скорее, создатель библиотеки на очень низком уровне) откроете Socket. А доступ к сокетам по умолчанию блокирующий.
// псевдокод
Socket s = new Socket();
// вызов блокируется, пока данные не будут доступны
s.read();
Однако операционные системы также позволяют помещать сокеты в non-blocking mode
, которые немедленно возвращаются, когда нет доступных данных. И затем вы обязаны проверить позже, чтобы узнать, есть ли какие-либо новые данные для чтения.
// псевдокод
Socket s = new Socket();
// псевдокод, обратитесь к любому туториалу по Java NIO
s.setBlockingFalse(true);
// ура, этот вызов вернется немедленно, даже если нет данных
s.read();
Системные вызовы
При выполнении приведенного выше вызова getURL()
Java не выполняет сетевой вызов (открытие сокета, чтение из него и т. д.) самостоятельно — она просит базовую операционную систему выполнить этот вызов. И вот в чем хитрость: всякий раз, когда вы используете старые добрые потоки Java, JVM будет использовать блокирующий системный вызов (TBD: показать стек вызовов ОС).
Однако при запуске внутри виртуального потока JVM будет использовать другой системный вызов для выполнения сетевого запроса, который является неблокирующим (например, используется epoll в системах на основе Unix), и вам, как Java программисту, не придется писать неблокирующий код самостоятельно, например какой-нибудь громоздкий код Java NIO.
Короче говоря, и игнорируя множество деталей, реальная разница между нашими вызовами getURL
внутри старых добрых потоков и виртуальных потоков заключается в том, что один вызов открывает миллион блокирующих сокетов, тогда как другой вызов открывает миллион неблокирующих сокетов.
Теперь, если бы вы попробовали этот (бессмысленный) пример в реальном мире, вы бы обнаружили, что в зависимости от вашей операционной системы, и если вы отправляете или получаете данные, вы столкнетесь с ограничениями сокетов операционной системы. Это напоминание о том, что использование виртуальных потоков не является автоматически масштабируемым решением для которого нет необходимости знать, что вы делаете (разве это не всегда так? :) ).
Вызовы файловой системы
Пока мы в этом разбираемся. Как будут вести себя виртуальные потоки при работе с файлами?
// Давайте прочитаем миллион файлов параллельно!
for (int i = 0; i < 1000000; i++) {
// Java 19 virtual threads to the rescue?
Thread.startVirtualThread(() -> readFile(someFile))
.start();
}
С сокетами все было просто, потому что их можно было просто настроить на режим non-blocking
(неблокирующий). Но вот при файловом доступе нет асинхронного IO (ну за исключением io_uring в новых ядрах).
Короче говоря, ваш вызов доступа к файлу внутри виртуального потока будет фактически делегирован (… барабанная дробь…) старому доброму потоку операционной системы, чтобы создать вам иллюзию неблокирующего доступа к файлам.
Как работают виртуальные потоки?
Несмотря на то, что старые добрые потоки Java и виртуальные потоки имеют общее имя… Threads
, онлайн сравнения/обсуждения кажутся мне чем-то вроде сравнения яблока с апельсином.
Это заставило меня думать о виртуальных потоках как о задачах, которые в конечном итоге будут выполняться в реальном потоке, называемом carrier thread
. И которым нужны базовые нативные вызовы для выполнения тяжелой неблокирующей работы.
В случае работы ввода/вывода (вызовы REST, вызовы базы данных, вызовы очередей, потоковые вызовы и т. д.) это абсолютно точно даст преимущества, и в то же время иллюстрирует, почему они совсем не помогут при работе с интенсивным использованием ЦП (или ухудшат ситуацию). Так что не стоит питать больших надежд, думая о добыче биткойнов в сотнях тысяч виртуальных потоков.
Шумиха и обещания
Почти каждый пост в блоге на первой странице поиска в Google, связанный с JDK 19, дословно копирует следующий текст, описывающий виртуальные потоки:
A preview of virtual threads, which are lightweight threads that dramatically reduce the effort of writing, maintaining, and observing high-throughput, concurrent applications. Goals include enabling server applications written in the simple thread-per-request style to scale with near-optimal hardware utilization (...) enable troubleshooting, debugging, and profiling of virtual threads with existing JDK tools.
Что означает:
Предварительный релиз виртуальных потоков, представляющие собой облегченные потоки, которые значительно снижают усилия по написанию, поддержке и наблюдению за высокопроизводительными параллельными приложениями. Цели включают в себя предоставление возможности серверным приложениям, написанным в простом стиле «поток на запрос», масштабироваться с почти оптимальным использованием аппаратного обеспечения (...), обеспечение возможности устранение неполадок, отладки и профилирования виртуальных потоков с помощью существующих инструментов JDK.
Хотя я действительно считаю, что виртуальные потоки — отличная функция, я также чувствую, что фразы, подобные приведенной выше, приведут к изрядной доле ажиотажа. Веб-серверы типа Jetty, уже давно используют коннекторы NIO, где всего несколько потоков способны поддерживать открытыми сотни тысяч или даже миллионы соединений.
Проблема реальных приложений заключается в том, что они делают «странные» вещи, например, обращаются к базам данных, работают с файловой системой, выполняют вызовы REST или обращаются к каким-то очередям/потокам.
И да, именно в этом типе работы с вводом/выводом Project Loom потенциально будет потенциально блистать. Loom дает вам, программисту или, возможно, даже «просто» сопровождающим библиотек и фреймворков (HTTP/баз данных/очередей), преимущество неблокирующего кода, без необходимости прибегать к довольно не интуитивной модели асинхронного программирования (вспомните о RxJava / Project Reactor) и всех вытекающих из этого последствий (поиск и устранение неисправностей, отладка и т.д.).
Однако забудьте об автоматическом масштабировании до миллиона частных потоков в реальных сценариях, не зная, что вы делаете. Бесплатных обедов не бывает.
Как насчет примера Thread.sleep?
Мы начали эту статью с того, что заставили потоки перейти в спящий режим. Итак, как это работает?
При вызове
Thread.sleep()
в старом добром Java-потоке, поддерживаемом ОС, вы, в свою очередь, сгенерируете нативный вызов, который переводит поток в спящий режим на определенный промежуток времени. Чтов любом случае является бессмысленным сценарием,довольно затратным для 100_000 потоков.В случае
VirtualThread.sleep()
, вы пометите виртуальный поток как спящий и создадите запланированную задачу на старой доброй Java (на основе потоков ОС)ScheduledThreadPoolExecutor
. Эта задача распаркует / возобновит ваш виртуальный поток по истечении заданного времени ожидания. Упражнение для вас: опять сравнение яблок и апельсинов?
Заключение
Хотите увидеть больше таких коротких технологических погружений? Оставьте комментарий ниже.
Тем временем ознакомьтесь со статьей Load Testing: An Unorthodox Guide (Нагрузочное тестирование: неортодоксальное руководство), чтобы узнать, почему вам следует беспокоиться о других вещах, кроме масштабирования.
Благодарности
Спасибо Тагиру Валееву, Всеволоду Толстопятову и Андреасу Эйзеле за комментарии/исправления/обсуждения.