Привет, хабр! Меня зовут Евгений Иванов, я разработчик YDB. Но сегодня я бы хотел представить Вашему вниманию пост не о YDB, а о виртуальных потоках Java 21 на примере TPC-C для PostgreSQL.
В посте о TPC-C для YDB, мы обсудили некоторые недостатки реализации TPC-C проектом Benchbase (при этом всё равно это очень классный проект). Одним из наиболее значимых недостатков, на наш взгляд, является ограничение конкурентного выполнения (concurrency limit) из-за создания слишком большого числа физических потоков. Мы решили эту проблему, перейдя на виртуальные потоки Java 21. Но оказалось, что бесплатный сыр бывает только в мышеловке. И в этом посте мы расскажем о примере дедлока в TPC-C для PostgreSQL, причиной которого является исключительно переход на виртуальные потоки — и никаких проблем обедающих философов.
Данный пост будет полезен Java разработчикам, которые планируют перейти на виртуальные потоки. Сначала мы кратко обсудим такие фундаментальные понятия, как синхронные и асинхронные запросы. А затем рассмотрим важную особенность виртуальных потоков: дедлоки непредсказуемы и могут возникать глубоко внутри библиотек, которые вы используете. К счастью, отладка очень проста и мы покажем, как находить такие дедлоки.
Почему мы говорим о PostgreSQL в блоге YDB
PostgreSQL — это система управления базами данных с открытым исходным кодом, известная своей высокой производительностью, богатым набором функций и продвинутым уровнем соответствия стандарту SQL. Что не менее важно, вокруг PostgreSQL сложилось активное и отзывчивое сообщество. Но всё хорошо лишь пока вашему проекту не требуются горизонтальное масштабирование и отказоустойчивость. Тогда приходится использовать сторонние решения, основанные на Postgres и реализующие шардирование, такие как Citus. Погонять одним слоном несложно, но иметь целое стадо слонов довольно непросто. Особенно когда слоны должны поддерживать консистентность реплик и выполнять распределенные транзакции с уровнем изоляции «serializable».

