Тестировать буду на jdk21 в котором виртуальные потоки доступны в релиз версии.

Веб сервер для тестирования возьму Jetty. Он с 12 версии нативно поддерживает работу с виртуальными потоками и достаточно распространен в продакшене.

java --version
java 21.0.2 2024-01-16 LTS
Java(TM) SE Runtime Environment (build 21.0.2+13-LTS-58)
Java HotSpot(TM) 64-Bit Server VM (build 21.0.2+13-LTS-58, mixed mode, sharing)

jetty 12.0.10

Статья посвящена именно виртуальным потокам в Jetty. Java API сейчас не интересно.
В Jetty виртуальные потоки подключаются не просто, а очень просто

threadPool.setVirtualThreadsExecutor(Executors.newVirtualThreadPerTaskExecutor());

И все. Дальше все должно работать само.

Замечание про тестовый стенд

На современных Интел процессорах есть проблема многопоточных бенчмарков. P и Q ядра. Они работают с разной скоростью и могут исказить результаты. Чтобы ее обойти минимальное количество потоков у меня будет 20, это соответствует количеству HT ядер на машине на которой запускаются тесты и позволяет загрузить все ядра.

Пишем код для тестирования

Честно бенчмаркать http методы Jetty полностью не очень тривиально. Там много внутренней магии, которую легко упустить вызывая какой-то код напрямую. В продакшене же она будет выполняться и скорость работы может оказаться совсем другой.

Я решил написать простенький веб сервис и тестировать его честно через http вызовы. Бенчмарки будут навешены непосредственно на http клиент.

Сервер

public static void runServer(boolean virtualPool, int port, int poolSize) throws Exception {
    BlockingQueue<Runnable> queue = new BlockingArrayQueue<>(10_000);
    QueuedThreadPool threadPool = new QueuedThreadPool(poolSize, poolSize / 2, queue);
    if (virtualPool) {
        threadPool.setVirtualThreadsExecutor(Executors.newVirtualThreadPerTaskExecutor());
    }

    Server server = new Server(threadPool);

    ServerConnector connector = new ServerConnector(server);
    connector.setPort(port);
    server.addConnector(connector);

    server.setHandler(new Handler.Abstract() {
        @Override
        public boolean handle(Request request, Response response, Callback callback) throws InterruptedException {
            callback.succeeded();
            int sleep = Integer.parseInt(request.getHeaders().get("sleep"));
            if(sleep > 0)
                Thread.sleep(sleep);
            long cpu = Integer.parseInt(request.getHeaders().get("cpu")) * 1_000_000L;
            if(cpu > 0)
                Blackhole.consumeCPU(cpu);
            return true;
        }
    });
    server.start();
}

В коде сервера есть некоторые особенности.

Что делает самый обычный метод любого API? Он ждет на IO и что-то считает. Соотношение ожидания и считания бывают разными, скорость ответа тоже бывает разной. Я в своем тестовом сервере повторил это логику.


Thread.sleep - изображает ожидание на IO.
Blackhole.consumeCPU - изображает какую-то деятельность по перекладыванию джейсонов. Магическая константа 1_000_000L на моем CPU дает примерно 1 миллисекунду работы.

Размер входящей очереди выставлен с большим запасом, чтобы не проливать запросы никогда.

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

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

Код запускающий сервер:

public static void main(String[] args) throws Exception {
    runServer(false, 8081, 20);
    runServer(true, 8082, 20);
    runServer(true, 8083, 200);
    runServer(false, 8084, 200);
    runServer(true, 8084, 2000);
}

Чтобы тестировать было удобнее я сразу стартую 5 инстансов с разными пулами. Почему пула в 2000 нет в физических потоках? Потому-что мне так захотелось. Потому что одно из преимуществ вирутальных потоков это возможность делать много, нет МНОГО потоков. И не платить за это.


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

Клиент

Http клиент я тоже взял от jetty. Очень рекомендую. Его API сделали для людей. Им удобно и приятно пользоваться. В отличии от API встроенного в jdk http клиента. Он удобен только рептилоидам.


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

Параметры JMH

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
@Fork(value = 1)
@Warmup(iterations = 1, timeUnit = TimeUnit.MILLISECONDS, time = 1000)
@Measurement(iterations = 2, timeUnit = TimeUnit.MILLISECONDS, time = 20000)

Параметризация теста

@Param({"1", "10", "100"})
public int sleep;

@Param({"1", "10", "100"})
public int cpu;

@Param({"10", "20", "80"})
public int threads;

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

threads подобран так чтобы проверить что будет при нагрузке меньше чем доступный пул соединений, равной и больше. При использовании виртуальных потоков вы всегда можете сделать пул больше чем любое возможное и невозможное количество входящих соединений. С DDOS бороться на уровне Jetty не надо. Это надо делать выше, где-то на вашем балансере. Или даже еще выше. До Jetty должны доходить только более-менее разумные запросы пользователей которые надо обработать.

