NVIDIA 初创加速计划,免费加速您的创业启动 了解详情
写点什么

Java 21 虚拟线程的陷阱:我们在 TPC-C for PostgreSQL 中遭遇死锁

作者:Evgeniy Ivanov

  • 2024-02-04
    北京
  • 本文字数:5419 字

    阅读完需:约 18 分钟

Java 21 虚拟线程的陷阱:我们在 TPC-C for PostgreSQL 中遭遇死锁

在上一篇关于 TPC-C 的文章中,我们讨论了 Benchbase 项目中 TPC-C 原始实现的一些缺点(尽管如此,它还是很棒)。其中一个缺点是因生成的物理线程太多所导致的并发性限制,我们通过切换到 Java 21 虚拟线程解决了这个问题。后来我们发现,像往常一样,天下没有免费的午餐。这篇文章中展示了一个案例研究,我们在 TPC-C for PostgreSQL 中遇到了虚拟线程死锁。

Java 21 哲学家就餐问题


这篇文章对正在考虑切换到虚拟线程的 Java 开发人员可能会有所帮助。我们着重强调了虚拟线程潜在的一个重要问题:死锁可能是不可预测的,因为它们可能发生在你所使用的库的深处。幸运的是,调试很简单,我们将探讨如何在发生死锁时找到它们。


我们为什么要在 YDB 的博客上讨论 PostgreSQL


PostgreSQL 是一个开源数据库管理系统,以高性能、丰富的特性集、先进的 SQL 遵从性以及充满活力的支持性社区而闻名。如果不考虑水平可扩展性和容错性,那么它是很棒的。最终,你会选择基于 PostgreSQL 的第三方解决方案,比如 Citus,它实现了 PostgreSQL 分片。养一只大象可能很有趣,但管理一群大象是一项挑战,特别是如果你希望维护多个一致的副本,并使用序列化隔离执行分布式事务。



与此相反,YDB 一开始设计时就是一个分布式数据库管理系统。YDB 将分布式事务作为一等公民,默认即在序列化隔离级别上运行。现在,我们正在积极地兼容 PostgreSQL,因为我们看到,PostgreSQL 用户对现有应用程序的自动扩展和容错性有着强烈的需求。这就是我们维护 TPC-C for PostgreSQL 的原因(我们希望很快将其合并到上游 Benchbase 项目中)。


背景和动机简述


首先,我们回顾下一些基本概念:并发、并行执行以及异步与同步请求。


并发意味着任务在同一时间以并行或顺序的方式执行。例如,你可能有两个活动:在编辑器中编写代码和与同事在 Slack 上聊天。你可以同时执行这两项任务,但不是并行执行。或者你可以带着你的狗散步,同时和朋友打电话。同样,你可以同时执行这两项任务,但这一次是并行执行。


现在,考虑一下应用程序向数据库发出请求的情况。请求通过网络发送,数据库提供服务,将应答发送回应用程序。注意,网络往返可能是请求中成本最高的部分,可能需要几毫秒。在等待回复时,你可以在应用程序端做些什么呢?


  1. 请求可能是同步的,也就是说,它将阻塞调用线程。这种方法的代码很简单:第 1 行发起有请求;第 2 行处理响应:


String userName = get_username_from_db(userId);System.out.printf("Hello, %s!", userName);
复制代码


  1. 请求可能是异步的。线程不会阻塞而是继续执行,而请求是并行处理的:


CompletableFuture<String> userNameFuture = get_username_from_db(userId);
// 注意,这是一种回调,它不会在"这里"执行,// 甚至在某些时候,它将与线程并行执行。// 在现实场景中,你将不得不使用互斥。userNameFuture.thenAccept(userName -> { System.out.println("Hello, %s!", userName);});execute_something_else();userNameFuture.get(); // 等待请求完成
复制代码


在每一种情况下都有两个并发任务:线程正在等待数据库的回复,而数据库正在处理请求。同步代码的编写非常简单,而且很容易阅读。但是,如果需要同时向数据库发出数千个请求,该怎么办呢?你必须为每个请求生成一个线程。在 Linux 中生成线程的成本很低,但生成的线程太多会令人非常担忧:


  1. 每个线程都需要一个堆栈。你分配的内存不能小于系统页面的大小,而页面的大小通常约为 4 KiB,除非你使用大页,其默认大小为 2 MiB。

  2. Linux 有一个调度器。如果有重置按钮的话,你可以尝试生成 10 万个准备执行的线程。


这就是在 Java 21 之前没有办法编写高并发性同步代码的原因:无法生成许多线程。Go 语言彻底改变了这一点:goroutine 提供了非常轻量级的并发,因此你可以高效地编写同步代码。建议你看下 Dmitry Vyukov 做的这个 关于 Go 调度器的演讲。Java 21 引入了虚拟线程,它在很多方面和 goroutine 类似。请记住,goroutine 和虚拟线程并不是一项新发明,而是用户级线程这一古老概念的转世。


现在就可以理解 Benchbase TPC-C 原始实现中数据库同步请求的问题了。要使数据库能够处理高负载,就必须运行许多 TPC-C 仓库,生成许多线程。在使用物理线程时,我们无法运行超过 3 万个终端线程,而在使用虚拟线程时,我们可以轻松拥有数十万个终端虚拟线程。


死锁很容易


假设你已经有了多线程 Java 代码。添加一个使用虚拟线程的选项非常简单,而且非常有益。只要简单地使用新的虚拟线程构建器替换标准线程创建代码,你的应用程序就可以处理数千个并发任务了,而且不会产生与物理线程相关的开销。下面这个例子来自我们的 TPC-C 实现:


