Рубрики
Без рубрики

Открытый JDK Ткацкий станок и структурированный параллелизм

Project Loom – один из проектов, спонсируемых группой Hotspot, инициированный для достижения высоких результатов… С тегами openjdk, java, loom.

Project Loom – один из проектов, спонсируемых группой Hotspot, инициированный для обеспечения высокой пропускной способности и облегченной модели параллелизма в мире JAVA. На момент написания этого поста project Loom все еще находится в активной разработке, и его API может измениться.

Зачем Смотреть?

Первый вопрос, который может и должен возникать при каждом новом проекте, – это почему? Почему нам нужно учиться чему-то новому и в чем это нам помогает? (если это действительно так)

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

Каждый поток, порожденный внутри JVM, заканчивается один к одному соответствующим потоком в пространстве ядра ОС со своим собственным стеком, регистрами, счетчиком программ и состоянием. Вероятно, самой большой частью каждого потока будет его стек, размер стека измеряется в мегабайтах и обычно составляет от 1 МБ до 2 МБ. Таким образом, эти типы потоков являются дорогостоящими как с точки зрения инициализации, так и с точки зрения времени выполнения. Невозможно создать 10 тысяч потоков на одной машине и ожидать, что это просто сработает.

Кто-то может спросить, зачем нам вообще нужно столько потоков? Учитывая, что процессоры имеют всего несколько гиперпотоков. например, внутреннее ядро процессора i9 имеет в общей сложности 16 потоков. Ну, процессор – это не единственный ресурс, который использует ваше приложение, любое программное обеспечение без ввода-вывода просто способствует глобальному потеплению! Как только потоку требуется ввод-вывод, ОС пытается выделить ему необходимый ресурс и тем временем планирует другой поток, которому требуется процессор. Таким образом, чем больше потоков у нас в приложении, тем больше мы можем использовать эти ресурсы параллельно.

Одним из очень типичных примеров является веб-сервер. каждый сервер способен обрабатывать тысячи открытых подключений в каждый момент времени, но для обработки такого количества подключений одновременно требуются либо тысячи потоков, либо асинхронный неблокирующий код ( Я **, вероятно, напишу еще один пост в ближайшие недели, чтобы объяснить больше об асинхронном коде**) и, как упоминалось ранее, тысячи потоков ОС – это не то, чему вы и ОС были бы рады!

Как Помогает Ткацкий Станок?

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

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

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

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

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

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

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

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

void notifyUser(User user) {
  try (var scope = new ConcurrencyScope()) {
   scope.submit( () -> notifyByEmail(user));
   scope.submit( () -> notifyBySMS(user));
  }
  LOGGER.info("User has been notified successfully");
}

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

Примечание: Чтобы упростить пример, мы предполагаем, что notifyByEmail и уведомление по SMS В приведенном выше примере обрабатывают все возможные угловые случаи внутри компании и всегда справляются.

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

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

Что мы собираемся решить

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

Мы используем класс Задание определено ниже, чтобы имитировать наши задания.

