【问题标题】:Large array and multiple threads - spinlocks or local buffers or something else?大数组和多线程 - 自旋锁或本地缓冲区或其他什么?
【发布时间】:2010-07-31 13:01:22
【问题描述】:
  • 我有一个包含 250k 个实体(大小为 20 字节)和 4 个线程的数组。
  • 每个线程修改约 100k 个随机实体(这意味着,您无法预测它将接触哪些实体)。它可以多次修改同一个实体。
  • 每个实体最多可修改 10 次。
  • 修改大约需要 10-6 秒。
  • 每个实体都被多个线程修改

最后两点是最重要的事实。第 5 点意味着我需要一种机制来保护我的实体不因并发访问/修改而损坏。第 4 点让我担心,考虑到获取锁的时间跨度很短,像互斥锁这样阻塞线程的经典锁定机制是否会产生大量开销。

我想出了两个想法:

  • 使用自旋锁来克服开销(假设我对开销的假设首先是正确的)。
  • 为每个线程提供一个数组的本地副本,它可以在不中断的情况下进行修改。在所有线程完成后,将所有数组合并为一个。这是可能的,因为如果一个实体有多个副本,我可以选择一个获胜者。

你推荐什么?你同意我的一个想法还是你推荐别的东西?如果我将数字更改为,您的建议是否会改变?:

  • 100 万个实体
  • 8 线程
  • ~500k 随机访问
  • 每个实体约 100 次修改

还请指点我在 C#/.Net 中的实现。提前致谢。

其他信息
实体是值类型(结构)。我无法为每个写入操作创建新对象 - 只能修改现有原语。

【问题讨论】:

  • 我不得不问,为什么会有多个线程呢?
  • @chibacity 他们处理一个更大的数据集(3M+),这个处理的结果必须存储在我的数组中。另一个数据集可以独立处理,因此不会发生冲突。但是,尝试将结果存储在数组中时可能会发生冲突。

标签: .net concurrency locking


【解决方案1】:

正如他们所说,给猫剥皮的方法不止一种(尽管为什么有人想要给猫剥皮是另一个问题):-)

使用 250K 对象和 4 个线程,您必须猜测冲突将(相对)很少见。这并不意味着我们可以忽略它们,但它可能会影响我们寻找它们的方式。除非确实存在冲突,否则测试关键部分的速度非常快。这意味着可能检查每个事务的关键部分是可行的,因为知道相对较少的检查将花费更多的 CPU 滴答声。

创建 250K 临界区是否可行?也许,我不确定。您可以使用以下方法创建一个非常轻量级的自旋锁:

while (0 != ::InterlockedExchange(&nFlag, 1)) {};
DoStuff();
nFlag = 0;

另一种方法可能是对数据集进行分区,并让每个线程处理一组唯一的对象。这使得冲突不可能发生,因此不需要锁定。根据问题的性质,您可以通过让每个线程对一系列数据进行操作来实现此目的,或者可能通过为每个工作线程操作一个队列并让一个或多个扫描线程识别需要处理的对象并将它们推送到适当的处理队列。

【讨论】:

  • 感谢您的回答。感谢您指出简单锁定方法的最终可行性。感谢您将我指向 InterlockedExchange - 不知道。不幸的是,无法对数据集进行分区。
【解决方案2】:

似乎最简单的解决方案适合这里。最简单的解决方案是锁定线程当前操作的实例。

我基于一个有锁和无锁的简单执行。

这个锁定实例的运行大约需要 10.09 秒。同样的运行,没有锁需要大约 9.03 秒:

const int numOfPersons = 250000;
var persons = new List<Person>(numOfPersons);
for (int i = 0; i < numOfPersons; i++)
{
    persons.Add(new Person());
}

var rand = new Random();

var sw = Stopwatch.StartNew();

for (int j = 0; j < 100; j++)
{
    for (int i = 0; i < 100000; i++)
    {
        int index = rand.Next(0, numOfPersons);

        Person person = persons[index];
        lock (person)
        {
            person.Name += "a";
        }
    }
}

Console.WriteLine(sw.Elapsed);

由于元素与线程的比例足够大,每个线程等待实例所需的时间预期可以忽略不计。

从示例中可以看出,锁定实例的时间开销约为 1 秒。此代码在 250,000 项大小的集合中执行 100 次 100,000 次修改。不管修改什么,1秒时间大致恒定。

【讨论】:

  • 这仅在 OP 提到的数组包含引用类型时才有效。
  • @chibacity,你是对的。如果元素是值类型,锁将毫无意义。
  • @Elisha 感谢您的回答。元素实际上是值类型。但是扩展数组来为每个实体保存一个额外的可锁定对象是没有问题的。
  • @Elisha 您对代码的哪一部分进行了计时? 6 秒似乎可以处理 250k 个元素。也许您的代码是修改绑定而不是锁定绑定。在我的情况下,现在占 0.45% 的比例可能更多。正如我所说,修改需要 ~10^-6 秒,因此在单核机器上迭代超过 250k 个元素将需要 ~0.25 秒。
  • @Dave 该示例包含可能导致差异的字符串连接。我猜你只是在设置一个开销更低的值。
【解决方案3】:

您的实体似乎是结构(每个 20 字节)。这是一个疯狂的猜测,因为我不知道你实际上想要做什么,但你不能让这些实体成为不可变的引用类型吗?

当您创建不可变引用类型时,您的数组将仅包含引用,其大小为 4 个字节(或 64 位上的 8 个字节),并且更改引用将始终是原子操作(除非您明确更改课程)。更改实体意味着创建一个新实体并将数组中的引用从旧的替换为新的。这种方式的变化是原子的。但是,当两个线程彼此紧接着写入同一个槽时,您仍然可以松动更改(但您似乎并不担心这一点,因为您正在谈论“挑选赢家”)。

我不知道这会对性能产生什么影响,因为您可能会明确选择值类型数组而不是引用类型数组。但是,有时最好使解决方案更简单,而不是更难。此解决方案还可能改善缓存局部性,因为您正在谈论对大数组的随机访问。因此,该数组将不适合 CPU 的缓存,并且您将有很多缓存未命中。

【讨论】:

  • “但是,当两个线程彼此紧接着写入同一个插槽时,您仍然可以松动更改” - 我确实担心!如果我有所有副本,则可以选择获胜者。但在你描述的场景中,一个副本永远丢失了,没有人会注意到。
  • 您还可以使用并发队列并添加每个更改,或者使用每个线程的队列。这样您就不会丢失任何更改。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2020-09-05
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2011-08-26
  • 1970-01-01
相关资源
最近更新 更多