首页 > 技术文章 > 多线程并发产生的原因

tomorrow0 2020-11-01 19:51 原文

背景

先看下面一段代码,看看运行结果

    class Program
    {
        static void Main(string[] args)
        {
            AccountTest account = new AccountTest();

            var tasks = new List<Task>();
            var task1 = Task.Factory.StartNew(() => account.Add());
            tasks.Add(task1);
            var task2 = Task.Factory.StartNew(() => account.Add());
            tasks.Add(task2);

            Task.WaitAll(tasks.ToArray());

            Console.WriteLine("Account is : " + account.Account);
            Console.ReadLine();
        }
    }

    public class AccountTest
    {
        public int Account = 0;

        public void Add()
        {
            // 累加 1000000 次
            for (int i = 1; i <= 1000000; i++)
            {
                ++this.Account;
            }
        }
    }

有一个AccountTest类,类里面有一个Account值,有一个Add方法功能是把Account值累加100万次;

Main方法里面开启了两个任务,两个任务共用一个AccountTest实例,两个任务的功能都是使Account值累加100万次;

正常来讲当两个任务都运行完毕后Account输出的值应该为200万;

但是最后输出的Account值为 100万到200万之间的随机数。

 原因

由于cpu和内存在处理速度上存在很大差距,为了弥补这种差距,也是为了利用好cpu强大的计算能力,cpu和内存之间加入了缓存,也就是我们经常听到的寄存器缓存、L1、L2和L3缓存;

首先我们看一下程序一般的处理流程如下图所示:

 

 在多核时代,有多个cpu,意味着每个cpu都有自己的一套缓存体系,但是内存只有一份;每个cpu里缓存的共享数据可能不一样,如果我们拿着这些本地缓存的数据做业务计算,极有可能出问题;

 

 如上图所示:线程1运行在cpu-01,线程2运行在cpu-02,着两个线程都并行运行,一开始线程1和线程2都从内存中取出变量m值都为0,然后都把变量m缓存起来,后来线程2修改了变量m的值为2同时写回了内存,这时内存中变量m的值为2,但是此时线程1缓存的变量m值还是为1不是最新的2,线程1还是使用变量m为1做业务计算,如果两个线程都是使m加1,这时线程1会覆盖线程2的修改,导致内存中m值只加了1次1,而不是两次1。

解决方法

C#的volatile关键字可以实现可见性,即告诉cpu程序不想使用你的缓存,所有的线程都读写内存数据,同时该关键字还能禁止cpu指令重排和优化;

但是如果我们只在AccountTest里面加上volatile关键字还是有问题

        public volatile int Account = 0;

因为在多核环境下可能存在多个线程同时读取内存里的共享数据,数据处理好后,然后同时更新写入的情况,这样还是有覆盖更新的问题;

解决方法1:对共享资源加锁,使得同一时刻只能一个线程使用共享资源

使用lock改造后的AccountTest如下:

    public class AccountTest
    {
        private readonly object lock_Account = new object();

        public int Account = 0;

        public void Add()
        {
            lock (lock_Account)
            {
                // 累加 1000000 次
                for (int i = 1; i <= 1000000; i++)
                {
                    ++this.Account;
                }
            }
        }
    }

解决方法2 :使用原子操作

首先我们找出线程不安全的部分就是Add方法里的 ++this.Account;

这分为三步:

1 先从内存中读取Account值

2 将Account+1

3 把Account+1值写回缓存

如果这三步是原子操作的话,那么这个类就是线程安全的

使用C#的Interlocked原子操作改写AccountTest如下

    public class AccountTest
    {

        public int Account = 0;

        public void Add()
        {
                // 累加 1000000 次
                for (int i = 1; i <= 1000000; i++)
                {
                    Interlocked.Add(ref this.Account, 1);
                }
        }
    }

 

推荐阅读