YDB же с самого своего рождения является распределённой СУБД. Распределенные транзакции YDB изначально являются ключевой частью YDB (first-class citizens) и по умолчанию выполняются с уровнем изоляции «serializable». В настоящее время мы активно занимаетмся совместимостью с PostgreSQL, поскольку видим сильный спрос среди пользователей PostgreSQL на автоматическое масштабирование и обеспечение отказоустойчивости существующих приложений. Вот почему мы поддерживаем TPC-C для PostgreSQL (и надеемся, что скоро наши патчи примут в Benchbase).
Основы и мотивация
Сначала вспомним основные понятия: что такое конкурентное (concurrent) и параллельное выполнение, и какие преимущества у синхронных запросов по сравнению с асинхронными.
Если несколько операций выполняется одновременно, то можно сказать, что они выполняются конкурентно. При этом выполнение может быть как параллельным, так и последовательным (в т.ч. поочередным). Например, вы можете одновременно писать код в IDE и общаться с коллегами в telegram. Скорее всего вы делаете это последовательно, переключаясь туда-сюда между задачами. Или же можно гулять с собакой и разговаривать по телефону — тогда операции выполняются параллельно.
Теперь представим, что приложение делает запрос к базе данных. Запрос будет отправлен по сети, выполнен СУБД, после чего приложение получит ответ. Обратите внимание, что круг по сети может быть самой долгой частью выполнения запроса и занимать несколько миллисекунд. Что же приложение делает, пока ждёт ответа?
-
Если запрос синхронный, он заблокирует поток выполнения. Такой код очень легко писать: в строке 1 отправка запроса, а в строке 2 обработка ответа. Например:
String userName = get_username_from_db(userId); System.out.printf("Hello, %s!", userName);
-
Запрос может быть асинхронным. Тогда поток не блокируется и программа продолжает выполняться, пока происходит параллельная обработка запроса:
CompletableFuture<String> userNameFuture = get_username_from_db(userId); // Note, that this is kind of callback, it's not executed "right here", // even more, at some point it will be executed in parallel with your thread. // In real life scenarios, you will have to use mutual exclusion. userNameFuture.thenAccept(userName -> { System.out.println("Hello, %s!", userName); }); execute_something_else(); userNameFuture.get(); // wait for the completion of your request
В любом случае есть две конкурентные (concurrent) задачи: ожидание приложением ответа и обработка запроса СУБД. Синхронный код очень легок в написании и понимании. Но что если нужно выполнять одновременно тысячи обращений к базе? Придется создать по потоку на каждый запрос. Создание потоков в Linux дёшево, но есть причины, по которым не стоит создавать слишком много потоков:
-
Каждому потоку нужен стек. Невозможно аллоцировать памяти меньше, чем размер одной страницы в вашей ОС, что обычно составляет 4 KiB или 2 MiB в случае больших страниц (huge pages).
-
Не забывайте о планировщике потоков Linux. Попробуйте создать 100 000 потоков, но сначала удостоверьтесь, что у вашего компьютера есть кнопка «reset».
Именно поэтому до Java 21 было невозможно писать синхронный код с очень высокой степенью конкурентного выполнения: большое число потоков просто не создать. В какой-то момент времени благодаря Go произошла революция: горутины реализуют легковесную конкурентность (lightweight concurrency), что позволяет писать синхронный код эффективно. Мы советуем посмотреть доклад Дмитрия Вьюкова о планировщике Go. Java 21 добавила поддержку виртуальных потоков, которые во многом похожи на горутины. Интересно, что ни горутины, ни виртуальные потоки не являются изобретением, это всего лишь реинкарнация потоков в пользовательском режиме (user-level threads).
Теперь читателю должна быть понятна проблема с синхронными запросами в TPC-C от Benchbase. Для обеспечения высокой нагрузки на СУБД, требуется запустить большое число складов TPC-C, создав при этом много потоков. У нас не получилось создать больше 30 000 терминалов-потоков, используя физические потоки. Но при использовании виртуальных потоков легко запустили сотни тысяч терминалов-потоков.
Дедлоки на раз-два
Представим, что у нас есть многопоточное приложение на Java. Переход на виртуальные потоки может помочь с производительностью, при этом он делается очень просто. Надо лишь поменять одну строчку кода, где создаются потоки, и приложение сможет обрабатывать тысячи конкурентных задач, без накладных расходов, связанных с физическими потоками. Вот пример из нашей реализации TPC-C:
if (useRealThreads) { thread = new Thread(worker); } else { thread = Thread.ofVirtual().unstarted(worker); }
Вот и всё, теперь приложение использует виртуальные потоки. Под капотом виртуальная машина Java создает пул потоков-носителей (carrier threads), которые выполняют пользовательские виртуальные потоки (virtual threads). Может показаться, что переход без каких-либо подводных камней, но в какой-то момент приложение неожиданно зависает.

