[Перевод] Как мы начали использовать виртуальные потоки Java 21 и на раз-два получили дедлок в TPC-C для PostgreSQL

Привет, хабр! Меня зовут Евгений Иванов, я разработчик 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».

uasxzpvgafysqk4fcdhftfpunfa.png

YDB же с самого своего рождения является распределённой СУБД. Распределенные транзакции YDB изначально являются ключевой частью YDB (first-class citizens) и по умолчанию выполняются с уровнем изоляции «serializable». В настоящее время мы активно занимаетмся совместимостью с PostgreSQL, поскольку видим сильный спрос среди пользователей PostgreSQL на автоматическое масштабирование и обеспечение отказоустойчивости существующих приложений. Вот почему мы поддерживаем TPC-C для PostgreSQL (и надеемся, что скоро наши патчи примут в Benchbase).

Основы и мотивация

Сначала вспомним основные понятия: что такое конкурентное (concurrent) и параллельное выполнение, и какие преимущества у синхронных запросв по сравнению с асинхронными.

Если несколько операций выполяется одновременно, то можно сказать, что они выполняются конкуренто. При этом выполнение может быть как параллельным, так и последовательным (в т.ч. поочередным). Например, вы можете одновременно писать код в IDE и общаться с коллегами в telegram. Скорее всего вы делаете это последовательно, переключаясь туда-сюда между задачами. Или же можно гулять с собакой и разговаривать по телефону — тогда операции выполняются параллельно.

Теперь представим, что приложение делает запрос к базе данных. Запрос будет отправлен по сети, выполнен СУБД, после чего приложение получит ответ. Обратите внимание, что круг по сети может быть самой долгой частью выполнения запроса и занимать несколько миллисекунд. Что же приложение делает, пока ждёт ответа?

  1. Если запрос синхронный, он заблокирует поток выполнения. Такой код очень легко писать: в строке 1 отправка запроса, а в строке 2 обработка ответа. Например:

String userName = get_username_from_db(userId);
System.out.printf("Hello, %s!", userName);
  1. Запрос может быть асинхронным. Тогда поток не блокируется и программа продолжает выполняться, пока происходит параллельная обработка запроса:

CompletableFuture 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();

userIdFuture.get(); // wait for the completion of your request

В любом случае есть две конкурентные (concurrent) задачи: ожидание приложением ответа и обработка запроса СУБД. Синхронный код очень легок в написании и понимании. Но что если нужно выполнять одновременно тысячи обращений к базе? Придется создать по потоку на каждый запрос. Создание потоков в Linux дёшево, но есть причины, по которым не стоит создавать слишком много потоков:

  1. Каждому потоку нужен стек. Невозможно аллоцировать памяти меньше, чем размер одной страницы в вашей ОС, что обычно составляет 4 KiB или 2 MiB в случае больших страниц (huge pages).

  2. Не забывайте о планировщике потоков 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). Может показаться, что переход без каких-либо подводных камней, но в какой-то момент приложение неожиданно зависает.

mo1zhdt_htf1qmz_lqol-g_imio.png

Наша реализация TPC-C для PostgreSQL использует пул потоков c3p0. При этом согласно стандарту TPC-C каждый терминал должен использовать собственное подключение. Однако в реальных сценариях это непрактично, поэтому мы добавили опцию ограничения числа соединений с СУБД. Число терминалов намного больше числа соединений с СУБД, поэтому некоторые терминалы должны дождаться свободного соединения (т.е. момента, когда другой терминал перестанет использовать соединение).

Мы запустили TPC-C и бенчмарк завис. К счастью, дебажить такое очень просто:

  1. Получите стеки, используя команду jstack -p .

  2. Получите дамп с более детальной информацией, включая данные о потоках-носителях и виртуальных потоках с помощью команды jcmd 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)

Таким образом, мы оказываемся в следующей ситуации:

  1. Абсолютно все потоки-носители захвачены виртуальными потоками, ожидающими соединения с базой.

  2. Виртуальные потоки, владеющие соединением с базой, не могут завершить запрос и освободить сессию, т.к. нет свободных потоков-носителей.

Дедлок на раз-два!

Согласно 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 или художнику-иллюстратору

Похоже, что несмотря на десятилея улучшений в области разработки ПО, серебряной пули по-прежнему не существует. Тем не менее виртуальные потоки Java 21 — значимая фича, благодаря которой синхронный код становится эффективным. Но используйте эту фичу осторожно и всё будет хорошо.

© Habrahabr.ru