Введение

Концепция «виртуальных потоков» в последнее время привлекла значительное внимание. Многие языки программирования обновляют свои библиотеки потоков для поддержки функции Virtual Threading. Java представляет виртуальный поток в качестве функции предварительного просмотра в выпуске Java 19. В этой статье представлено глубокое введение в поток от базового до более глубокого уровня, чтобы выразить преимущества виртуальных потоков и преимущества, которые они предлагают по сравнению с обычными методами создания потоков.

Краткое введение в тему

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

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

Параллельное и параллельное выполнение

Параллельное выполнение. Компьютер выполняет несколько задач одновременно. Например, предположим, что у вас есть 4-ядерный процессор, и вы выполняете четыре разные задачи на каждом ядре. Каждая задача может выполняться одновременно.

Параллельное выполнение. Компьютер создает иллюзию одновременного выполнения нескольких задач, когда количество задач превышает число ядер ЦП. Например, предположим, что у вас есть 4-ядерный процессор, и вы выполняете 8 различных задач. Поскольку у вас всего 4 ядра, вашей ОС придется выполнять переключение контекста для выполнения этих 8 различных задач. Здесь ОС создает иллюзию одновременного выполнения 8 разных задач. Однако на самом деле одновременно могут выполняться только 4 инструкции, поскольку у нас всего 4 ядра.

Почему темы?

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

Предположим, у вас есть компьютер с процессором из 4 ядер. Вы пишете программу, которая вычисляет сумму двух чисел, и запускаете ее в 12 потоках, как показано ниже.

class SumOfNums {

  static void sum() {
    int a = 1, b = 2;
    int sum = a + b;
    System.out.println(sum);
  }

  public static void main(String[] args) {
    for (int i = 1; i <= 12; i++) {
      Thread t = new Thread(SumOfNums::sum);
      t.start();
    }
  }
}

Сколько потоков может работать параллельно? Все 12 потоков работают параллельно? Поскольку мы создали 12 потоков, значит ли это, что все 12 потоков выполняются параллельно? Ответ - нет. У нас всего 4 ядра процессора. Это означает, что может произойти максимум 4 параллельных выполнения. Каждый поток должен быть выделен ядру ЦП для выполнения, так как у нас всего 4 ядра, и только 4 потока одновременно могут выполняться параллельно. CPU выполняет переключение контекста между этими 12 потоками, чтобы запускать их одновременно.

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

Задачу можно разделить на два типа: привязка к процессору и привязка к вводу-выводу.

Привязка к ЦП: когда выполнение задачи сильно зависит от ЦП, например, арифметические, логические, реляционные операции и т. д., такие задачи называются задачами, привязанными к ЦП.

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

К какому типу мы можем отнести нашу вышеприведенную sum задачу? мы инициируем две переменные a и b, присваиваем им значения, добавляем эти два числа и присваиваем результат новой переменной sum, а затем печатаем ее на консоли. Здесь нет операций ввода/вывода. Следовательно, мы можем классифицировать эту задачу sum как задачу, связанную с процессором.

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

Как количество потоков влияет на эффективность системы?

Рассмотрим нашу задачу sum, которая сильно зависит от процессора. Мы выполняем его в 12 потоках, что позволяет одновременное выполнение. Внутри ЦП переключается между этими потоками, используя свои 4 ядра. Не завершив один поток, ЦП переключается на другой поток для обеспечения параллелизма.

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

Как насчет задачи вычисления уникальных слов?

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

Допустим, мы запускаем это в одном потоке, как показано ниже.

Как показано, ЦП бездействует, когда происходит операция чтения и записи файла. Это приводит к неоптимальному использованию его полной вычислительной мощности, т. Е. Не достижению 100% использования ЦП.

Теперь давайте представим, что мы запускаем это с использованием 4 потоков, так как раньше у нас было 4 ядра процессора. Давайте визуализируем этот сценарий на рисунке ниже.

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

Повысится ли эффективность, если я увеличу количество потоков?

Давайте посмотрим, что произойдет, если мы запустим эту задачу на 8 потоках.

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

