C#线程:排它锁

排他锁结构有三种:lock语句MutexSpinLock
其中lock是最方便最常用的结构。而其他两种结构多用于处理特定的情形:Mutex可以跨越多个进程(计算机范围锁)。SpinLock可用于实现微优化,可以在高并发场景下减少上下文切换。

lock语句

先看如下代码:

class ThreadUnsafe
{
    static int _val1 = 1, _val2 = 1;
    static void Go()
    {
        if (_val2 != 0)
            Console.WriteLine(_val1 / _val2);
        _val2 = 0;
    }
}

以上的类不是线程安全的。如果两个线程同时调用Go方法,则有可能出现除数为0的错误。因为_val2有可能被第一个线程设置为0,而第二个线程正处于if和Console.WriteLine语句之间。下例使用了lock来修正这个错误:

class ThreadSafe
{
    static readonly object _locker = new object();
    static int _val1 = 1, _val2 = 1;
    static void Go()
    {
        lock (_locker)
        {
            if (_val2 != 0)
                Console.WriteLine(_val1 / _val2);
            _val2 = 0;
        }   
    }
}

每一次只能有一个线程锁定同步对象_locker,而其他线程则被阻塞,直至锁释放。如果参与竞争的线程多于一个,则它们需要在准备队列中排队,并以先到先得的方式获得锁。排他锁会强制以所谓序列的方式访问被锁保护的资源,因为线程之间的访问是不能重叠的。因此,本例中的锁保护了Go方法中的访问逻辑,也保护了_val1和_val2字段。

Monitor.Enter方法和Monitor.Exit方法

C#的lock语句是包裹在try/finally语句块中的Monitor.EnterMonitor.Exit语法糖,因此上例Go方法的实际操作为(以下代码对部分逻辑进行了简化):

Monitor.Enter(_locker);
try
{
    if (_val2 != 0)
        Console.WriteLine(_val1 / _val2);
    _val2 = 0;
}
finally
{
    Monitor.Exit(_locker);
}

如果调用Monitor.Exit之前并没有对同一个对象调用Monitor.Enter,则该方法会抛出异常。

lockTaken重载

上述示例代码中有一个不易发现的漏洞。如果在Monitor.Enter和try语句块之间抛出了(很少见)异常,那么锁的状态是不确定的。但若已经获得了锁,那么这个锁就永远无法释放,因为已经没有机会进入try/finally代码块了。因此这种情况会造成锁泄露。为了防范这种风险,Monitor.Enter进行了如下重载。
Enter方法执行结束后,当且仅当该方法执行时抛出了异常且没有获得锁时,lockTaken为false。

bool lockTaken = false;
try
{
    Monitor.Enter(_locker, ref lockTaken);
    if (_val2 != 0)
        Console.WriteLine(_val1 / _val2);
    _val2 = 0;
}
finally
{
    if(lockTaken)
        Monitor.Exit(_locker);
}

TryEnter

Monitor还提供了TryEnter方法来指定一个超时时间(以毫秒为单位的整数或者一个TimeSpan值)。如果在指定时间内获得了锁,则该方法返回true,如果超时并且没有获得锁,该方法返回false。如果不给TryEnter方法提供任何参数,且当前无法获得锁,则该方法会立即超时。和Enter方法一样,TryEnter方法也进行了重载,并在重载中接受lockTaken参数。

选择同步对象

若一个对象在各个参与线程中都是可见的,那么该对象就可以作为同步对象。但是该对象必须是一个引用类型的对象(这是必须满足的条件)。同步对象通常是私有的(因为这样便于封装锁逻辑),而且一般是实例字段或者静态字段。
同步对象本身也可以是被保护的对象,如下面_list。

List<string> _list = new List<string>();
void Test()
{
    lock(_list){_list.Add("aaa")}
    ...
}

如果一个字段仅作为锁存在(如前一节中的_locker),则可以精确地控制锁的范围和粒度。
除此之外,Lambda表达式或匿名方法中捕获的局部变量也可以作为同步对象进行锁定。

使用锁的时机

使用锁的基本原则是:若需要访问可写的共享字段,则需要在其周围加锁。即便对于最简单的情况(例如对某个字段进行赋值),也必须考虑进行同步。
以下示例中的Increment和Assign方法,不是线程安全的和线程安全的写法:

// 不是线程安全
class TreadUnsafe
{
    static int _x;
    static void Increment() { _x++; }
    static void Assign() { _x = 123; }
}
// 线程安全
class ThreadSafe
{
    static readonly object _locker = new object();
    static int _x;
    static void Increment() { lock(_locker) _x++; }
    static void Assign() { lock (_locker) _x = 123; }
}

锁与原子性

如果使用同一个锁对一组变量的读写操作进行保护,那么可以认为这些变量的读写操作是原子的。
假设我们只在locker锁中对x和y字段进行读写:lock(locker) { if(x!=0) y/=x; }则可以称x和y是以原子方式访问的。
因为上述代码块是无法分割执行的,也不可能被其他能够更改x和y的值的且破坏其输出结果的线程抢占。因此只要x和y永远在相同的排他锁中进行访问,那么上述代码就永远不会发生除数为零的错误。

嵌套锁

线程可以用嵌套(重入)的方式重复锁住同一个对象:

lock(locker)
    lock(locker)
        lock(locker)
        { ... }

在使用嵌套锁时,只有最外层的lock语句退出时(或者执行相同数目的Monitor.Exit时)对象的锁才会解除。

当锁中的方法调用另一个方法时,嵌套锁很奏效,线程只会阻塞在第一个(最外层的)锁上。

static readonly object _locker = new object();

static void Main()
{
    lock (_locker)
    {
        AnotherMethod();
    }
}

static void AnotherMethod()
{
    lock (_locker) { Console.WriteLine("Another method"); }
}

死锁

两个线程互相等待对方占用的资源就会使双方都无法继续执行,从而形成死锁。
演示死锁的最简单的方法是使用两个锁:

object locker1 = new object();
object locker2 = new object();

new Thread(() =>
{
    lock (locker1)
    {
        Thread.Sleep(1000);
        lock (locker2) ;
    }
}).Start();

lock (locker2)
{
    Thread.Sleep(1000);
    lock (locker1) ;
}

死锁是多线程中最难解决的问题之一,尤其是当其涉及了很多相互关联的对象时。而其中最难的部分是确定调用者持有了哪些锁。
当锁定一个对象的方法调用时,务必警惕该对象是否可能持有当前对象的引用。此外,请确认是否真正有必要在调用其他类的方法时添加锁。

posted @ 2022-08-30 15:49  一纸年华  阅读(518)  评论(0编辑  收藏  举报