托管对象本质-第二部分-对象头布局和锁成本
托管对象本质-第二部分-对象头布局和锁成本
原文地址: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」创作共享协议。欢迎转载,请在明显位置给出出处及链接。