Немного бойлерплейта
HttpClient client = new HttpClient();
ExecutorService fixedTpe;

@Setup
public void prepare() throws Exception {
    client.start();
    fixedTpe = Executors.newFixedThreadPool(threads);
}

@TearDown
public void close() throws Exception {
    client.stop();
    fixedTpe.shutdown();
}
private void waitlUntilEnd(List<Callable<Object>> tasks) throws InterruptedException {
    List<Future<Object>> futures = fixedTpe.invokeAll(tasks);
    futures.forEach(f-> {
        try {
            f.get();
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException(e);
        }
    });
}

private Callable<Object> createAsyncRequest(int port, int sleep, int cpu) {
    return Executors.callable(() -> {
                        try {
                            createRequest(port, sleep, cpu);
                        } catch (InterruptedException | TimeoutException | ExecutionException e) {
                            throw new RuntimeException(e);
                        }
                    }
            );
}

private void createRequest(int port, int sleep, int cpu) throws InterruptedException, TimeoutException, ExecutionException {
    client.newRequest("localhost", port)
            .scheme("http")
            .version(HttpVersion.HTTP_1_1)
            .method(HttpMethod.GET)
            .timeout(100, TimeUnit.SECONDS)
            .headers(httpFields -> {
                httpFields.add("sleep", sleep);
                httpFields.add("cpu", cpu);
            })
            .timeout(10, TimeUnit.SECONDS)
            .send();
}

public static void main(String[] args) throws Exception {
    org.openjdk.jmh.Main.main(args);
}

Сам код тестирования

@Benchmark
public void testNonVirtual20() throws InterruptedException {
    List<Callable<Object>> tasks = new ArrayList<>();

    for (int i = 0; i < 1000; ++i)
        tasks.add(createAsyncRequest(8081, sleep, cpu));
    waitlUntilEnd(tasks);
}

@Benchmark
public void testVirtual20() throws InterruptedException {
    List<Callable<Object>> tasks = new ArrayList<>();

    for (int i = 0; i < 1000; ++i)
        tasks.add(createAsyncRequest(8082, sleep, cpu));
    waitlUntilEnd(tasks);
}

@Benchmark
public void testVirtual200() throws InterruptedException {
    List<Callable<Object>> tasks = new ArrayList<>();

    for (int i = 0; i < 1000; ++i)
        tasks.add(createAsyncRequest(8083, sleep, cpu));
    waitlUntilEnd(tasks);
}

@Benchmark
public void testNonVirtual200() throws InterruptedException {
    List<Callable<Object>> tasks = new ArrayList<>();

    for (int i = 0; i < 1000; ++i)
        tasks.add(createAsyncRequest(8084, sleep, cpu));
    waitlUntilEnd(tasks);
}

@Benchmark
public void testVirtual2000() throws InterruptedException {
    List<Callable<Object>> tasks = new ArrayList<>();

    for (int i = 0; i < 1000; ++i)
        tasks.add(createAsyncRequest(8085, sleep, cpu));
    waitlUntilEnd(tasks);
}

Код специально максимально простой и однозначный. Проверяем скорость работы 1000 запросов с разными параметрами.

1000 чтобы все запросы точно не влезли в любой пул физических потоков сервера. При этом влезли в пул виртуальных потоков.

Результаты

Аббревиатуры: Первая колонка это невиртуальные или виртуальные потоки на сервере и их количество. Число во второй колонке - количество потоков на клиенте.

cpu=1 sleep=1 Бейслайн когда сервер примерно ничего не делает.

NonVirtual200

80

82

NonVirtual200

20

178

NonVirtual20

80

202

NonVirtual20

20

209

Virtual20

80

211

Virtual2000

80

222

Virtual200

80

226

NonVirtual200

10

365

NonVirtual20

10

369

Virtual200

20

789

Virtual20

20

789

Virtual2000

20

790

Virtual2000

10

1571

Virtual200

10

1575

Virtual20

10

1577

Физические потоки побеждают в одном конкретном кейсе. В остальным все примерно одинаково.

Тоже самое, но сервер равномерно что-то делает. Заодно тут точно избавимся от влияния скорости клиента

cpu=10 wait=10

Virtual2000

80

772

Virtual200

80

774

Virtual20

80

776

NonVirtual200

80

779

NonVirtual200

20

1586

Virtual20

20

1587

Virtual200

20

1589

Virtual2000

20

1589

NonVirtual20

80

1773

NonVirtual20

20

1809

NonVirtual20

10

3178

Virtual200

10

3178

Virtual20

10

3180

