拓冰建站拓冰建站
首页 / 资讯中心 / 正文

.NET Mono 运行时协作式挂起(Cooperative Suspend)机制深度解析

.NET Mono 运行时协作式挂起Cooperative Suspend机制深度解析【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime导读本文基于仓库中的设计文档 docs/design/mono/web/coop-suspend.md系统讲解 .NET Mono 运行时如何挂起线程以配合垃圾回收GC等操作。文章从抢占式Preemptive、协作式Cooperative与混合式Hybrid三种挂起策略的取舍出发深入剖析 Mono 当前基于状态机的实现——包括线程状态枚举、GC Safe/Unsafe/Neutral 三种代码模式、以及MONO_PREPARE_BLOCKING等关键宏的用法。读完本文你将理解 Mono 为什么选择协作挂起 混合补充的路线知道如何阅读和修改 mono-threads-state-machine.c 中的状态转换逻辑并掌握在运行时原生代码中安全编写 GC 感知代码safepoint、blocking 区、GC critical region的实战要领。一、背景运行时为什么需要挂起线程运行时在执行很多任务时最主要的场景是垃圾回收需要把线程挂起。历史上 Mono 使用信号signal或类似 API 完成这一操作但这种方式存在一系列深层次问题促使团队重新设计了整套挂起机制。挂起问题的本质是当运行时需要停止线程例如 GC 的某个阶段时可以走两条完全不同的路线——抢占式或协作式。1.1 抢占式挂起Preemptive Suspend抢占式方案由运行时向目标线程发送信号目标线程的信号处理器将自身置入睡眠直到收到恢复信号在 Windows 或 Apple 平台上也可以直接使用内核调用停止线程。任意点挂起线程会在任意位置被挂起挂起者必须运行在信号上下文signal context这种极度受限的环境中锁被占用线程可能在持有运行时锁或 libc 锁时被挂起导致连printf这类最基本的操作都无法执行无法检查上下文在 watchOS、WebAssembly 等平台上缺乏足够的 OS 能力去检查被挂起线程的上下文——既看不到寄存器的内容也看不到栈因此抢占式挂起在这些系统上对 GC 等需要检查线程状态的操作毫无用处。1.2 协作式挂起Cooperative Suspend协作式方案的思路是线程在运行时发出请求后主动自我挂起。这要求频繁的轮询polling与检查点checkpointing是业界广为理解并采用的成熟模型。只要线程在运行托管代码它最终都会到达某个安全点safepoint并自我挂起因此挂起点总是恰到好处的当线程调用原生代码时协作模式需要更多簿记工作原生代码中没有 safepoint可能阻塞任意长的时间。因此运行时在所有线程从托管代码GC Unsafe因为可操作托管内存切换到原生代码GC Safe因为不允许访问托管内存的位置打上标记线程处于 GC Safe 模式时运行时不去尝试挂起它而是放行直到它试图回到 GC Unsafe 模式为止协作式的问题在于依赖嵌入方embedder和原生代码配合——如果原生代码突然回调 MonoGC 可能认为该线程不在托管代码中而它实际上又在运行托管代码从而引发问题。要使用抢占式语义原生代码必须显式标注 GC 转换点告诉运行时线程何时在 GC Safe 与 GC Unsafe 模式之间切换。1.3 混合式挂起Hybrid SuspendMono 的最终选择混合式挂起结合了前两种方案线程处于托管代码或 Mono 运行时自身时处于GC Unsafe模式运行时尝试协作式挂起——期待线程到达 safepoint 后自我挂起线程调用外部原生代码时切换到GC Safe模式并转为抢占式挂起——无论它在跑什么原生代码都会被停下来无法通过触碰托管内存或调用运行时函数来破坏运行时的假设。混合式需要更多簿记工作每个嵌入 API 函数都要在入口从 GC Safe 切换到 GC Unsafe、出口再切回但所有簿记都由运行时完成无需用户代码介入。这正是混合式的价值嵌入方代码无需感知挂起策略表现和抢占式完全一样同时又比纯抢占式更不容易把线程挂起在让运行时尴尬的状态。在源码层面这一策略枚举定义于 mono-threads-coop.h 中typedef enum { MONO_THREADS_SUSPEND_FULL_PREEMPTIVE 1, MONO_THREADS_SUSPEND_FULL_COOP 2, MONO_THREADS_SUSPEND_HYBRID 3, } MonoThreadsSuspendPolicy;配套的辅助函数同样位于 mono-threads-coop.h分别回答是否启用 blocking 转换是否启用 safepoint是否启用多阶段 STW等问题。从源码结构看目前只有混合式挂起使用多阶段 stop-the-worldmono_threads_suspend_policy_is_multiphase_stw_enabled仅在p MONO_THREADS_SUSPEND_HYBRID时返回 TRUE。二、协作式 / 混合式挂起的工作原理协作式挂起把挂起者suspender thread的能力限制为仅向目标线程请求自我挂起。目标线程通过两种方式响应挂起请求频繁轮询自身状态以及在运行时失去对线程控制的点pinvoke、阻塞系统调用检查其状态。据此代码可划分为三类分别决定了协作式挂起如何处理2.1 托管代码Managed code托管代码会在函数序言prologue、catch 处理器和循环回边back-edge of loops处检查挂起请求从而保证挂起请求在有界时间内被响应。这些检查点即所谓的safepoint。该机制由 mini.c 中的mono_insert_safepoints实现文档指向了 Mono 的历史提交 0e12ff3 作为参考实现[1]。它会在方法周围插入OP_GC_SAFE_POINT操作码随后各后端为这些新操作码生成机器码。当前仓库中mini.c 的insert_safepoint函数展示了具体插入逻辑它分配一个指向mono_polling_required的 AOT 常量MONO_PATCH_INFO_GC_SAFE_POINT_FLAG新建OP_GC_SAFE_POINT指令然后根据基本块类型异常处理器块、入口块或其他块将轮询地址与 safepoint 指令插入到合适位置static void insert_safepoint (MonoCompile *cfg, MonoBasicBlock *bblock) { MonoInst *poll_addr, *ins; if (cfg-disable_gc_safe_points) return; g_assert (mini_safepoints_enabled ()); NEW_AOTCONST (cfg, poll_addr, MONO_PATCH_INFO_GC_SAFE_POINT_FLAG, (gpointer)mono_polling_required); MONO_INST_NEW (cfg, ins, OP_GC_SAFE_POINT); ins-sreg1 poll_addr-dreg; /* ...根据基本块类型插入 poll_addr 与 ins... */ }轮询侧的核心在 mono-threads-coop.hMONO_API_DATA volatile size_t mono_polling_required; static inline void mono_threads_safepoint (void) { if (G_UNLIKELY (mono_polling_required)) mono_threads_state_poll (); }即JIT 生成的代码在 safepoint 处检查全局mono_polling_required标志一旦置位就调用mono_threads_state_poll真正执行轮询与可能的自我挂起。2.2 外部原生代码Foreign native code外部原生代码包括 pinvoke 以及运行时被嵌入时的任意原生代码。外部代码不触碰托管对象因此 GC 可以安全地忽略其栈与正在执行的代码。在执行 pinvoke 之前运行时保存当前线程寄存器并将其转换到等效于已挂起的状态——GC 可以原样采用这份已保存的状态同时忽略该线程仍在继续运行的事实。2.3 运行时原生代码Runtime native code运行时原生代码涵盖全部运行时代码——metadata、utils 与 mini其中icall 需要特别小心。运行时代码直接操作原始对象指针因此 GC 必须感知它们。处理方式是把运行时代码当作托管代码对待但不会有编译器自动插入 safepoint——轮询代码的插入与检查点checkpointing必须手动完成。此外一旦保存了线程状态之后访问托管内存的方式也必须格外谨慎。三、当前实现线程状态机当前实现是一个状态机刻画线程的即时状态。状态定义于 mono-threads.h 中状态枚举值含义StartingSTATE_STARTING 0x00线程初始状态处于此状态时不应发生任何重要事件DetachedSTATE_DETACHED 0x01线程正在关闭不会触碰托管内存或执行运行时工作RunningSTATE_RUNNING 0x02线程正在运行托管或运行时代码无待处理的挂起请求AsyncSuspendedSTATE_ASYNC_SUSPENDED 0x03线程被异步挂起正处于信号处理器中或已被thread_suspend调用在纯协作式挂起运行时线程时该状态不会出现SelfSuspendedSTATE_SELF_SUSPENDED 0x04线程自我挂起它试图切换到 blocking但存在待处理的挂起请求于是先自我挂起稍后回到 Running 并重试切换 blockingAsyncSuspendRequestedSTATE_ASYNC_SUSPEND_REQUESTED 0x05线程正在运行托管或运行时代码且另一线程请求挂起它BlockingSTATE_BLOCKING 0x06线程正在执行不会触碰托管内存的代码无待处理的挂起请求BlockingAsyncSuspendedSTATE_BLOCKING_ASYNC_SUSPENDED 0x07线程正在执行 blocking 代码时被抢占式挂起即混合式挂起模式恢复后回到执行 blocking 代码BlockingSelfSuspendedSTATE_BLOCKING_SELF_SUSPENDED 0x08线程执行完 blocking 代码但存在针对它的待处理挂起正在等待被恢复BlockingSuspendRequestedSTATE_BLOCKING_SUSPEND_REQUESTED 0x09线程正在执行不触碰托管内存的代码且有人请求挂起它在纯协作式模式下该线程被视为仍处于挂起状态值得注意的是源码中MonoThreadStateMachine是一个位域联合体同样定义于 mono-threads.h将整个线程状态压缩进一个 32 位整型typedef union { int32_t raw; struct { int32_t state : 7; /* 状态见上表 */ int32_t no_safepoints : 1; /* 该线程是否允许 safepoint */ int32_t suspend_count : 8; /* 挂起计数最大值 THREAD_SUSPEND_COUNT_MAX 0xFF */ }; } MonoThreadStateMachine;这样设计是为了让状态转换可以依赖原子 CAS见 mono-threads-state-machine.c 中的thread_state_cas基于mono_atomic_cas_i32——读写状态与挂起计数在单次原子操作内完成无需额外加锁。状态机转换图如下图片来自仓库 docs/design/mono/web/images/coop-state-machine.png除了状态之外还有一批**转换transition**用于把线程从一个状态迁移到另一个状态。四、源码结构三个核心文件的分工线程挂起被建模为状态机后相关代码分布在三个核心文件中文件职责mono-threads-state-machine.c状态机的全部转换逻辑一个转换对应一个函数所有对thread_state变量的修改都发生在这里。新函数必须遵循既有函数的模板并且要么在 switch 中处理每个状态、要么在注释中说明每个状态mono-threads.c线程基础设施的可移植实现针对具体目标平台的功能由多个后端完成见下本文件中的 ifdef 数量应保持在最低限度mono-threads-coop.c协作式后端不使用操作系统提供的任何异步 API后端文件的组织从 mono-threads.h 顶部的宏选择可以看出按平台区分了多种后端#ifdef HOST_WASM #define USE_WASM_BACKEND #elif defined (_POSIX_VERSION) #if defined (__MACH__) !defined (USE_SIGNALS_ON_MACH) #define USE_MACH_BACKEND #else #define USE_POSIX_BACKEND #endif #elif HOST_WIN32 #define USE_WINDOWS_BACKEND #endif仓库中可见对应的后端实现文件例如 mono-threads-posix.c、mono-threads-posix-signals.c、mono-threads-windows.c、mono-threads-mach.c、mono-threads-wasm.c以及多个 UNIX 变体linux、android、freebsd、netbsd、openbsd、aix、haiku、sunos。五、将运行时代码适配到协作式挂起三种 GC 模式要让运行时代码与协作式挂起协同工作必须满足两个性质有界时间内挂起——通过轮询以及阻塞前的检查点checkpointing访问托管堆时与 GC 协调。这两个性质互为补充。据此运行时中的每一段代码区域都被划分为三种类型之一明确标示能做什么、不能做什么GC Unsafe 模式此模式下GC 在发生显式轮询或转换到 GC Safe 模式之前无法推进。可以触碰托管内存读/写可以调用 GC Unsafe 或 GC Neutral 函数可以通过 pinning 将托管指针传给 GC Safe 区域/函数可以返回托管指针不能调用外部原生代码嵌入方回调、pinvoke 等不能调用阻塞函数/系统调用不能被 detachGC Safe 模式此模式下GC假定线程已被挂起并扫描最后一次保存的状态。可以调用外部函数可以调用阻塞函数/系统调用可以调用 GC Safe 或 GC Neutral 函数可以读取被 pin 的托管内存不能触碰托管内存读/写不能被 detachGC Neutral 模式此模式仅表示该函数在 Safe 与 Unsafe 模式下都能工作。对 GC 的实际影响取决于线程执行该函数时的动态模式。可以调用 GC Neutral 函数不能调用外部函数不能调用阻塞函数/系统调用不能读取被 pin 的托管内存不能触碰托管内存读/写不能被 detach此外还有一个特殊的函数组被允许以 detached 状态运行它们唯一被允许做的事情是attach、选择一个 GC 模式然后调用常规 GC 函数。所有函数都可以在模式之间来回转换。运行时提供的宏定义于 mono-threads-coop.h让函数中的某个区域以不同模式运行这些宏定义了 GC Safe / GC Unsafe 之间的可能转换。六、模式转换宏详解MONO_SUSPEND_CHECK作用轮询当前 GC 状态必要时挂起线程。适用条件仅在 GC Unsafe 模式下合法。使用场景发生大量计算、但没有显式阻塞时。MONO_PREPARE_BLOCKING / MONO_FINISH_BLOCKING作用创建一个 C 词法作用域造成从 Unsafe 到 Safe 模式的转换。适用条件仅在 Unsafe 模式下合法。使用场景适合包裹可能长时间阻塞的系统调用socket、IO。关键警告托管指针绝不能泄漏进 GC Safe 区域——GC 可能在线程处于该区域时运行并把被引用的对象搬移导致裸对象指针失效。原文档给出了经典的反面示例MonoArray *x; int res; MONO_PREPARE_BLOCKING res read (1, mono_array_addr (x, char, 0), mono_array_length (x), 0); // 若 read 阻塞在 OS 时发生 GC对象 x 可能被搬移 // x 将指向垃圾数据甚至指向另一个对象的中间。 // 而 OS 向传入 read 的缓冲区写入数据时会覆盖托管内存。 MONO_FINISH_BLOCKING要在 GC Safe 区域安全地使用对象引用必须用GC handle 将对象 pin 在托管堆中且不能访问该对象的任何 ref 字段。MONO_PREPARE_RESET_BLOCKING / MONO_FINISH_RESET_BLOCKING作用创建一个 C 词法作用域造成到 Unsafe 模式的转换退出时恢复之前的模式。适用条件任何模式下都合法。使用场景覆盖代码原本预期在 GC Safe 模式、但现在需要处于 GC Unsafe的情况。例如第一次调用 pinvoke 会命中 trampoline后者需要先把运行时移回 GC Unsafe 模式再去解析pinvoke 解析完成后必须恢复之前的模式。七、托管对象句柄与 GC Critical Region7.1 Mono coop handlesMonoObjectHandleMonoObjectHandle允许原生代码持有一个指向托管对象的句柄。目前原生代码直接持有裸托管指针之所以看起来没问题只是因为 GC 扫描原生栈时采用了保守式技术凡是看起来可能被原生栈引用的对象都会被 pin。未来运行时希望摆脱保守式扫描coop handles 正是原生代码与 GC 协调的方式原文档标注 TODO表示该部分仍待进一步文档化。7.2 MONO_PREPARE_GC_CRITICAL_REGION / MONO_FINISH_GC_CRITICAL_REGION当线程处于 Unsafe 模式并使用 coop handles 时它可能需要进入GC critical region——即以非原子方式操作托管对象、绝不能被打断的区域。在该区域内线程不得从 Unsafe 转换到 Safe 模式线程可以使用gc_handle_obj从 coop handle 取得托管对象的裸指针。GC critical region可以嵌套例如先进入一个 critical region再调用一个再次进入 critical region 的函数。7.3 MONO_REQ_GC_CRITICAL 与 MONO_REC_GC_NOT_CRITICAL在 checked 构建ENABLE_CHECKED_BUILD下这对宏用于断言线程分别处于 / 不处于 GC critical region是调试期验证正确性的利器。八、调试手段文档记录了两类调试辅助设施线程状态转储thread state dump当挂起超时失败时转储每个线程的状态并在开头附带一张提示卡cue card帮助解析日志开关togglesmono-threads.h 中为特定线程事件的日志提供了开关。这些日志极其冗长VERY verbose但在排查问题时确实能帮助弄清发生了什么。此外从 mono-threads-state-machine.c 的源码还可以看到两个与调试/正确性相关的细节state_name函数将状态数值映射为可读字符串STARTING、DETACHED、RUNNING等供转储与日志使用check_thread_state在 checked 构建中验证状态的一致性例如STATE_RUNNING时suspend_count必须为 0并可通过THREADS_STATE_MACHINE_DEBUG_ENABLED/ENABLE_CHECKED_BUILD_THREAD读取suspend_count与no_safepoints位域。九、已知问题原文档诚实地列出了当前实现仍未解决的若干问题理解这些有助于避免在相关区域踩坑9.1 无法完整处理嵌入 APIEmbedding API当前系统没有考虑运行时被嵌入使用的场景这归结为两个问题原生线程调用托管代码后继续做自己的事线程可能没有被留在合适的状态嵌入 API 允许裸对象访问这与协作式挂起不兼容——需要设计如何向嵌入方暴露 coop 能力。9.2 线程启动/结束处理仍不佳线程启动与结束存在大量 hack。如果挂起请求恰好命中正处于启动/结束过程中的线程会间歇性失败。9.3 不允许嵌套 blocking 状态早期设计决定禁止嵌套 blocking 状态——因为嵌套更复杂且可能在嵌套之间隐藏 bug。代价是难以用单个 blocking 区域覆盖大段代码。9.4 线程 attach/detach 待修订运行时的 attach/detach 机制需要重新审视设计者认为它已不太契合当前需求。十、给运行时开发者的实践要点综合全文可将协作式挂起的开发准则浓缩为以下几点托管代码不需要你操心 safepoint——mini.c 的mono_insert_safepoints/insert_safepoint会在序言、catch 处理器与循环回边自动插入OP_GC_SAFE_POINT运行时原生代码必须手动处理——大段计算用MONO_SUSPEND_CHECK轮询可阻塞的系统调用用MONO_PREPARE_BLOCKING/MONO_FINISH_BLOCKING包裹且严禁把托管裸指针泄漏进 GC Safe 区域需要时先 pin修改状态机时严格遵循模板——所有thread_state的修改都必须在 mono-threads-state-machine.c 中、一个转换一个函数且每个状态要么在 switch 中处理、要么在注释中说明使用 coop handles 与 GC critical region 走向精确式 GC——这是运行时摆脱保守式栈扫描的方向。参考资料本文所依据的设计文档docs/design/mono/web/coop-suspend.md状态机转换图docs/design/mono/web/images/coop-state-machine.png状态机实现mono-threads-state-machine.c协作式后端与挂起策略宏mono-threads-coop.c、mono-threads-coop.h线程状态枚举与位域联合体mono-threads.h可移植线程基础设施mono-threads.csafepoint 插入逻辑mini.c 中的mono_insert_safepoints/insert_safepoint原始设计参考提交Mono 提交0e12ff3017d470676e94e561cd0de4ca22230532[1]【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门