Наша реализация TPC-C для PostgreSQL использует пул потоков c3p0. При этом согласно стандарту TPC-C каждый терминал должен использовать собственное подключение. Однако в реальных сценариях это непрактично, поэтому мы добавили опцию ограничения числа соединений с СУБД. Число терминалов намного больше числа соединений с СУБД, поэтому некоторые терминалы должны дождаться свободного соединения (т.е. момента, когда другой терминал перестанет использовать соединение).
Мы запустили TPC-C и бенчмарк завис. К счастью, дебажить такое очень просто:
-
Получите стеки, используя команду
jstack <PID>. -
Получите дамп с более детальной информацией, включая данные о потоках-носителях и виртуальных потоках с помощью команды
jcmd <PID> Thread.dump_to_file -format=text jcmd.dump.1.
Мы обнаружили, что виртуальные потоки, ожидающие соединения, захватывают поток-носитель и не отпускают его. Вот стек виртуального потока:
#7284 "TPCCWorker<7185>" virtual java.base/java.lang.Object.wait0(Native Method) java.base/java.lang.Object.wait(Object.java:366) com.mchange.v2.resourcepool.BasicResourcePool.awaitAvailable(BasicResourcePool.java:1503) com.mchange.v2.resourcepool.BasicResourcePool.prelimCheckoutResource(BasicResourcePool.java:644) com.mchange.v2.resourcepool.BasicResourcePool.checkoutResource(BasicResourcePool.java:554) com.mchange.v2.c3p0.impl.C3P0PooledConnectionPool.checkoutAndMarkConnectionInUse(C3P0PooledConnectionPool.java:758) com.mchange.v2.c3p0.impl.C3P0PooledConnectionPool.checkoutPooledConnection(C3P0PooledConnectionPool.java:685) com.mchange.v2.c3p0.impl.AbstractPoolBackedDataSource.getConnection(AbstractPoolBackedDataSource.java:140) com.oltpbenchmark.api.BenchmarkModule.makeConnection(BenchmarkModule.java:108) com.oltpbenchmark.api.Worker.doWork(Worker.java:428) com.oltpbenchmark.api.Worker.run(Worker.java:304) java.base/java.lang.VirtualThread.run(VirtualThread.java:309)
и стек потока-носителя:
"ForkJoinPool-1-worker-254" #50326 [32859] daemon prio=5 os_prio=0 cpu=12.39ms elapsed=489.99s tid=0x00007f3810003140 [0x00007f37abafe000] Carrying virtual thread #7284 at jdk.internal.vm.Continuation.run(java.base@21.0.1/Continuation.java:251) at java.lang.VirtualThread.runContinuation(java.base@21.0.1/VirtualThread.java:221) at java.lang.VirtualThread$$Lambda/0x00007f3c2424e410.run(java.base@21.0.1/Unknown Source) at java.util.concurrent.ForkJoinTask$RunnableExecuteAction.exec(java.base@21.0.1/ForkJoinTask.java:1423) at java.util.concurrent.ForkJoinTask.doExec(java.base@21.0.1/ForkJoinTask.java:387) at java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(java.base@21.0.1/ForkJoinPool.java:1312) at java.util.concurrent.ForkJoinPool.scan(java.base@21.0.1/ForkJoinPool.java:1843) at java.util.concurrent.ForkJoinPool.runWorker(java.base@21.0.1/ForkJoinPool.java:1808) at java.util.concurrent.ForkJoinWorkerThread.run(java.base@21.0.1/ForkJoinWorkerThread.java:188)
Видно, что зависание происходит на вызове Object.wait(), который используется вместе с synchronized. Из-за этого поток-носитель захватывается и не освобождается для выполнения другого виртуального потока. При этом потоки, владеющие соединением с базой, освобождают потоки-носители, т.к. выполняют сетевой ввод-вывод:
java.base/java.lang.VirtualThread.park(VirtualThread.java:582) java.base/java.lang.System$2.parkVirtualThread(System.java:2639) java.base/jdk.internal.misc.VirtualThreads.park(VirtualThreads.java:54) java.base/java.util.concurrent.locks.LockSupport.park(LockSupport.java:369) java.base/sun.nio.ch.Poller.pollIndirect(Poller.java:139) java.base/sun.nio.ch.Poller.poll(Poller.java:102) java.base/sun.nio.ch.Poller.poll(Poller.java:87) java.base/sun.nio.ch.NioSocketImpl.park(NioSocketImpl.java:175) java.base/sun.nio.ch.NioSocketImpl.park(NioSocketImpl.java:201) java.base/sun.nio.ch.NioSocketImpl.implRead(NioSocketImpl.java:309) java.base/sun.nio.ch.NioSocketImpl.read(NioSocketImpl.java:346) java.base/sun.nio.ch.NioSocketImpl$1.read(NioSocketImpl.java:796) java.base/java.net.Socket$SocketInputStream.read(Socket.java:1099) java.base/sun.security.ssl.SSLSocketInputRecord.read(SSLSocketInputRecord.java:489) java.base/sun.security.ssl.SSLSocketInputRecord.readHeader(SSLSocketInputRecord.java:483) java.base/sun.security.ssl.SSLSocketInputRecord.bytesInCompletePacket(SSLSocketInputRecord.java:70) java.base/sun.security.ssl.SSLSocketImpl.readApplicationRecord(SSLSocketImpl.java:1461) java.base/sun.security.ssl.SSLSocketImpl$AppInputStream.read(SSLSocketImpl.java:1066) org.postgresql.core.VisibleBufferedInputStream.readMore(VisibleBufferedInputStream.java:161) org.postgresql.core.VisibleBufferedInputStream.ensureBytes(VisibleBufferedInputStream.java:128) org.postgresql.core.VisibleBufferedInputStream.ensureBytes(VisibleBufferedInputStream.java:113) org.postgresql.core.VisibleBufferedInputStream.read(VisibleBufferedInputStream.java:73) org.postgresql.core.PGStream.receiveChar(PGStream.java:465) org.postgresql.core.v3.QueryExecutorImpl.processResults(QueryExecutorImpl.java:2155) org.postgresql.core.v3.QueryExecutorImpl.execute(QueryExecutorImpl.java:574) org.postgresql.jdbc.PgStatement.internalExecuteBatch(PgStatement.java:896) org.postgresql.jdbc.PgStatement.executeBatch(PgStatement.java:919) org.postgresql.jdbc.PgPreparedStatement.executeBatch(PgPreparedStatement.java:1685) com.mchange.v2.c3p0.impl.NewProxyPreparedStatement.executeBatch(NewProxyPreparedStatement.java:2544) com.oltpbenchmark.benchmarks.tpcc.procedures.NewOrder.newOrderTransaction(NewOrder.java:214) com.oltpbenchmark.benchmarks.tpcc.procedures.NewOrder.run(NewOrder.java:147) com.oltpbenchmark.benchmarks.tpcc.TPCCWorker.executeWork(TPCCWorker.java:66) com.oltpbenchmark.api.Worker.doWork(Worker.java:442) com.oltpbenchmark.api.Worker.run(Worker.java:304) java.base/java.lang.VirtualThread.run(VirtualThread.java:309)
Таким образом, мы оказываемся в следующей ситуации:
-
Абсолютно все потоки-носители захвачены виртуальными потоками, ожидающими соединения с базой.
-
Виртуальные потоки, владеющие соединением с базой, не могут завершить запрос и освободить сессию, т.к. нет свободных потоков-носителей.
Дедлок на раз-два!
Согласно JEP 444:
There are two scenarios in which a virtual thread cannot be unmounted during blocking operations because it is pinned to its carrier:
When it executes code inside a synchronized block or method, or
When it executes a native method or a foreign function.
Проблема в том, что код с synchronized может находится глубоко внутри используемых библиотек. В нашем случае это c3p0. Исправление очень простое: мы обернули получение соединений в java.util.concurrent.Semaphore. Благодаря этому виртуальные потоки теперь блокируются на семафоре, отпуская поток-носитель, а внутрь c3p0 попадают только тогда, когда есть свободное соединение.
Заключение
Похоже, что несмотря на десятилетия улучшений в области разработки ПО, серебряной пули по-прежнему не существует. Тем не менее виртуальные потоки Java 21 — значимая фича, благодаря которой синхронный код становится эффективным. Но используйте эту фичу осторожно и всё будет хорошо.
ссылка на оригинал статьи https://habr.com/ru/articles/786550/
Добавить комментарий