如何以线程安全的方式增加(添加值到)十进制数?

14
我有一个decimal变量,在多个线程同时访问。Interlocked类函数根本不支持decimal,所以我唯一剩下的选择是使用lock(){}。这似乎有点过度了。
有没有其他方法以线程安全的方式增加decimal变量的值?
6个回答

20

使用锁并不过度。它是必需的。

像 System.Decimal 这样的结构类型从来都不是原子的,也不适合本地 CPU 字长。这就是为什么 Interlocked 也没有为其提供重载。


6
不行。 decimal 的内部表示过于复杂,无法使用CPU级别的原子操作进行修改(大多数情况下,Interlocked 使用的就是这种操作,这也是你感兴趣的)。当CPU无法原子处理某些数量时,手动锁定是唯一的选择。您可以选择同步原语(例如使用lock还是互斥量),但仅限于此。

3

您仍然可以使用InterLocked,但是这时需要将十进制数转换为Int64。在进行转换时,您需要决定要保留多少小数位以保持精度。例如,如果您想保留4位小数,可以执行以下操作:

//Declare up front accessible from all threads
Int64 totalAmount = 0;
        
//Inside the thread you do this
var amount = (Int64)(decimalAmount * 10000); //10.000 is to preserve 4 decimal places
Interlocked.Add(ref totalAmount, amount);
        
//After all threads have finished, go back to decimal type.
var totalDecimalAmount = totalAmount / 10000;

请注意,根据您想要保留的小数位数,您将失去精度。而Decimal.MaxValue79,228,162,514,264,337,593,543,950,335,而Int64.MaxValue9,223,372,036,854,775,807。因此,非常大的数字无法容纳。在保留4位小数的情况下,在Int64溢出之前最大的数字是9,223,372,036,854,775,807 / 10000 = 922,337,203,685,477
我这样使用它,因为这里的数字永远不会超过1,000,000,000,并且我确定在Parallel.For循环中使用这种方式的Interlocked比使用lock或互斥更快。

2
@MarcL. 放心。我使用的 Parallel.For 只有在完成后才会结束。然后,在执行 / 10000 操作之前,确保 totalAmount 在线程安全的情况下被累加即可。并非所有代码都是“关键部分”,只有总和计算是如此。在所有线程处理完毕后,将评估 totalDecimalAmount - Mike de Klerk
我真的应该更仔细地阅读代码示例中的注释。没有任何花括号、省略号或其他伪代码指示,我只是直接阅读了它。 - Marc L.

2

如果您不介意将总数作为对象封装的 decimal,您可以使用以下方法:

private static object myTotal = 0M;
static void InterlockedAddTotal(decimal val) {
    object next;
    object current;
    do {
        current = myTotal;
        next = val + (decimal)current;
    } while (Interlocked.CompareExchange(ref myTotal, next, current) != current);
}

尽管此方法不使用锁,但它将decimal封装在一个对象中,这会带来自己的性能影响。根据情况,使用锁可能更便宜。

你有比较过这个解决方案和锁定方法的性能吗? - nicolas2008
你可以用 _mytotal 替换 myTotal,然后创建一个属性 myTotal,将 _myTotal 转换为 decimal 类型以实现可用性。虽然解决方案很好,但我花了很长时间才理解。 - The Lemon

1
创建一个带有十进制值的类,并将引用分配给该类,是使赋值原子化的一种方法。由于类引用是64(32)位的,因此类引用的赋值是原子的。
class A
{
    decimal Value{get;set;}
}

var x=new A(){Value=10};
var y=new A(){Value=20};

x=y;//atomic

2
尽管此赋值操作将是原子性的,但每次进行赋值都需要分配一个新对象,这是一个性能问题。另外,比较实例需要重写Equals()方法以比较它们的Value属性。 - Artemious
1
这不会执行原子递增,因为两个线程可以读取一个 A,执行自己的递增并写回自己的值,最后一个写入者获胜。 - Jon Bates
1
这不会执行原子递增,因为两个线程可能会读取一个 A,执行自己的递增操作并写回自己的值,最后一个写入的线程会获胜。 - undefined

0

如果关键部分非常轻量级,您可以使用自旋锁来获得更好的性能。

由于decimal在内部由4个整数组成,即使设置值也不是原子操作,因此在以后读取该值时还需要使用相同的自旋锁

public class Counter
{
    private decimal _value = 0;
    private SpinLock _lock = new (); 
    public void Increment(decimal amount)
    {
        var lockTaken = false;
        try
        {
            _lock.Enter(ref lockTaken);
            _value += amount;
        }
        finally
        {
            if (lockTaken)
            {
                _lock.Exit(true); // use true on x64
            }
        }
    }
}

1
我的理解是,在让出当前线程之前,lock 也会在争用的情况下稍微旋转一下,因此我不认为 SpinLock 的性能会比 lock 更好。就个人而言,我根本不使用这种机制。引用 Albahari 的书中的话:“当编写自己的可重用同步构造时,SpinLock 是最有意义的。即使如此,自旋锁也不像听起来那么有用。它仍然限制并发性,并浪费 CPU 时间。” - Theodor Zoulias

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