并行中的内存屏障。

8

Microsoft的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是否会以某种方式执行所有这些操作?我查看了参考源代码,但很难跟踪代码。我没有看到任何lock块或MemoryBarrier调用。


1
预计Parallel.For()在其开头和结尾处有一个内存屏障,你看过Parallel.For()的源代码吗? - Ian Ringrose
但是在它调用的方法中呢?我希望它位于任务系统的顶部,并且在任务系统的某个地方有内存屏障,用于标记任务的开始和结束。 - Ian Ringrose
是的,我也这样预计。但我还没有完全跟踪代码。 - adv12
1
@HansPassant,我相信微软会做得很好,但我曾经编写过有问题的线程代码,了解如何正确编写线程代码可以帮助我自己编写代码,并更重要的是,让我知道在使用微软的模式和库时可以依靠微软做什么,以及仍需实现哪些锁定等操作。 - adv12
显示剩余2条评论
4个回答

6

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

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


1
@adv12 因为 i 变量是一个参数并且在本地堆栈上,而所有其他迭代值都在方法内部声明,所以一切都是在本地发生的,就像 Patrick Hofman 所说的那样,不可能有交叉。 - Icepickle
@Icepickle,是的,数组中的每个单元格都是由一个线程或另一个线程填充的,但据我所知,如果没有适当的锁定/内存屏障/等等,由一个线程写入的单元格不一定对其他线程可见。 - adv12
1
谁在乎呢?我们可以假设只有调用线程会读取数组。 - Patrick Hofman
2
@PatrickHofman,没错,我的问题是,有什么保证调用线程能看到所有并行线程写入的值?我认为,在某个层面上,必须进行锁定或内存屏障;我想知道在哪里以及如何实现。 - adv12
1
@adv12 并行库等待所有任务完成,所有写操作都是同步的,因此CLR保证所有写操作都是最终的。在需要内存屏障的情况下,您可能会遇到一种情况,即不知道操作何时发生,但在这里,您确切地知道何时发生操作。 - VMAtm
显示剩余6条评论

6
你要找的内存屏障在任务调度器中。
ParallelFor将工作分解为任务,然后工作窃取调度程序执行这些任务。工作窃取调度程序所需的最小内存屏障是:
1.创建任务后的“释放”栅栏。 2.当任务被窃取时的“获取”栅栏。 3.当窃取的任务完成时的“释放”栅栏。 4.等待任务的线程的“获取”栅栏。
请查看此处,其中1是使用原子(“Interlocked”)操作排队任务的隐含值。请查看此处,其中2是通过原子操作、易失性读取和/或锁定暗示的,当任务被窃取时。
我无法追踪到3和4在哪里。 3和4可能是由某种原子连接计数器暗示的。

感谢您的研究和回答实际问题!我已经接受了Henk的答案,但是您也得到了我的赞同。我承认这有点超出我的能力范围,但每次我问这些问题时,我都会更好地理解线程。 - adv12
@adv12,如果新答案更合适,那么你完全可以切换接受的答案,绝对没有理由不这样做。同时也要考虑到未来可能会对你的问题感兴趣的读者,他们需要正确的答案。 - Icepickle

3

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

并行读取本质上是线程安全的,由于每个任务的唯一性,所以写入也是线程安全的。

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

对于您编号的项目,1)和4)已经被Parallel.For()隐含了,2)和3)根本不需要。


2
谢谢你的回答。如果不需要(2)和(3),那么是什么保证了在并行线程上执行的读取操作看到的是初始化的值(而不是它们恰好在缓存行中拥有的旧数据),并且是什么保证了在并行线程上执行的写入操作对调用线程可见(除了.NET在Windows上将写入视为易失性的实现细节之外)?基本上,我认为两个线程之间的内存可见性取决于两个线程上的内存屏障,在写入后一个,在读取前一个。 - adv12
样例的范围是Parallel.For()是唯一进行并行处理的部分。如果您考虑到写入matA或matB的“外部”线程:不存在任何情况可以产生有用的结果。 - H H
我不认为我在考虑“外部”线程,只是有印象称两个线程(调用线程和Parallel.For中的线程池线程)需要共同工作,以确保读取线程看到写入线程所写的内容。我认为这个过程是:写入线程写入,写入线程使用内存屏障,读取线程使用内存屏障,读取线程读取。我认为你说的比必要的内存屏障更多;我正在努力理解为什么。 - adv12
matA和matB在.For()开始之前就已经填充好了,而且它们永远不会改变。为什么它们需要屏障或任何形式的同步? - H H
2
知道它们不这样做会非常令人鼓舞。但是,我印象中它们可能会对调用线程填充,但不会对并行线程填充,这是由于CPU缓存等原因,并且需要锁定或内存屏障才能保证并行线程看到调用线程写入它们的内容,尽管这发生在线程之前。就像我所说的,我很想被说服这不是这种情况。如果是这样,那么对于像我这样的智力水平来说,线程化的代码就太复杂了。 - adv12
由于CPU缓存等原因,开始时请相信您的硬件、操作系统和框架。如果这是情况,那么什么都不会起作用。 - H H

2
我认为你非常看重内存屏障的想法,但我真的无法理解你的担忧。让我们看一下你所调查的代码:
  1. 在主线程中初始化并填充了3个数组。就像给一个变量赋值并调用方法一样 - CLR确保方法获取参数的新鲜值。可能存在的问题是:如果初始化是通过后台线程和/或其他线程同时进行的,则在这种情况下,你是正确的,需要一些同步构造体,例如内存屏障或lock语句或其他技术。

  2. 用于并行执行的代码获取了0matARows之间的所有值,并为它们创建任务数组。你需要理解两种不同的并行化代码的方式:按操作和按数据。在此处,我们有多个带有相同lambda操作的行。变量temp的分配未共享,因此它们是线程安全的,不需要内存屏障,因为没有旧值或新值。再次提醒,在第一种情况下,如果其他线程更新初始矩阵,则需要在此处使用同步构造体。

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

这个示例非常简单,实际算法通常需要更复杂的逻辑。你可以研究各种方法来证明代码是线程安全的,而不使用同步,使代码成为无锁状态。


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