Изучение конструкции новых виртуальных потоков Java
Посмотрите, как ввод-вывод определяет эффективность революции потоков в JEP 444.

Виртуальные потоки, реализованные в JEP 444, являются, пожалуй, самой ожидаемой функцией Java 21. В этом путешествии мы расскажем не только о том, как работают виртуальные потоки, но и о том, почему они работают.

Начало работы с Java I/O

Начнем с блокирующего ввода/вывода, преимущество которого заключается в концептуальной простоте. Например, класс Socket предоставляет такие методы, как getInputStream(), что позволяет написать код, подобный следующему однопоточному пользовательскому коду, который считывает (по одной строке за раз) данные из сокета и распечатывает их в блокирующем режиме:
Статью в оригинале можно прочитать тут

try (var sock = new Socket(hostname, port);
  var from = new BufferedReader(
      new InputStreamReader(sock.getInputStream()))) {

  for (String l = null; (l = from.readLine()) != null; ) {
      System.out.println(l);
  }
}
Обратите внимание, что если InputStream, возвращаемый функцией getInputStream(), поддерживается каналом в неблокирующем режиме, то операции чтения входного потока будут выбрасывать исключение java.nio.channels.IllegalBlockingModeException — другими словами, этот поток должен находиться в блокирующем режиме.

Блокирующая природа вызовов методов чтения проявляется в декорирующих классах InputStreamReader и BufferedReader, которые предоставляют гораздо более удобный интерфейс для человека, чем низкоуровневое чтение через сокеты. Однако эта простота имеет и потенциальные недостатки.

  • Необходимо прочитать данные из файла и занести их в кучу Java в виде объектов (по одному объекту String на строку).
  • Поток не сможет выполнять другие операции до завершения операции с файлом.
Первую из этих проблем можно решить, используя API Channels (часть библиотеки Java NIO) для чтения содержимого файла в буфер памяти, который не является частью кучи Java, и сделать это следующим образом:

var file = Path.of("/Users/bje/reasonable.txt");

try (var channel = FileChannel.open(file)) {
    var buffer = ByteBuffer.allocateDirect(1_000_000);
    var bytesRead = channel.read(buffer);

    System.out.println("Bytes read [" + bytesRead + "]");
    BusinessProcess.doSomethingWithFile(buffer);
} catch (IOException e) {
    e.printStackTrace();
}
Однако такой код все равно является блокирующим, и для решения этой проблемы необходимо сделать что-то более сложное. Один из вариантов — использовать классы неблокирующего асинхронного ввода-вывода из NIO.2. В них вызовы ввода-вывода возвращаются немедленно, независимо от того, завершилась операция ввода-вывода или нет.

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

Простой пример, в котором из файла считывается до 100 мегабайт, а затем выдается результат (количество прочитанных байт), может выглядеть следующим образом:

var file = Path.of("/Users/bje/enormous.txt");

try (var channel = AsynchronousFileChannel.open(file)) {
    var buffer = ByteBuffer.allocateDirect(100_000_000);
    Future<Integer> result = channel.read(buffer, 0);

    while (!result.isDone()) {
        BusinessProcess.doSomethingElse();
    }

    var bytesRead = result.get();
    System.out.println("Bytes read [" + bytesRead + "]");
    BusinessProcess.doSomethingWithFile(buffer);
} catch (IOException | ExecutionException | InterruptedException e) {
    e.printStackTrace();
}
Обратите внимание, что тип возвращаемого значения из метода read() - это Future<Integer>, который представляет собой контейнер, способный хранить как неполный, так и завершенный результат. (Объекты Future в Java очень похожи на обещания в JavaScript).

Тот факт, что метод read() возвращается немедленно, в то время как фактическое копирование файла в память происходит в другом потоке (и автоматически обрабатывается средой выполнения), позволяет этому однопоточному пользовательскому коду заниматься чем-то еще, пока ожидается завершение долгой операции копирования.

