编写高质量代码改善C#程序的157个建议——建议89:在并行方法体中谨慎使用锁

建议89:在并行方法体中谨慎使用锁

除了建议88所提到的场合,要谨慎使用并行的情况还包括:某些本身就需要同步运行的场合,或者需要较长时间锁定共享资源的场合。

在对整型数据进行同步操作时,可以使用静态类Interlocked的Add方法,这就极大地避免了由于进行原子操作长时间锁定某个共享资源所带来的同步性能损耗。回顾建议83中的例子。

static void Main(string[] args)  
{  
    int[] nums = new int[] { 1, 2, 3, 4 };  
    int total = 0;  
    Parallel.For<int>(0, nums.Length, () =>
        {  
            return 1;  
        }, (i, loopState, subtotal) =>
        {  
            subtotal += nums[i];  
            return subtotal;  
        },  
        (x) => Interlocked.Add(ref total, x)  
        );  
    Console.WriteLine("total={0}", total);  
    Console.ReadKey();  
} 

理论上,针对total的加法操作,需要使用一个同步锁,否则就无法避免一次torn read(即两次mov操作所导致的字段内存地址边界对齐问题)。FCL通过提供Interlocked类型解决了这个问题。FCL用来解决简单类型的原子性操作还提供了volatile关键字。不过这些都不是本建议所要讨论的重点。FCL现有的原子性操作为我们同步整型数据的时候,带来了性能上的提高。但是,在其他一些场合,我们却不得不考虑因为同步锁带来的损耗。

来看一个例子:

static void Main(string[] args)  
{  
    SampleClass sample = new SampleClass();  
    Parallel.For(0, 10000000, (i) =>
    {  
        sample.SimpleAdd();  
    });  
    Console.WriteLine(sample.SomeCount);  
}  
 
class SampleClass  
{  
    public long SomeCount { get; private set; }  
 
    public void SimpleAdd()  
    {  
        SomeCount++;  
    }  
} 

这段代码的输出或许是:

8322580

显然,这与我们的期待输出10000000有很大的差距。为了保证输出正确,必须为并行中的方法体加锁(假设SampleClass是外部提供的API,无权进行源码修改在其内部加锁):

object syncObj = new object();  
Parallel.For(0, 10000000, (i) =>
{  
    lock (syncObj)  
    {  
        sample.SimpleAdd();  
    }  
}); 

经过以上修改后,代码输出就正确了。但是,这段代码也带来了另外的问题。由于锁的存在,系统的开销也增加了,同步带来的线程上下文切换,使我们牺牲了CPU时间与空间性能。简单地说,就是这段代码还不如不用并行。在建议73中曾经提到过,锁其实就是让多线程变成单线程(因为同时只允许有一个线程访问资源)。所以,我们需要谨慎地对待并行方法中的同步问题。如果方法体的全部内容都需要同步运行,就完全不应该使用并行。

转自:《编写高质量代码改善C#程序的157个建议》陆敏技