如何轻松使计数器属性线程安全?

14

我有一个类中只有计数器的属性定义,这必须是线程安全的,但实际上并不安全,因为 getset 不在同一个锁中。如何解决这个问题?

    private int _DoneCounter;
    public int DoneCounter
    {
        get
        {
            return _DoneCounter;
        }
        set
        {
            lock (sync)
            {
                _DoneCounter = value;
            }
        }
    }

1
相关链接:https://dev59.com/3HRB5IYBdhLWcg3wz6QK - dee-see
你可以使用Interlocked增量。那应该很容易。 - DarthVader
@Svisstack,如果你真的在询问“计数器”类型的属性(请参见Sean U的答案),将“this”更新为“counter”可能是个好主意。 - Alexei Levenkov
这个程序有什么不安全的地方?你想实现什么目标? - Andrew Savinykh
5个回答

30
如果你想要实现这个属性,以确保DoneCounter = DoneCounter + 1不会受到竞争条件的影响,那么就不能在属性的实现中完成。这个操作并不是原子操作,它实际上包括三个不同的步骤:
  1. 获取DoneCounter的值。
  2. 加1
  3. 将结果存储在DoneCounter中。
你必须防范一个上下文切换可能发生在任何一个步骤之间的可能性。在getter或setter内部进行锁定也没用,因为锁的作用域仅存在于其中一个步骤(步骤1或3)中。如果你想要确保所有三个步骤一起完成而不被中断,那么你的同步必须涵盖所有三个步骤。这意味着它必须发生在包含它们所有的上下文中。这可能最终会成为不属于包含DoneCounter属性的任何类的代码。
使用你的对象的人负责确保线程安全。一般来说,没有读写字段或属性的类可以通过这种方式变得“线程安全”。但是,如果你可以更改类的接口,使setter不再必需,那么就有可能使它更加线程安全。例如,如果你知道DoneCounter只会增加和减少,那么可以像这样重新实现它:
private int _doneCounter;
public int DoneCounter { get { return _doneCounter; } }
public int IncrementDoneCounter() { return Interlocked.Increment(ref _doneCounter); }
public int DecrementDoneCounter() { return Interlocked.Decrement(ref _doneCounter); }

我使用计数器的方式是 Counter += X; 而且 X 不等于 1 ;-) 我有一个计数器,从中减去的值不等于 1。 - Svisstack
5
那么你可以使用Interlocked.Add()代替。 - Sean U
2
这段代码确保调用 IncrementDoneCounter() 两次将始终将计数器增加两次,这可能是 OP 想要的。但是,对于那些希望保持不同计数值(例如唯一 ID)的人来说,使用此代码的用户需要修改它以利用对 Interlocked.Increment 调用的返回值。 - Brian

5

使用Interlocked类提供了原子操作,即在多线程环境下具有天然的线程安全性,就像这个 LinqPad 示例中一样:

void Main()
{
    var counters = new Counters();
    counters.DoneCounter += 34;
    var val = counters.DoneCounter;
    val.Dump(); // 34
}

public class Counters
{
    int doneCounter = 0;
    public int DoneCounter
    {
        get { return Interlocked.CompareExchange(ref doneCounter, 0, 0); }
        set { Interlocked.Exchange(ref doneCounter, value); }
    }
}

我不建议采用这种方法。 += 不是原子操作。在这种情况下,我建议使用 Interlocked.Add 而不是 +=。顺便说一下,获取/设置 Int32 确实 是原子操作(请参见 https://dev59.com/CGgt5IYBdhLWcg3w3xXC#11745604),所以我不确定这段代码想要实现什么。 - Brian
1
请参见 https://dotnetfiddle.net/TmHvku 中有关这种竞争条件的演示。请注意,dotnetfiddle 倾向于避免竞争条件(可能它不会非常频繁地切换线程),因此您可能无法生成错误的结果,除非您在本地测试。 - Brian

2
你到底是想用计数器做什么?锁对整数属性并没有太大作用,因为无论是否加锁,整数的读写都是原子性的。使用锁的唯一好处是添加内存屏障;在读取或写入共享变量之前和之后使用Threading.Thread.MemoryBarrier()可以达到相同的效果。
我猜你真正的问题是想要执行类似于“DoneCounter+=1”的操作,即使使用锁,也会执行以下事件序列:
获取锁 获取_DoneCounter 释放锁 将一个添加到读取的值 获取锁 将_DoneCounter设置为计算出的值 释放锁
这并不是很有帮助,因为值可能会在获取和设置之间更改。需要的是一种方法,可以在没有任何中间操作的情况下执行获取、计算和设置。有三种方法可以实现这个目标:
1. 在整个操作期间获取并保持锁定状态 2. 使用Threading.Interlocked.Increment向_Counter添加值 3. 使用Threading.Interlocked.CompareExchange循环更新_Counter 使用这些方法中的任何一种,都可以基于旧值计算出_Counter的新值,以确保写入的值是基于写入时_Counter的值。

2

如果您预计不仅有一些线程偶尔同时写入计数器,而且有很多线程会不断这样做,那么您需要有几个计数器,至少相距一个缓存行,并且让不同的线程写入不同的计数器,在需要总数时将它们求和。

这可以使大多数线程互不干扰,从而防止它们将彼此的值刷新出核心并相互拖慢速度。(除非您能保证每个线程都是单独的,否则仍需要交错)。

对于绝大多数情况,您只需要确保偶尔的争用不会破坏值,这种情况下 Sean U 的答案在各方面都更好(像这样的条带式计数器在没有争用的情况下速度更慢)。


0

1
哈哈哈,那个页面上的例子太糟糕了,微软加油。这可能是实现他所寻找的最简单的方法。 - SoWeLie
+1,看起来是有效的,我将属性重写为公共变量,但我不能接受你,因为问题是关于属性的,我认为如果volatile变量位于类内(私有)并且用公共属性包装,则这将不起作用。 - Svisstack
这应该可以工作: 类 c { 私有易失性 int _n; 公共 nValue {get {返回 this._n; } set { this._n = value; }} } - user191966
3
这样做行不通。volatile不能防止线程交错执行读写操作,只能防止它们使用处理器缓存或寄存器。它适用于允许线程从值中读取或向其写入但从不两者都有的情况。在其他任何地方,它能够可靠做到的就是让你的程序运行更慢。 - Sean U
互锁和锁定操作会带来自己的内存屏障,这意味着在写入它们的许多情况下(但不是所有情况),您不需要使用它。 - Jon Hanna
这个不起作用,我就是不明白我的代码中的并发示例;-) - Svisstack

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