【问题标题】:Memory barriers in Parallel.ForParallel.For 中的内存屏障
【发布时间】:2017-01-14 19:03:53
【问题描述】:

Microsoft's documention of Parallel.For 包含以下方法:

static void MultiplyMatricesParallel(double[,] matA, double[,] matB, double[,] result)
{
    int matACols = matA.GetLength(1);
    int matBCols = matB.GetLength(1);
    int matARows = matA.GetLength(0);

    // A basic matrix multiplication.
    // Parallelize the outer loop to partition the source array by rows.
    Parallel.For(0, matARows, i =>
    {
        for (int j = 0; j < matBCols; j++)
        {
            double temp = 0;
            for (int k = 0; k < matACols; k++)
            {
                temp += matA[i, k] * matB[k, j];
            }
            result[i, j] = temp;
        }
    }); // Parallel.For
}

在此方法中,可能有多个线程从matAmatB 读取值,这两个值都是在调用线程上创建和初始化的,并且可能有多个线程将值写入result,稍后由调用方读取线。在传递给Parallel.For 的 lambda 中,数组读取和写入没有显式锁定。由于此示例来自 Microsoft,因此我假设它是线程安全的,但我试图了解幕后发生的事情以使其成为线程安全的。

根据我所阅读的内容和我在 SO 上提出的其他问题(例如 this one),据我所知,需要几个内存屏障才能使这一切正常工作。它们是:

  1. 创建和初始化matAmatB 后调用线程上的内存屏障,
  2. 在从matAmatB 读取值之前,每个非调用线程上的内存屏障,
  3. 将值写入result 后,每个非调用线程上的内存屏障,并且
  4. 在从 result 读取值之前调用线程上的内存屏障。

我理解正确吗?

如果是这样,Parallel.For 会以某种方式完成所有这些吗?我去挖掘参考源,但在关注the code 时遇到了麻烦。我没有看到任何lock 块或MemoryBarrier 调用。

【问题讨论】:

  • 预计 Parallel.For() 在它的开头和结尾都有内存屏障,你看过Parallel.For() 的源代码吗?
  • @IanRingrose,是的,正如我在问题中提到并链接到的。我没有找到任何MemoryBarrier 调用或锁定块。
  • 但是在它调用的方法中呢,我希望它位于任务系统的顶部,并且任务系统中的某处是任务开始和结束的内存屏障。
  • 是的,我希望如此。尚未完全跟踪代码。
  • @HansPassant 我确实相信 MS 做对了,但是我编写的线程代码是错误的,并且当我必须自己做时,准确理解需要做的事情对我有帮助,更重要的是,帮助我准确了解我可以依靠 MS 为我做什么,以及在使用 Microsoft 的模式和库时我仍然需要实现什么锁定等。

标签: c# multithreading memory-barriers parallel.for


【解决方案1】:

由于数组已经创建,写入或读取它不会导致任何调整大小。此外,代码本身会阻止读取/写入数组中的相同位置。

底线是代码总是可以计算在数组中读取和写入的位置,并且这些调用永远不会相互交叉。因此,它是线程安全的。

【讨论】:

  • 我了解线程不会相互踩踏,我同意这意味着您不必担心互斥性,但我仍然(一如既往)对内存感到困惑能见度。如果您阅读 Jon Skeet 对链接问题的回答,尤其是代码块,您会发现如果一个方法在没有获取锁的情况下进行读写,它不一定会看到“新鲜”数据和其他方法,即使他们确实锁定某些东西,不一定看到“坏”方法的写入。 (在下一条评论中继续...)
  • @adv12 由于i var 是一个参数并且在本地堆栈上,并且所有其他迭代值都在方法内部声明,所以一切都在本地发生,不能像帕特里克霍夫曼那样交叉说
  • 谁在乎?我们可以假设只有调用线程会读取数组。
  • @PatrickHofman,对,我要问的是,什么保证调用线程看到所有并行线程写入的值?我认为,在某种程度上,必须存在锁定或内存障碍;我想知道在哪里以及如何。
  • @adv12 并行库等待所有任务完成,所有写入都是同步的,因此 CLR 保证所有写入都是最终的。在需要内存屏障的情况下,您确实会遇到不知道操作何时发生的情况,在这里您可以确切地知道何时发生。
【解决方案2】:

您正在寻找的内存屏障位于任务调度程序中。

ParallelFor 将工作分解为任务,然后一个工作窃取调度程序执行这些任务。工作窃取调度程序所需的最小内存屏障是:

  1. 创建任务后的“释放”栅栏。
  2. 任务被盗时“获取”围栏。
  3. 完成被盗任务时的“释放”栅栏。
  4. 等待任务的线程的“获取”栅栏。