if (useRealThreads) {    thread = new Thread(worker);} else {    thread = Thread.ofVirtual().unstarted(worker);}
复制代码


这样就行了。现在,你在使用虚拟线程了。在后台,Java 虚拟机会创建一个线程池carrier threads,它会执行virtual threads。这种转换看起来很完美,直到你的应用程序意外停止。


我们的 PostgreSQL TPC-C 实现利用了 c3p0 连接池。TPC-C 标准规定,每个终端都必须有自己的连接。然而,在许多实际的场景中,这是不现实的。因此,我们包含了一个选项用于限制数据库连接的数量。


终端的数量远远大于可用连接的数量。因此,部分终端必须等待会话变为可用,即由另一个终端释放。


当我们初次运行 TPC-C 时,应用程序意外停止了。幸运的是,调试很简单:


  1. 使用jstack -p <PID>捕获线程堆栈。

  2. 使用jcmd <PID> Thread.dump_to_file -format=text jcmd.dump.1创建更详细的当前状态转储,其中包括有关carrier threadsvirtual threads的信息。


经过研究,我们发现一些等待会话的虚拟线程锚定了它们的载体线程。下面是一个出现这种情况的虚拟线程的堆栈:


#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搭配使用的方法)中夯住了。这将导致载体线程被锚定,也就是说它不会被释放用于执行其他虚拟线程。同时,会话持有者在等待 I/O 操作时释放了它们的载体线程:


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 指出:


在两种情况下,虚拟线程在阻塞操作期间无法卸载,因为它被锚定在它的载体线程上:

当它执行同步块或方法中的代码时,或者当它执行本机方法或外部函数时。


问题是,这种同步代码可能深嵌在你所使用的库中。在我们的示例中,它位于 c3p0 库中。因此,修复很简单:我们只需用java.util.concurrent.Semaphore封装连接。通过这种修改,虚拟线程会被阻塞在信号量上,关键的是,载体线程得以释放,而不是在 c3p0 中陷入绝境。因此,我们永远不会阻塞在 c3p0 内部,因为我们只在有空闲会话可用时才进入 c3p0 代码。


小结



这是弗雷德·布鲁克斯所著《人月神话》一书的封面。这本书的封面艺术版权属于出版商 Addison-Wesley 或封面艺术家。


尽管软件开发已经发展了几十年,但似乎仍然没有什么银弹。不过,Java 21 虚拟线程是一个了不起的特性,如果使用得当,可以带来显著的好处:即使并发很高,也很容易编写出高效的异步代码。


原文链接:

https://blog.ydb.tech/how-we-switched-to-java-21-virtual-threads-and-got-deadlock-in-tpc-c-for-postgresql-cca2fe08d70b


2024-02-04 08:0011293

评论

发布
暂无评论

关于 WordPress-Automatic 的分布式办公实践

刘培培

分布式办公 远程办公

工作感悟随笔

程序员小岑

感悟

聊聊Redis SDS

huizhou92

redis

作为自由职业者,我的近况

一尘观世界

程序员 自由职业 复盘

程序员《后浪》-图文版

Java_若依框架教程

后浪 程序媛 娱乐

自助设备系列——增长点

孙苏勇

产品 行业资讯 智能设备

Redis学习笔记(列表类型)

编程随想曲

redis

go 怎样做 stw

huizhou92

Go 语言

理解go 的 sort

huizhou92

Go 语言

Java并发编程基础--Java内存模型

Java收录阁

DDD 实践手册(5. Factory 与 Repository)

Joshua

企业架构 设计模式 领域驱动设计 DDD 架构模式

Java并发编程--ReentrantLock

Java收录阁

并发编程

游戏开发通用技术和工具

波波

编程 游戏开发 H5游戏

回"疫"录(10):危机与希望

小天同学

疫情 回忆录 现实纪录 纪实

电脑城最简单骗局,仍然有无数人上当

周三不加班

电脑城 电脑选购

程序员• 后浪

古时的风筝

程序员 后浪

C++ sqlite3使用指南

泰伦卢

c c++ C#

深入浅出虚拟内存

泰伦卢

c c++ C#

架构师快问快答2

IT民工大叔

数据湖引擎是什么鬼

数据社

大数据 数据仓库 数据湖 数据架构

时间足够爱你

rmrf

学习 思考 持之以恒

想看懂stl代码,先搞定type_traits是关键

泰伦卢

c c++ C#

C++中glog源码剖析以及如何设计一个高效 log模块

泰伦卢

c c++ C#

原创 | 使用JUnit、AssertJ和Mockito编写单元测试和实践TDD (三)单元测试在整个测试体系中的位置

编程道与术

软件测试 TDD 单元测试 集成测试 验收测试

未完成乞丐版 Args

escray

学习 CSD 认证实战营

和孩子聊聊死亡

陈医僧Ethan

感悟 育儿

无所不能 就像妈妈一样

Neco.W

思考 情绪

每日算法之leetcode 50 Power

田镇珲

递归 LeetCode 分治

MacOS高效使用指南-我的体系化方案以及软件清单

lmymirror

高效工作 效率工具 知识管理 Mac 操作系统

Web百度离线地图开发

玏佾

WebGIS 离线地图 Web离线地图

Netty 源码解析(八): 回到 Channel 的 register 操作

猿灯塔

Java 21 虚拟线程的陷阱:我们在 TPC-C for PostgreSQL 中遭遇死锁_编程语言_InfoQ精选文章