并发环境下的无序加载

7
以下是Joe Duffy的书(《Windows并发编程》)中的一小段摘录,后面跟着一个代码片段。代码片段用于并发环境(由许多线程使用)中,在这种情况下, LazyInit<T>类用于创建仅在实际需要使用代码中的值(类型为T)时才初始化的对象。
如果有人可以详细说明逐步场景如何导致无序的加载-到-加载(create a problem),我将不胜感激。也就是说,如果一个线程先加载字段再加载引用,而不是我们期望的先加载引用,然后通过引用得到字段的值,两个或多个使用该类并将引用及其字段赋值给变量的线程如何有问题?
我知道这种情况很少发生(由于加载的无序性而失败)。实际上,我能够看到一个线程可能会在不知道引用值(指针?)的情况下先错误地读取字段值, 但是,如果发生这种情况,则该线程会自行更正(就好像它不在并发环境中一样),如果注意到预读取值不正确,那么最终加载将成功。换句话说,另一个线程的存在如何使加载线程不“意识到”加载线程中的无序加载是无效的?
我希望我真正看到问题的传达方式是正确的。
摘录:
因为上述所有处理器(以及.NET内存模型)在某些情况下允许加载到加载重排,所以m_value的加载可能会在对象字段的加载之后移动。效果将类似,并且将m_value标记为易失性可以防止它。将对象的字段标记为易失性并不是必需的,因为值的读取是获取栅栏并防止后续加载移动之前的,无论它们是否易失性。这可能对一些人来说看起来很荒谬:如何在引用对象本身之前读取字段?这似乎违反数据依赖性,但实际上并非如此:一些新的处理器(如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;
        }
    }
}

1
@Willingtolearn 我回答了你的额外问题(我能看到已删除的回答)。我还修复了代码片段中的一个错误。 - usr
3个回答

4
一些新的处理器(例如 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);
}

处理器会尝试预测下一个需要访问的内存地址以填充缓存。
实质上,您不能再仅依赖于数据依赖关系,因为在未知包含对象指针的情况下,字段可能会被加载。
您提出了以下问题:

if (this.m_value == lastValueSeen) 真的是放置基于上次看到的m_value值的预测结果的语句吗?我明白在顺序编程(非并发)中,无论上次看到了什么值,测试必须总是失败的,但在并发编程中,这个测试(预测)可能会成功,处理器的执行流将继续尝试打印无效值(i..e,将someFieldValuePrefetched设置为null)

我的问题是,为什么这个虚假预测只能在并发编程中而不是在顺序,非并发编程中成功。在并发编程中,当处理器接受这个虚假预测时,m_value的可能值是什么(即,它必须是null,非null)?

是否成功取决于最后一次执行时,this.m_value是否经常与上次相同,并非取决于线程。如果很少更改,则预测通常会成功。

根据最近的答案,我了解到在并发环境中,通过将this.m_value声明为volatile来避免这种推测,而在顺序环境中则无需添加关键字volatile。为什么只有在多线程环境下需要避免推测呢?换句话说,为什么在顺序、单线程编程中,this.m_value不可能与“上次看到”的相同值(更精确地说是无效值)? - WITL
在顺序环境中,猜测可能会失败,只是在这种情况下,CPU 会丢弃猜测的值。猜测主要是关于预取可能需要的高速缓存行。这在多线程场景中可能会引起麻烦,因为加载缓存行的顺序被交换了。这样一个线程可以在看到对 m_value 的写入之前就看到对 SomeField 的写入。var old = m_value; m_value = new object(); old.SomeField = 1; 可以反过来看。 - usr
跟上“缓存一致性”方面的最新进展,我认为“写入”的顺序会发生变化,因为每当第二个线程执行“m_value = new object()”时,我假设“缓存一致性”要求使lastValueSeen失效。我很想听听熟悉这个主题的人对此有何看法。非常感谢! - WITL
不使用抽象的内存模型术语(这很困难),我会解释一下可能会在物理上发生的情况(这更容易)。假设 T2 所执行的写入操作按它们编写的顺序(首先是m_value,然后是SomeField)被物理地写入主内存。如果我们另外假设缓存一致性,则T1无法看到过时的m_value。在这种情况下,我们没问题。我们必须假设以下之一:没有缓存一致性或T2中的重新排序写入(也许m_value只被写入了一个寄存器)。 - usr
显示剩余5条评论

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的推测读取可能会退役并创建一个空引用异常,这肯定是一个问题。


0
首先,我必须说我非常感谢您在这件事上的帮助。为了加深我的理解,以下是我对此的看法,请纠正我如果我错了。
如果线程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;

我的第一个问题是:当“线程T1行1”被执行时,this.m_value的值是多少?我认为它等于“线程T2行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”之前执行,那么即使这没有意义,SomeField在线程T1中也将为1,因为当SomeField变为1时,m_value被赋予一个新值,该新值对于SomeField来说将具有0的值。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接