Знакомство с проектом Project Loom
и виртуальными потоками Java
Знакомство с проектом Project Loom и виртуальными потоками Java

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

Давайте поговорим о проекте Project Loom, который исследует новые особенности языка Java, API и среды выполнения для облегченного параллелизма, включая новые конструкции для виртуальных потоков.

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

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

Мало того, но куча Java представляет собой только один непрерывный подмножество кучи процесса, по крайней мере, в реализации HotSpot JVM (другие JVM могут отличаться), поэтому модель памяти потоков на уровне ОС естественным образом переносится в домен языка Java.

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

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

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

На самом деле, в очень ранних версиях Java потоки JVM мультиплексировались на потоки ОС (также известные как потоки платформы), что называлось "зелеными потоками", потому что в тех самых ранних реализациях JVM фактически использовался только один поток платформы.

Однако практика использования одного потока платформы сошла на нет примерно в эпоху Java 1.2 и Java 1.3 (и немного раньше в ОС Solaris от Sun). Современные версии Java, работающие на основных ОС применяют правило, согласно которому один поток Java равен ровно одному потоку ОС.

Это означает, что использование Thread.start() вызывает системный вызов создания потока (например, clone() в Linux) и фактически создает новый поток ОС.

Project Loom в OpenJDK ставит своей главной целью пересмотреть эту древнюю реализацию и создать новые объекты Thread, которые могут выполнять код, но не соответствуют напрямую выделенным потокам ОС.

Или, говоря иначе, Project Loom создает модель выполнения, в которой объект, представляющий контекст выполнения, не обязательно должен быть запланирован ОС. Таким образом, в некотором смысле, проект Loom представляет собой возвращение к чему-то аналогичному зеленым потокам.

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

Например, вспомним EJBs (Jakarta Enterprise Beans, ранее Enterprise JavaBeans) как форму ограниченной среды, которая слишком амбициозно пыталась виртуализировать среду. Можно ли считать EJBs прототипом идей, которые позже найдут применение в современных PaaS-системах и, в меньшей степени, в Docker и Kubernetes?

Итак, если Loom — это (частичное) возвращение к идее "зеленых потоков",то одним из способов подхода к этому может быть следующий вопрос: Что изменилось в среде, что делает интересным возвращение к старой идее, которая ранее не считалась полезной?

Чтобы немного разобраться в этом вопросе, давайте рассмотрим пример - попробуем разрушить JVM, создав слишком много потоков, следующим образом:
Статью в оригинале можно прочитать тут

