Сравнение виртуальных и обычных потоков в Java
Я люблю стректрейсы и понятный линейный код. И соответственно не люблю реактивщину. Все примеры будут нереактивными с последовательным понятным кодом.
Примеры запускались на доступной сегодня jdk.
openjdk version "19-loom" 2022-09-20
OpenJDK Runtime Environment (build 19-loom+6-625)
OpenJDK 64-Bit Server VM (build 19-loom+6-625, mixed mode, sharing)
Не забываем про --enable-preview
флажок.
В этой jdk доступны такие методы для экспериментирования с виртуальными потоками:
/**
* Creates a virtual thread to execute a task and schedules it to execute.
*
* This method is equivalent to:
*
{@code Thread.ofVirtual().start(task); }
*
* @param task the object to run when the thread executes
* @return a new, and started, virtual thread
* @throws UnsupportedOperationException if preview features are not enabled
* @see Inheritance when creating threads
* @since 19
*/
@PreviewFeature (feature = PreviewFeature.Feature.VIRTUAL_THREADS)
public static Thread startVirtualThread (Runnable task) { … }
и
/**
* Creates an Executor that starts a new virtual Thread for each task.
* The number of threads created by the Executor is unbounded.
*
* This method is equivalent to invoking
* {@link #newThreadPerTaskExecutor(ThreadFactory)} with a thread factory
* that creates virtual threads.
*
* @return a new executor that creates a new virtual Thread for each task
* @throws UnsupportedOperationException if preview features are not enabled
* @since 19
*/
@PreviewFeature(feature = PreviewFeature.Feature.VIRTUAL_THREADS)
public static ExecutorService newVirtualThreadPerTaskExecutor() { .... }
Не очень много, но для экспериментов хватит.
Общий код запуска тестов:
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 1)
@Measurement(iterations = 2)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
public class BenchmarkThreading {
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(BenchmarkThreading.class.getSimpleName())
.forks(1)
.build();
new Runner(opt).run();
}
//тут тесты
}
Производительность
Для начала проверим самое простое. Создание потоков. Убедимся что виртуальные потоки работают так как и ожидается.
@Benchmark
public void testCreateVirtualThread(Blackhole blackhole) {
for (int i=0; i<100; ++i) {
int finalI = i;
Thread.startVirtualThread(() -> blackhole.consume(finalI));
}
}
@Benchmark
public void testCreateThread(Blackhole blackhole) {
for (int i = 0; i < 1000; ++i) {
int finalI = i;
var thread = new Thread(() -> blackhole.consume(finalI));
thread.start();
}
}
Benchmark Mode Cnt Score Error Units
BenchmarkThreading.testCreateThread avgt 199158,959 us/op
BenchmarkThreading.testCreateVirtualThread avgt 53,674 us/op
Результат получился ожидаемый и не удивительный. Виртуальные потоки создаются на порядки быстрее обычных как и ожидается.
А что они нам дадут в более-менее реальных примерах использования? Нормальная программа на Джаве не создает потоки в нагруженных участках кода, а использует пулы и экзекуторы.
Попробуем экзекутором выполнить микрозадачи:
@Benchmark
public void testVirtualExecutorSmallTask(Blackhole blackhole) {
try(var executor = Executors.newVirtualThreadPerTaskExecutor()){
for (int i = 0; i < 100; ++i) {
int finalI = i;
executor.submit(() -> blackhole.consume(finalI));
}
}
}
@Benchmark
public void testCachedExecutorSmallTask(Blackhole blackhole) throws InterruptedException {
try(var executor = Executors.newCachedThreadPool()){
for (int i = 0; i < 100; ++i) {
int finalI = i;
executor.submit(() -> blackhole.consume(finalI));
}
}
}
@Benchmark
public void testFixedExecutorSmallTask(Blackhole blackhole) throws InterruptedException {
try(var executor = Executors.newFixedThreadPool(20)){
for (int i = 0; i < 100; ++i) {
int finalI = i;
executor.submit(() -> blackhole.consume(finalI));
}
}
}
Benchmark Mode Cnt Score Error Units
BenchmarkThreading.testCachedExecutorSmallTask avgt 2 1233,639 us/op
BenchmarkThreading.testFixedExecutorSmallTask avgt 2 2156,590 us/op
BenchmarkThreading.testVirtualExecutorSmallTask avgt 2 96,231 us/op
Результат тоже хорош. За исключение того что с размером fixed пула я не угадал. Ну ладно, на практике в продакшен коде типовой мидл тоже никогда не угадает.
А что если сделать тест еще ближе к реальности? В нормальном коде в поток выносят операции занимающее какое-то значимое количество времени.
На моей тестовой машине Blackhole.consumeCPU(100_000_000)
занимает около 200 мс что можно принять разумным временем на задачу которую уже можно отправлять в отдельный поток.
@Benchmark
public void testVirtualExecutorNormalTask(Blackhole blackhole) {
try(var executor = Executors.newVirtualThreadPerTaskExecutor()){
for (int i = 0; i < 100; ++i) {
executor.submit(() -> Blackhole.consumeCPU(100_000_000));
}
}
}
@Benchmark
public void testCachedExecutorNormalTask(Blackhole blackhole) throws InterruptedException {
try(var executor = Executors.newCachedThreadPool()){
for (int i = 0; i < 100; ++i) {
executor.submit(() -> Blackhole.consumeCPU(100_000_000));
}
}
}
@Benchmark
public void testFixedExecutorNormalTask(Blackhole blackhole) throws InterruptedException {
try(var executor = Executors.newFixedThreadPool(20)){
for (int i = 0; i < 100; ++i) {
executor.submit(() -> Blackhole.consumeCPU(100_000_000));
}
}
}
Benchmark Mode Cnt Score Error Units
BenchmarkThreading.testCachedExecutorNormalTask avgt 2 5249759,575 us/op
BenchmarkThreading.testFixedExecutorNormalTask avgt 2 5247051,750 us/op
BenchmarkThreading.testVirtualExecutorNormalTask avgt 2 5246058,750 us/op
Разницы нет. Это было ожидаемо. На такой нагрузке работа с потоками занимает пренебрежимо малое время по сравнению с бизнес логикой. Не загромождая статью исходниками покажу результат для других значений Blackhole.consumeCPU(ххх)
10_000_000 или 20мс на задачу
Benchmark Mode Cnt Score Error Units
BenchmarkThreading.testCachedExecutorNormalTask avgt 2 553018,934 us/op
BenchmarkThreading.testFixedExecutorNormalTask avgt 2 564500,005 us/op
BenchmarkThreading.testVirtualExecutorNormalTask avgt 2 530236,755 us/op
1_000_000 или 2мс на задачу
Benchmark Mode Cnt Score Error Units
BenchmarkThreading.testCachedExecutorNormalTask avgt 2 65124,411 us/op
BenchmarkThreading.testFixedExecutorNormalTask avgt 2 54710,276 us/op
BenchmarkThreading.testVirtualExecutorNormalTask avgt 2 53285,513 us/op
100_000 или 0.2мс на задачу
Benchmark Mode Cnt Score Error Units
BenchmarkThreading.testCachedExecutorNormalTask avgt 2 14088,289 us/op
BenchmarkThreading.testFixedExecutorNormalTask avgt 2 8267,134 us/op
BenchmarkThreading.testVirtualExecutorNormalTask avgt 2 5792,022 us/op
10_000 или 0.02мс на задачу
Benchmark Mode Cnt Score Error Units
BenchmarkThreading.testCachedExecutorNormalTask avgt 2 2377,223 us/op
BenchmarkThreading.testFixedExecutorNormalTask avgt 2 2757,024 us/op
BenchmarkThreading.testVirtualExecutorNormalTask avgt 2 664,795 us/op
Разница становится явно видна на совсем маленьких задачах. Там где менеджмент потоков начинает занимать значимое время от всей остальной логики.
Можно сделать вывод что в типовом нормальном Джава коде плюсов по производительности от простого включения виртуальных потоков мы не заметим. Если вы у себя её заметили, то стоит покопаться по коду поискать где вы используете потоки для слишком маленьких задач.
Зато мы получаем возможность кидать в отдельный поток просто все что угодно. Разница для микрозадач колоссальна. Это откроет некоторые возможности более удобно писать код и лучше утилизировать все доступные ядра во вроде бы однопоточном коде. Может быть наконец-то появится смысл в .parallelStream()
при использовании виртуальных потоков внутри.
И как обычно пойдет куча ошибок с созданием слишком большого и ненужного числа виртуальных потоков, со всеми радостями отладки без стектрейсов потом. Исследовать непойманное исключение в логах в котором нет ни одной строчки твоего кода это очень увлекательный процесс.
А что с памятью?
Уже давно ходят слухи что потоки в Джаве очень прожорливы до памяти. Я читал версии что каждый поток стоит мегабайты памяти просто так на создание. И виртуальные потоки всех нас спасут от покупки дополнительной памяти в наши кластера.
Исследовать расход памяти в Джаве на что-то это довольно неоднозначный процесс. Предлагаю тривиально оценить расход памяти на какое-то число созданных, запущенных и ничего не делающих потоков. Это довольно типовая ситуация когда основная часть потоков висит на IO и ждет данных. Обычно именно таких потоков хочется побольше для удобства разработки.
Приложение для оценки простейшее:
public static void main(String[] args) {
for(int i=0; i<100; ++i) {
var thread = new Thread(() -> {
Blackhole.consumeCPU(1);
try {
Thread.sleep(100_000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
thread.start();
}
System.exit(0);
}
JDK17 LTS. Тех кто еще не обновился мне уже даже не жалко. Давно пора обновиться было.
openjdk version "17.0.3" 2022-04-19
OpenJDK Runtime Environment Temurin-17.0.3+7 (build 17.0.3+7)
OpenJDK 64-Bit Server VM Temurin-17.0.3+7 (build 17.0.3+7, mixed mode, sharing)
Никаких особых ключей запуска: -XX:+UnlockDiagnosticVMOptions -XX:NativeMemoryTracking=summary -XX:+PrintNMTStatistics -Xmx4G
71
Thread (reserved=75855304, committed=4_793_800)
(thread #71) (stack: reserved=75497472, committed=4435968)
(malloc=178456 #744)
(arena=179376 #245)
1_016
Thread (reserved=1069151112, committed=65_549_192)
(thread #1016) (stack: reserved=1066401792, committed=62799872)
(malloc=1526056 #7146)
(arena=1223264 #2040)
10_018
Thread (reserved=10532726584, committed=643_856_184)
(thread #10018) (stack: reserved=10505682944, committed=616812544)
(malloc=15013392 #70316)
(arena=12030248 #20051)
Видна хорошая закономерность с расходом около 64 килобайт памяти на пустой поток.
Виртуальные потоки в этом месте память под себя не требуют, и будут занимать что-то схожее с типичным Джава объектом размером в десятки-сотни байт. Можно упрощенно считать что это 0 по сравнению с 64 килобайтами на классический поток.
Выводы
Отрицательно:
Нас ждут увлекательные баталии в код ревью о новых практиках написания кода.
Количество ошибок с многопоточностью заметно возрастет.
Нейтрально:
Виртуальные потоки не дадут никакого ускорения в типичном джава приложении без переписывания кода.
Виртуальные потоки не уменьшат потребление памяти нормально сделанным приложением. 64 килобайта * 1_000 типовых потоков, это неинтересно.
Положительно:
Виртуальные потоки дадут возможность по новому писать код. Паралелим все что не запрещено математикой.
.parallelStream обретает смысл.
Виртуальные потоки дадут возможность более эффективно утилизировать доступные ядра. Без выделения больших независимых кусков кода и реактивщины.