Тестирование производительности виртуальных потоков Java в Jetty
Тестировать буду на 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 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> tasks) throws InterruptedException {
List> futures = fixedTpe.invokeAll(tasks);
futures.forEach(f-> {
try {
f.get();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
});
}
private Callable
Сам код тестирования
@Benchmark
public void testNonVirtual20() throws InterruptedException {
List> 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> 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> 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> 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> 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 используемые уже очень много лет которые проросли во все библиотеки. И эти приемы несовместимы с виртуальными потоками. Но это тема для другой статьи. Точно надо тестировать именно вашу бизнес логику, особенно ее нагруженные части, после включения виртуальных потоков. Могут быть сюрпризы в самых неожиданных местах.