线程安全的几种解决方案
线程能访问什么
在讲线程安全之前,我们需要对线程本身有一些更深的认识。众所周知,一个进程里可以拥有多个线程,其中有一个主线程。同一个进程的各个线程使用同一个虚拟内存地址空间,这意味着从系统层面上,线程之间是可以互相共享彼此的数据的。但是从编程语言层面上,线程的访问受到了一定的限制,我们需要明白:在C#中,线程可以访问什么数据?
首先,所有线程都可以访问全局变量,因为全局变量具有最大的作用域。
其次,线程可以访问局部变量,只需要线程执行到此函数即可,局部变量储存在栈中,生命周期即是函数执行时期,其他线程也可以在函数结束前访问此变量,如果当前执行的线程可以将变量地址传达出去的话。
不同的线程的局部变量,储存在不同的栈中。这是因为线程可以拥有自己的栈,下面是来自《操作系统导论》的一张图片,左边是单线程程序的内存,其中主线程的栈在最底下,而右边是拥有两个线程的程序的内存,其中两个线程分别拥有自己的栈(图中是Stack1和Stack2)。
最后,线程也可以拥有自己的私有变量,称为线程变量。线程变量其实是储存在堆中的,堆在进程中属于公共区域,所以这里的线程变量其实指的是逻辑关系,即虽然储存在公共堆中,但是线程变量被某一个特定的线程“认领”了,每个线程只能访问属于自己的拷贝,而不能访问其他线程的。在C#中,这样的变量通过 ThreadLocal 实现,下文会讲到。
什么是“线程安全”
既然各个线程可以访问同一份数据,例如所有线程访问同一个全局变量,那么在访问过程就可能会发送干扰。例如下面这段代码:
using System; using System.Threading; namespace Test { class Program { static int count = 0; static void Main(string[] args) { Thread thread = new Thread(adder); thread.Start(); for (int i = 0; i < 1000000; i++) { count++; } thread.Join(); Console.WriteLine(count); } static void adder() { for (int i = 0; i < 1000000; i++) { count++; } } } }
这段代码实现两个线程同时对一个全局变量递增100万次,但是最后输出的结果却不是2000000,而是比这个低的一个随机值。这是因为产生了干扰,对于变量递增,我们可以简单看作是三个步骤:
- 从内存中读取变量当前的值
- 将该值递增
- 将新的值写入内存
在两个线程执行递增的时候,可能会出现下面这种情况:
- 线程一执行了步骤1,记录了count的值,然后执行了步骤2
- 在线程一执行步骤3之前,线程二执行了步骤3
- count变量递增,线程一在步骤1所记录的值不再是当前的值
- 线程一继续执行步骤3,将递增的值写入内存
在上面这个流程中,两个线程分别执行了一次递增,结果应该是count增加了2,但实际上count变量只增加了1。究其原因,是因为递增这个操作可能存在中间态。如果某个操作要么还未执行,要么已经执行完毕,只有这两种状态的话,我们称它为原子的,显然这里的递增不是原子的。
如果一个变量,在多个线程访问的时候不会出现互相破坏的情况,我们就称这个变量是线程安全的,显然,使用原子操作是实现线程安全的一种方式,接下来我们会介绍其他几种方法。
使用局部变量
上面说了,线程拥有自己的栈,而线程如果没有变量的地址,是无法访问其他线程的栈变量的,所以局部变量是线程安全的。在不影响效果的情况下,我们可以将线程所需要的数据作为函数参数传入,线程在自己的栈内对数据进行处理。当然,这种方法过于局限,因为数据的使用范围被限制了,很多时候我们需要多个线程同时修改同一个数据,使用局部变量就不行了。
使用线程变量
使用线程变量,可以把一个上下文注入线程内部,各个线程拥有一份这个数据的拷贝,而不会产生破坏,C#通过 ThreadLocal 创建一个线程变量,如下面的代码:
using System; using System.Threading; namespace Test { class Program { static ThreadLocal<string> threadLocal = new ThreadLocal<string>(); static void Main(string[] args) { threadLocal.Value = "A"; Console.WriteLine("threadID:{0}, value:{1}", Environment.CurrentManagedThreadId, threadLocal.Value); Thread thread1 = new Thread(() => { threadLocal.Value = "B"; Console.WriteLine("threadID:{0}, value:{1}", Environment.CurrentManagedThreadId, threadLocal.Value); }); Thread thread2 = new Thread(() => { threadLocal.Value = "C"; Console.WriteLine("threadID:{0}, value:{1}", Environment.CurrentManagedThreadId, threadLocal.Value); }); thread1.Start(); thread1.Join(); Console.WriteLine("threadID:{0}, value:{1}", Environment.CurrentManagedThreadId, threadLocal.Value); thread2.Start(); thread2.Join(); Console.WriteLine("threadID:{0}, value:{1}", Environment.CurrentManagedThreadId, threadLocal.Value); } } }
输出结果是
threadID:1, value:A threadID:5, value:B threadID:1, value:A threadID:6, value:C threadID:1, value:A
可以看到各个线程之间的值是独立的,即每个线程都有一份 threadLocal 的拷贝。从效果上看,线程变量和局部变量是一样的,但使用线程变量更好。例如,当你重构代码,需要为一个线程方法注入新数据时,使用局部变量需要改变方法的签名(例如在类的构造函数中加入一个新参数),这样对接口的破坏性极大,这个时候使用 ThreadLocal 更好。另一方面,一个 ThreadLocal 对象便可以被所有线程访问,拷贝是自动创建的,而不需要手动编写代码。
线程变量和局部变量有一样的缺点,那就是无法支持多个线程同时地修改同一个数据。
使用互斥锁
如果要支持多个线程安全、同时地修改同一个数据,最常用的方法就是使用锁。锁可以确保数据在某一时刻只可以被某一个线程操作:当线程需要操作数据时,它可以尝试获取锁,如果锁空闲,线程就持有该锁,并对数据进行操作,而其他线程尝试获取锁时,锁被占用,线程就必须等待锁被释放,直到自己持有该锁。即哪个线程拥有锁,哪个线程就可以操作数据。对最开始的例子进行修改:
class Program { static int count = 0; private static readonly object countLock = new object(); static void Main(string[] args) { Thread thread = new Thread(adder); thread.Start(); for (int i = 0; i < 1000000; i++) { lock(countLock){ count++; } } thread.Join(); Console.WriteLine(count); } static void adder() { for (int i = 0; i < 1000000; i++) { lock (countLock) { count++; } } } }
使用 lock 关键字,获取指定对象(代码中是countlock)的锁,执行代码块,然后释放锁。这里代码块中对count递增,最后输出的结果一定是2000000。代码中声明的是私有的、静态的、只读的对象,这样可以防止外部改变锁的状态。
lock 关键字,是C#中使用互斥锁的最简单的方法,但是 lock 可能会导致死锁,更完善的方法是使用 Monitor 或者 Mutex,这里不再详细说明。
使用乐观锁
在上面的代码中,虽然我们使用锁保证了线程安全,但是有些时候我们不需要这么严格,例如在线程比较少的时候,数据不安全的概率自然比较低,而频繁地获取和释放锁是有性能代价的,这个时候就可以使用乐观锁。
本质上乐观锁并没有加锁,而是基于一种叫CAS(Compare And Swap)的方法,具体来说:当一个线程访问了一项数据,在操作之前它先将当前的数据状态记录下来,等它操作结束后写入数据前,将自己之前记录的状态与数据当前的状态进行比较,如果相同,说明没有其他线程中途修改了这个数据,这个时候线程认为写入是安全的。如果不同,说明数据被其他线程修改了,那么线程可以选择取消当前操作,并进行重试(也可以是其他处理方法)。
乐观锁是保持乐观态度的,只有当它发现数据可能存在危险的时候,它才进行处理。对于比较安全的场景来说,例如线程数量少,并发量低的场景,乐观锁的性能代价是很低的。但是,如果数据存在危险的概率很大,那么乐观锁的性能代价则会高于悲观锁,因为乐观锁对危险情况的处理的成本(例如重试)远高于获取/释放悲观锁的成本,这个时候还不如直接加锁。所以,要根据实际情况灵活选择。
上面的CAS方法其实是有缺陷的,那就是“ABA”问题:当线程比较数据时,如果数据相同,并不能断定没有其他线程修改过该数据,可能数据最开始是A,被某个线程修改为B,又被另一个线程修改回A,这个时候数据其实已经被篡改了。为了防止这种情况,可以为数据增加一个版本号,每次修改递增版本号,通过比较版本号来判断。