Привет, хабр! Меня зовут Евгений Иванов, я разработчик YDB. Но сегодня я бы хотел представить Вашему вниманию пост не о YDB, а о виртуальных потоках Java 21 на примере TPC-C для PostgreSQL.
![Дедлок у обедающих философов](https://habrastorage.org/webt/dp/0q/7m/dp0q7mabdurvyux8zyx5hsh3b1q.jpeg)
В посте о TPC-C для YDB, мы обсудили некоторые недостатки реализации TPC-C проектом Benchbase (при этом всё равно это очень классный проект). Одним из наиболее значимых недостатков, на наш взгляд, является ограничение конкурентного выполнения (concurrency limit) из-за создания слишком большого числа физических потоков. Мы решили эту проблему, перейдя на виртуальные потоки Java 21. Но оказалось, что бесплатный сыр бывает только в мышеловке. И в этом посте мы расскажем о примере дедлока в TPC-C для PostgreSQL, причиной которого является исключительно переход на виртуальные потоки - и никаких проблем обедающих философов.
Данный пост будет полезен Java разработчикам, которые планируют перейти на виртуальные потоки. Сначала мы кратко обсудим такие фундаментальные понятия, как синхронные и асинхронные запросы. А затем рассмотрим важную особенность виртуальных потоков: дедлоки непредсказуемы и могут возникать глубоко внутри библиотек, которые вы используете. К счастью, отладка очень проста и мы покажем, как находить такие дедлоки.
Почему мы говорим о PostgreSQL в блоге YDB
PostgreSQL — это система управления базами данных с открытым исходным кодом, известная своей высокой производительностью, богатым набором функций и продвинутым уровнем соответствия стандарту SQL. Что не менее важно, вокруг PostgreSQL сложилось активное и отзывчивое сообщество. Но всё хорошо лишь пока вашему проекту не требуются горизонтальное масштабирование и отказоустойчивость. Тогда приходится использовать сторонние решения, основанные на Postgres и реализующие шардирование, такие как Citus. Погонять одним слоном несложно, но иметь целое стадо слонов довольно непросто. Особенно когда слоны должны поддерживать консистентность реплик и выполнять распределенные транзакции с уровнем изоляции "serializable".
![](https://habrastorage.org/webt/ua/sx/zp/uasxzpvgafysqk4fcdhftfpunfa.png)
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). Может показаться, что переход без каких-либо подводных камней, но в какой-то момент приложение неожиданно зависает.
![](https://habrastorage.org/webt/mo/1z/hd/mo1zhdt_htf1qmz_lqol-g_imio.png)
Наша реализация 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 попадают только тогда, когда есть свободное соединение.
Заключение
![Обложка книги Фреда Брукса "Мифический человекомесяц". Авторские права на обложку книги принадлежат издательству Addison-Wesley или художнику-иллюстратору](https://habrastorage.org/webt/w1/lg/fk/w1lgfkdxqsvjsqejwku_jhbxohs.jpeg)
Похоже, что несмотря на десятилетия улучшений в области разработки ПО, серебряной пули по-прежнему не существует. Тем не менее виртуальные потоки Java 21 - значимая фича, благодаря которой синхронный код становится эффективным. Но используйте эту фичу осторожно и всё будет хорошо.