C# 线程安全的 getter 性能差异

7
我正在编写一个线程安全对象,它基本上代表一个double,并使用锁来确保安全的读写。在代码中,我使用了许多这些对象(20-30),每秒读写它们100次,并且我正在测量每个时间步骤的平均计算时间。我开始研究获取器的几个选项,在运行多次测试并收集样本以平均计算时间后,我发现某些实现比其他实现表现更好,但这些实现并非我预期的实现。
实现1)计算时间平均值= 0.607ms:
protected override double GetValue()
{
    lock(_sync)
    {
        return _value;
    }
}

实现2)计算时间平均值 = 0.615毫秒:

protected override double GetValue()
{
    double result;
    lock(_sync)
    {
        result = _value;
    }
    return result;
}

实现 3) 计算时间平均值 = 0.560ms:

protected override double GetValue()
{
    double result = 0;
    lock(_sync)
    {
        result = _value;
    }
    return result;
}
我的期望:我本来以为实现3是最差的(因为这是我的原始代码,所以可能是机会或懒惰的编码方式),但出乎意料的是它在性能方面始终表现最好。我希望实现1是最快的。我也希望实现2至少和实现3一样快,如果不是更快,因为我只是删除了对double结果的赋值,而这个结果被覆盖,所以是不必要的。 我的问题是:有人可以解释一下为什么我测量的这三种实现具有相对的性能吗?这对我来说似乎是违反直觉的,我真的很想知道为什么。
我意识到这些差异并不重要,但它们的相对度量每次运行测试时都是一致的,每个测试收集数千个样本以平均计算时间。另外,请记住,我进行这些测试是因为我的应用程序需要非常高的性能,或至少是我可以合理获得的性能。我的测试案例只是一个小测试案例,当在发布中运行代码时,我的代码的性能将非常重要。
编辑:请注意,我正在使用MonoTouch并在iPad Mini设备上运行代码,因此可能与c#无关,而与MonoTouch的跨编译器有关。

