在并发环境中乱序加载
以下是Joe Duffy的书(Windows上的并行编程)一书的片段,后面跟着该段涉及的那段代码。 这段代码的意图是在并发环境(由许多线程使用)中工作,其中这个LazyInit<T>
类用于创建一个只有当值(类型T)真的需要在码。
我很感激,如果有人能详细说明这种逐步的方案,那么无序的负载到负载可能会产生问题。 也就是说,如果两个或多个线程使用该类并将引用及其字段分配给变量可能会成为问题,如果每个线程的加载顺序先加载字段,然后再加载引用,而不是我们期望的它是(首先加载引用,然后是通过引用获得的字段值)?
我知道这很少发生(因为无序加载失败)。 事实上,我可以看到一个线程可以在不知道引用值(指针?)是什么的情况下,首先错误地读取了字段的值,但是如果发生这种情况,那么该线程会自行纠正(就像它不在并发环境),如果它发现过早的负载值是不正确的; 在这种情况下,装载最终会成功。 换句话说,另一个线程的存在如何使加载线程不能“意识到”加载线程中的乱序加载无效?
我希望我能够在我真正看到它的时候设法表达这个问题。
片段:
因为除了.NET内存模型之外,上述所有处理器都允许在某些情况下进行从加载到重新排序,所以在加载对象的字段后,m_value的负载可能会移动。 效果会类似,并将m_value标记为volatile来防止它。 将对象的字段标记为不稳定是不必要的,因为读取该值是一个获取栅栏,并防止之后的负载在之前移动,而不管它们是否是易失性的。 这对某些人来说可能看起来很荒谬:如何在引用对象本身之前读取一个字段? 这似乎违反了数据依赖性,但它不会:一些较新的处理器(如IA64)采用价值推测并提前执行加载。 如果处理器恰好猜测引用写入之前引用和字段的正确值,则推测性读取可能会退出并产生问题。 这种重新排序是非常罕见的,在实践中可能永远不会发生,但它是一个问题。
代码示例:
public class LazyInitOnlyOnceRef<T> where T : class
{
private volatile T m_value;
private object m_sync = new object();
private Func<T> m_factory;
public LazyInitOnlyOnceRef(Func<T> factory) { m_factory = factory; }
public T Value
{
get
{
if (m_value == null)
{
lock (m_sync)
{
if (m_value == null)
m_value = m_factory();
}
}
return m_value;
}
}
}
一些较新的处理器(如IA64)采用价值推测,并会提前执行加载。 如果处理器恰好猜测引用写入之前引用和字段的正确值,则推测性读取可能会退出并产生问题。
这基本上对应于以下源转换:
var obj = this.m_value;
Console.WriteLine(obj.SomeField);
变
[ThreadStatic]
static object lastValueSeen = null; //processor-cache
//...
int someFieldValuePrefetched = lastValueSeen.SomeField; //prefetch speculatively
if (this.m_value == lastValueSeen) {
//speculation succeeded (accelerated case). The speculated read is used
Console.WriteLine(someFieldValuePrefetched);
}
else {
//speculation failed (slow case). The speculated read is discarded.
var obj = this.m_value;
lastValueSeen = obj; //remember last value
Console.WriteLine(obj.SomeField);
}
处理器会尝试预测下一次需要加热缓存的内存地址。
从本质上讲,您不能再依赖数据依赖性,因为可以在指向包含对象的指针已知之前加载字段。
你问:
如果(this.m_value == lastValueSeen)真的是prdeiction(基于值看上次每个m_value)被用于测试的语句。 我知道,在顺序编程(非并发)中,测试必须总是失败,无论上次看到什么值,但在并发编程中,测试(预测)可能成功,并且处理器的执行流将随之导致尝试打印无效值(i..e,null someFieldValuePrefetched)
我的问题是,这种错误的预测怎么会只能在并发编程中成功,而不能在顺序的非并发编程中成功。 关于这个问题,在并发编程中,当处理器接受这个错误预测时,m_value的可能值是多少(即它必须为空,非空)?
猜测是否this.m_value
,不取决于线程,而取决于this.m_value
的值是否与最后一次执行的值相同。 如果它很少发生变化,投机往往会成功。
首先,我必须说我非常感谢你在这件事上的帮助。 为了磨练我的理解,下面是我的看法,如果我错了,请纠正我。
如果线程T1要执行不正确的推测加载路径,将执行以下代码行:
Thread T1 line 1: int someFieldValuePrefetched = lastValueSeen.SomeField; //prefetch speculatively
Thread T1 line 2: if (this.m_value == lastValueSeen) {
//speculation succeeded (accelerated case). The speculated read is used
Thread T1 line 3: Console.WriteLine(someFieldValuePrefetched);
}
else {
//speculation failed (slow case). The speculated read is discarded.
…..
….
}
另一方面,线程T2需要执行以下代码行。
Thread T2 line 1: old = m_value;
Thread T2 line 2: m_value = new object();
Thread T2 line 3: old.SomeField = 1;
我的第一个问题是:当执行“Thread T1 line 1”时,this.m_value的线索是什么? 我猜想它在执行“Thread T2 line 2”之前等于旧的m_value,是否正确? 否则,推测分支将不会选择加速路径。这导致我询问线程T2是否也必须以无序方式执行其代码行。 也就是说,是否执行“线程T2线1”,“线程T2线3”,“线程T2线2”而不是“线程T2线1”,“线程T2线2”,“线程T2线3”? 如果是这样,那么我相信volatile关键字还会阻止线程T2以乱序方式执行代码,对吗?
我可以看到线程T1的“线程T1线2”将在线程T2的“线程T2线1”和“线程T2线3”之后和“线程T2线2”之前执行,则线程T1中的SomeField将为1,即使如你所述,这是没有道理的,因为当SomeField变为1时,m_value被分配一个新值,对于SomeField,值将为0
如果它仍然是实际的,请考虑以下代码,它来自Joe Duffy的CPOW:
MyObject mo = new LazyInit<MyObject>(someFactory).Value;
int f = mo.field;
if (f == 0)
{
//Do Something...
Console.WriteLine(f);
}
下面的文字也来自“如果在初始读取mo.field到变量f之间的时间以及随后在Console.WriteLine中使用f的时间足够长,编译器可能会认为它会更高效重新读取mo.field两次....编译器可能会决定如果保持该值会产生寄存器压力,导致堆栈空间使用效率降低:
...
if (mo.field == 0)
{
////Do Something...
Console.WriteLine(mo.field);
}
所以,我认为这可能是退役参考资料的一个很好的例子。 在随后使用mo.field时,mo 的推测性读取可能会退出并创建一个null ref异常,这肯定是一个问题。
链接地址: http://www.djcxy.com/p/76673.html