Тестуємо віртуальні потоки в Java 21
Усім привіт. Я Сергій Моренець, розробник, викладач, спікер і технічний письменник, хочу поділитися з вами досвідом свого тестування та використання в Java такої крутої фічі, як віртуальні потоки (проєкт Loom). П’ять років розробки, два роки публічного тестування — і тепер ви можете використовувати їх у своїх проєктах. Але які переваги та недоліки такого переходу?
У цій статті я взяв три популярні вебфреймворки (Spring MVC, Spring WebFlux, Micronaut) і протестував їхню продуктивність з і без віртуальних потоків. Також я перевірив їхню швидкодію в проєктах, упакованих у GraalVM Native images. Для тестування було обрано досить інноваційний підхід — Jmeter без UI на базі спеціального DSL.
Сподіваюся, що ця стаття буде корисною для всіх, хто хоче дізнатися на практиці, як провести таку міграцію і яка від цього практична користь.
Підготовка до тестування
У поточній версії є фіча, яку ніяк не можна залишити без уваги і вивчення — віртуальні потоки. Я вже кілька разів розповідав про цю функціональність. Якщо говорити коротко, то вони дозволяють писати простіший код у багатозадачних проєктах, не вдаючись до реактивного API. Важливо розуміти, що це не інфраструктурні зміни (як, наприклад, збирачі сміття), а зміна API. Тобто необхідно конкретно вказати JVM, що ви хочете використовувати їх. Водночас зараз у проєктах основні потоки, що працюють (або пули потоків) — це не ті, що розробник створює сам, а ті, що створюються готовими бібліотеками, фреймворками або вебсерверами. Використання віртуальних потоків може дозволити досягти деякого приросту в продуктивності, хоча це і не є їхнім головним завданням.
Чи буде такий приріст чи ні — відповідь на це питання може дати лише performance testing. Але перш ніж перейти до тестування, потрібно зрозуміти, а чи підтримують віртуальні потоки технології, що існують? На щастя, Micronaut 4, який вийшов улітку, вже заявив про таку підтримку. Це цікаво, тому що їхній REST API спочатку був побудований на реактивному підході. Про підтримку віртуальних потоків у Jersey (JAX-RS) нічого не відомо, а ось два мікрофрейморки Quarkus і Helidon уже заявили про їхню підтримку. Spring також може використовувати нову функціональність, правда у версії 3.2, яка ще не вийшла на ринок, але вже є RC-версії, які можна буде протестувати.
Для тестування можна вибрати будь-яку зі знайомих технологій — K6, JMeter, Gatling чи Wrk. Але цікавіше познайомитися з новим підходом у роботі з JMeter. Раніше ми створювали тестові сценарії в GUI, а потім за допомогою командного рядка запускали тести. Але в 2020 році компанія Abstracta, яка займається тестуванням ПЗ, створила новий проєкт — Jmeter-java-dsl.
Мета проєкту — писати performance-тести без необхідності використовувати GUI, створювати скрипти-конфігурації на XML, які не так просто підтримувати. Розробники й раніше могли використовувати API від JMeter, але це було досить низькорівневе програмування, яке вимагало тривалого вивчення цього API. JMeter-java-dsl пропонує високорівневий DSL, що дозволяє писати прості функціональні тести. Крім того, ви можете:
- конвертувати JMX-файли, що існують, у тестовий Java код;
- зберігати тестовий код як JMX-файл;
- налагоджувати тести (що раніше було неможливо);
- використовувати DSL Recorder;
- писати розподілені тести (за допомогою JMeter, BlazeMeter чи Azure Load Testing).
Зараз підтримуються лише деякі connectors — HTTP, GraphQL, JDBC, але для нас цього достатньо. Для тестування оберемо два REST-сервіси:
- Повертає перелік статичних даних.
- Повертає дані із СУБД (причому ми будемо використовувати драйвер, що блокує, для всіх платформ).
Додамо JMeter-java-dsl
-залежність для Maven-конфігурації:
<dependency>
<groupId>us.abstracta.jmeter</groupId>
<artifactId>jmeter-java-dsl</artifactId>
<version>1.23</version>
<scope>test</scope>
</dependency>
Почнемо з Micronaut. Для тестування виберемо PaymentProviderController
, який бере дані з MonoDB. Для тестування додамо метод getStaticStrings
:
@Controller("/providers")
public class PaymentProviderController {
@Inject
private PaymentProviderRepository paymentProviderRepository;
@Inject
private Transformer transformer;
@Get("/static")
public List<PaymentProviderDTO> getStaticList() {
return List.of(new PaymentProviderDTO("1", "STRIPE", "default"), new PaymentProviderDTO("2", "PAYPAL", "config"));
}
@Get
public List<PaymentProviderDTO> findAll() {
return paymentProviderRepository.findAll().stream()
.map(payment -> transformer.transform(payment, PaymentProviderDTO.class)).collect(Collectors.toList());
}
Тепер перейдемо до тесту. Створимо клас PaymentProviderControllerPerformanceTest
:
public class PaymentProviderControllerPerformanceTest {
@ParameterizedTest
@ValueSource(ints = { 5, 100, 500, 2500 })
void getStaticList_success(int threadCount) throws IOException {
TestPlanStats stats = testPlan(
threadGroup(threadCount, 10, httpSampler("http://localhost:8030/providers/static")),
jtlWriter("target/jtls").logOnly(SampleStatus.ERROR)).run();
double errorPercentage = (double) stats.overall().errorsCount() / stats.overall().samplesCount();
assertAll(() -> assertTrue(stats.overall().sampleTime().mean().compareTo(Duration.ofMillis(1000)) < 0),
() -> assertTrue(errorPercentage <= 0.1),
() -> assertTrue(stats.overall().sampleTime().perc90().compareTo(Duration.ofSeconds(1)) < 2));
}
Код такого тесту досить простий і зрозумілий, особливо якщо ви вже знайомі з JMeter:
- Ми використовуємо десять ітерацій.
- Кількість потоків (віртуальних користувачів) змінюється від пʼяти до 2500.
- Усі запити записуються до папки
target/jtls
. Але оскільки запитів багато, і це гальмуватиме тест, ми вказали варіант запису тільки тих запитів, які повернули помилку. - Після виконання тесту ми перевіряємо результати (latency, відсоток помилок і так званий 90% Line).
Такий тест разюче відрізняється від того, що ми писали раніше. Насамперед він перевіряє не функціональність, а performance, і тому його немає сенсу запускати після кожної найменшої зміни. Зараз він запускається поряд з усіма, а працює досить довго, тому гальмуватиме збірку. Потрібно перевести його з категорії дефолтних до опціональної групи. Для цього додамо тег Performance
:
@Tag("performance")
public class PaymentProviderControllerPerformanceTest {
Тепер налаштуємо Maven так, щоб цей тест запускався тільки за певного параметра. На щастя, в Maven Surfire плагіні є інтеграція з Junit 5, тому додамо його до pom.xml
:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<groups>!${tags}</groups>
<excludedGroups>${tags}</excludedGroups>
</configuration>
</plugin>
<properties>
<tags>performance</tags>
</properties>
Виникає питання, навіщо потрібно вказувати обидва атрибути (groups
і exludedGroups
)? Справа в тому, що якщо написати так:
<configuration>
<excludedGroups>${tags}</excludedGroups>
</configuration>
То Maven Surfire-плагін взагалі не запустить тестів. Тут немає такої конвенції, що тести без тегів завжди запускаються. Тепер можна запустити звичайні тести:
mvn</i><i> test
або performance-тести, перевизначивши властивість tags
:
mvn clean test -Dtags="!performance"
Цікаво, що в документації з плагіна вказується, що можна перевизначити атрибути groups
і excludedGroups
, але такий варіант не спрацював:
mvn test -DexcludeGroups="!performance" -Dgroups=performance
Запускаємо випробування. Це результати для статичного списку рядків:
Threads | Time(avg), ms | Errors(%) | Req/sec |
5 | 6 | 0 | 259 |
100 | 33 | 0 | 1628 |
500 | 545 | 4.7 | 452 |
2500 | 1287 | 11.8 | 770 |
Потім результати для динамічного списку, який береться з БД:
Threads | Time(avg), ms | Errors(%) | Req/sec |
5 | 8 | 0 | 234 |
100 | 47 | 0 | 1283 |
500 | 663 | 5.8 | 438 |
2500 | 871 | 6.4 | 1109 |
Micronaut оголосив про підтримку віртуальних потоків ще в 2022 році, як тільки вийшла JDK 19. Спочатку в ньому використовувався стандартний I/O-пул потоків від Netty, але якщо помістити анотацію @ExecuteOn(BLOCKING)
на REST-сервіс, то використовуватиме інша концепція — один (віртуальний) потік на кожне завдання:
@Get("/static")
@ExecuteOn(TaskExecutors.BLOCKING)
public List<PaymentProviderDTO> getStaticList() {
Внутрішньо для цього використовується метод newThreadPerTaskExecutor у класі Executors
:
public static ExecutorService newThreadPerTaskExecutor(ThreadFactory threadFactory) {
return ThreadPerTaskExecutor.create(threadFactory);
}
Знову запустимо тести для статичного списку:
Threads | Time(avg), ms | Errors(%) | Req/sec |
5 | 7 | 0 | 213 |
100 | 38 | 0 | 1524 |
500 | 148 | 0 | 2321 |
2500 | 751 | 3.24 | 1778 |
Потім результати для динамічного списку, який береться з БД:
Threads | Time(avg), ms | Errors(%) | Req/sec |
5 | 14 | 0 | 177 |
100 | 60 | 0 | 1101 |
500 | 671 | 5.82 | 436 |
2500 | 835 | 5.71 | 1820 |
Під час порівняння результатів можна побачити таке:
- Для невеликої кількості потоків
(5-100) система з віртуальними потоками працює на15-20% повільніше, можливо через накладні витрати. - Для великої (500+) кількості потоків перший REST-сервіс показав видатні результати з віртуальними потоками, обігнавши дефолтний варіант у
2-2,5 рази. Сервіс із динамічним списком показав приблизно ідентичні результати для 500 потоків і трохи краще для 2500 потоків. Якщо час відповіді приблизно такий самий, то кількість запитів на секунду в півтора рази вища.
Використовуємо GraalVM Native image
Ще одна приємна новина — GraalVM, починаючи з жовтня 2022 року, підтримує віртуальні потоки. Цікаво перевірити, як вони працюють у native images, тим паче Micronaut із самого початку конструювався як GraalVM-friendly. Спочатку зберемо Native image з використанням звичайних потоків:
docker compose -f docker-compose.yml -f docker-compose-graalvm.yml build payment
Слід зазначити, що ми будемо використовувати GraalVM 22.3.3 на базі Oracle Linux 9 та JDK 21:
FROM ghcr.io/graalvm/native-image-community:21 as graalvm
Знову запустимо тести для статичного списку:
Threads | Time(avg), ms | Errors(%) | Req/sec |
5 | 5 | 0 | 238 |
100 | 29 | 0 | 1763 |
500 | 112 | 0 | 2839 |
2500 | 844 | 8.84 | 1134 |
Тепер спробуємо запустити цей код як native image, але на віртуальних потоках. Знову запустимо тести для статичного списку:
Threads | Time(avg), ms | Errors(%) | Req/sec |
5 | 9 | 0 | 226 |
100 | 29 | 0 | 1798 |
500 | 552 | 4.9 | 452 |
2500 | 1054 | 8.7 | 1103 |
А ось для динамічного списку протестувати не вийшло через досить дивну помилку в Mongo-драйвері:
org.bson.codecs.configuration.CodecConfigurationException: Can’t find a codec for CodecCacheKey{clazz=class model.PaymentProvider, types=null}.
at org.bson.internal.ProvidersCodecRegistry.lambda$get$0(ProvidersCodecRegistry.java:87)
at [email protected]/java.util.Optional.orElseGet(Optional.java:364)
at org.bson.internal.ProvidersCodecRegistry.get(ProvidersCodecRegistry.java:80)
at org.bson.internal.ProvidersCodecRegistry.get(ProvidersCodecRegistry.java:50)
at com.mongodb.internal.operation.Operations.createFindOperation(Operations.java:186)
at com.mongodb.internal.operation.Operations.find(Operations.java:176)
at com.mongodb.internal.operation.SyncOperations.find(SyncOperations.java:98)
at com.mongodb.client.internal.FindIterableImpl.asReadOperation(FindIterableImpl.java:246)
at com.mongodb.client.internal.FindIterableImpl.asReadOperation(FindIterableImpl.java:41)
at com.mongodb.client.internal.MongoIterableImpl.execute(MongoIterableImpl.java:130)
at com.mongodb.client.internal.MongoIterableImpl.iterator(MongoIterableImpl.java:90)
at com.mongodb.client.internal.MongoIterableImpl.forEach(MongoIterableImpl.java:116)
at com.mongodb.client.internal.MongoIterableImpl.into(MongoIterableImpl.java:125)
at persistence.repository.MongoSyncPaymentProviderRepository.findAll(MongoSyncPaymentProviderRepository.java:65)
at web.PaymentProviderController.findAll(PaymentProviderController.java:49)
Я додав тикет на цю помилку і відповідь від розробників прийшла досить швидко. Оскільки у нашому випадку йде спроба виклику Reflection API, а всі такі спроби повинні бути суворо задокументовані, потрібно лише додати анотацію @ReflectiveAccess
на PaymentProvider:
public class PaymentProvider extends BaseEntity {
Перезбираємо застосунок, запускаємо та отримуємо ще одну помилку:
Caused by: java.lang.NoSuchMethodException: dto.PaymentProviderDTO.<init>()
at [email protected]/java.lang.Class.checkMethod(DynamicHub.java:1065)
at [email protected]/java.lang.Class.getConstructor0(DynamicHub.java:1228)
at [email protected]/java.lang.Class.getDeclaredConstructor(DynamicHub.java:2930)
at common.infra.util.ReflectionUtil.createInstance(ReflectionUtil.java:58)
Ця проблема також пов’язана з попередньою, але ми вже не можемо поставити анотацію @ReflectiveAccess
, оскільки PaymentProviderDTO
розташований в іншому модулі. Тому використовуємо іншу інструкцію — @ReflectionConfig
:
@ReflectionConfig(type = PaymentProviderDTO.class, accessType = { ALL_DECLARED_CONSTRUCTORS,
AccessType.ALL_DECLARED_FIELDS })
public class GraalVMConfiguration {
}
Запускаємо тест для динамічного списку:
Threads | Time(avg), ms | Errors(%) | Req/sec |
5 | 7 | 0 | 236 |
100 | 34 | 0 | 1587 |
500 | 135 | 0 | 2516 |
2500 | 921 | 6.4 | 1078 |
І той самий REST-сервіс, але на віртуальних потоках:
Threads | Time(avg), ms | Errors(%) | Req/sec |
5 | 8 | 0 | 227 |
100 | 33 | 0 | 1631 |
500 | 593 | 5.2 | 446 |
2500 | 1335 | 11.8 | 1051 |
Якщо порівняти результати, то віртуальні потоки не додали швидкодії, або погіршили її. Особливо це виявилося на 500 потоках — падіння продуктивності в пʼять (!) разів. Важко сказати, з чим це пов’язано. Можливо, з тим, що код native image і так оптимізований, а може підтримка віртуальних потоків у GraalVM зроблена недостатньо добре або ще не закінчена.
Spring MVC
Тепер перейдемо до Spring та Spring Boot. Тут уже давно ведеться робота над підтримкою віртуальних потоків, яка буде остаточно завершена до виходу Spring Boot 3.2. Поки що ця версія не вийшла, але вже є 3.2 RC1, на якій ми спробуємо запустити performance-тести.
Як здійснюється ця підтримка? Насамперед за рахунок глобального власності spring.threads.virtual.enabled
. Якщо його встановити в true
, то починається справжня магія:
- Tomcat використовує пул віртуальних потоків замість звичайних. Це можливо, тільки якщо у вас є Tomcat 10.1.x або вище. Аналогічна історія з Jetty.
- Запуск асинхронних методів здійснюється через TaskExecutor на базі віртуальних потоків. Це може призвести до незвичайних side effects. Якщо у вас є клас з анотацією
@Scheduled
, то він запускав non-deamon-потік для своєї роботи. Тепер буде запускатися deamon-потік, а це означає, що програма може завершитися, тільки-но розпочавшись. Тому в 3.2 додали нову властивість spring.main.keep-alive, єдине завдання якої — просто запускати новий non-daemon-потік. - Деякі обробники повідомлень у messaging systems (Pulsar, RabbitMQ, Kafka) також використовують віртуальні потоки.
- Java клієнти для Redis використовують віртуальні потоки
Якщо вам необхідно якийсь зі своїх бінів вмикати / вимикати залежно від цієї властивості, то для цього є нова зручна анотація @<b>ConditionalOnThreading</b>
:
@Bean
@ConditionalOnThreading(Threading.VIRTUAL)
TomcatVirtualThreadsWebServerFactoryCustomizer tomcatVirtualThreadsProtocolHandlerCustomizer() {
А наш REST-контролер виглядає так:
@RestController
@RequestMapping("providers")
@RequiredArgsConstructor
public class PaymentProviderController {
private final PaymentProviderRepository paymentProviderRepository;
@GetMapping("static")
public List<PaymentProviderDTO> getStaticList() {
return List.of(new PaymentProviderDTO("1", "STRIPE", "default"), new PaymentProviderDTO("2", "PAYPAL", "config"));
}
@GetMapping
public List<PaymentProviderDTO> findAll() {
return paymentProviderRepository.findAll().stream().map(provider -> new PaymentProviderDTO(provider.getId().toHexString(),
provider.getProviderType().name(), provider.getName())).toList();
}
Запускаємо тест для статичного списку:
Threads | Time(avg), ms | Errors(%) | Req/sec |
5 | 7 | 0 | 217 |
100 | 42 | 0 | 1400 |
500 | 163 | 0 | 2153 |
2500 | 700 | 3.25 | 1875 |
І для динамічного списку:
Threads | Time(avg), ms | Errors(%) | Req/sec |
5 | 8 | 0 | 228 |
100 | 51 | 0 | 1191 |
500 | 514 | 4.3 | 445 |
2500 | 1086 | 8.6 | 1086 |
Тепер увімкнемо підтримку віртуальних потоків у application.properties
:
spring.threads.virtual.enabled=true
Запускаємо додаток і одразу одержуємо помилку:
Caused by: java.lang.ClassNotFoundException: org.springframework.boot.SpringApplication$AbandonedRunException
at java.base/java.net.URLClassLoader.findClass(Unknown Source)
at java.base/java.lang.ClassLoader.loadClass(Unknown Source)
at org.springframework.boot.loader.net.protocol.jar.JarUrlClassLoader.loadClass(JarUrlClassLoader.java:104)
at org.springframework.boot.loader.launch.LaunchedClassLoader.loadClass(LaunchedClassLoader.java:91)
at java.base/java.lang.ClassLoader.loadClass(Unknown Source)
Я створив тикет на цю проблему, виявилося, що це відома помилка в Spring Boot, і потрібно обновитись і перейти з 3.2.0 RC1 на SNAPSHOT-версію (майбутній RC2).
Запускаємо тест для статичного списку:
Threads | Time(avg), ms | Errors(%) | Req/sec |
5 | 7 | 0 | 218 |
100 | 38 | 0 | 1000 |
500 | 142 | 0 | 2276 |
2500 | 946 | 6.2 | 1078 |
І для динамічного списку:
Threads | Time(avg), ms | Errors(%) | Req/sec |
5 | 8 | 0 | 239 |
100 | 50 | 0 | 1240 |
500 | 394 | 2.9 | 453 |
2500 | 1308 | 11.6 | 1061 |
Якщо порівняти результати, то для статичного списку перехід на віртуальні потоки не дав жодних переваг. У деяких прикладах було зниження продуктивності на
Spring WebFlux
Тепер спробуємо той самий тест, але для Spring Web Flux. Інженери Spring спеціально розробили цей реактивний вебфреймворк, щоб разом з реактивними драйверами баз даних він дозволив повністю позбутися викликів, що блокують, у багатопотокових застосунках.
@GetMapping("static")
public Flux<PaymentProviderDTO> getStaticList() {
return Flux.just(new PaymentProviderDTO("1", "STRIPE", "default"), new PaymentProviderDTO("2", "PAYPAL", "config"));
}
Запускаємо тест для статичного списку:
Threads | Time(avg), ms | Errors(%) | Req/sec |
5 | 7 | 0 | 240 |
100 | 41 | 0 | 1335 |
500 | 338 | 2.3 | 460 |
2500 | 1232 | 10.3 | 756 |
І для динамічного списку:
Threads | Time(avg), ms | Errors(%) | Req/sec |
5 | 11 | 0 | 205 |
100 | 79 | 0 | 918 |
500 | 500 | 3.5 | 442 |
2500 | 996 | 7.2 | 1096 |
Якщо порівняти результати, то для статичного списку Spring MVC (з віртуальними потоками) обійшов Spring Web Flux на
Spring Boot підтримує упаковку в GraalVM native image двома способами:
- За допомогою Cloud Native buildpacks (інтегрованих у Spring Boot Maven-плагін).
- За допомогою GraalVM Native image плагіна.
На жаль, обидва способи не працювали через цю і цю проблему, залишається лише здогадуватися про те, як би відбулося тестування.
Висновки
Micronaut і Spring — не єдині технології, які вже підтримують віртуальні потоки. У Quarkus це досягається за допомогою анотації @RunOnVirtualThread
. Helidon запустив окремий проеєкт Helidon Nima, збудований на віртуальних потоках.
Якщо підбити підсумки, можна відзначити, що Java-індустрія позитивно сприйняла таку фічу, як віртуальні потоки. Практично всі вебфреймворки та вебсервери до виходу JDK 21 додали необхідну підтримку, щоправда, це було зроблено різними способами. Якщо Spring Boot для цього є одна глобальна властивість, то в Micronaut / Quarkus ви також можете вказати, що REST-сервіс (або група REST-сервісів) будуть виконуватися всередині віртуальних потоків.
Якщо проаналізувати результати performance-тестів, то в цілому системи, де використовувалися віртуальні потоки, не поступилися так званим реактивним неблокуючим технологіям (Spring Web Flux, Micronaut).
Результати відрізнялися для різних систем і одночасного навантаження. Тому якщо вам хочеться протестувати віртуальні потоки для вашого проєкту, то потрібно також моніторити системні метрики (кількість одночасних потоків, завантаження CPU, обсяг пам’яті, що використовується) і вибирати відповідний сценарій використання. Єдиний виняток — GraalVM native image, де віртуальні потоки в Micronaut показали себе набагато гірше.
Чи варто зараз переходити на них? Це більш актуально для проєктів із синхронним блокуючим потоком (наприклад, Spring MVC), де такий перехід здійснюється додаванням однієї властивості. Якщо ж у вас проєкт зав’язаний на реактивні технології, то тут потрібно зважити всі за і проти, оскільки міграція може бути витратною за часом і ресурсами.
24 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів