- Регистрация
- 23 Август 2023
- Сообщения
- 3 641
- Лучшие ответы
- 0
- Реакции
- 0
- Баллы
- 243
Offline
Всем привет!
Сегодня хочу затронуть тему виртуальных потоков и выяснить на сколько они лучше (быстрее) и в каких случаях, чем обычные потоки операционной системы (или как еще их называют платформенные потоки).
Долгое время java полагалась только на потоки операционной системы для обработки параллельных операций. Это выглядело следующим образом, когда веб-сервер получал запрос, он обычно выделял один поток операционной системы для его обработки. Эта модель называется «поток на запрос». Она очень проста — мы просто пишем блокирующий код так, как если бы он был синхронный, а операционная система обрабатывает переключение контекста между потоками в рамках какого-то процесса.
Несмотря на свою простоту, эта модель имеет существенный недостаток: потоки операционной системы обходятся дорого. Каждый поток потребляет от 1 до 2 МБ памяти только на свой стек, а переключение между ними включает в себя переключение контекста на уровне операционной системы, что также является ресурсоемкой операцией.
Представьте, что есть задача по обработке 100 000 одновременных запросов с помощью этой модели. Это приведёт к потреблению десятков гигабайт памяти только под стеки потоков и, скорее всего, к аварийному завершению JVM задолго до достижения такого количества потоков, также будет тратиться время на переключение контекста между потоками. Пропускная способность системы резко упадет из-за накладных расходов.
Для решения этой проблемы разработчики прибегают к сложным асинхронным моделям программирования, к реактивному программированию, к управлению событиями. Хотя эти подходы и являются мощным инструментом, но они вносят значительную сложность, затрудняя чтение, отладку и сопровождение кода. Мы меняем простоту блокирующего кода на сложности, связанные с неблокирующими коллбэками и реактивными потоками.
И тут мы плавно переходим к виртуальным потокам Java (Java Virtual Threads). Виртуальные потоки впервые были представлены в качестве предварительной функции в java 19 (Loom), а в java 21 стали настоящим прорывом. Виртуальные потоки предлагают новую парадигму, сочетая простоту программирования модели «поток на запрос» с масштабируемостью асинхронных решений. Они позволяют писать блокирующий код, который не блокирует потоки операционной системы, который при этом может обрабатывать огромное количество параллельных операций без накладных расходов платформенные потоков.
Что же такое виртуальные потоки? Виртуальные потоки — это легковесные потоки полностью управляемые виртуальной машиной, а не операционной системой. JVM распределяет большое количество виртуальных потоков по небольшому пулу потоков базовой платформы или потоков-носителей. Когда виртуальный поток выполняет блокирующую операцию (например, ожидает ответа от базы данных), JVM отсоединяет его от текущего потока носителя. Важно отметить, что если виртуальный поток выполняет код внутри блока synchronized, он присоединяется к потоку-носителю. В этот момент JVM не может его отсоединить, и преимущество виртуального потока теряется — он блокирует реальный поток операционной системы. Если поток-носитель все таки освобождается, то он забирает другой виртуальный поток. После завершения блокирующей операции отсоединенный виртуальный поток ставится в очередь на повторное подсоединение на доступный поток-носитель и возобновляется его выполнение.
Благодаря продуманной системе мультиплексирования можно одновременно запускать сотни тысяч виртуальных потоков, каждый из которых использует несколько потоков-носителей платформы. Операционная система видит только потоки-носители, совершенно не зная о существовании виртуальных потоков, управляемых JVM.
В принципе с теорией о виртуальных потоках — это все. Давайте сейчас рассмотрим на примере создание и работу данных потоков.
Вначале на простом примере посмотрим как их создавать и на сколько данные потоки быстрее работают от обычных.
public class VirtualThreadExecutorExample {
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
int numberOfTasks = 100_000;
workingOperationSystemThreads(numberOfTasks);
workingVirtualThreads(numberOfTasks);
long endTime = System.currentTimeMillis();
System.out.println("All " + numberOfTasks + " tasks finished. Total time: " + (endTime - startTime) + "ms");
}
private static void workingOperationSystemThreads(int numberOfTasks) {
try (ExecutorService executor = Executors.newFixedThreadPool(1000)) {
IntStream.range(0, numberOfTasks).forEach(i -> {
executor.submit(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Task " + i + " finished.");
});
});
}
}
private static void workingVirtualThreads(int numberOfTasks) {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, numberOfTasks).forEach(i -> {
executor.submit(() -> {
try {
Thread.sleep(Duration.ofMillis(100));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Task " + i + " finished.");
});
});
}
}
}
Напишем два метода, которые по сути будут отличаться только созданием потоков. Создать пул витруальных потоков можно так
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()
Для обычных будем использовать FixedThreadPool с количеством потоков 1000
ExecutorService executor = Executors.newFixedThreadPool(1000)
Метод очень простой: мы просто имитируем какую-то работу - спим 100 мс и выводим на экран результат.
Сейчас по очереди запустим каждый метод и посмотрим сколько времени будет работать каждый из них.
11371 мс отработал метод в обычными потоками workingOperationSystemThreads().
3051 мс отработал метод с виртуальными потоками workingVirtualThreads().
То есть виртуальные потоки справились в 3,7 раза быстрее обычных в данном примере.
Теперь разберемся как работать с виртуальными потоками в приложении Spring Boot.
Код приложения будет доступен по ссылке.
Данное приложение будет очень простое. По сути тут только один контроллер,
@Slf4j
@RestController
public class WorkController {
@GetMapping("/work")
public String doWork(@RequestParam(defaultValue = "100") int duration) throws InterruptedException {
String threadInfo = "Processing on Thread: " + Thread.currentThread().getName() + " (Virtual: " + Thread.currentThread().isVirtual() + ")";
log.info(threadInfo + " - Starting work for " + duration + "ms.");
Thread.sleep(Duration.ofMillis(duration));
log.info(threadInfo + " - Finished work.");
return "Work done in " + duration + "ms on " + Thread.currentThread().getName() + " (Virtual: " + Thread.currentThread().isVirtual() + ")";
}
}
в котором мы будем снова имитировать какую-то работу сервиса — будем спать 100 мс и выводить в консоль записи.
А также файл application.yml
server:
port: 8083
tomcat:
threads:
max: 200
spring:
threads:
virtual:
enabled: true
Где мы запускаемся на порту 8083, задаем максимальное число потоков томкату 200 и вот самое главное — просто одной настройкой выбираем будем ли мы использовать виртуальные потоки spring.threads.virtual.enabled: true
Теперь запустим наше приложение и дернем наш эндпойнт с помощью постмана.
Мы видим, что в ответе пришло, что действительно мы используем виртуальные потоки.
Сейчас поменяем настройку в application.yml,
перезапустим приложение и еще раз дернем наш эндпойнт.
Мы увидим, что (Virtual: false), то есть мы не используем уже виртуальных потоков. Так одной настройкой можно подключать витруальные потоки в спринге.
Сейчас попробуем протестировать как виртуальные и обычные потоки будут работать под нагрузкой.
Для этого будем использовать инструмент JMeter от Apache.
Установка этого инструмента довольно проста. Нужно зайти на страницу данного инструмента, загрузить архив с ним для вашей ОС. Потом его распаковать в папку, зайти с помощью какого-то терминала (я использую PowerShell) в распакованную папку, зайти там в папку bin и выполнить команду .\jmeter.bat (для винды). Должна запуститься консоль, которая выглядит следующим образом.
Далее счелкаем правой кнопкой мыши на Test Plan идем Add → Threads (Users) → Thread Group и выставляем
Number of Threads (users): 5000
Ramp-Up Period (seconds): 10
Loop Count: 20
Это выйдет: 5000 пользователей * 20 запросов = 100 000 запросов.
Далее счелкаем снова правой кнопкой мыши по Thread Group и идем Add → Sampler → HTTP Request. Тут настраиваем наш эндпойнт, который будем подвергать тестированию.
Осталось добавить только отчет. Снова правой кнопкой мыши по Thread Group и идем Add → Listener → Summary Report
В принципе все - можно начать тестирование.
Кейс первый 100 000 запросов с 200 потоками в томкате и обычными потоками.
Далее меняем настройку на виртуальные потоки и снова запускаем тестирование.
Кейс второй 100 000 запросов с 200 потоками в томкате и виртуальными потоками.
Результаты для лучшей читабельности сведем в таблицу.
Как видно из таблицы виртуальные потоки показали результаты лучше.
Так среднее время ответа быстрее почти в 9 раз 247мс против 2203мс.
Максимальное время ответа, которое ждал пользователь при обычных потоках достигла 3,3 сек (при том что в методе была задержка только на 0,1сек) против 0,5 сек при виртуальных потоках, то есть в 6,3 раза быстрее.
Виртуальные потоки обрабатывали 6961 запроса в сек, тогда как обычные всего 1800. Производительность выросла почти в 4 раза.
Для обработки 100 000 запросов системе на виртуальных потоках понадобилось всего 14 секунд, против 55 секунд на обычных. Это прямое следствие того, что процессор не простаивает в ожидании переключения контекста между тяжелыми потоками операционной системы.
Какой вывод можно сделать: виртуальные потоки не ускоряют выполнение бизнес-кода,
но радикально повышают масштабируемость систем, работающих с большим количеством блокирующих операций.
Также хотелось бы отметить несколько важных моментов.
Виртуальные потоки не будут давать преимущества, если они выполняют синхронизированные блоки или вызывают собственные методы. В данном случае виртуальные потоки могут быть привязаны к своему потоку-носителю и не освобождают его, блокируя другим виртуальным потокам возможность использовать этот поток-носитель.
Также может возникнуть проблема с переменными ThreadLocal, которые копируются для каждого потока, что может привести к увеличению потребления памяти при интенсивном использовании.
Виртуальные потоки превосходно справляются с операциями, использующими ввод-вывод или блокирующими операциями. Для задач, которые используют ресурсы процессора (например, ресурсоемких вычислений), оптимальное количество потоков обычно близко к количеству ядер. Использование большого количества виртуальных потоков для таких задач - не ускорит их выполнение, а может наоборот привести к ненужным накладным расходам на переключение контекста.
Хотя виртуальные потоки являются легковесными необходимо с помощью специальных инструментов наблюдать за активностью виртуальных потоков, использованием потоков-носителей и выявлять потенциальные проблемы с привязкой потоков.
Виртуальные потоки — это значительное изменение парадигмы создания высокопроизводительных и масштабируемых приложений. Они возвращают простоту модели «поток на запрос», обеспечивая при этом беспрецедентную параллельность, позволяя приложениям без труда обрабатывать сотни тысяч запросов. Понимание их преимуществ и недостатков позволяет создавать более эффективные, поддерживаемые и надежные сервисы.
Всем спасибо, кто дочитал статью до конца. Всем пока!
Сегодня хочу затронуть тему виртуальных потоков и выяснить на сколько они лучше (быстрее) и в каких случаях, чем обычные потоки операционной системы (или как еще их называют платформенные потоки).
Долгое время java полагалась только на потоки операционной системы для обработки параллельных операций. Это выглядело следующим образом, когда веб-сервер получал запрос, он обычно выделял один поток операционной системы для его обработки. Эта модель называется «поток на запрос». Она очень проста — мы просто пишем блокирующий код так, как если бы он был синхронный, а операционная система обрабатывает переключение контекста между потоками в рамках какого-то процесса.
Несмотря на свою простоту, эта модель имеет существенный недостаток: потоки операционной системы обходятся дорого. Каждый поток потребляет от 1 до 2 МБ памяти только на свой стек, а переключение между ними включает в себя переключение контекста на уровне операционной системы, что также является ресурсоемкой операцией.
Представьте, что есть задача по обработке 100 000 одновременных запросов с помощью этой модели. Это приведёт к потреблению десятков гигабайт памяти только под стеки потоков и, скорее всего, к аварийному завершению JVM задолго до достижения такого количества потоков, также будет тратиться время на переключение контекста между потоками. Пропускная способность системы резко упадет из-за накладных расходов.
Для решения этой проблемы разработчики прибегают к сложным асинхронным моделям программирования, к реактивному программированию, к управлению событиями. Хотя эти подходы и являются мощным инструментом, но они вносят значительную сложность, затрудняя чтение, отладку и сопровождение кода. Мы меняем простоту блокирующего кода на сложности, связанные с неблокирующими коллбэками и реактивными потоками.
И тут мы плавно переходим к виртуальным потокам Java (Java Virtual Threads). Виртуальные потоки впервые были представлены в качестве предварительной функции в java 19 (Loom), а в java 21 стали настоящим прорывом. Виртуальные потоки предлагают новую парадигму, сочетая простоту программирования модели «поток на запрос» с масштабируемостью асинхронных решений. Они позволяют писать блокирующий код, который не блокирует потоки операционной системы, который при этом может обрабатывать огромное количество параллельных операций без накладных расходов платформенные потоков.
Что же такое виртуальные потоки? Виртуальные потоки — это легковесные потоки полностью управляемые виртуальной машиной, а не операционной системой. JVM распределяет большое количество виртуальных потоков по небольшому пулу потоков базовой платформы или потоков-носителей. Когда виртуальный поток выполняет блокирующую операцию (например, ожидает ответа от базы данных), JVM отсоединяет его от текущего потока носителя. Важно отметить, что если виртуальный поток выполняет код внутри блока synchronized, он присоединяется к потоку-носителю. В этот момент JVM не может его отсоединить, и преимущество виртуального потока теряется — он блокирует реальный поток операционной системы. Если поток-носитель все таки освобождается, то он забирает другой виртуальный поток. После завершения блокирующей операции отсоединенный виртуальный поток ставится в очередь на повторное подсоединение на доступный поток-носитель и возобновляется его выполнение.
Благодаря продуманной системе мультиплексирования можно одновременно запускать сотни тысяч виртуальных потоков, каждый из которых использует несколько потоков-носителей платформы. Операционная система видит только потоки-носители, совершенно не зная о существовании виртуальных потоков, управляемых JVM.
В принципе с теорией о виртуальных потоках — это все. Давайте сейчас рассмотрим на примере создание и работу данных потоков.
Вначале на простом примере посмотрим как их создавать и на сколько данные потоки быстрее работают от обычных.
public class VirtualThreadExecutorExample {
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
int numberOfTasks = 100_000;
workingOperationSystemThreads(numberOfTasks);
workingVirtualThreads(numberOfTasks);
long endTime = System.currentTimeMillis();
System.out.println("All " + numberOfTasks + " tasks finished. Total time: " + (endTime - startTime) + "ms");
}
private static void workingOperationSystemThreads(int numberOfTasks) {
try (ExecutorService executor = Executors.newFixedThreadPool(1000)) {
IntStream.range(0, numberOfTasks).forEach(i -> {
executor.submit(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Task " + i + " finished.");
});
});
}
}
private static void workingVirtualThreads(int numberOfTasks) {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, numberOfTasks).forEach(i -> {
executor.submit(() -> {
try {
Thread.sleep(Duration.ofMillis(100));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Task " + i + " finished.");
});
});
}
}
}
Напишем два метода, которые по сути будут отличаться только созданием потоков. Создать пул витруальных потоков можно так
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()
Для обычных будем использовать FixedThreadPool с количеством потоков 1000
ExecutorService executor = Executors.newFixedThreadPool(1000)
Метод очень простой: мы просто имитируем какую-то работу - спим 100 мс и выводим на экран результат.
Сейчас по очереди запустим каждый метод и посмотрим сколько времени будет работать каждый из них.
11371 мс отработал метод в обычными потоками workingOperationSystemThreads().
3051 мс отработал метод с виртуальными потоками workingVirtualThreads().
То есть виртуальные потоки справились в 3,7 раза быстрее обычных в данном примере.
Теперь разберемся как работать с виртуальными потоками в приложении Spring Boot.
Код приложения будет доступен по ссылке.
Данное приложение будет очень простое. По сути тут только один контроллер,
@Slf4j
@RestController
public class WorkController {
@GetMapping("/work")
public String doWork(@RequestParam(defaultValue = "100") int duration) throws InterruptedException {
String threadInfo = "Processing on Thread: " + Thread.currentThread().getName() + " (Virtual: " + Thread.currentThread().isVirtual() + ")";
log.info(threadInfo + " - Starting work for " + duration + "ms.");
Thread.sleep(Duration.ofMillis(duration));
log.info(threadInfo + " - Finished work.");
return "Work done in " + duration + "ms on " + Thread.currentThread().getName() + " (Virtual: " + Thread.currentThread().isVirtual() + ")";
}
}
в котором мы будем снова имитировать какую-то работу сервиса — будем спать 100 мс и выводить в консоль записи.
А также файл application.yml
server:
port: 8083
tomcat:
threads:
max: 200
spring:
threads:
virtual:
enabled: true
Где мы запускаемся на порту 8083, задаем максимальное число потоков томкату 200 и вот самое главное — просто одной настройкой выбираем будем ли мы использовать виртуальные потоки spring.threads.virtual.enabled: true
Теперь запустим наше приложение и дернем наш эндпойнт с помощью постмана.
Мы видим, что в ответе пришло, что действительно мы используем виртуальные потоки.
Сейчас поменяем настройку в application.yml,
перезапустим приложение и еще раз дернем наш эндпойнт.
Мы увидим, что (Virtual: false), то есть мы не используем уже виртуальных потоков. Так одной настройкой можно подключать витруальные потоки в спринге.
Сейчас попробуем протестировать как виртуальные и обычные потоки будут работать под нагрузкой.
Для этого будем использовать инструмент JMeter от Apache.
Установка этого инструмента довольно проста. Нужно зайти на страницу данного инструмента, загрузить архив с ним для вашей ОС. Потом его распаковать в папку, зайти с помощью какого-то терминала (я использую PowerShell) в распакованную папку, зайти там в папку bin и выполнить команду .\jmeter.bat (для винды). Должна запуститься консоль, которая выглядит следующим образом.
Далее счелкаем правой кнопкой мыши на Test Plan идем Add → Threads (Users) → Thread Group и выставляем
Number of Threads (users): 5000
Ramp-Up Period (seconds): 10
Loop Count: 20
Это выйдет: 5000 пользователей * 20 запросов = 100 000 запросов.
Далее счелкаем снова правой кнопкой мыши по Thread Group и идем Add → Sampler → HTTP Request. Тут настраиваем наш эндпойнт, который будем подвергать тестированию.
Осталось добавить только отчет. Снова правой кнопкой мыши по Thread Group и идем Add → Listener → Summary Report
В принципе все - можно начать тестирование.
Кейс первый 100 000 запросов с 200 потоками в томкате и обычными потоками.
Далее меняем настройку на виртуальные потоки и снова запускаем тестирование.
Кейс второй 100 000 запросов с 200 потоками в томкате и виртуальными потоками.
Результаты для лучшей читабельности сведем в таблицу.
Как видно из таблицы виртуальные потоки показали результаты лучше.
Так среднее время ответа быстрее почти в 9 раз 247мс против 2203мс.
Максимальное время ответа, которое ждал пользователь при обычных потоках достигла 3,3 сек (при том что в методе была задержка только на 0,1сек) против 0,5 сек при виртуальных потоках, то есть в 6,3 раза быстрее.
Виртуальные потоки обрабатывали 6961 запроса в сек, тогда как обычные всего 1800. Производительность выросла почти в 4 раза.
Для обработки 100 000 запросов системе на виртуальных потоках понадобилось всего 14 секунд, против 55 секунд на обычных. Это прямое следствие того, что процессор не простаивает в ожидании переключения контекста между тяжелыми потоками операционной системы.
Какой вывод можно сделать: виртуальные потоки не ускоряют выполнение бизнес-кода,
но радикально повышают масштабируемость систем, работающих с большим количеством блокирующих операций.
Также хотелось бы отметить несколько важных моментов.
Виртуальные потоки не будут давать преимущества, если они выполняют синхронизированные блоки или вызывают собственные методы. В данном случае виртуальные потоки могут быть привязаны к своему потоку-носителю и не освобождают его, блокируя другим виртуальным потокам возможность использовать этот поток-носитель.
Также может возникнуть проблема с переменными ThreadLocal, которые копируются для каждого потока, что может привести к увеличению потребления памяти при интенсивном использовании.
Виртуальные потоки превосходно справляются с операциями, использующими ввод-вывод или блокирующими операциями. Для задач, которые используют ресурсы процессора (например, ресурсоемких вычислений), оптимальное количество потоков обычно близко к количеству ядер. Использование большого количества виртуальных потоков для таких задач - не ускорит их выполнение, а может наоборот привести к ненужным накладным расходам на переключение контекста.
Хотя виртуальные потоки являются легковесными необходимо с помощью специальных инструментов наблюдать за активностью виртуальных потоков, использованием потоков-носителей и выявлять потенциальные проблемы с привязкой потоков.
Виртуальные потоки — это значительное изменение парадигмы создания высокопроизводительных и масштабируемых приложений. Они возвращают простоту модели «поток на запрос», обеспечивая при этом беспрецедентную параллельность, позволяя приложениям без труда обрабатывать сотни тысяч запросов. Понимание их преимуществ и недостатков позволяет создавать более эффективные, поддерживаемые и надежные сервисы.
Всем спасибо, кто дочитал статью до конца. Всем пока!