C#线程:排它锁
排他锁结构有三种:lock语句
、Mutex
和SpinLock
。
其中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.Enter
和Monitor.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) ;
}
死锁是多线程中最难解决的问题之一,尤其是当其涉及了很多相互关联的对象时。而其中最难的部分是确定调用者持有了哪些锁。
当锁定一个对象的方法调用时,务必警惕该对象是否可能持有当前对象的引用。此外,请确认是否真正有必要在调用其他类的方法时添加锁。
本文来自博客园,作者:一纸年华,转载请注明原文链接:https://www.cnblogs.com/nullcodeworld/p/16639609.html