NonVirtual200

10

3180

Virtual2000

10

3181

cpu=100 wait=100

NonVirtual200

80

7651

Virtual20

80

7736

Virtual2000

80

7766

Virtual20

20

12437

Virtual200

20

12493

NonVirtual200

20

12518

Virtual2000

20

12567

NonVirtual20

80

13825

NonVirtual20

10

22369

NonVirtual200

10

22764

Virtual200

10

24751

Virtual2000

10

24756

Virtual20

10

24759

Результаты примерно такие же. При достаточном количестве физические потоки не хуже или даже немного лучше.

Теперь другие кейсы. А что будет с сервисом который реально что-то делает, а не ждет на IO?

cpu=10 wait=1

NonVirtual200

80

771

Virtual2000

80

773

Virtual20

80

774

Virtual200

80

775

NonVirtual200

20

849

NonVirtual20

80

937

NonVirtual20

20

948

Virtual200

20

1108

Virtual20

20

1113

Virtual2000

20

1117

NonVirtual200

10

1605

NonVirtual20

10

1606

Virtual20

10

1991

Virtual2000

10

1992

Virtual200

10

1994

cpu=100 wait=1

NonVirtual200

80

7594

Virtual2000

80

7661

NonVirtual200

20

7702

Virtual20

80

7719

Virtual200

80

7746

Virtual200

20

7934

Virtual2000

20

7937

NonVirtual20

20

8254

NonVirtual20

80

8270

Virtual20

20

10182

NonVirtual200

10

14511

NonVirtual20

10

14614

Virtual2000

10

14963

Virtual200

10

14983

Virtual20

10

14985

Тут полное равенство. Довольно ожидаемо, ЦПУ больше не становится. Работу сделать быстрее не получается.

И последний вариант. Сервис в основном ждет на IO

cpu=1 wait=10

NonVirtual200

80

368

Virtual200

80

372

Virtual2000

80

381

Virtual20

80

385

NonVirtual200

20

1102

Virtual20

20

1134

Virtual2000

20

1151

Virtual200

20

1184

NonVirtual20

80

1192

NonVirtual20

20

1231

NonVirtual200

10

1982

NonVirtual20

10

1999

Virtual20

10

2073

Virtual2000

10

2137

Virtual200

10

2142

cpu=1 wait=100

NonVirtual200

80

1730

Virtual20

80

1733

Virtual200

80

1734

Virtual2000

80

1735

NonVirtual200

20

5503

Virtual20

20

5512

Virtual200

20

5520

Virtual2000

20

5525

NonVirtual20

80

6154

NonVirtual20

20

6454

Virtual200

10

10997

NonVirtual200

10

10997

NonVirtual20

10

11019

Virtual2000

10

11038

Virtual20

10

11039

Виртуальные потоки побеждают. Как и писали в документации лучше всего они умеют ждать.

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

Выводы

Для веб сервисов ограниченных IO виртуальные потоки смотрятся немного лучше физических. Нужное количество виртуальных потоков стоит дешевле чем соответствующее число физических потоков. Вроде бы можно переходить, ставить пул немного (раза в два, будем честными) больше входящего потока паралельных запросов и радоваться. Изменения в коде минимальные, польза есть. Чудес при этом ждать не стоит. Основной выигрыш будет благодаря почти бесплатному увеличению размера пула потоков для обработки запросов и более параллельному ожиданию.

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

Ложка дегтя

Одна проблема виртуальных потоков очевидна и ожидаема. Если вы можете принять больше входящих соединений одновременно и у вас IO баунд нагрузка, значит вы ждете чего-то внешнего. Обычно это БД или другие сервисы. При увеличении размера пула и одновременной обработке большего количества запросов то чего вы ждете может и не справиться с возросшей нагрузкой. Стоит это проверить заранее.

Вторая проблема менее очевидна. Ваш CPU баунд сервис может стать медленнее при внедрении виртуальных потоков. Есть типовые приемы программирования на Java используемые уже очень много лет которые проросли во все библиотеки. И эти приемы несовместимы с виртуальными потоками. Но это тема для другой статьи. Точно надо тестировать именно вашу бизнес логику, особенно ее нагруженные части, после включения виртуальных потоков. Могут быть сюрпризы в самых неожиданных местах.

Комментарии (1)


  1. Andrey_Solomatin
    10.06.2024 13:18
    +1

    У асинхроных процессов есть один подводный камень, все внутри должно быть асинхронным и желательно на одном Executor крутиться, чтобы работало эффективно.

    Вариант 200 виртуальных потоков в одном Executor и 20 любых других в другом Executor на подключение к базе данных, скорее всего упрётся в подключение к базе данных. Если асинхронного клиента к базе нет, то прорыва в производительности не стоит ожидать.