Можно использовать периодические проверки (с помощью метода isDone()) и затем вызывать метод get() для получения результатов, когда асинхронная активность ввода/вывода будет завершена.

Это, очевидно, сложнее, чем вариант с блокировкой, поскольку требует учитывать проработку асинхронных аспектов. В частности, программа должна быть структурирована таким образом, чтобы во время выполнения операции ввода/вывода можно было выполнять задачи, не связанные с данными, например, doSomethingElse() в приведенном выше примере.

Еще одним, более тонким аспектом, является то, что, несмотря на схожесть названий, AsynchronousFileChannel и FileChannel являются практически не связанными между собой классами. Фактически единственным общим наследованием у них заключается в том, что оба класса реализуют java.nio.channels.Channel, который имеет всего два метода.

public interface Channel extends Closeable {
    public boolean isOpen();
    public void close() throws IOException;
}
Примечательно, что в этом определении отсутствуют методы read, которые имеют разные сигнатуры в этих двух классах.

// FileChannel
public abstract int read(ByteBuffer dst) throws IOException;

// AsynchronousFileChannel
public abstract Future<Integer> read(ByteBuffer dst, long position);
Такое различие между двумя классами призвано подчеркнуть, а не скрыть различную природу вызовов. Это был осознанный и намеренный выбор со стороны разработчиков языка Java.

Другие подходы

Если вам интересно, то в некоторых других языках программирования, например в C#, применяется подход async-await к неблокирующему вводу/выводу, который пытается предоставить гибкий и общий способ превращения обычного однопоточного блокирующего кода в асинхронную, неблокирующую версию.

О версии этого подхода на языке C# следует сказать следующее:

  • Ключевое слово async превращает метод в асинхронный метод, называемый async-методом.

  • Когда выполнение достигает вызова метода, оформленного ключевым словом await, вызывающий метод приостанавливается и передает поток обратно вызывающему методу.

  • Ключевое слово await можно использовать только внутри метода, помеченного как async; попытка сделать иначе вызывает ошибку компиляции.

Подход async-await достигается за счет того, что компилятор автоматически преобразует async-метод в машину состояний.

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

В случае асинхронной задачи, возвращающей значение, также необходимо представлять неполные результаты. В Kotlin эту роль выполняют экземпляры типа Deferred<T>, в то время как в C# они имеют тип Task<TResult>.

Этот паттерн, который иногда называют асинхронная модель на основе задач (TAP), обладает гибкостью и универсальностью. Однако он имеет ряд недостатков, главным из которых является дополнительная сложность, которая побудила разработчиков языка Java исследовать другой путь, нежели простое добавление в язык асинхронного ожидания на основе машины состояний.

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

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

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

Это явление иногда называют асинхронной заразой или цветными функциями, названными так в 2015 году в блоге "Какого цвета ваша функция". Кстати, автор той заметки, Боб Нистром, неверно предсказал, что Java планирует поддерживать подход async-await.

Назад к Java

Наблюдение за опытом программистов, использующих подход async-await в других языках, привело команду разработчиков языка Java к выводу, что это неправильное направление для Java, и они стали искать другие варианты. Как вы уже догадались, окончательным решением проблемы в Java стали (спойлер) виртуальные потоки.

Однако прежде чем перейти к этой теме, необходимо рассмотреть еще один аспект блокирующего и неблокирующего ввода-вывода.

Традиционный API ввода-вывода в Java изначально был блокирующим, но JEP 353 в Java 13, который реализовывал старый API Socket, и JEP 373 в Java 15, который реализовывал старый API DatagramSocket, все изменили.

Обратите внимание, что в связи с тем, когда были выпущены эти два JEP, если ваши приложения работают на Java 17 (или на последней функциональной версии), то вы уже используете эти усовершенствования. С другой стороны, если ваши приложения все еще основаны на Java 8 или Java 11, то вы не используете эти улучшения.

