【笔记】C# 线程安全

线程安全的几种解决方案

线程能访问什么

在讲线程安全之前,我们需要对线程本身有一些更深的认识。众所周知,一个进程里可以拥有多个线程,其中有一个主线程。同一个进程的各个线程使用同一个虚拟内存地址空间,这意味着从系统层面上,线程之间是可以互相共享彼此的数据的。但是从编程语言层面上,线程的访问受到了一定的限制,我们需要明白:在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. 从内存中读取变量当前的值
  2. 将该值递增
  3. 将新的值写入内存

在两个线程执行递增的时候,可能会出现下面这种情况:

  • 线程一执行了步骤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,这个时候数据其实已经被篡改了。为了防止这种情况,可以为数据增加一个版本号,每次修改递增版本号,通过比较版本号来判断。