Тестирование производительности виртуальных потоков 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