查看here,其中 1 隐含在用于将任务入队的原子(“互锁”)操作中。请看here,其中 2 是指原子操作、易失性读取和/或任务被盗时的锁定。

我无法找到 3 和 4 在哪里。 3 和 4 可能由某种原子连接计数器暗示。

【讨论】:

  • 感谢您的研究工作和回答实际问题!我已经接受了 Henk 的回答,但你有我的支持。我承认这有点过头了,但每次我问这些问题中的一个时,我都会更好地理解线程。
  • @adv12 您没有理由不能切换已接受的答案,如果新答案更合适,绝对不会,还请考虑未来可能对您问题的正确答案感兴趣的读者
【解决方案3】:

在线程内部(实际上是:任务)对 matA 和 matB 的访问是只读的,结果是只写的。

并行读取本质上是线程安全的,写入是线程安全的,因为i 变量对于每个任务都是唯一的。

在这段代码中不需要内存屏障(除了在整个 Parallel.For 之前/之后,但可以假设)。

表格你编号的项目,
Parallel.For()
隐含了 1) 和 4) 2) 和 3) 根本不需要。

【讨论】:

  • 感谢您的回答。如果不需要 (2) 和 (3),如何保证在并行线程上完成的读取会看到初始化值(而不是它们碰巧在缓存行中的一些旧数据),以及什么保证在并行线程上完成的写入对调用线程是可见的(除了 Windows 上的 .NET 碰巧将写入视为易失性的实现细节)?基本上,我的印象是两个线程之间的内存可见性取决于两个线程上的内存屏障,一个在写入之后,一个在读取之前。
  • 示例的范围是 Parallel.For() 是唯一的并行。如果您正在考虑写入 matA 或 matB 的“外部”线程:没有任何情况可以产生有用的结果。
  • 我不认为我在考虑“外部”线程,只是印象中两个线程(在Parallel.For 中调用线程和线程池线程)需要协同工作以保证读取线程查看写入线程写入的内容。我以为过程是:写线程写,写线程使用内存屏障,读线程使用内存屏障,读线程读。我认为您是在说这比必要的内存障碍更多。我试图理解为什么。
  • matA 和 matB 在 .For() 甚至开始之前就被填充并且它们永远不会改变。为什么他们需要屏障或任何形式的同步?
  • 知道他们没有这样做会非常令人鼓舞。但我的印象是,由于 CPU 缓存等原因,它们可能会出现在调用线程而不是并行线程中,并且需要锁定或内存屏障来保证并行线程看到调用线程写入的内容他们,即使那发生在线程之前。就像我说的,我很想确信事实并非如此。如果是这样,线程代码对于我这种聪明的人来说太复杂了。
【解决方案4】:

我认为您对内存屏障的想法印象深刻,但我真的无法理解您的担忧。让我们看看你调查过的代码:

  1. ma​​in 线程中初始化并填充了 3 个数组。所以这就像你给一个变量赋值并调用一个方法——CLR 确保你的方法为参数获取新的值。如果初始化是在后台和/同时由其他线程完成的,则此处可能出现的问题可能发生。在这种情况下你是对的,你需要一些同步结构,内存屏障或lock 语句或其他技术。

  2. 并行执行的代码获取从0matARows 的所有值,并为它们创建一个任务数组。您需要了解并行化代码的两种不同方法:通过操作和通过数据。在这里,我们有多个行,它们具有相同的 lambda 操作。 temp 变量的赋值不是共享的,因此它们是线程安全的,并且不需要内存屏障,因为没有新旧值。同样,首先,如果其他线程更新初始矩阵,您需要在此处使用同步构造。

  3. Parallel.For 确保所有任务都已完成(运行到完成、被取消或出错),直到继续执行下一条语句,因此循环内的代码将作为正常方法执行。你为什么不需要这里的屏障?因为所有的写操作都是在不同的行上进行的,它们之间没有交集,所以是数据并行。但是,与其他情况一样,如果其他线程需要来自某些循环迭代的新值,您仍然需要同步。所以这段代码是线程安全的,因为它在数据上是几何并行的,并且不会产生竞争条件。

这个例子很简单,真正的算法一般需要比较复杂的逻辑。您可以研究各种方法来证明代码是线程安全的,而无需使用同步来使代码无锁。

【讨论】:

    猜你喜欢
    • 2014-02-04
    • 1970-01-01
    • 1970-01-01
    • 2014-08-19
    • 1970-01-01
    • 2018-08-24
    • 2011-09-28
    • 2011-07-05
    • 1970-01-01
    相关资源
    最近更新 更多