public class Job {
  public void doIt() {
    try {
      Thread.sleep(100l);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
}

Первая попытка

В первой попытке давайте напишем его с помощью Кэшированного пула потоков и Потоки ОС.

public class ThreadBasedJobRunner implements JobRunner {
@Override
public long run(List jobs) {
  var start = System.nanoTime();
  var executor = Executors.newCachedThreadPool();
  for (Job job : jobs) {
    executor.submit(job::doIt);
  }

  executor.shutdown();

  try {
    executor.awaitTermination(1, TimeUnit.DAYS);
  } catch (InterruptedException e) {
    e.printStackTrace();
    Thread.currentThread().interrupt();
  }
   var end = System.nanoTime();
   long timeSpentInMS = Util.nanoToMS(end - start);
   return timeSpentInMS;
  }

}

В этой попытке мы не применили ничего из project Loom. Просто кэшированный пул потоков, чтобы гарантировать, что будут использоваться простаивающие потоки, а не создавать новый поток.

Давайте посмотрим, сколько времени потребуется для выполнения 10 000 заданий с помощью этой реализации. Я использовал приведенный ниже код, чтобы найти 10 самых быстрых запусков кода. Чтобы все было просто, не использовался инструмент микро-бенчмаркинга.

public class ThreadSleep {
  public static void main(String[] args) throws InterruptedException {
    List timeSpents = new ArrayList<>(100);
    var jobs = IntStream.range(0, 10000).mapToObj(n -> new Job()).collect(toList());
    for (int c = 0; c <= 100; c++) {
      var jobRunner = new ThreadBasedJobRunner();
      var timeSpent = jobRunner.run(jobs);
      timeSpents.add(timeSpent);

    }
    Collections.sort(timeSpents);
    System.out.println("Top 10 executions took:");
    timeSpents.stream().limit(10)
        .forEach(timeSpent -> System.out.println("%s ms".formatted(timeSpent)));   
  }
}

Результат на моей машине:

Top 10 executions took:  
694 ms  
695 ms  
696 ms  
696 ms  
696 ms  
697 ms  
699 ms  
700 ms  
700 ms  
700 ms

Пока у нас есть код, который в лучшем случае занимает около 700 мс для выполнения 10 000 заданий на моей машине. Давайте на этот раз реализуем JobRunner с помощью функций ткацкого станка.

Вторая попытка (с волокнами)

В реализации с Волокнами или Виртуальными потоками я также буду кодировать параллелизм структурированным способом.

public class FiberBasedJobRunner implements JobRunner {
  @Override
  public long run(List jobs) {
    var start = System.nanoTime();
    var factory = Thread.builder().virtual().factory();
    try (var executor = Executors.newUnboundedExecutor(factory)) {
      for (Job job : jobs) {
        executor.submit(job::doIt);
      }
    }

   var end = System.nanoTime();
   long timeSpentInMS = Util.nanoToMS(end - start);
   return timeSpentInMS;
  }
}

Возможно, первой примечательной особенностью этой реализации является ее лаконичность, если вы сравните ее с ThreadBasedJobRunner, вы заметите, что в этом коде меньше строк! Основная причина – новое изменение в интерфейсе ExecutorService, которое теперь расширяет Автоклавируемый и в результате мы можем использовать его в области “попробуй с ресурсами”. Коды после блока try будут выполнены, как только будут выполнены все отправленные задания.

Это именно та основная конструкция, которую мы используем для написания структурированных параллельных кодов на JAVA.

Другая новая вещь в приведенном выше коде – это новый способ, которым мы можем создавать фабрики потоков. Класс Thread имеет новый статический метод с именем builder , который можно использовать для создания Нить или Нитевидный завод . Что делает эта строка кода, так это создает фабрику потоков, которая создала Виртуальные потоки.

var factory = Thread.builder().virtual().factory();

Теперь давайте посмотрим, сколько времени потребуется для выполнения 10 000 заданий с помощью этой реализации.

Top 10 executions took:  
121 ms  
122 ms  
122 ms  
123 ms  
124 ms  
124 ms  
124 ms  
125 ms  
125 ms  
125 ms

Учитывая, что проект Loom все еще находится в стадии активной разработки, и все еще есть возможности для повышения скорости, но результат действительно отличный. Многие области применения, будь то полностью или частично, могут извлечь выгоду из волокон с минимальными усилиями! Единственное, что нужно изменить, это ThreadFactory пулов потоков, и все!

В частности, в этом примере скорость выполнения приложения улучшилась ~в 6 раз, однако скорость – это не единственное, чего мы здесь достигли!

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

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

Пожалуйста, не стесняйтесь делиться со мной своими отзывами в комментариях!

Оригинал: “https://dev.to/psychoir/openjdk-loom-and-structured-concurrency-2e0e”