Windows DLL加载器锁:Rust线程如何使JVM挂起

Lobsters Hottest 新闻

摘要

QuestDB工程师调试一个偶发的Windows挂起问题,该问题由涉及Windows DLL加载器锁、Rust线程本地存储销毁、JNI分离和JVM垃圾收集安全点机制的死锁引起。

<p><a href="https://lobste.rs/s/hpoydf/windows_dll_loader_lock_how_rust_thread">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/05/19 12:41

# Windows DLL 加载器锁:Rust 线程如何挂起你的 JVM 来源:https://questdb.com/blog/windows-dll-loader-lock-rust-jni-deadlock/ QuestDB 是开源的时间序列数据库,适用于从交易大厅到任务控制中心等高性能工作负载。它提供超低延迟、高摄取吞吐量和多层存储引擎。原生支持 Parquet 和 SQL,让你的数据保持可移植性、AI 就绪,且无供应商锁定。 --- ## 引言 (https://questdb.com/blog/windows-dll-loader-lock-rust-jni-deadlock/#introduction) 几周前,我们在 Windows CI 流水线中遇到了一次静默的、偶发的挂起。经过深入调查,我们发现了一个死锁,导致进程完全冻结,无法提取 Java 堆栈跟踪。这篇博文将带您了解我们的调试过程,并包含 Java 虚拟机垃圾回收、Rust 的线程本地存储、JNI(Java Native Interface)附加协议以及一个关键的 Windows 内核原语——加载器锁——的底层细节。 > **TL;DR:** 在 Windows 上,操作系统在线程终止期间(特别是在 Rust 的 TLS 销毁期间)持有进程范围内的**加载器锁**。TLS 销毁会触发 `jni-rs`,后者尝试将线程从 JVM 分离。此步骤将线程从“Native”状态转换为“VM”状态,并且由于 GC 正在运行,此转换在**安全点屏障**处被阻塞。Rust 线程等待 GC 将其取消暂停。同时,GC 正在等待一个*新创建*的 Java 线程报告到达。然而,这个新线程无法到达安全点;它被阻塞在 OS 初始化阶段,等待**加载器锁**(由 Rust 线程持有)。 ## 初现端倪:本地复现与线程转储 (https://questdb.com/blog/windows-dll-loader-lock-rust-jni-deadlock/#the-first-clues:-a-local-reproducer-and-thread-dumps) 我们的 CI 流水线使用 Azure Pipelines 在 Linux、MacOS 和 Windows 上运行一套测试。在 Windows 上,我们注意到有些测试套件会偶尔挂起,直到任务超时。我的第一反应是在本地重现该问题以收集更多细节。经过几次尝试后,挂起发生了,我成功捕获了一个进程转储。利用这个进程转储,我使用 WinDbg (https://learn.microsoft.com/en-us/windows-hardware/drivers/debuggercmds/windbg-overview) 提取了本地堆栈,并使用 jhsdb (https://docs.oracle.com/en/java/javase/11/tools/jhsdb.html) 提取了 Java 堆栈。我们发现了三条线索: 1. **主线程卡在了 GC 中:** ``` "Time-limited test" #4053 daemon prio=5 tid=0x0000019183c06c30 nid=0x2270 waiting on condition [0x00000019f0afe000] java.lang.Thread.State: RUNNABLE JavaThread state: _thread_blocked - java.lang.Runtime.gc() @bci=0 (Compiled frame; information may be imprecise) - java.lang.System.gc() @bci=3, line=1907 (Compiled frame) - io.questdb.ServerMain.start(boolean) @bci=66, line=251 (Interpreted frame) - locked <0x0000000704bc3138> (a com.questdb.AbstractEntBootstrapTest$EntGriffinServerMain) - io.questdb.ServerMain.start() @bci=2, line=239 (Interpreted frame) ``` 它正在等待所有线程到达一个“安全点”,这是 JVM 在 VM 级别操作(包括垃圾回收)期间安全暂停线程的一种机制。 2. **几个 Rust Tokio 工作线程** 在它们的 `on_thread_stop` 钩子中,正在进行 JNI 调用。 ``` 53 Id: 4994.575c Suspend: 0 Teb: 00000019`f07a8000 Unfrozen "tokio-runtime-worker" # Call Site 00 ntdll!NtWaitForSingleObject+0x14 01 KERNELBASE!WaitForSingleObjectEx+0x8e 02 jvm!XXX+0x1cb607 03 jvm!XXX+0x5e9b4 04 jvm!XXX+0x624b9 05 jvm!XXX+0x11a06f 06 qdb_ent14818614347342639976!jni::wrapper::jnienv::JNIEnv::call_method_unchecked<jni::wrapper::objects::jmethodid::JMethodID>+0xa681 [C:\w\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\jni-0.21.1\src\wrapper\macros.rs @ 86] 07 qdb_ent14818614347342639976!qdb_ent::call_method<qdb_ent::call_void_method::closure$0,qdb_ent::call_void_method::closure_env$0>+0xf0 [C:\w\questdb-ent\rust\qdb-ent\src\lib.rs @ 56] 08 qdb_ent14818614347342639976!qdb_ent::call_void_method+0x49 [C:\w\questdb-ent\rust\qdb-ent\src\lib.rs @ 86] 09 qdb_ent14818614347342639976!qdb_ent::tokio::ThreadLifetimeListener::on_thread_stop+0x35 [C:\w\questdb-ent\rust\qdb-ent\src\tokio.rs @ 46] 0a qdb_ent14818614347342639976!qdb_ent::tokio::Java_com_questdb_tokio_TokioRuntime_create::closure$4+0xe [C:\w\questdb-ent\rust\qdb-ent\src\tokio.rs @ 101] ``` 注意:我们使用 `XXX` 隐藏了一些地址,因为调试符号不可用。 3. **存在几个未命名的线程散落各处。** > **附注:什么是安全点?** > 安全点 (https://github.com/openjdk/jdk/blob/9ec46968fbfddf99a8349cb6903d24b1c2fdaf1d/src/hotspot/share/runtime/safepoint.hpp#L36) 是执行中的一个点,在该点线程的状态对 JVM 是完全可以描述的:所有对象引用都位于已知位置(寄存器、栈槽或堆),并且没有堆突变在进行中。JVM 只有在*所有* 变异器线程同时停在安全点时,才能执行某些全局操作——最显著的是 GC。(自 JDK 10 起,线程本地握手 (https://openjdk.org/jeps/312) 允许对单个线程进行某些操作,但 GC 仍然需要全局停止。) > 该机制:JVM 预分配 (https://github.com/openjdk/jdk/blob/a34994476e8f4783c9f5a83a9c3db63ad605b323/src/hotspot/share/runtime/safepointMechanism.cpp#L66-L77) 两个连续的内存页——一个“坏”页(无访问权限)和一个“好”页(可读)。JIT 编译器在方法返回和循环回边处发出 (https://github.com/openjdk/jdk/blob/9cca4f7c760bea9bf79f7c03f37a70449acad51e/src/hotspot/share/opto/parse1.cpp#L2278) 轮询指令。为了启用安全点,VM 将线程的轮询地址从好页切换到坏页 (https://github.com/openjdk/jdk/blob/9e843f56ec0e4126e8256dff44f47c56e5282d20/src/hotspot/share/runtime/safepointMechanism.inline.hpp#L101-L104)。读取坏页会触发 SIGSEGV(或在 Windows 上造成访问违规),JVM 的信号处理程序会捕获该信号并用于在安全点屏障处阻塞线程 (https://github.com/openjdk/jdk/blob/d328e4e7e2f58fbfeb661f3502f95016159d7230/src/hotspot/os/windows/os_windows.cpp#L2718)。 > 通过 JNI 执行本地代码的线程处于特殊的“Native”状态 (https://github.com/openjdk/jdk/blob/f5bc6ee90d73da00cab5cad283b9517c692bc895/src/hotspot/share/utilities/globalDefinitions.hpp#L990),并且不进行轮询——它们被视为“安全” (https://github.com/openjdk/jdk/blob/1992b69a4794d1f2f65eaeb6dbb1e1e23a948b6e/src/hotspot/share/runtime/safepoint.cpp#L525),因为不应持有直接的对象引用。然而,当一个本地线程转换回“VM”状态时(通过任何 JNI 调用,包括 `DetachCurrentThread`),它*必须*检查安全点标志 (https://github.com/openjdk/jdk/blob/db6fa5923cd0394dfb44c7e46c3e7ccc102a933a/src/hotspot/share/runtime/interfaceSupport.inline.hpp#L106)。如果安全点正在进行,该线程会阻塞,直到 VM 操作完成。 ## 误导线索 (https://questdb.com/blog/windows-dll-loader-lock-rust-jni-deadlock/#the-red-herring) 堆栈跟踪指向了我们的 Rust 集成,但我并不确信。大多数失败的测试套件从未触及 Rust 代码。它们没有产生 Tokio 线程,也没有调用本地函数,然而系统却在它们执行期间停止响应。此外,我们还向 Rust 拆卸逻辑和 tokio 添加了广泛的日志记录,以确认没有来自*我们的* tokio 运行时的线程散落。我们在 CI 运行器中添加了 ProcDump (https://learn.microsoft.com/en-us/sysinternals/downloads/procdump)(`procdump -ma`),以便在挂起发生时捕获完整的内存转储。 ## 突破:安全点超时与加载器锁 (https://questdb.com/blog/windows-dll-loader-lock-rust-jni-deadlock/#the-breakthrough:-safepoint-timeout-and-the-loader-lock) 当我在处理 `ProcDump` 时,我的同事 **Jaromir Hamala** 成功地在启用安全点超时的情况下触发了故障(JVM 标志 `-XX:+SafepointTimeout -XX:SafepointTimeoutDelay=60000`)。由于我们知道主线程卡在了 `System.gc()` 中,我们怀疑是安全点问题。 ``` 2025-12-04T23:08:45.7599743Z [71.871s][warning][safepoint] 2025-12-04T23:08:45.7600792Z [71.871s][warning][safepoint] # SafepointSynchronize::begin: Timeout detected: 2025-12-04T23:08:45.7602055Z [71.871s][warning][safepoint] # SafepointSynchronize::begin: Timed out while spinning to reach a safepoint. 2025-12-04T23:08:45.7603054Z [71.872s][warning][safepoint] # SafepointSynchronize::begin: Threads which did not reach the safepoint: 2025-12-04T23:08:45.7604092Z [71.872s][warning][safepoint] # "Thread-84" #105 daemon prio=5 os_prio=0 cpu=0.00ms elapsed=60.01s ... runnable 2025-12-04T23:08:45.7605091Z [71.872s][warning][safepoint] java.lang.Thread.State: RUNNABLE 2025-12-04T23:08:45.7605880Z [71.872s][warning][safepoint] 2025-12-04T23:08:45.7607402Z [71.872s][warning][safepoint] # SafepointSynchronize::begin: (End of list) 2025-12-04T23:55:36.3930473Z ##[error]The task has timed out. ``` 在这种情况下,GC 需要一个安全点,但一个线程(`Thread-84`)不合作。0 CPU 使用率与 `RUNNABLE` 状态结合令人怀疑。这意味着线程没有在做工作,但也没有在等待标准的 Java 锁。从 JVM 的角度来看,该线程状态健康,但它卡在了 Java 之外的某个地方。 Jaromir 随后偶然发现了 JNA Issue #1479 (https://github.com/java-native-access/jna/issues/1479):*在 Windows 10+ 上,不正确的线程分离会导致 LDR 死锁*。加载器锁是一个每进程互斥量,Windows 在 DLL 加载/卸载以及启动/终止线程时持有它。Rust 文档 (https://doc.rust-lang.org/stable/std/thread/struct.LocalKey.html#synchronization-in-thread-local-destructors) 实际上警告过这一点。当线程退出时,它会在持有加载器锁的情况下运行线程本地存储(TLS)析构器。如果这些析构器尝试与同步原语交互,可能会导致死锁 (https://learn.microsoft.com/en-us/windows/win32/dlls/dynamic-link-library-best-practices)。 基于此,Jaromir 形成了一个关于死锁如何发生的理论: 1. **Rust 线程退出**:一个 Rust 线程完成执行。Windows 开始线程拆卸并获取加载器锁。 2. **TLS 析构器运行**:在持有加载器锁的情况下,线程运行其 TLS 析构器。其中一个析构器(来自 `jni-rs`)尝试将线程从 JVM 分离。 3. **分离触发安全点检查**:JNI 分离调用将线程从“Native”状态转换为“VM”状态。此转换需要轮询安全点。 4. **GC 阻塞 Rust 线程**:GC 已经在运行,因此 Rust 线程在安全点屏障处阻塞,等待 GC 完成时被取消暂停。 5. **GC 等待新的 Java 线程**:GC 无法完成,直到*所有*线程都到达安全点。一些新创建的 Java 线程还没有报告到达。 6. **新线程被加载器锁阻塞**:这些新的 Java 线程卡在 OS 初始化中——Windows 阻塞它们,因为它们需要加载器锁,而 Rust 线程仍然持有该锁。 **循环完成**:Rust 线程等待 GC,GC 等待新线程,新线程等待加载器锁,而加载器锁由 Rust 线程持有。 > **附注:JVM 如何在 Windows 上启动线程?** > 理解 Windows 线程创建对于这个死锁至关重要。JVM 并不会简单地调用 `CreateThread` 并让它运行。相反,它使用一种两阶段方法,为 JVM 和 OS 之间的状态分歧创造了窗口。 > **阶段 1:挂起创建。** JVM 使用 `CREATE_SUSPENDED` 标志创建线程 (https://github.com/openjdk/jdk/blob/d328e4e7e2f58fbfeb661f3502f95016159d7230/src/hotspot/os/windows/os_windows.cpp#L743-L755): > ```cpp > const unsigned initflag = CREATE_SUSPENDED | STACK_SIZE_PARAM_IS_A_RESERVATION; > thread_handle = (HANDLE)__beginthreadex(nullptr, (unsigned)stack_size, &thread_native_entry, thread, initflag, &thread_id); > ``` > 这会分配线程的所有 OS 资源,但线程尚未执行。JVM 将内部状态标记为 `INITIALIZED` (https://github.com/openjdk/jdk/blob/d328e4e7e2f58fbfeb661f3502f95016159d7230/src/hotspot/os/windows/os_windows.cpp#L800)。 > **阶段 2:危险的交接。** 当需要启动线程时,`os::start_thread` (https://github.com/openjdk/jdk/blob/72ebca8a0b19fac8a9483e5a3a98b454176fc342/src/hotspot/share/runtime/os.cpp#L847) 按顺序执行两件事: > ```cpp > void os::start_thread(Thread* thread) { > OSThread* osthread = thread->osthread(); > osthread->set_state(RUNNABLE); // 1. 标记为 RUNNABLE > pd_start_thread(thread); // 2. 然后调用 ResumeThread() > } > ``` > 状态在*线程实际恢复之前*设置为 `RUNNABLE`。这发生在父线程的上下文中。 > **间隙。** 在调用 `ResumeThread()` (https://github.com/openjdk/jdk/blob/d328e4e7e2f58fbfeb661f3502f95016159d7230/src/hotspot/os/windows/os_windows.cpp#L3802) 之后,新线程由 Windows 调度,但它必须先获取加载器锁以完成 DLL 初始化,然后才能运行用户代码。OSThread 状态文档 (https://github.com/openjdk/jdk/blob/2979806c72561cb4d4e8ac3d44dbcea347ace966/src/hotspot/share/runtime/osThreadBase.hpp#L45) 承认了这个间隙:*“已启动且可运行,但不一定正在运行。”* 这创建了一个关键窗口:JVM 认为线程 `RUNNABLE` 并期望它到达安全点,但该线程可能被阻塞在 OS 级别,等待加载器锁。如果另一个线程无限期地持有该锁(例如,被阻塞在 JVM 安全点上),新线程将永远无法取得进展——而 JVM 将永远等待一个看似健康但实际上冻结在内核空间的线程。 ## 10 秒之谜 (https://questdb.com/blog/windows-dll-loader-lock-rust-jni-deadlock/#the-10-second-mystery) 这个理论很坚实,但它没有解释“纯 Java”的崩溃。为什么这发生在不使用 Rust 的测试中?我终于成功地从挂起的 CI 运行器中捕获了一个有效的 ProcDump(`-ma` 用于完整转储),该运行器在一次“不相关”的测试中。我打开了转储,期望只看到 Java 线程。相反,我发现 Tokio 工作线程正在被销毁。但这是怎么回事?这些测试没有使用我们的 Rust 模块。让我们看看其中一个 Tokio 线程: ``` 34 Id: 2ac.a68 Suspend: 0 Teb: 0000003f`d963a000 Unfrozen "opendal-tokio-worker-18" # : Call Site 00 : ntdll!NtWaitForAlertByThreadId+0x14 ... 0a : ntdll!ImageTlsCallbackCaller+0x1a 0b : ntdll!LdrpCallInitRoutine+0x6b 0c : ntdll!LdrpCallTlsInitializers+0xc5 <-- 确凿证据 (TLS 析构器) 0d : ntdll!LdrShutdownThread+0x14e 0e : ntdll!RtlExitUserThread+0x3e 0f : kernel32!BaseThreadInitThunk+0x19 10 : ntdll!RtlUserThreadStart+0x2b ``` 罪魁祸首是 OpenDAL (https://github.com/apache/opendal),我们在代码库的其他部分使用它们的 Java 库。事实证明,如果你没有向 OpenDAL 的 Java 绑定提供 `AsyncExecutor`(它只不过是 Tokio 运行时的包装器),它会在一个*全局静态变量*中创建一个默认的 Tokio 运行时。当我们的 Rust 密集型测试完成时,它们清理了自己的资源,但 OpenDAL 的全局运行时即使在未使用它的测试中也持续存在。 ```rust // bindings/java/src/executor.rs static mut RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new(); ``` Jaromir 注意到了一个奇特的模式:在 Rust 相关测试和“不相关”的失败之间恰好有 10 秒的延迟。我查阅了 Tokio 源代码 (https://github.com/tokio-rs/tokio/blob/03fe44c10302fdb55c29dbe5b08d4f8769c80272/tokio/src/runtime/blocking/pool.rs#L502-L604)。虽然主线程池永远存活,但**阻塞线程有一个空闲超时**: ```rust // DISCL (原文此处截断,但翻译中保留其作为注释的意图) ```

相似文章

用户更改键盘布局时程序挂起的问题

The Old New Thing (Raymond Chen)

一个调试故事,讲述了当用户更改键盘布局(例如使用 Win+Space 快捷键)时,Windows 程序挂起的原因,是由于一个后台线程创建了窗口但没有泵送消息。修复方法是要么泵送消息,要么销毁窗口。

核心转储流行病学:修复一个18年的旧bug

OpenAI Blog

OpenAI工程师详细描述了Rockset的C++数据基础设施中看似不可能的崩溃的诊断过程,揭示了一个Azure上的静默硬件损坏bug以及GNU libunwind中存在18年的竞态条件,最终通过崩溃数据的流行病学分析得以解决。

从零编写调试器

Hacker News Top

本文开启了一个使用Rust从零构建调试器的系列,首先介绍如何使用操作系统调试API附加到Windows进程。