多线程并发产生的原因
背景
先看下面一段代码,看看运行结果
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); } } }