.NET Profiling API 中 ObjectID 的安全使用时机:GC 阻塞语义、对象移动跟踪与 CORPROF_E_UNSUPPORTED_CALL_SEQUENCE 强制检查
发布时间:2026/9/17 14:17:34 作者:尧图编辑部 阅读量:1,286

.NET Profiling API 中 ObjectID 的安全使用时机GC 阻塞语义、对象移动跟踪与 CORPROF_E_UNSUPPORTED_CALL_SEQUENCE 强制检查【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtimeObjectID 是 .NET Profiling API 暴露给 profiler 的核心标识符之一但它并非一个安全句柄——它本质上是托管堆上对象的指针会随 GC 被回收或移动。本文以 CoreCLR 仓库中的《When is it safe to use ObjectIDs》技术笔记为主线结合 corprof.idl 与 proftoeeinterfaceimpl.cpp 的源码实现系统讲解 ObjectID 的两个合法使用窗口、通过 MovedReferences/SurvivingReferences 维护对象缓存的方法以及 .NET 4.0 起引入的调用序列强制检查。读完本文你将掌握在 profiler 中安全解引用 ObjectID、避免 GC 堆损坏的完整实战规则。ObjectID 是什么指向托管堆对象的裸指针在深入何时安全之前必须先明确 ObjectID 的本质。在 corprof.idl 中ObjectID 被定义为一个指针宽度的无符号整数typedef UINT_PTR ClassID; typedef UINT_PTR ObjectID;正如同系列的调试笔记 Debugging - SOS and IDs 所揭示的ObjectID 的内部结构其实就是指向托管堆对象的Object*。这意味着它不是由 CLR 维护的稳定句柄表条目而是一个直接指向 GC 堆内存的地址因此它天然受 GC 的两大行为影响对象可能被回收collect对象可能在堆上被移动compact/move它只是裸指针这一点也解释了为什么在调试时可以用 SOS 扩展的!DumpObject命令直接按地址检查它所指的对象。既然 ObjectID 的底层是堆指针问题就来了GC 随时可能让这个地址失效或指向别的对象那么 profiler 到底在什么时机拿到的 ObjectID 才是可信的核心规则ObjectID 只有两个合法使用窗口原文档给出了简洁而严格的总体指导如果你打算解引用一个 ObjectID或者把它传给某个 ICorProfilerInfo(2,3,4) 方法那么你必须满足以下两个条件之一从 GC 内部、由正在执行 GC 的线程发起。典型场景是在某个 GC 回调中例如GarbageCollectionStarted、MovedReferences、GarbageCollectionFinished等。此时可以保证 GC 正被这次回调阻塞着堆上的对象处于稳定状态ObjectID 暂时不会变化。从把该 ObjectID 交给你的那个回调内部发起。CLR 在回调中把 ObjectID 传给你的那一刻GC 被该回调阻塞因此回调内部对该 ObjectID 的读取是安全的。需要特别强调的是由回调给你这个限定不是任意回调里拿到任意 ObjectID 都安全而是这个 ObjectID 必须是当前这个回调自身作为参数传递给你的。你在别处缓存的旧 ObjectID不能指望在任意回调里拿来就用。这条规则背后的逻辑可以从 corprof.idl 中 GC 事件区的注释得到印证除ObjectAllocated外所有 GC 相关回调都是在运行时挂起suspended期间触发的因此在运行时恢复、下一次 GC 发生之前任何 ObjectID 的值都不会变化。换句话说GC 回调期间与派发回调期间这两类窗口的共性正是GC 处于被阻塞状态从而保证引用对象原地不动。为什么必须这样CLR 内部代码路径的假设原文档明确指出这一规则并非教条而是源于 CLR 内部的真实约束CLR 的某些代码路径假设调用它们的线程已经阻塞了 GC以确保被引用的对象保持在原地但在早期版本中并没有任何机制来强制这一假设成立。后果是可想而知的如果在 GC 正在移动对象的中途某个线程拿着过期的 ObjectID 去访问内存轻则读到错误的内存内容引用到错误的地址重则造成非确定性的 GC 堆损坏nondeterministic GC heap corruptions——这类 bug 极难复现也极难排查。一个直观的反面例子就是原文档中点名的场景在你自己创建的线程非托管线程上调用GetObjectSize。这样的调用不会天然阻塞 GC因此是 unsafe 的。关于这一点下文强制检查一节会展示运行时是如何从源码层面拒绝这种调用的。禁止盲目缓存 ObjectID必须用 MovedReferences / SurvivingReferences 维护把回调传来的 ObjectID 随手缓存到 profiler 自己的数据结构里留到以后再用在原文档中被明确称为big no-no。因为 GC 之后缓存里的地址要么指向已被回收的内存要么指向已被搬走对象的旧位置。如果你确实需要长期持有对象引用例如构建对象到元数据的映射表唯一合法的方式是在每一次 GC 时利用MovedReferences/SurvivingReferences回调把缓存中所有 ObjectID 更新到新位置。即便如此还有一个前提条件CLR 仍只允许你在上面说的两类时机把 ObjectID 传给 Info 方法否则会收到错误 HRESULT即下文要讲的强制检查。MovedReferences压缩型 GC 的对象搬迁报告在压缩型compactingGC 中存活对象会被搬移到堆的新位置MovedReferences回调corprof.idl以范围为单位报告搬迁信息HRESULT MovedReferences( [in] ULONG cMovedObjectIDRanges, [in, size_is(cMovedObjectIDRanges)] ObjectID oldObjectIDRangeStart[], [in, size_is(cMovedObjectIDRanges)] ObjectID newObjectIDRangeStart[], [in, size_is(cMovedObjectIDRanges)] ULONG cObjectIDRangeLength[]);三个数组是平行的oldObjectIDRangeStart[i]是第 i 段对象搬迁前的起始地址newObjectIDRangeStart[i]是搬迁后的起始地址cObjectIDRangeLength[i]是这段连续对象的个数。换算规则在 IDL 注释中写得很清楚如果某个 ObjectID 满足oldObjectIDRangeStart[i] ObjectID oldObjectIDRangeStart[i] cObjectIDRangeLength[i]那么它搬迁后的新值就是ObjectID - oldObjectIDRangeStart[i] newObjectIDRangeStart[i]。利用这个公式你就能在缓存中把所有命中的旧 ObjectID 原地换算成新地址。SurvivingReferences非压缩型 GC 与 LOH 的存活报告与 MovedReferences 相对SurvivingReferencescorprof.idl用于报告没有被移动的存活对象HRESULT SurvivingReferences( [in] ULONG cSurvivingObjectIDRanges, [in, size_is(cSurvivingObjectIDRanges)] ObjectID objectIDRangeStart[], [in, size_is(cSurvivingObjectIDRanges)] ULONG cObjectIDRangeLength[]);它的语义规则包括一般用于非压缩型 GC压缩型 GC 走 MovedReferences唯一的例外是大对象堆LOH——LOH 从不压缩因此 CLR总是对 LOH 对象调用 SurvivingReferences同一次 GC 中该回调可能被多次调用内部缓冲区有限、server GC 多线程上报等原因多次调用之间信息是累积的——任何一次 SurvivingReferences 里报告的引用都存活于本次 GC。64 位平台的 2 号版本MovedReferences2 / SurvivingReferences2原文档写作于 2011 年当时的 API 已无法覆盖今天的 64 位寻址需求。在 corprof.idl 中ICorProfilerCallback4提供了升级版MovedReferences2与SurvivingReferences2区别在于把cObjectIDRangeLength从ULONG换成了SIZE_T——因为原版本在 64 位平台上会把超过 4GB 的对象范围报告为UINT32_MAX从而失真。IDL 注释同时说明如果 profiler 实现了 ICorProfilerCallback4CLR 会先调用 2 号版本只有 2 号版本返回成功才调用旧版本profiler 可以让 2 号版本返回失败来省去这次多余的旧版本回调。关键细节回调进行中不可检查对象这里有一个容易被忽略的陷阱MovedReferences/ObjectReferences等回调进行期间传给你的 ObjectID 是不可检查的——因为 GC 可能正处于从旧位置搬到新位置的中间状态。IDL 注释反复强调这些回调里不要尝试检查对象要等到GarbageCollectionFinished回调corprof.idl到达时所有对象才都已就位于最终位置此时才可以安全检查。唯一的例外是GarbageCollectionStartedcorprof.idl它发生时对象还在原始位置因此该回调内可以按原始地址检查对象返回后所有 ObjectID 都应视为失效直到收到GarbageCollectionFinished。如何订阅这些回调上述 GC 回调统一由事件掩码中的COR_PRF_MONITOR_GC0x00000080控制定义见 corprof.idl。profiler 在Initialize中调用SetEventMask2corprof.idl设置该标志位后即可接收GarbageCollectionStarted/Finished、MovedReferences、SurvivingReferences、ObjectReferences、RootReferences*、HandleCreated/Destroyed、FinalizeableObjectQueued等一整套 GC 事件ObjectAllocated由COR_PRF_MONITOR_OBJECT_ALLOCATED单独控制。.NET 4.0 的强制检查CORPROF_E_UNSUPPORTED_CALL_SEQUENCE原文档特别指出即使你维护了每次都正确更新的缓存CLR 仍然只允许在上述两类时机把 ObjectID 传给 Info 方法否则收到错误 HRESULT——这项强制检查是 .NET 4.0 新增的。这个 HRESULT 就是CORPROF_E_UNSUPPORTED_CALL_SEQUENCE它的来龙去脉在同系列的另一篇笔记 CORPROF_E_UNSUPPORTED_CALL_SEQUENCE 中有详细说明早在 CLR 2.0 就引入了它用来拒绝以不支持的方式调用 ICorProfilerInfo典型如 hijacking profiler 在任意时刻强行改线程上下文后重新进入 CLR可能与 CLR 正在持有的锁冲突导致死锁或 AV。在今天的 CoreCLR 源码中这条检查链路依然存在且可读。以 proftoeeinterfaceimpl.cpp 中的检查逻辑为例运行时在允许一次对象检查前会依次验证先看全局状态g_profControlBlock.fGCInProgress——如果当前正处于 GC 进行中直接放行对应规则 1GC 线程上的 GC 回调内部是安全的否则要求当前必须是托管线程否则返回CORPROF_E_NOT_MANAGED_THREAD最后检查pThread-PreemptiveGCDisabled()即线程必须处于协作式模式cooperative mode——只有处于 GC 回调派发上下文中的托管线程才满足该条件不满足就返回CORPROF_E_UNSUPPORTED_CALL_SEQUENCE。可以看到这套检查精确地把安全窗口落实成了可执行的条件要么 GC 正在进行GC 回调内要么线程正处于阻塞 GC 的协作式模式产生 ObjectID 的回调内。其余场合如你自建的线程、preemptive 模式、hijack 之后的任意时刻一律拒绝。正因如此在自己创建的线程上调用GetObjectSizecorprof.idl64 位建议使用GetObjectSize2见 corprof.idl这类操作在 .NET 4.0 之后会稳定地以错误 HRESULT 失败而不是像 v1.x 那样碰运气——可能死锁、可能崩溃、可能给出错误答案也可能偶尔正确。另外要注意一个常被误用的边界从 profiler自己创建的线程调用 Info 方法在原文档的语境里并不自动等于安全因为该线程没有阻塞 GC 的语义不过 CORPROF_E_UNSUPPORTED_CALL_SEQUENCE 笔记中有另一条相关规则——hijacking 语境下profiler 自己创建的线程因为从未进入过 CLR、不存在打断后重入的冲突而被视为同步调用。两者关注点不同前者讨论 GC 阻塞与对象存活性后者讨论锁冲突。实践中最稳妥的做法仍是涉及 ObjectID 解引用的一切操作都放在合法窗口内完成。附带的引用类回调与检查时机除了 MovedReferences/SurvivingReferencesGC 期间还有一类引用图回调值得了解它们同样遵循回调中不可检查、GarbageCollectionFinished后可检查的原则ObjectReferencescorprof.idl在 GC 完成后报告堆上每个存活对象引用了哪些对象可与RootReferences一起拼出完整的对象引用图CLR 保证每个引用只上报一次。ConditionalWeakTableElementReferencescorprof.idlICorProfilerCallback5报告依赖句柄dependent handle的键/值 ObjectID 对同样明确要求回调中不得检查对象。ObjectAllocatedcorprof.idl唯一的例外——它在对象分配时同步触发不是 GC 挂起期间的回调因此 IDL 注释中特别强调除 ObjectAllocated 外的所有 GC 回调都发生在运行时挂起期间。ObjectAllocated给你的是刚分配、尚未经历移动的地址通常可以直接使用但它不承担缓存维护职责。实战速查表场景是否安全依据在GarbageCollectionStarted回调内按原始地址检查对象✅ 安全对象尚未移动corprof.idl在GarbageCollectionFinished回调内按最终地址检查对象✅ 安全所有移动已完成corprof.idl在MovedReferences/ObjectReferences/ConditionalWeakTableElementReferences回调内检查对象❌ 不安全对象正在/刚被移动各回调 IDL 注释在任意回调内使用该回调自己传给你的 ObjectID✅ 安全GC 被回调阻塞原文档规则 2把 ObjectID 缓存起来跨 GC 继续使用❌ 不安全除非每次 GC 用 MovedReferences/SurvivingReferences 更新原文档big no-no在 profiler 自建线程上调用GetObjectSize❌ 不安全返回CORPROF_E_UNSUPPORTED_CALL_SEQUENCEproftoeeinterfaceimpl.cpp总结ObjectID 安全使用的本质是确保在访问它的那一刻GC 处于被阻塞状态。CoreCLR 通过两个层次保护 profiler语义层要求 profiler 在 GC 回调或产生 ObjectID 的回调内使用它并借助 MovedReferences/SurvivingReferences 系列回调含 64 位平台的 2 号版本维护任何长期缓存强制层则从 .NET 4.0 起在运行时对每次 Info 调用做线程模式与 GC 状态检查不满足条件即返回CORPROF_E_UNSUPPORTED_CALL_SEQUENCE。理解并遵守这套规则是编写健壮、可长期运行、不会破坏 GC 堆的 .NET profiler 的基本前提。【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考