结构数组的 C# 并发性

本文关键字:并发 数组 结构 | 更新日期: 2023-09-27 18:36:57

给定一个结构数组:

public struct Instrument
{
    public double NoS;
    public double Last;
}
var a1 = new Instrument[100];

还有一个线程任务池,它正在写入这些元素,其基础是单个元素最多可以由两个线程同时写入,每个线程一个用于双精度字段(有效地按主题进行上游排队)。

以及双精度可以在 64 位上原子写入的知识。(编辑这个错误地说原来是32位)

我需要使用数组中的所有值定期执行计算,我希望它们在计算过程中保持一致。

因此,我可以使用以下命令对阵列进行快照:

var snapshot = a1.Clone();

现在我的问题是关于同步的细节。如果我使成员易失性,我认为这根本不会对克隆有所帮助,因为读/写获取/释放不在数组级别。

现在我可以有一个数组锁,但这会增加对将数据写入数组的最频繁过程的大量争用。所以不理想。

或者,我可以有一个每行锁定,但这将是一个真正的痛苦,因为它们都需要在克隆之前获得,同时我已经备份了所有写入。

现在,如果快照没有最新的值,如果是微秒等,这并不重要,所以我想我可能会摆脱没有锁定。我唯一担心的是是否存在持续一段时间没有缓存写回的情况。这是我应该担心的事情吗?编写器位于 TPL 数据流中,唯一的逻辑是在结构中设置这两个字段。我真的不知道函数范围如何或是否倾向于与缓存回写相关。

想法/建议?

edit:如果我对结构中的变量使用互锁写入怎么办?

edit2:写入量远高于读取量。还有两个单独的并发服务写入Nos & Last字段。因此,它们可以同时编写。这会导致原子性的参考对象方法出现问题。

编辑3:更多细节。假设数组有 30-1000 个元素,每个元素每秒可以更新多次。

结构数组的 C# 并发性

由于 Instrument 包含两个双精度值(两个 64 位值),因此无法以原子方式写入它(即使在 64 位计算机上也是如此)。这意味着 Clone 方法如果不执行某种同步,就永远无法创建线程安全的副本。

TLDR;不要使用结构,使用不可变的类。

通过一个小的重新设计,你可能会有更多的运气。尝试使用 .NET 框架中的不可变数据结构和并发集合。例如,将Instrument设置为不可变类

// Important: Note that Instrument is now a CLASS!! 
public class Instrument
{
    public Instrument(double nos, double last)
    {
        this.NoS = nos;
        this.Last = last;
    }
    // NOTE: Private setters. Class can't be changed
    // after initialization.
    public double NoS { get; private set; }
    public double Last { get; private set; }
}

这样更新Instrument意味着您必须创建一个新,这使得推理变得更加容易。当您确定只有一个线程在处理单个Instrument您就完成了,因为工作线程现在可以安全地执行此操作:

Instrument old = a[5];
var newValue = new Instrument(old.NoS + 1, old.Last - 10);
a[5] = newValue;

由于引用类型为 32 位(或 64 位计算机上的 64 位),因此更新引用可确保原子。克隆现在将始终产生正确的副本(它可能缺少后面,但这对您来说似乎不是问题)。

更新

重新阅读您的问题后,我发现我误读了它,因为一个线程不是写入Instrument,而是写入仪器值,但解决方案实际上是相同的:使用不可变的引用类型。例如,一个简单的技巧是将NoSLast属性的支持字段更改为对象。这使得更新它们是原子的:

// Instrument can be a struct again.
public struct Instrument
{
    private object nos;
    private object last;
    public double NoS
    {
        get { return (double)(this.nos ?? 0d); }
        set { this.nos = value; }
    }
    public double Last
    {
        get { return (double)(this.last ?? 0d); }
        set { this.last = value; }
    }
}

更改其中一个属性时,值将被装箱,并且装箱值是不可变的引用类型。这样,您可以安全地更新这些属性。

以及双精度可以在 32 位上原子写入的知识。

不,这不能保证:

12.5 变量引用的原子性

以下数据类型的读取和写入应为原子:布尔值、字符、字节、字节、短、 ushort、uint、int、float 和引用类型。此外,读取和 写入上一个列表中具有基础类型的枚举类型 也应该是原子的。其他类型的读取和写入,包括长、 ulong、double 和 decimal 以及用户定义的类型不需要 是原子的。

(强调我的)

不保证 32 位甚至 64 位上的双精度。由2个双打组成的strcut更成问题。你应该重新考虑你的策略。

你可以(ab)使用ReaderWriterLockSlim

写作时采取读锁定(因为你说作家之间没有争用)。并在克隆时进行写锁定。