Итак, в нашей первой задаче — вычислении суммы — использование более 4 потоков снижает производительность из-за ненужного переключения контекста. Но здесь 8 потоков приводят к повышению эффективности. Из этого мы узнали, что мы должны разумно выбирать количество потоков для задачи, исходя из того, как долго и как часто выполняются операции ввода-вывода. Количество потоков прямо пропорционально количеству операций ввода-вывода, происходящих в системе. Чем больше операций ввода-вывода, тем больше количество потоков повысит эффективность.

Таким образом, в нашей первоначальной задаче вычисления суммы использование более 4 потоков фактически снижало производительность из-за ненужного переключения контекста. Это было связано с тем, что задача была сильно привязана к ЦП. Однако в текущем сценарии использование 8 потоков приводит к повышению эффективности. Это подчеркивает важность выбора количества потоков с учетом частоты и продолжительности операций ввода-вывода. Количество потоков должно быть прямо пропорционально количеству операций ввода-вывода в системе. Более высокие требования ввода-вывода требуют большего количества потоков для оптимизации эффективности.

Как Thread работает внутри?

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

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

1. Поток ядра

Это также известно как OS Thread. Потоки ядра управляются и планируются ядром операционной системы. Каждый поток представлен блоком управления потоком (TCB) в ядре (аналогично PCB для процессов), который содержит информацию о состоянии потока, приоритете и других свойствах. Потоки ядра относительно тяжеловесны и требуют системных вызовов для создания, планирования и синхронизации.

2. Пользовательская тема

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

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

  • Модель M:1: все пользовательские потоки сопоставляются с одним потоком ядра. Отображение обрабатывается планировщиком библиотеки.
  • Модель 1:1:каждый пользовательский поток сопоставляется с одним потоком ядра.
  • Модель M:N:все пользовательские потоки сопоставляются с пулом потоков ядра.

Модель внутренней обработки потоков, используемая в Java

Зеленая нить. В самой ранней версии Java использовалась модель многопоточности Зеленая нить. В этой модели потоки управляются и планируются непосредственно JVM. Модель зеленого потока использует сопоставление потоков M:1. Зеленые потоки значительно быстрее, чем собственные потоки. Одна из проблем, с которой столкнулась Java в этой модели, заключалась в том, что она не могла масштабироваться на несколько процессоров, из-за чего Java не могла использовать несколько ядер. Также сложно реализовать зеленые потоки в библиотеках, потому что для хорошей работы им потребуется очень низкоуровневая поддержка. Позже Java удалила зеленую многопоточность и переключилась на нативную многопоточность. Это сделало потоки Java медленнее, чем зеленые потоки.

Собственная тема: Начиная с версии 1.2. Java прекратила поддержку Green thread и переключилась на модель Native thread. Собственные потоки управляются JVM с помощью базовой ОС. Собственные потоки очень эффективны для запуска, но их запуск и остановка требуют больших затрат. Вот почему в настоящее время мы используем объединение потоков. Эта модель следует отображению потоков 1:1, где каждый поток Java отображается в отдельный поток ядра. Когда создается поток Java, операционная система создает соответствующий собственный поток для выполнения кода потока. С тех пор Java следует модели нативных потоков и продолжает делать это по сей день.

Что не так с текущей моделью потоков в Java?

В предыдущем разделе мы поняли, что Java использует модель Native-thread. Давайте посмотрим, что не так с этой моделью.

  • Библиотека потоков Java была написана в самой ранней версии Java.
  • Это тонкая оболочка над потоком платформы (он же Native Thread).
  • Нативные потоки очень дороги в создании и поддержке.
  • Собственные потоки должны хранить свой стек вызовов в памяти. Для этого от 2 МБ до 20 МБ (это число зависит от JVM и платформы) заранее зарезервировано в памяти. Если у вас 4 ГБ ОЗУ, вы можете создать только около 200 потоков, учитывая, что каждый поток занимает 20 МБ ОЗУ.
  • Поскольку собственный поток является системным ресурсом, для запуска нового собственного потока требуется 1 миллисекунда.
  • Переключение контекста в собственных потоках также дорого обходится, так как требует системного вызова ядра.
  • Эти ограничения ограничивают количество создаваемых потоков и могут привести к снижению производительности и увеличению использования памяти. Следовательно, мы не можем создавать много потоков.
  • Мы не можем масштабировать наше приложение, добавляя больше потоков, из-за переключения контекста и их объема памяти затраты на поддержание этих потоков значительны и влияют на производительность.

