托管对象本质-第二部分-对象头布局和锁成本
托管对象本质-第二部分-对象头布局和锁成本
原文地址:https://devblogs.microsoft.com/premier-developer/managed-object-internals-part-2-object-header-layout-and-the-cost-of-locking/
原文作者:Sergey
译文作者:杰哥很忙
目录
托管对象本质1-布局
托管对象本质2-对象头布局和锁成本
托管对象本质3-托管数组结构
托管对象本质4-字段布局
我从事当前项目时遇到了一个非常有趣的情况。对于给定类型的每个对象,我必须创建一个始终增长的标识符,但需要注意:
1) 该解决方案可以在多线程环境中工作
2) 对象的数量相当大,多达千万。
3) 标识应该按需创建,因为不是每个对象都需要它。
在最初的实现过程中,我还没有意识到应用程序将处理的数量,因此我提出了一个非常简单的解决方案:
public class Node { public const int InvalidId = -1; private static int s_idCounter; private int m_id; public int Id { get { if (m_id == InvalidId) { lock (this) { if (m_id == InvalidId) { m_id = Interlocked.Increment(ref s_idCounter); } } } return m_id; } } }
代码使用双重检查的锁模式,允许在多线程环境中初始化标识字段。在其中一个分析会话中,我注意到具有有效 ID 的对象数量达到数百万个实例,主要令我惊讶的是,它并没有在性能方面引起任何问题。
之后,我创建了一个基准测试,以查看与无锁定方法相比,锁语句在性能方面的影响。
public class NoLockNode { public const int InvalidId = -1; private static int s_idCounter; private int m_id = InvalidId; public int Id { get { if (m_id == InvalidId) { // Leaving double check to have the same amount of computation here if (m_id == InvalidId) { m_id = Interlocked.Increment(ref s_idCounter); } } return m_id; } }
为了分析性能差异,我将使用基准DotNet
List<NodeWithLock.Node> m_nodeWithLocks => Enumerable.Range(1, Count).Select(n => new NodeWithLock.Node()).ToList(); List<NodeNoLock.NoLockNode> m_nodeWithNoLocks => Enumerable.Range(1, Count).Select(n => new NodeNoLock.NoLockNode()).ToList(); [Benchmark] public long NodeWithLock() { // m_nodeWithLocks has 5 million instances return m_nodeWithLocks .AsParallel() .WithDegreeOfParallelism(16) .Select(n => (long)n.Id).Sum(); } [Benchmark] public long NodeWithNoLock() { // m_nodeWithNoLocks has 5 million instances return m_nodeWithNoLocks .AsParallel() .WithDegreeOfParallelism(16) .Select(n => (long)n.Id).Sum(); }
在这种情况下,NoLockNode 不适合多线程方案,但我们的基准测试也不会尝试同时从不同的线程获取两个实例的 Id。当争用很少发生时,基准测试模拟了真实场景,在大多数情况下,应用程序只是使用已创建的标识符。
Method | 平均值 | 标准差 |
---|---|---|
NodeWithLock | 152.2947 ms | 1.4895 ms |
NodeWithNoLock | 149.5015 ms | 2.7289 ms |
我们可以看到,差别非常小。CLR 是如何做到获得 100 万个锁而几乎无开销呢?
为了阐明 CLR 行为,让我们用另一个案例来扩展我们的基准测试套件。我们添加另一个Node
类,该类在构造函数中调用 GetHashCode
方法(其非重写版本),然后丢弃结果:
public class Node { public const int InvalidId = -1; private static int s_idCounter; private object syncRoot = new object(); private int m_id = InvalidId; public Node() { GetHashCode(); } public int Id { get { if (m_id == InvalidId) { lock(this) { if (m_id == InvalidId) { m_id = Interlocked.Increment(ref s_idCounter); } } } return m_id; } } }
Method | 平均值 | 标准差 |
---|---|---|
NodeWithLock | 152.2947 ms | 1.4895 ms |
NodeWithNoLock | 149.5015 ms | 2.7289 ms |
NodeWithLockAndGetHashCode | 541.6314 ms | 4.0445 ms |
GetHashCode
调用的结果被丢弃,调用本身不会影响整体的测试时间,因为基准从测量中排除了构造时间。但问题是:有在NodeWithLock
这个例子中,为什么锁语句的开销几乎为0,而在NodeWithLockAndGetHashCode
中对象实例调用GetHashCode
方法时,开销明险不同?
轻量锁、锁膨胀和对象头布局
CLR 中的每个对象都可用于创建关键区域以实现互斥执行。你可能会认为,为了做到这一点,CLR为每个CLR对象创建一个内核对象。但是,这种方法没有意义,因为只有很小一部分对象用作同步的句柄。因此,CLR 按需创建同步所需的重量级的数据结构非常有意义。此外,如果 CLR 不需要冗余数据结构,就不会创建它们。
如你所知,每个托管对象都有一个称为对象头的辅助字段。对象头本身可用于不同的目的,并且可以根据当前对象的状态保留不同的信息。
CLR 可以同时存储对象的哈希代码、领域特定信息、与锁相关的数据以及和一些其他内容。显然,4 个字节的对象头根本不足以满足所有这些功能。因此,CLR 将创建一个称为同步块表的辅助数据结构,并且只在对象头本身中保留一个索引。但是 CLR 会尽量避免这种情况,并尝试在标头本身中放置尽可能多的数据。
下面是对象头最重要的字节的布局:
如果BIT_SBLK_IS_HASH_OR_SYNCBLKINDEX
位为 0,则头本身保留所有与锁相关的信息,锁称为"轻量锁"。在这种情况下,对象头的总体布局如下:
如果BIT_SBLK_IS_HASH_OR_SYNCBLKINDEX
位为 1,为对象创建的同步块或计算哈希代码。如果BIT_SBLK_IS_HASHCODE
为 1(第26位),则双字其余部分(0 ~ 25位)是对象的哈希代码,否则,0 ~ 25位表示同步块索引:
译者补充:1字=2字节,双字即为4字节
双字的其余部分说的就是对象头4字节低于26位的部分。上一节我们说了即使64位对象头是8字节,实际也只是用了4个字节。
我们可以使用 WinDbg 和 SoS 扩展来研究轻量锁。首先,我们对一个简单对象的锁语句中停止执行,这不会调用 GetHashCode 方法:
object o = new object(); lock (o) { Debugger.Break(); }
在 WinDbg 中,我们将运行 .loadby sos clr
来加载 SOS 调试扩展,然后运行两个命令:DumpHeap -thinlock
查看所有轻量锁, DumpObj obj
查看我们在锁语句中使用实例的状态:
0:000> !DumpHeap -thinlock Address MT Size 02d223e0 725c2104 12 ThinLock owner 1 (00ea5498) Recursive 0 Found 1 objects. 0:000> !DumpObj /d 02d223e0 Name: System.Object MethodTable: 725c2104 ThinLock owner 1 (00ea5498), Recursive 0
至少有两种情况可以将轻量锁升级为"重量锁":
(1) 另一个线程的同步根上的争用,需要创建内核对象;
(2) CLR 无法将所有信息保留在对象标头中,例如,对 GetHashCode
方法的调用。
CLR 监视器实现了一种"混合锁",在创建真正的 Win32 内核对象之前尝试先自旋。以下是来自 Joe Duffy 的《Windows并发编程》中的监视器的简短描述:"在单 CPU 计算机上,监视器实现将执行缩减的旋转等待:当前线程的时间片通过在等待之前调用 SwitchToThread
切换到调度器。在多 CPU 计算机上,监视器每隔一段时间就会产生一个线程,但是在返回到某个线程之前,繁忙的线程会旋转一段时间,使用指数后退方案来控制它重新读取锁状态的频率。所有这一切都是为了在英特尔超线程计算机上正常工作。如果在固定旋转等待期用完后锁仍然不可用,就会尝试将回退到使用基础 Win32 事件的真实等待。我们讨论一下它是如何工作的。
译者补充: CLR使用的是混合锁,先尝试使用轻量锁,若锁长时间被占用,自旋带来的开销会大于用户态到内核态转换带来的开销,此时就会尝试使用重量锁。
译者补充: 换句直白的话来说,单线程下在未获取待锁等待之前,会尝试切换到其他线程,而在多线程下使用锁时,首先会尝试用自旋锁,而自旋的时间以指数变化上升,若最终仍然没有获取到,则会调用实际的win32 内核模式的真实等待时间。
我们可以检查,在这两种情况下,锁膨胀确实发生,一个轻量锁被升级为重量锁:
object o = new object(); // Just need to call GetHashCode and discard the result o.GetHashCode(); lock (o) { Debugger.Break(); }
0:000> !dumpheap -thinlock Address MT Size Found 0 objects. 0:000> !syncblk Index SyncBlock MonitorHeld Recursion Owning Thread Info SyncBlock Owner 1 011790a4 1 1 01155498 4ea8 0 02db23e0 System.Object
正如您所看到的,只需在同步对象上调用 GetHashCode
,我们将获得不同的结果。现在没有轻量锁,同步根具有与其关联的同步块。
如果其他线程长时间占用锁,我们可以得到相同的结果:
object o = new object(); lock (o) { Task.Run(() => { // 线程征用轻量级锁 lock (o) { } }); // 10 ms 不够,CLR 自旋会超过10ms. Thread.Sleep(100); Debugger.Break(); }
在这种情况下,会有一样的结果:轻量锁会升级同时会创建同步块。
0:000> !dumpheap -thinlock Address MT Size Found 0 objects. 0:000> !syncblk Index SyncBlock MonitorHeld Recursion Owning Thread Info SyncBlock Owner 6 00d9b378 3 1 00d75498 1884 0 02b323ec System.Object
结论
现在,基准输出应该更容易理解。如果 CLR 可以使用轻量锁,则可以获取数百万个锁,而开销几乎为0。轻量锁非常高效。要获取锁,CLR 将更改对象头中的几个位用来存储线程 ID,等待线程将旋转,直到这些位变为非零。另一方面,如果轻量锁被升级为"重量锁",开销会变得更加明显。特别是当获得重量锁的对象数量相当大时。
微信扫一扫二维码关注订阅号杰哥技术分享
出处:https://www.cnblogs.com/Jack-Blog/p/12259258.html
作者:杰哥很忙
本文使用「CC BY 4.0」创作共享协议。欢迎转载,请在明显位置给出出处及链接。
【推荐】还在用 ECharts 开发大屏?试试这款永久免费的开源 BI 工具!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 从二进制到误差:逐行拆解C语言浮点运算中的4008175468544之谜
· .NET制作智能桌面机器人:结合BotSharp智能体框架开发语音交互
· 软件产品开发中常见的10个问题及处理方法
· .NET 原生驾驭 AI 新基建实战系列:向量数据库的应用与畅想
· 从问题排查到源码分析:ActiveMQ消费端频繁日志刷屏的秘密
· Windows桌面应用自动更新解决方案SharpUpdater5发布
· 我的家庭实验室服务器集群硬件清单
· C# 13 中的新增功能实操
· Supergateway:MCP服务器的远程调试与集成工具
· Vue3封装支持Base64导出的电子签名组件
2016-02-04 实现更简单的异步操作