Первоначальная реализация сокета была основана на непубличном классе PlainSocketImpl с поддержкой SocketInputStream и SocketOutputStream. Новая реализация опирается на новый класс NioSocketImpl, который объединяет реализацию кода сокета с нативным вводом/выводом.

Это означает, что, хотя для программиста сохраняется блокирующий API, под капотом используется неблокирующий ввод-вывод. Но почему это важно и как это связано с виртуальными потоками? Здесь есть своя история.

Операционные системы и боттлнек в потоках

Java версии 1.0, появившаяся в 1995 году, стала первой основной платформой программирования, включившей потоки в основной язык. До появления потоков для реализации многопроцессорности языки программирования должны были использовать несколько процессов и различные механизмы для передачи данных.

С точки зрения операционной системы, потоки — это независимые единицы выполнения, принадлежащие одному процессу. Каждый поток имеет счетчик инструкций выполнения и стек вызовов, но разделяет кучу данных со всеми остальными потоками одного и того же процесса.

Потоки планируются и управляются ОС.Для изменения выполнения потоков выполняется переключение контекста (context switch). Поскольку они разделяют одну и ту же кучу, переключение между двумя потоками в одном процессе обходится дешевле, чем между двумя потоками в разных процессах. Это приводит к облегчению контекстного переключения.

Потоки обеспечивают значительное повышение производительности (особенно в части пропускной способности), поскольку все данные, которые необходимо передавать, остаются в памяти и в одной и той же куче процесса. Однако участие ОС в управлении потоками приводит к некоторым проблемам масштабируемости, которые не так просто решить.

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

Для JVM-процессов, работающих в Linux x64, размер стека по умолчанию составляет 1 МБ, и это пространство резервируется ОС каждый раз при запуске нового потока. Таким образом, математика довольно проста. Например, для 2000 потоков необходимо зарезервировать 20 Гбайт стекового пространства.

Вы можете увидеть, что это создает огромное несоответствие между количеством объектов, которые может создать Java-программа (миллионы или даже миллиарды), и возможным количеством потоков платформы, которые может создать и обслуживать ОС, в значительной степени из-за требований к стековому пространству. Фактически, это требование накладывает ограничение на количество потоков в процессе, которое называется узким местом потоков (thread bottleneck).

Кстати, узкое место для потоков не является специфичным для Java: Требование к стековому пространству исходит от ОС, и ему подвержены все среды программирования. Независимо от языка, это узкое место становится все более серьезной проблемой, поскольку современные серверные процессы (неважно, написанные на Java или другом языке программирования) должны управлять гораздо большим количеством контекстов выполнения, чем это было раньше.

В результате возникает противоречие между использованием потоков наиболее простым и естественным способом — когда каждый поток выполняет одну последовательную задачу - и ограничениями, которые накладывает ОС и платформа на количество потоков. Из-за требований к пространству стека идея создания нового потока платформы для обработки каждого входящего запроса не может быть реализована, особенно если вы хотите, чтобы приложение могло масштабироваться.

Ввод виртуальных потоков

Решением этой проблемы в Java, позволяющим увеличить плотность потоков при сохранении простоты кода, являются виртуальные потоки, предоставляемые Project Loom.

Project Loom обсуждался в статье "Coming to Java 19: Virtual threads and platform threads" и (в гораздо более раннем этапе проекта, который может быть не совсем точен по сравнению с тем, что было предоставлено в действительности) в статье "Going inside Java's Project Loom and virtual threads". Описание виртуальных потоков можно прочитать в этих статьях, но для полноты картины приведем здесь краткое объяснение этой новой возможности языка.

Ниже приведены два основных аспекта виртуальных потоков, которые отличаются от потоков платформы:

  • Устранить роль ОС в создании и управлении виртуальными потоками

  • Заменить статическое распределение сегментов потоков на более гибкую модель