//
// Please do not actually run this code... it may crash your VM or laptop
//
public class CrashTheVM {
    private static void looper(int count) {
        var tid = Thread.currentThread().getId();
        if (count > 500) {
            return;
        }
        try {
            Thread.sleep(10);
            if (count % 100 == 0) {
                System.out.println("Thread id: "+ tid +" : "+ count);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        looper(count + 1);
    }

    public static Thread makeThread(Runnable r) {
        return new Thread(r);
    }

    public static void main(String[] args) {
        var threads = new ArrayList<Thread>();
        for (int i = 0; i < 20_000; i = i + 1) {
            var t = makeThread(() -> looper(1));
            t.start();
            threads.add(t);
            if (i % 1_000 == 0) {
                System.out.println(i + " thread started");
            }
        }
        // Join all the threads
        threads.forEach(t -> {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
    }
}
Код запускает 20 000 потоков и выполняет минимальный объем обработки в каждом из них, или, по крайней мере, пытается это сделать. На практике приложение, скорее всего, умрет или заблокирует машину задолго до достижения этого устойчивого состояния, хотя есть возможность заставить довести пример до завершения, если машина или операционная система замедлены и не могут создать потоки достаточно быстро, чтобы вызвать истощение ресурсов.

На рисунке 1 показан пример того, что произошло на моем MacBook Pro 2019 года выпуска прямо перед тем, как машина полностью перестала реагировать на запросы. На этом изображении показаны противоречивые статистические данные, например количество потоков, поскольку ОС уже не справляется со своими задачами.
Рисунок 1. Изображение, показывающее слишком большое количество потоков: внимание!
Не пытайтесь повторить это дома.
Хотя этот пример, очевидно, не является полностью репрезентативным для практического производственного Java-приложения, он показывает, что произойдет, например, в среде веб-сервиса с одним потоком на соединение. Вполне разумно ожидать, что современный высокопроизводительный веб-сервер будет обрабатывать десятки тысяч (или более) одновременных соединений, и все же этот пример наглядно демонстрирует несостоятельность архитектуры с одним потоком на соединение для такой задачи.

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

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

Одним из способов достижения этой цели стала поэтапная событийно-управляемая архитектура (SEDA), впервые появившаяся 15 лет назад. SEDA можно представить как систему, в которой объект домена перемещается из пункта A в пункт Z по многоступенчатому конвейеру с различными преобразованиями, происходящими по пути. Это может быть реализовано в распределенной системе с использованием системы обмена сообщениями или в одном процессе с использованием блокирующих очередей и пула потоков для каждого этапа.

На каждом этапе подхода SEDA обработка доменного объекта описывается объектом Java, который содержит код для реализации преобразования этапа. Для корректной работы код должен гарантированно завершаться; в нем не должно быть бесконечных циклов. Однако фреймворк не может обеспечить выполнение этого требования.

Существуют заметные недостатки подхода SEDA, в том числе дисциплина, требуемая от программистов для использования этой архитектуры. Давайте поищем лучшую альтернативу - и это проект Loom.


Представляем Project Loom

Project Loom — это проект OpenJDK, цель которого — обеспечить "простой в использовании, высокопроизводительный облегченный параллелизм и новые модели программирования на платформе Java". Проект стремится достичь этой цели путем добавления новых конструкций:

  • Виртуальные потоки

  • Ограниченные продолжения

  • Устранение хвостовых вызовов

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

Основные преимущества виртуальных потоков заключаются в следующем:

  • Их создание и блокировка обходятся недорого.

  • Можно использовать планировщики выполнения Java (пулы потоков).

  • Нет структур данных уровня ОС для стека.

Устранение участия ОС в жизненном цикле виртуального потока - вот что устраняет узкое место в масштабируемости. Крупномасштабные JVM-приложения могут работать с миллионами и даже миллиардами объектов, так почему же они должны быть ограничены всего несколькими тысячами объектов, управляемых ОС (это один из способов понять, что такое поток)?

Разрушить это ограничение и открыть новые стили параллельного программирования - вот главная цель Project Loom.

Давайте посмотрим на виртуальные потоки в действии. Скачайте бета-сборку Project Loom и запустите jshell, как показано в следующем примере:


$ jshell 
|  Welcome to JShell -- Version 16-loom
|  For an introduction type: /help intro

jshell> Thread.startVirtualThread(() -> {
   ...>     System.out.println("Hello World");
   ...> });
Hello World
$1 ==> VirtualThread[<unnamed>,<no carrier thread>]

jshell>
Вы сразу видите конструкцию виртуального потока в выводе. Код также использует новый статический метод startVirtualThread(), чтобы запустить лямбда-выражение в новом контексте выполнения, который является виртуальным потоком. Это настолько просто!

Виртуальные потоки должны быть включены по умолчанию: существующие кодовые базы должны продолжать работать точно так же, как они работали до появления Project Loom. Ничего не должно ломаться, и все должны исходить из консервативного предположения о том, что весь существующий Java-код действительно нуждается в постоянной "легкой обертке над ОС" архитектуры потоков, которая до сих пор была единственной в этом деле.

Итак, в чем же преимущество? Появление виртуальных потоков открывает новые горизонты и в других отношениях. До сих пор язык Java предлагал два основных способа создания новых потоков:

  • Создайте подкласс java.lang.Thread и вызовите унаследованный метод start().

  • Создайте экземпляр Runnable и передайте его конструктору Thread; затем запустите полученный объект.

Поскольку концепция потоков меняется, имеет смысл пересмотреть и методы, которые вы используете для создания потоков. Вы уже познакомились с новым статическим фабричным методом создания виртуальных потоков fire-and-forget, но существующий API потоков нуждается в улучшении и в некоторых других аспектах.
Строители потоков

Одно из важных новых понятий — это класс Thread.Builder, который был добавлен в качестве внутреннего класса Thread. Увидим его в действии, заменив метод makeThread() в предыдущем примере на следующий код:

public static Thread makeThread(Runnable r) {
        return Thread.builder().virtual().task(r).build();
    }
Этот код вызывает метод virtual() в конструкторе, чтобы явно создать виртуальный поток, который будет выполнять Runnable. Конечно, можно было бы обойтись без вызова virtual(), и тогда был бы создан традиционный, планируемый ОС объект потока. Но какой в этом смысл?

Если вы замените виртуальную версию makeThread() и перекомпилируете пример с версией Java, поддерживающей Loom, вы сможете выполнить полученный двоичный файл.

На этот раз программа выполняется до конца без проблем, а общий профиль нагрузки выглядит как на рисунке 2.
Рисунок 2. Создание большого количества виртуальных потоков вместо традиционных потоков Java
Это лишь один из примеров философии Project Loom в действии, которая заключается в локализации изменений, которые необходимо внести в Java-приложения, только в тех местах кода, которые создают потоки.

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

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

Обратите внимание, что другие части библиотеки потоков должны быть обновлены для лучшей поддержки Project Loom. Например, ThreadBuilder может также создавать экземпляры ThreadFactory, которые можно передавать различным исполнителям, как показано здесь:


jshell> var tb = Thread.builder();
tb ==> java.lang.Thread$BuilderImpl@2e0fa5d3

jshell> var tf = tb.factory();
tf ==> java.lang.Thread$KernelThreadFactory@2e5d6d97

jshell> var vtf = tb.virtual().factory();
vtf ==> java.lang.Thread$VirtualThreadFactory@377dca04
Очевидно, что в какой-то момент виртуальные потоки должны быть присоединены к реальному потоку ОС для выполнения. Эти потоки ОС, на которых выполняется виртуальный поток, называются несущими носителями. За время своего существования один виртуальный поток может выполняться в нескольких различных несущих потоках. Это несколько напоминает то, как обычные потоки выполняются на разных физических ядрах процессора — и то, и другое является примером планирования выполнения.

Вы уже видели несущие потоки в некоторых выводах jshell в одном из предыдущих примеров.

Программирование с использованием виртуальных потоков

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

Разработчики Java привыкли создавать объекты задач, часто основанные на Runnable или Callable, и передавать их исполнителям, опираясь на пулы потоков, которые существуют для экономии драгоценных ресурсов потоков. Что, если бы все это вдруг изменилось?

Project Loom пытается решить проблему масштабирования потоков, вводя новое понятие потока, которое дешевле существующих понятий и не сопоставляется напрямую с потоком ОС. Эта новая возможность по-прежнему выглядит и ведет себя как современные потоки, которые уже знакомы программистам Java.

Это означает, что вместо того, чтобы изучать совершенно новый стиль программирования (например, стиль передачи продолжения, подход с обещаниями/будущим или обратные вызовы), среда выполнения Project Loom сохраняет для виртуальных потоков ту же модель программирования, которую уже известна по существующим на данный момент времени потокам. Другими словами, виртуальные потоки — это просто потоки, по крайней мере, с точки зрения программиста.

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

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

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

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

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

Использование временных срезов для прерывания виртуальных потоков было бы возможно, и JVM уже способна взять на себя контроль над выполнением потоков Java. Например, она делает это в безопасных точках JVM.

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

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

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

Разработчики Loom полагают, что, поскольку виртуальные потоки никогда не нуждаются в объединении, они никогда не должны объединяться в пул. Вместо этого модель заключается в неограниченном создании виртуальных потоков. Для этого был добавлен неограниченный исполнитель. Доступ к нему можно получить с помощью нового фабричного метода Executors.newVirtualThreadExecutor().

По умолчанию планировщик для виртуальных потоков — это планировщик с распределением работы, введенный в ForkJoinPool. (Интересно, что аспект распределения работы в fork/join стал гораздо более важным, чем рекурсивная декомпозиция задач).

Дизайн Project Loom основан на понимании разработчиками вычислительных накладных расходов, которые будут присутствовать на различных потоках в их приложениях.

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

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

Вот пример того, как дизайн Project Loom может привести к неожиданному поведению, когда используется пользовательское планирование виртуальных потоков:

public final class TangledLoom {
    public static void main(String[] args) {
        var scheduler = Executors.newFixedThreadPool(2);
        Runnable r = () -> {
            System.out.println(Thread.currentThread().getName() +" starting ");
            while (true) {
                int total = 0;
                for (int i = 0; i < 10; i++) {
                    total = total + hashing(i, 'X');
                }
                System.out.println(Thread.currentThread().getName() +" : "+ total);
            }
        };
        var tA = Thread.builder().virtual(scheduler).name("A").task(r).build();
        var tB = Thread.builder().virtual(scheduler).name("B").task(r).build();
        var tC = Thread.builder().virtual(scheduler).name("C").task(r).build();
        tA.start();
        tB.start();
        tC.start();
        try {
            tA.join();
            tB.join();
            tC.join();
        } catch (Throwable tx) {
            tx.printStackTrace();
        }
    }

    private static int hashing(int length, char c) {
        final StringBuilder sb = new StringBuilder();
        for (int j = 0; j < length * 1_000_000; j++) {
            sb.append(c);
        }
        final String s = sb.toString();
        return s.hashCode();
    }
}
Когда вы запустите этот код, вы должны увидеть следующее поведение:

$ java TangledLoom
B starting 
A starting 
B : -1830232064
C starting 
C : -1830232064
B : -1830232064
C : -1830232064
B : -1830232064
C : -1830232064
B : -1830232064
C : -1830232064
Это пример голодания потоков; несчастный поток A, кажется, никогда не продвинется вперед.

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


Когда появится Project Loom?

Разработка Loom ведется в отдельном репозитории; она не входит в основную линейку JDK. Это означает, что пока слишком рано говорить о том, когда изменения могут появиться в официальном выпуске Java.

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

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

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