Как стать автором
Обновить

Сравнение виртуальных и обычных потоков в Java

Время на прочтение8 мин
Количество просмотров17K

Я люблю стректрейсы и понятный линейный код. И соответственно не люблю реактивщину. Все примеры будут нереактивными с последовательным понятным кодом.

Примеры запускались на доступной сегодня 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.
 *
 * <p> This method is equivalent to:
 * <pre>{@code Thread.ofVirtual().start(task); }</pre>
 *
 * @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 <a href="#inheritance">Inheritance when creating threads</a>
 * @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.
 *
 * <p> 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() при использовании виртуальных потоков внутри.

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

АПД: Следующий тест производительности добавлен по просьбе из комментариев. Спасибо комментатору, который напомнил мне что никогда нельзя доверять мысленным тестам производительности и всегда надо всё проверять на бенчмарками.

Я для этого теста буду использовать немного другие параметры экзекутора. Для максимального приближения к реальности. Допустим есть http сервер с пулом принимающим соединения в 100 потоков и выполняющий в них задачи с паузами в 100мс. У меня на машине всего 4 ядра, значит при нагрузке более 4% скорость работы теста определяется быстродействием кода, а не чем-то еще.

Экзекуторы:

Executors.newVirtualThreadPerTaskExecutor()
Executors.newCachedThreadPool()
Executors.newFixedThreadPool(100))

Тест:

@Benchmark
public void testCachedExecutorNormalTask() {
    try (var executor = Executors.newCachedThreadPool()) {
        for (int i = 0; i < 100; ++i) {
            executor.submit(() -> {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                Blackhole.consumeCPU(1_000_000);
            });
        }
    }
}

Результаты:

100мс ожидания + 20мс работы. Нагрузка пула около 15%, 
что очень блико к реальной продакшен нагрузке хорошо сконфигуренного сервера.
Benchmark                                         Mode  Cnt       Score   Error  Units
BenchmarkThreading.testCachedExecutorNormalTask   avgt    2  677262,310          us/op
BenchmarkThreading.testFixedExecutorNormalTask    avgt    2  659306,103          us/op
BenchmarkThreading.testVirtualExecutorNormalTask  avgt    2  679307,987          us/op	

100мс ожидания + 2мс работы. Тут скорость уже перестала зависеть от скорости работы кода.
Benchmark                                         Mode  Cnt       Score   Error  Units
BenchmarkThreading.testCachedExecutorNormalTask   avgt    2  196600,036          us/op
BenchmarkThreading.testFixedExecutorNormalTask    avgt    2  191974,914          us/op
BenchmarkThreading.testVirtualExecutorNormalTask  avgt    2  196900,749          us/op

Результаты показывают что при типичной нагрузке на типичный пул http сервера виртуальные потоки не дают никакого заметного ускорения. Если цифры как следует выкрутить в сторону уменьшения аналогично предыдущим тестам мы получим ускорение. Но положа руку на сердце у кого из вас АПИшка работает на порядки быстрее чем в этом тесте?

А что с памятью?

Уже давно ходят слухи что потоки в Джаве очень прожорливы до памяти. Я читал версии что каждый поток стоит мегабайты памяти просто так на создание. И виртуальные потоки всех нас спасут от покупки дополнительной памяти в наши кластера.

Исследовать расход памяти в Джаве на что-то это довольно неоднозначный процесс. Предлагаю тривиально оценить расход памяти на какое-то число созданных, запущенных и ничего не делающих потоков. Это довольно типовая ситуация когда основная часть потоков висит на 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 обретает смысл.

  • Виртуальные потоки дадут возможность более эффективно утилизировать доступные ядра. Без выделения больших независимых кусков кода и реактивщины.

Теги:
Хабы:
Всего голосов 17: ↑14 и ↓3+17
Комментарии30

Публикации

Истории

Работа

Java разработчик
347 вакансий

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань