Что такое Project Loom для Java?

Java с самого начала своего развития обладала хорошими возможностями многопоточности и параллелизма и может эффективно использовать многопоточные и многоядерные процессоры. Java Development Kit (JDK) 1.1 имел базовую поддержку потоков платформы (или потоков операционной системы (ОС)), а JDK 1.5 имел больше утилит и обновлений для улучшения параллелизма и многопоточности. В JDK 8 появилась поддержка асинхронного программирования и дополнительные улучшения параллелизма. Несмотря на то, что в течение нескольких версий ситуация продолжала улучшаться, за последние три десятилетия в Java не было ничего революционного, кроме поддержки параллелизма и многопоточности с использованием потоков ОС.

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

Что такое Project Loom?

Цель Project Loom — резко сократить усилия по написанию, поддержке и наблюдению за высокопроизводительными параллельными приложениями, которые наилучшим образом используют доступное оборудование.

— Рон Пресслер (технический руководитель Project Loom)

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

Одним из наиболее распространенных случаев использования параллелизма является обслуживание запросов по проводам с помощью сервера. Для этого предпочтительным подходом является модель thread-per-request, когда отдельный поток обрабатывает каждый запрос. Пропускную способность таких систем можно объяснить с помощью закона Литтла, который гласит, что в стабильной системе средний параллелизм (количество запросов, одновременно обрабатываемых сервером), L, равен пропускной способности (средней скорости обработки запросов), λ, умноженной на задержку (среднюю продолжительность обработки каждого запроса), W. Отсюда можно вывести, что пропускная способность равна среднему параллелизму, деленному на задержку (λ = L/W).

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

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

Project Loom стремится устранить эти проблемы в текущей модели параллелизма путем введения двух новых возможностей: виртуальных потоков и структурированного параллелизма.

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

Выпуск Java 19 запланирован на сентябрь 2022 года, и виртуальные потоки будут входить в предварительную версию. Ура!

Виртуальные потоки — это легковесные потоки, которые не привязаны к потокам ОС, а управляются JVM. Они подходят для программирования в стиле «поток-запрос», не имея ограничений, присущих потокам ОС. Вы можете создавать миллионы виртуальных потоков без ущерба для пропускной способности. Это очень похоже на корутины, такие как goroutines, ставшие известными благодаря языку программирования Go (Golang).

Новые виртуальные потоки в Java 19 будут довольно просты в использовании. Сравните с goroutines языка Golang или coroutines языка Kotlin.

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

Thread.startVirtualThread(() -> {
    System.out.println("Hello, Project Loom!");
});
Вход в полноэкранный режим Выход из полноэкранного режима

Гораутин

go func() {
    println("Hello, Goroutines!")
}()
Вход в полноэкранный режим Выход из полноэкранного режима

Корутина Kotlin