1
考虑到它们非常相似(即使从 IL 的角度来看,我也不认为它们在计算上会有太大的差异),以及基准测试时间如此接近,我怀疑罪魁祸首是你的测试方法。它是否编译为“发布”模式?你是如何进行基准测试的?其他进程可能会干扰处理资源/时间吗?你是否模拟了锁争用,如果是,又是如何模拟的?编辑:此外,我怀疑你的真实应用程序在“lock”块中有更复杂的工作?因为就目前而言,锁对我来说似乎是多余的。 - Chris Sinclair
3
如果没有争夺锁,那些测试就毫无意义。 - H H
3
好的,请仔细查看 System.Threading.Interlocked - H H
1
当在x86 Release模式下编译时,前两个实现具有相同的本机代码。第三个实现也是一样的,除了额外的fldzfstp指令来将变量初始化为0。 - Michael Liu
@ChrisSinclair 不,我目前没有编译发布模式。我正在通过在移动设备上模拟相同的测试案例20秒(足够长的时间让测量平均值"稳定")来进行基准测试。我没有模拟任何锁争用,因为我更感兴趣的是与锁无关的代码差异,并尝试了解可能的IL影响(我对C#非常陌生)。另外,请问您能否解释一下为什么您认为锁似乎是多余的呢?Double类型的读/写不是原子操作,所以我认为这可以确保跨线程安全地读取,不是吗? - Camputer
显示剩余7条评论
2个回答

15
坦白说,这里还有其他更好的方法。以下是输出结果(忽略 x1,它是为 JIT 准备的):
x5000000
Example1        128ms
Example2        136ms
Example3        129ms
CompareExchange 53ms
ReadUnsafe      54ms
UntypedBox      23ms
TypedBox        12ms

x5000000
Example1        129ms
Example2        129ms
Example3        129ms
CompareExchange 52ms
ReadUnsafe      53ms
UntypedBox      23ms
TypedBox        12ms

x5000000
Example1        129ms
Example2        161ms
Example3        129ms
CompareExchange 52ms
ReadUnsafe      53ms
UntypedBox      23ms
TypedBox        12ms

所有这些都是线程安全的实现。正如您所看到的,最快的是一个有类型的盒子,其次是一个无类型(object)盒子。接下来就是(大约同样的速度)Interlocked.CompareExchange/Interlocked.Read——请注意,后者仅支持long,因此我们需要进行一些位操作以将其视为double

显然,应在目标框架上进行测试。

为了好玩,我还测试了一个Mutex;在同一规模的测试中,需要约3300ms。

using System;
using System.Diagnostics;
using System.Threading;
abstract class Experiment
{
    public abstract double GetValue();
}
class Example1 : Experiment
{
    private readonly object _sync = new object();
    private double _value = 3;
    public override double GetValue()
    {
        lock (_sync)
        {
            return _value;
        }
    }
}
class Example2 : Experiment
{
    private readonly object _sync = new object();
    private double _value = 3;
    public override double GetValue()
    {
        lock (_sync)
        {
            return _value;
        }
    }
}

class Example3 : Experiment
{
    private readonly object _sync = new object();
    private double _value = 3;
    public override double GetValue()
    {
        double result = 0;
        lock (_sync)
        {
            result = _value;
        }
        return result;
    }
}

class CompareExchange : Experiment
{
    private double _value = 3;
    public override double GetValue()
    {
        return Interlocked.CompareExchange(ref _value, 0, 0);
    }
}
class ReadUnsafe : Experiment
{
    private long _value = DoubleToInt64(3);
    static unsafe long DoubleToInt64(double val)
    {   // I'm mainly including this for the field initializer
        // in real use this would be manually inlined
        return *(long*)(&val);
    }
    public override unsafe double GetValue()
    {
        long val = Interlocked.Read(ref _value);
        return *(double*)(&val);
    }
}
class UntypedBox : Experiment
{
    // references are always atomic
    private volatile object _value = 3.0;
    public override double GetValue()
    {
        return (double)_value;
    }
}
class TypedBox : Experiment
{
    private sealed class Box
    {
        public readonly double Value;
        public Box(double value) { Value = value; }

    }
    // references are always atomic
    private volatile Box _value = new Box(3);
    public override double GetValue()
    {
        return _value.Value;
    }
}
static class Program
{
    static void Main()
    {
        // once for JIT
        RunExperiments(1);
        // three times for real
        RunExperiments(5000000);
        RunExperiments(5000000);
        RunExperiments(5000000);
    }
    static void RunExperiments(int loop)
    {
        Console.WriteLine("x{0}", loop);
        RunExperiment(new Example1(), loop);
        RunExperiment(new Example2(), loop);
        RunExperiment(new Example3(), loop);
        RunExperiment(new CompareExchange(), loop);
        RunExperiment(new ReadUnsafe(), loop);
        RunExperiment(new UntypedBox(), loop);
        RunExperiment(new TypedBox(), loop);
        Console.WriteLine();
    }
    static void RunExperiment(Experiment test, int loop)
    {
        // avoid any GC interruptions
        GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
        GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
        GC.WaitForPendingFinalizers();

        double val = 0;
        var watch = Stopwatch.StartNew();
        for (int i = 0; i < loop; i++)
            val = test.GetValue();
        watch.Stop();
        if (val != 3.0) Console.WriteLine("FAIL!");
        Console.WriteLine("{0}\t{1}ms", test.GetType().Name,
            watch.ElapsedMilliseconds);

    }

}

你能否在TypedBox.GetValue中移除unsafe修饰符? - Guillaume86
@Guillaume86 抱歉,复制粘贴错误;是的 - 我会编辑;这不会改变数字。 - Marc Gravell
@MarcGravell 谢谢您提供非常有趣的比较!我有一个问题,TypedBox实现是否只允许读取,因为使用了readonly关键字?我确实需要安全地读写,我只是没有在原始帖子中包含写入代码,尽管我在SetValue方法中使用了相同的锁机制。如果在类型化和非类型化Box示例中添加SetValue方法,这些实现是否仍然是线程安全的?我不明白这些类中的读取是如何独占/原子性的,我错过了什么吗?(对c#新手来说) - Camputer
@计算机编程中,如果你要写一个新的Box(value),你需要分配它。不过,在考虑这个问题时,你可能需要在两个Box示例的两个字段中添加volatile关键字。语言规范保证对引用的访问始终是原子性的,并且对象是不可变的,这保证了一旦我们解除引用,值就是正确的。 - Marc Gravell

6

仅测量并发读取是误导性的,您的缓存会给出比真实用例更好几个数量级的结果。因此,我在Marc的示例中添加了SetValue:

using System;
using System.Diagnostics;
using System.Threading;

abstract class Experiment
{
    public abstract double GetValue();
    public abstract void SetValue(double value);
}

class Example1 : Experiment
{
    private readonly object _sync = new object();
    private double _value = 3;
    public override double GetValue()
    {
        lock (_sync)
        {
            return _value;
        }
    }

    public override void SetValue(double value)
    {
        lock (_sync)
        {
            _value = value;
        }

    }

}
class Example2 : Experiment
{
    private readonly object _sync = new object();
    private double _value = 3;
    public override double GetValue()
    {
        lock (_sync)
        {
            return _value;
        }
    }

    public override void SetValue(double value)
    {
        lock (_sync)
        {
            _value = value;
        }
    }

}



class Example3 : Experiment
{
    private readonly object _sync = new object();
    private double _value = 3;
    public override double GetValue()
    {
        double result = 0;
        lock (_sync)
        {
            result = _value;
        }
        return result;
    }

    public override void SetValue(double value)
    {
        lock (_sync)
        {
            _value = value;
        }
    }
}

class CompareExchange : Experiment
{
    private double _value = 3;
    public override double GetValue()
    {
        return Interlocked.CompareExchange(ref _value, 0, 0);
    }

    public override void SetValue(double value)
    {
        Interlocked.Exchange(ref _value, value);
    }
}
class ReadUnsafe : Experiment
{
    private long _value = DoubleToInt64(3);
    static unsafe long DoubleToInt64(double val)
    {   // I'm mainly including this for the field initializer
        // in real use this would be manually inlined
        return *(long*)(&val);
    }
    public override unsafe double GetValue()
    {
        long val = Interlocked.Read(ref _value);
        return *(double*)(&val);
    }

    public override void SetValue(double value)
    {
        long intValue = DoubleToInt64(value);
        Interlocked.Exchange(ref _value, intValue);
    }
}
class UntypedBox : Experiment
{
    // references are always atomic
    private volatile object _value = 3.0;
    public override double GetValue()
    {
        return (double)_value;
    }

    public override void SetValue(double value)
    {
        object valueObject = value;
        _value = valueObject;
    }
}
class TypedBox : Experiment
{
    private sealed class Box
    {
        public readonly double Value;
        public Box(double value) { Value = value; }

    }
    // references are always atomic
    private volatile Box _value = new Box(3);
    public override double GetValue()
    {
        Box value = _value;
        return value.Value;
    }

    public override void SetValue(double value)
    {
        Box boxValue = new Box(value);
        _value = boxValue;
    }
}
static class Program
{
    static void Main()
    {
        // once for JIT
        RunExperiments(1);
        // three times for real
        RunExperiments(5000000);
        RunExperiments(5000000);
        RunExperiments(5000000);
    }
    static void RunExperiments(int loop)
    {
        Console.WriteLine("x{0}", loop);
        RunExperiment(new Example1(), loop);
        RunExperiment(new Example2(), loop);
        RunExperiment(new Example3(), loop);
        RunExperiment(new CompareExchange(), loop);
        RunExperiment(new ReadUnsafe(), loop);
        RunExperiment(new UntypedBox(), loop);
        RunExperiment(new TypedBox(), loop);
        Console.WriteLine();
    }
    static void RunExperiment(Experiment test, int loop)
    {
        // avoid any GC interruptions
        GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
        GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
        GC.WaitForPendingFinalizers();

        int threads = Environment.ProcessorCount;

        ManualResetEvent done = new ManualResetEvent(false);

        // Since we use threads, divide the original workload
        //
        int workerLoop = Math.Max(1, loop / Environment.ProcessorCount);
        int writeRatio = 1000;
        int writes = Math.Max(workerLoop / writeRatio, 1);
        int reads = workerLoop / writes;

        var watch = Stopwatch.StartNew();

        for (int t = 0; t < Environment.ProcessorCount; ++t)
        {
            ThreadPool.QueueUserWorkItem((state) =>
                {
                    try
                    {
                        double val = 0;

                        // Two loops to avoid comparison for % in the inner loop
                        //
                        for (int j = 0; j < writes; ++j)
                        {
                            test.SetValue(j);
                            for (int i = 0; i < reads; i++)
                            {
                                val = test.GetValue();
                            }
                        }
                    }
                    finally
                    {
                        if (0 == Interlocked.Decrement(ref threads))
                        {
                            done.Set();
                        }
                    }
                });
        }
        done.WaitOne();
        watch.Stop();
        Console.WriteLine("{0}\t{1}ms", test.GetType().Name,
            watch.ElapsedMilliseconds);

    }
}

读写比为1000:1时的结果如下:

x5000000
Example1        353ms
Example2        395ms
Example3        369ms
CompareExchange 150ms
ReadUnsafe      161ms
UntypedBox      11ms
TypedBox        9ms

100:1 (读:写)

x5000000
Example1        356ms
Example2        360ms
Example3        356ms
CompareExchange 161ms
ReadUnsafe      172ms
UntypedBox      14ms
TypedBox        13ms

10:1(读写比)

x5000000
Example1        383ms
Example2        394ms
Example3        414ms
CompareExchange 169ms
ReadUnsafe      176ms
UntypedBox      41ms
TypedBox        43ms

2:1 (读作:写入)

x5000000
Example1        550ms
Example2        581ms
Example3        560ms
CompareExchange 257ms
ReadUnsafe      292ms
UntypedBox      101ms
TypedBox        122ms

1:1 (读作:写)

x5000000
Example1        718ms
Example2        745ms
Example3        730ms
CompareExchange 381ms
ReadUnsafe      376ms
UntypedBox      161ms
TypedBox        200ms

*更新了代码,删除了不必要的写入ICX操作,因为该值总是被覆盖。还修正了计算读取次数以按线程划分(同一工作)的公式。


感谢您在其中添加了SetValue,因为我认为它很重要。问题:在TypedBox实现中创建新的Box实例不是很昂贵吗?这就是为什么随着结果中写入比例的增加,执行时间开始逐渐增加的原因吗? - Camputer
回答你的问题,我们必须对代码进行实际的分析 - Remus Rusanu
@RemusRusanu 我的意思是,一般来说,如果我正在执行大量读写和运行时间敏感的计算,我的直觉告诉我尽可能避免实例化新对象。这有道理吗? - Camputer
@Camputer:确实如此。我对结果感到有些困惑,因为盒子示例对我来说似乎太好了。但请记住,这个示例只显示了基本安全的原子操作。线程正在覆盖彼此的值,并且是最后写入者获胜的情况,这不太可能是真实情况的使用方式。 - Remus Rusanu
@MarcGravell:我必须说,我对Box/Object案例为什么如此快速感到非常困惑...我的钱都放在ICX上。我想知道是否有IL/JIT的层次来隐藏裸金属级别上发生的事情,但我现在没有时间深入挖掘。 - Remus Rusanu
显示剩余3条评论

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