Реальный пример

Рассмотрим веб-сервер, развернутый на машине с 16 ГБ ОЗУ. Как и обычный веб-сервер, этот веб-сервер использует стиль «поток на запрос», где каждый запрос пользователя обрабатывается отдельным потоком. Имея 16 ГБ ОЗУ и предполагая, что для каждого потока требуется 20 МБ ОЗУ, система может поддерживать до 800 потоков. В современном мире внутренние API-интерфейсы часто включают такие задачи, как операции с базами данных или передача сообщений другим API-интерфейсам через вызовы REST/SOAP. Следовательно, эти системы в основном связаны с вводом-выводом, а не с процессором.

Предположим, что для одного запроса операции ввода-вывода занимают 100 миллисекунд, обработка запроса занимает 0,1 миллисекунды, а обработка ответа также занимает 0,1 миллисекунды, как показано ниже.

Предполагая, что этот веб-сервер получает 800 запросов в секунду, каждый запрос обрабатывается отдельными потоками. Количество потоков достигло своего максимального значения.

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

Total CPU Time = Request preparation time + Response preparation time
               = 0.1ms + 0.1ms
               = 0.2 ms

Для одного запроса требуется 0,2 миллисекунды процессорного времени. Как насчет 800 запросов?

Total CPU time for 800 requests = 800 * 0.2 milliseconds
                                = 160 milliseconds

Подводя итог нашей мощности, за секунду наш сервер может обработать только 800 запросов, потому что мы можем создать максимум 800 потоков. 1 секунда = 1000 миллисекунд. Давайте посчитаем загрузку процессора за 1 секунду:

CPU Utilization = 160 milliseconds / 1000 milliseconds
                = 16%

За секунду было использовано всего 16% процессора. Это показывает, что мы недостаточно используем ЦП, мы не оптимально используем ЦП на полную мощность.

Сколько потоков требуется, чтобы использовать не менее 90% ЦП?

16% = 800 threads
90% = ? threads
number of threads required = (800 * 90) / 16
                           = 4500 threads

Это показывает, что наш внутренний сервер может обрабатывать 4500 запросов в секунду, используя 90% ЦП. Из-за ограничения мы можем создать только 800 потоков.

Сколько оперативной памяти требуется для создания 4500 потоков? учитывая, что для каждого потока требуется 20 МБ ОЗУ: = 4500 * 20 = 90 ГБ.

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

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

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

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

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

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

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

Как работает виртуальный поток?

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

JVM использует модель сопоставления потоков M:N для сопоставления виртуальных потоков с собственными потоками.

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

  1. Использование нового фабричного метода ofVirtual() в существующем классе Thread.
for (int i = 0; i < 5; i++) {
  Thread vThread = Thread.ofVirtual().start(() -> System.out.println("Hello World"));
}

2. Используя новый метод newVirtualThreadExecutor() от фабрики Executors.

public static void main(String[] args) {
  var executor = Executors.newVirtualThreadExecutor();

  for (int i = 0; i < 5; i++) {
    executor.submit(() -> System.out.println("Hello World"));
  }
   
  executor.awaitTermination();
  System.out.println("All virtual threads are finished");
}

Зеленая ветка и виртуальная ветка

Похоже ли виртуальный поток на старый зеленый поток Java?

Зеленые потоки имели сопоставление N: 1 с потоками ОС. Все зеленые потоки выполнялись в одном потоке ОС. С виртуальными потоками несколько виртуальных потоков могут выполняться в нескольких собственных потоках (сопоставление M:N). Еще немного деталей от JEP 425

Заключение

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