runBlocking {
    launch {
        println("Hello, Kotlin coroutines!")
    }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Интересный факт: до JDK 1.1 в Java была поддержка зеленых потоков (они же виртуальные потоки), но эта возможность была удалена в JDK 1.1, так как эта реализация была не лучше платформенных потоков.

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

  • Это Thread в коде, времени выполнения, отладчике и профилировщике.
  • Это сущность Java, а не обертка вокруг собственного потока.
  • Их создание и блокировка — дешевые операции
  • Их не следует объединять в пулы
  • Виртуальные потоки используют планировщик ForkJoinPool, похищающий работу.
  • Подключаемые планировщики могут быть использованы для асинхронного программирования
  • Виртуальный поток имеет свою собственную стековую память
  • API виртуальных потоков очень похож на API потоков платформы и, следовательно, более прост в освоении/миграции.

Давайте рассмотрим несколько примеров, демонстрирующих возможности виртуальных потоков.

Общее количество потоков

Во-первых, давайте посмотрим, сколько потоков платформы и виртуальных потоков мы можем создать на одной машине. Моя машина — Intel Core i9-11900H с 8 ядрами, 16 потоками и 64 ГБ оперативной памяти под управлением Fedora 36.

Платформенные потоки

var counter = new AtomicInteger();
while (true) {
    new Thread(() -> {
        int count = counter.incrementAndGet();
        System.out.println("Thread count = " + count);
        LockSupport.park();
    }).start();
}
Вход в полноэкранный режим Выход из полноэкранного режима

На моей машине код завершился после 32_539 потоков платформы.

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

var counter = new AtomicInteger();
while (true) {
    Thread.startVirtualThread(() -> {
        int count = counter.incrementAndGet();
        System.out.println("Thread count = " + count);
        LockSupport.park();
    });
}
Вход в полноэкранный режим Выход из полноэкранного режима

На моей машине процесс завис после 14_625_956 виртуальных потоков, но не разбился, а по мере освобождения памяти продолжал медленно работать.

Пропускная способность задачи

Давайте попробуем выполнить 100 000 задач, используя потоки платформы.

try (var executor = Executors.newThreadPerTaskExecutor(Executors.defaultThreadFactory())) {
    IntStream.range(0, 100_000).forEach(i -> executor.submit(() -> {
        Thread.sleep(Duration.ofSeconds(1));
        System.out.println(i);
        return i;
    }));
}
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь используется newThreadPerTaskExecutor с фабрикой потоков по умолчанию и, таким образом, используется группа потоков. Когда я запустил этот код и засек время, я получил цифры, показанные здесь. Я получаю лучшую производительность, когда использую пул потоков с помощью Executors.newCachedThreadPool().

# 'newThreadPerTaskExecutor' with 'defaultThreadFactory'
0:18.77 real,   18.15 s user,   7.19 s sys,     135% 3891pu,    0 amem,         743584 mmem
# 'newCachedThreadPool' with 'defaultThreadFactory'
0:11.52 real,   13.21 s user,   4.91 s sys,     157% 6019pu,    0 amem,         2215972 mmem
Вход в полноэкранный режим Выход из полноэкранного режима

Не так уж плохо. Теперь давайте сделаем то же самое, используя виртуальные потоки.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 100_000).forEach(i -> executor.submit(() -> {
        Thread.sleep(Duration.ofSeconds(1));
        System.out.println(i);
        return i;
    }));
}
Вход в полноэкранный режим Выход из полноэкранного режима

Если я запущу и засеку время, то получу следующие цифры.

0:02.62 real,   6.83 s user,    1.46 s sys,     316% 14840pu,   0 amem,         350268 mmem
Вход в полноэкранный режим Выход из полноэкранного режима

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

Запуск Java Microbenchmark Harness (JMH) с тем же кодом дает следующие результаты, и вы можете видеть, что виртуальные потоки превосходят платформенные потоки с огромным отрывом.

# Throughput
Benchmark                             Mode  Cnt  Score   Error  Units
LoomBenchmark.platformThreadPerTask  thrpt    5  0.362 ± 0.079  ops/s
LoomBenchmark.platformThreadPool     thrpt    5  0.528 ± 0.067  ops/s
LoomBenchmark.virtualThreadPerTask   thrpt    5  1.843 ± 0.093  ops/s

# Average time
Benchmark                             Mode  Cnt  Score   Error  Units
LoomBenchmark.platformThreadPerTask   avgt    5  5.600 ± 0.768   s/op
LoomBenchmark.platformThreadPool      avgt    5  3.887 ± 0.717   s/op
LoomBenchmark.virtualThreadPerTask    avgt    5  1.098 ± 0.020   s/op
Вход в полноэкранный режим Выход из полноэкранного режима

Исходный код бенчмарка можно найти на GitHub. Вот некоторые другие значимые бенчмарки для виртуальных потоков:

  • Интересный бенчмарк с использованием ApacheBench на GitHub от Elliot Barlas
  • Бенчмарк с использованием Akka actors на Medium от Александра Закусило
  • Бенчмарки JMH для задач ввода-вывода и не ввода-вывода на GitHub от Colin Cachia

Структурированный параллелизм

Структурированный параллелизм станет инкубаторной функцией в Java 19.

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

Рассмотрим следующий пример с использованием java.util.concurrent.ExecutorService.