不确定我会这样做,除非真的别无选择。 对于维护此内容的人来说,可能会感到困惑。

单个

数组元素或单个结构字段的读写通常是独立的。 如果当一个线程正在写入特定结构实例的特定字段时,没有其他线程将尝试访问该字段,则结构数组将是隐式线程安全的,除了强制执行上述条件的逻辑之外,不需要任何锁定。

如果一个线程可能会尝试读取double而另一个线程正在写入它,但两个线程不可能同时尝试写入,则可以采取多种方法来确保读取不会看到部分写入的值。 尚未提及的一种方法是定义一个int64字段,并使用自定义方法在那里读取和写入double值(按位转换它们,并根据需要使用Interlocked)。

另一种方法是为每个数组插槽设置一个 changeCount 变量,该变量递增,因此在写入结构之前,两个 LSB 在任何其他内容之前都是"10",之后Interlocked.Increment 2(请参阅下面的注释)。 在代码读取结构之前,它应该检查写入是否正在进行中。 如果没有,它应该执行读取并确保写入尚未开始或发生(如果在读取开始后发生写入,请循环回开头)。 如果在代码想要读取时正在进行写入,则应获取共享锁,检查写入是否仍在进行中,如果是,则使用联锁操作在锁上设置 changeCountMonitor.Wait 的 LSB。 编写结构的代码应在其Interlocked.Increment中注意到 LSB 已设置,并且应Pulse锁。 如果内存模型确保按顺序处理单个线程的读取,并且按顺序处理单个线程的写入,并且一次只有一个线程尝试写入数组插槽,则此方法应将多处理器开销限制为非争用情况下的单个Interlocked操作。 请注意,在使用此类代码之前,必须仔细研究有关内存模型暗示或不暗示的规则,因为它可能很棘手。

顺便说一句,如果想让每个数组元素成为类类型而不是结构体,还可以采用两种方法:

    使用
  1. 不可变的类类型,并在要更新元素时随时使用"Interlocked.CompareExchange"。 要使用的模式是这样的:
      我的类老瓦尔,新瓦尔;  做  {    oldVal = theArray[下标];    newVal = new MyClass(oldVal.this, oldVal.that+5);或任何变化  } while (Threading.Interlocked.CompareExchange(theArray[subscript], newVal, oldVal) != oldVal);
    此方法将始终生成数组元素的逻辑正确的原子更新。 如果在读取数组元素和更新数组元素之间,其他内容更改了值,则"CompareExchange"将使数组元素不受影响,并且代码将循环回去重试。 在没有争用的情况下,此方法效果相当好,尽管每次更新都需要生成一个新的对象实例。 但是,如果许多线程尝试更新相同的数组槽,并且"MyClass"的构造函数需要大量时间来执行,则代码可能会抖动,重复创建新对象,然后发现它们在可以存储时已经过时。 代码总是会向前推进,但不一定很快。
  2. 使用可变类,并在希望读取或写入类对象时随时锁定它们。 这种方法可以避免在更改某些内容时创建新的类对象实例,但锁定会增加其自身的一些开销。 请注意,读取和写入都必须被锁定,而不可变类方法只需要在写入时使用"互锁"方法。

我倾向于认为结构数组比类对象数组更好的数据持有者,但这两种方法都有优点。

好的,所以在午餐时考虑了一下。

我在这里看到两个,可能是 3 个解决方案。

第一个重要说明:不可变的想法在我的用例中不起作用,因为我有两个服务并行运行到 NoS 和 Last 独立写入。这意味着我需要在这两个服务之间增加一层同步逻辑,以确保当新 ref 由一个服务创建时,另一个服务不会做同样的事情。经典的竞争条件问题,所以绝对不适合这个问题(虽然是的,我可以为每个双打都有一个参考并这样做,但在这一点上它变得荒谬)

解决方案 1整个缓存级别锁定。也许使用旋转锁,只锁定所有更新和快照(使用 memcpy)。这是最简单的,对于我所说的卷来说可能完全没问题。

解决方案 2使所有对双精度的写入都使用互锁写入。当我想拍摄快照时,使用互锁读取来迭代数组和每个值以填充副本。这可能会导致每个结构撕裂,但双精度完好无损,这很好,因为这会不断更新数据,因此最新的概念有点抽象。

解决方案 3不要认为这会起作用,但是对所有双精度进行互锁写入,然后只使用memcopy呢?我不确定我是否会撕裂双打?(请记住,我不在乎结构级别的撕裂)。

如果解决方案 3 有效,那么我想它的最佳性能,但除此之外,我更倾向于解决方案 1。