目录html
原文地址:https://devblogs.microsoft.com/premier-developer/managed-object-internals-part-2-object-header-layout-and-the-cost-of-locking/
原文做者:Sergey
译文做者:杰哥很忙node
托管对象本质1-布局
托管对象本质2-对象头布局和锁成本
托管对象本质3-托管数组结构
托管对象本质4-字段布局c++
我从事当前项目时遇到了一个很是有趣的状况。对于给定类型的每一个对象,我必须建立一个始终增加的标识符,但须要注意:
1) 该解决方案能够在多线程环境中工做
2) 对象的数量至关大,多达千万。
3) 标识应该按需建立,由于不是每一个对象都须要它。编程
在最初的实现过程当中,我尚未意识到应用程序将处理的数量,所以我提出了一个很是简单的解决方案:c#
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,等待线程将旋转,直到这些位变为非零。另外一方面,若是轻量锁被升级为"重量锁",开销会变得更加明显。特别是当得到重量锁的对象数量至关大时。
微信扫一扫二维码关注订阅号杰哥技术分享
出处:http://www.javashuo.com/article/p-repvuqsw-dw.html 做者:杰哥很忙 本文使用「CC BY 4.0」创做共享协议。欢迎转载,请在明显位置给出出处及连接。