В обоих случаях среда выполнения JVM берет на себя роль, которую обычно играет ОС для потоков платформы.

Первый момент заключается в том, что виртуальные потоки — это просто исполняемые объекты Java. Для их выполнения требуется поток платформы (в контексте виртуальных потоков называемый несущим потоком), но эти потоки платформы являются общими. Это устраняет связь 1:1 между потоками Java и потоками ОС, и вместо этого устанавливается временная связь виртуального потока с несущим потоком — но только на время выполнения виртуального потока.

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

В совокупности это означает, что количество виртуальных потоков теоретически может масштабироваться аналогично количеству Java-объектов.

Виртуальные потоки и функция ввода/вывода

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

Посмотрите на модель состояния платформенного потока на рис. 1. В этой модели планировщик ОС перемещает потоки платформы на ядро и с ядра (то есть выполняет контекстные переключения).
Рисунок 2. Иерархия потоков
Существует несколько способов, с помощью которых потоки платформы могут покинуть ядро.

  • Поток может выйти, но этот случай не очень интересен.

  • Поток может добровольно и временно приостановиться (yield) и отдать оставшееся выделенное ему в данный момент время. Он может сделать это либо на фиксированное время, выполнив Thread.sleep(), либо до выполнения условия, выполнив Object.wait().

  • Поток может работать, не встречая блокирующих вызовов стандартной библиотеки, инструкций sleep или wait. В этом случае по истечении отведенного потоку времени (иногда называемого квантом времени) планировщик перемещает поток в конец очереди на выполнение и ждет, пока он не окажется в начале очереди и не получит право на повторный запуск. (Квант времени обычно составляет 10 миллисекунд — или 100 миллисекунд в старых операционных системах). Однако, за исключением вычислительных задач на практике этот случай встречается не так часто.

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

Как обрабатываются эти условия? Введение виртуальных потоков означает, что не все потоки планируются ОС, а только платформенные. Поэтому виртуальные потоки не представляются ОС как запланированные сущности. Вместо этого в JVM появляется дополнительный планировщик на уровне ВМ, который отвечает за управление доступом к общим потокам-носителям.

Эти несущие потоки принадлежат пулу потоков, а именно исполнителю ForkJoinPool. (Обратите внимание, что этот пул потоков отличается от общего пула, используемого для параллельных потоков).

Если поток сталкивается с блокирующим вызовом, он продолжает выполняться нормально, если он является платформенным потоком. Однако, если это виртуальный поток (и, следовательно, временно смонтированный на несущем потоке), то среда выполнения просто диспетчеризирует операцию ввода-вывода, а затем завершает работу, отсоединяя виртуальный поток от носителя.

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

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

На сегодняшний день большинство распространенных операций блокировки были адаптированы для поддержки виртуальных потоков. Однако некоторые операции, такие как Object.wait(), пока не поддерживаются и, наоборот, захватывают несущий поток . В этом случае пул потоков реагирует временным увеличением числа потоков платформы в пуле, чтобы компенсировать захваченный поток.

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

Что все это значит

Виртуальные потоки — это усовершенствование JVM. Это средство принципиально отличается от подхода async-await, принятого в Kotlin, C# и других языках.

В частности, в байткоде нет реального "отпечатка" виртуальных потоков, поскольку виртуальные потоки — это просто потоки, и работа с ними является частью платформы (то есть JVM), а не пользовательского кода.

Цель виртуальных потоков — позволить Java-программистам писать код в традиционном последовательном стиле с потоками, к которому они уже привыкли. Как только виртуальные потоки станут общепринятыми, отпадет необходимость в явном управлении уступами или использовании неблокирующих, основанных на обратных вызовах или реактивных операций в пользовательском коде.

Существуют и другие преимущества,такие как возможность отладчикам и профилировщикам работать с виртуальными потоками так же, как они делают это с традиционными потоками. Конечно, это означает, что разработчикам инструментов и библиотек придется проделать дополнительную работу для поддержки виртуальных потоков.

