• 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) ;
    }
    

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

  • 相关阅读:
    有一种尺度叫圆融
    十大经典排序算法
    Maximal Square
    Word Break
    Flatten List
    Triangle
    Paint Fence
    Longest Increasing Continuous Subsequence
    Minimum Size Subarray Sum
    Letter Combinations of a Phone Number
  • 原文地址:https://www.cnblogs.com/nullcodeworld/p/16639609.html
Copyright © 2020-2023  润新知