2

给定一个结构数组:

public struct Instrument
{
    public double NoS;
    public double Last;
}

var a1 = new Instrument[100];

以及一个线程任务池,它基于单个元素最多可以由两个线程同时写入,一个用于双字段中的每一个(有效地按主题进行上游排队)来写入这些元素。

并且可以在 64 位上原子地写入 double 的知识。(编辑这个误说原来是32位)

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

所以我可以使用以下方法对数组进行快照:

var snapshot = a1.Clone();

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

现在我可以有一个数组锁,但这会在最频繁的向数组中写入数据的过程中增加很多争用。所以不太理想。

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

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

想法/建议?

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

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

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

4

5 回答 5

5

由于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; }
    }
}

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

于 2012-06-12T09:21:26.357 回答
2

并且可以在 32 位上原子地写入 double 的知识。

不,不能保证:

12.5 变量引用的原子性

以下数据类型的读取和写入应是原子的:bool、char、byte、sbyte、short、ushort、uint、int、float 和引用类型。此外,具有上一个列表中的基础类型的枚举类型的读取和写入也应该是原子的。其他类型的读取和写入,包括 long、ulong、double和 decimal,以及用户定义的类型,不必是原子的。

(强调我的)

对于 32 位或什至 64 位上的双精度,不做任何保证。由 2 个双打组成的Astrcut更成问题。你应该重新考虑你的策略。

于 2012-06-12T09:26:06.630 回答
1

你可以(ab)使用ReaderWriterLockSlim.

写时取读锁(因为你说写者之间没有争用)。并在克隆时取写锁。

除非真的别无选择,否则我不确定我会这样做。对于维护这一点的人来说,这可能会让人感到困惑。

于 2012-06-12T09:32:02.000 回答
1

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

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

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

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

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

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

于 2012-06-12T15:43:42.763 回答
0

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

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

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

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

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

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

如果解决方案 3 有效,那么我猜它的最佳性能,但否则我更倾向于解决方案 1。

于 2012-06-12T13:16:22.423 回答