Однако в целом среда Java стремится снизить когнитивную нагрузку на конечных Java-разработчиков и переложить эту сложность на платформу и библиотеки. В этом смысле дизайн виртуальных потоков является классической компромиссной чертой в Java.

Начало работы с виртуальными потоками

Иерархия наследования Thread была расширена за счет появления виртуальных потоков, как показано на рис. 2.
Рисунок 2. Иерархия потоков
Обратите внимание, что класс VirtualThread явно объявлен как final и не может быть подклассифицирован. Поэтому любой существующий класс, расширяющий Thread напрямую, будет являться платформенным потоком. Это является частью принципа, согласно которому появление виртуальных потоков не должно изменять семантику существующего Java-кода.

Еще одной важной особенностью виртуальных потоков являются строители потоков (thread builders): Класс VirtualThread является непубличным, и разработчику необходимо каким-то образом создавать виртуальные потоки. Поэтому в Thread были добавлены новые статические методы для создания объектов-строителей, как показано ниже.

jshell> var tb = Thread.ofVirtual() // or .ofPlatform()
tb ==> java.lang.ThreadBuilders$VirtualThreadBuilder@b1bc7ed
Строители могут задать имя и задачу Runnable.

jshell> var t = tb.name("MyThread").unstarted(() -> System.out.println("Virtual World"))
t ==> VirtualThread[#24,MyThread]/new
Это создает поток в состоянии NEW, чтобы его можно было запустить обычным способом. Вы также можете создать и сразу же запустить поток, вызвав start() непосредственно на объекте builder. Обратите внимание на несколько иной вывод .toString(), который производят виртуальные потоки.

Фабрики потоков также доступны из билдеров.

jshell> var tf = tb.factory()
tf ==> java.lang.ThreadBuilders$VirtualThreadFactory@c4437c4
Класс Greeting определяет сущность, которая сохраняется в базе данных. Это сущность Jakarta Persistence с тремя полями. Аннотация @Entity идентифицирует его как сущность Jakarta Persistence, а аннотации @Id и @GeneratedValue определяют первичный ключ и способ его генерации. В остальном это обычный Java-объект.

package dukes.data;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Greeting {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    private String name;
    private String message;

    // constructor/getters/setters
}
Эти фабрики можно использовать так же, как и любые другие фабрики потоков, с которыми вы могли сталкиваться при работе с потоками платформы.

Рекомендации по работе с виртуальными потоками

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

  • Проверяйте дату выхода статей в Java Magazine или других изданиях, в которых обсуждаются виртуальные потоки, поскольку многое изменилось по сравнению с различными предварительными версиями.

  • Не думайте, что виртуальные потоки — это что-то простое и не требующее усилий.

  • Узнайте, с какими проблемами могут помочь справиться виртуальные потоки.

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

  • Ожидайте, что вы узнаете несколько новых интуитивных понятий и паттернов для виртуальных потоков.

  • Не используйте синхронизированные блоки и не вызывайте синхронизированные методы из виртуальных потоков.

  • Не используйте Thread.yield() для виртуальных потоков; он работает, но использовать его не рекомендуется.

Для освоения всего этого потребуется время, и сообществу Java необходимо адаптироваться к виртуальным потокам.

В частности, выбор между виртуальными и платформенными потоками для конкретной задачи является важным моментом проектирования. Программист должен решить, является ли данная задача связанной с вычислениями (и поэтому должна решаться платформенным потоком), или же она в основе своей ориентирована на ввод-вывод (и поэтому хорошо подходит для виртуального потока).

Заключение

В Java 21 (появился в сентябре 2023 года) виртуальные потоки станут стандартной, окончательной функцией. Насколько широким будет прямое использование виртуальных потоков — в отличие от использования их в основном библиотеками и фреймворками — пока неясно.

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