Это руководство поможет вам понять, что представляет собой 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;
    }));
}
  1. Пример, использованный в сообщениях блога, позволяет спать 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 (Нагрузочное тестирование: неортодоксальное руководство), чтобы узнать, почему вам следует беспокоиться о других вещах, кроме масштабирования.

Благодарности

Спасибо Тагиру Валееву, Всеволоду Толстопятову и Андреасу Эйзеле за комментарии/исправления/обсуждения.

Комментарии (3)


  1. Akela_wolf
    14.11.2022 15:44

    Ну то есть корутины из Котлина. Главный плюс (и он же по идее минус) - семантика вызовов наподобие Thread.sleep меняется в зависимости от того в настоящем потоке он выполняется или в легком.


  1. Cdracm
    14.11.2022 15:47

    В случае VirtualThread.sleep(), вы пометите виртуальный поток как спящий и создадите запланированную задачу на старой доброй Java (на основе потоков ОС) ScheduledThreadPoolExecutor. Эта задача распаркует / возобновит ваш виртуальный поток по истечении заданного времени

    что-то мне кажется, это не то что происходит во время выполнения sleep. я думаю, что sleep помечает тред как доступный для диспетчеризации корутин в него. никаких ScheduledTask не должно создаваться при этом.


  1. dyadyaSerezha
    15.11.2022 06:00
    +2

    Проблема реальных приложений заключается в том, что они делают глупые вещи, например, обращаются к базам данных, работают с файловой системой, выполняют вызовы REST или обращаются к каким-то очередям/попотокам.

    Хммм... А вы уверены, что это глупые вещи? А что же тогда умные?