void handleOrder() throws ExecutionException, InterruptedException {
    try (var esvc = new ScheduledThreadPoolExecutor(8)) {
        Future<Integer> inventory = esvc.submit(() -> updateInventory());
        Future<Integer> order = esvc.submit(() -> updateOrder());

        int theInventory = inventory.get();   // Join updateInventory
        int theOrder = order.get();           // Join updateOrder

        System.out.println("Inventory " + theInventory + " updated for order " + theOrder);
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

  • Представьте, что updateInventory() не работает и выбрасывает исключение. Затем метод handleOrder() выбрасывает исключение при вызове inventory.get(). Пока все в порядке, но что насчет updateOrder()? Поскольку она выполняется в собственном потоке, она может завершиться успешно. Но теперь у нас возникает проблема с несоответствием инвентаризации и заказа. Предположим, что updateOrder() является дорогой операцией. В таком случае мы просто зря тратим ресурсы, и нам придется написать какую-то защитную логику для возврата обновлений, сделанных для заказа, поскольку наша общая операция провалилась.
  • Представьте, что updateInventory() — это дорогая длительная операция, а updateOrder() выдает ошибку. Задача handleOrder() будет заблокирована на inventory.get(), даже если updateOrder() выдаст ошибку. В идеале, мы бы хотели, чтобы задача handleOrder() отменяла updateInventory(), когда происходит сбой в updateOrder(), чтобы мы не теряли время.
  • Если поток, выполняющий handleOrder(), прерывается, прерывание не распространяется на подзадачи. В этом случае updateInventory() и updateOrder() просочатся и продолжат выполняться в фоновом режиме.

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

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

void handleOrder() throws ExecutionException, InterruptedException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Future<Integer> inventory = scope.fork(() -> updateInventory());
        Future<Integer> order = scope.fork(() -> updateOrder());

        scope.join();           // Join both forks
        scope.throwIfFailed();  // ... and propagate errors

        // Here, both forks have succeeded, so compose their results
        System.out.println("Inventory " + inventory.resultNow() + " updated for order " + order.resultNow());
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

В отличие от предыдущего примера с использованием ExecutorService, теперь мы можем использовать StructuredTaskScope для достижения того же результата, ограничивая время жизни подзадач лексической областью, в данном случае телом оператора try-with-resources. Код стал гораздо более читабельным, и замысел также ясен. StructuredTaskScope также автоматически обеспечивает следующее поведение.

  • Обработка ошибок с замыканием — Если updateInventory() или updateOrder() терпят неудачу, вторая задача отменяется, если только она уже не завершена. Это управляется политикой отмены, реализуемой ShutdownOnFailure(); возможны и другие политики.

  • Распространение отмены — Если поток, выполняющий handleOrder(), прерывается до или во время вызова join(), оба форка отменяются автоматически, когда поток выходит из области видимости.

  • Наблюдаемость — дамп потока четко отображает иерархию задач, при этом потоки, выполняющие updateInventory() и updateOrder(), показаны как дочерние по отношению к области видимости.

Состояние проекта Loom

Проект Loom стартовал в 2017 году и претерпел множество изменений и предложений. Виртуальные потоки изначально назывались волокнами, но позже они были переименованы, чтобы избежать путаницы. Сегодня, когда Java 19 приближается к релизу, проект предоставил две функции, о которых говорилось выше. Одна — в виде предварительного просмотра, а другая — в виде инкубатора. Следовательно, путь к стабилизации этих функций должен быть более точным.

Что это значит для обычных разработчиков Java?

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

Что это значит для разработчиков библиотек Java?

Когда эти функции будут готовы к производству, это станет большим событием для библиотек и фреймворков, использующих потоки или параллелизм. Авторы библиотек получат огромный прирост производительности и масштабируемости, одновременно упрощая кодовую базу и делая ее более удобной для обслуживания. Большинство проектов Java, использующих пулы потоков и потоки платформы, выиграют от перехода на виртуальные потоки. Среди кандидатов — серверное программное обеспечение Java, такое как Tomcat, Undertow и Netty, и веб-фреймворки, такие как Spring и Micronaut. Я ожидаю, что большинство веб-технологий Java перейдут на виртуальные потоки из пулов потоков. Веб-технологии Java и модные библиотеки реактивного программирования, такие как RxJava и Akka, также могут эффективно использовать структурированный параллелизм. Это не означает, что виртуальные потоки будут единственным решением для всех; по-прежнему будут существовать случаи использования и преимущества асинхронного и реактивного программирования.


Если вам понравилась эта статья, пожалуйста, оставьте лайк или комментарий.

Вы можете следить за мной в Twitter и LinkedIn.

Изображение на обложке было создано с использованием фотографии Питера Херрманна на Unsplash.

Эта статья была первоначально опубликована в блоге разработчиков Okta.

Оцените статью
devanswers.ru
Добавить комментарий