一、排它锁的结构
排它锁结构有三种:lock语句、Mutex和SpinLock。
其中lock是最方便,最常用的结构。而其他两种结构多用于处理特定的情形。
Mutex可以跨越多个进程(计算机范围锁)。
SpinLock可用于实现微优化。它可以在高并发场景下减少上下文切换。
二、lock语句
如下的类,这个类不是线程安全的,如果方法被两个线程同时调用,可能会出现被零除的错误,因为 _para2 可以在一个线程中设置为零,而另一个线程正在执行之间 if 语句和 Console.WriteLine。。
class ThreadUnsafe
{
static int _para1 = 1, _para2 = 1;
static void M_Method()
{
if (_para2!= 0) Console.WriteLine (_para1 / _para2);
_para2= 0;
}
}
加锁保证线程安全,一次只有一个线程可以锁定同步对象(在本例中为 _locker),并且任何竞争线程都会被阻塞,直到锁被释放。
class ThreadSafe
{
static readonly object _locker = new object();
static int _para1, _para2;
static void M_Method()
{
lock (_locker)
{
if (_para2 != 0) Console.WriteLine (_para1 / _para2);
_para2 = 0;
}
}
}
同步对象
若一个对象在各个参与线程中都是可见的,那么该对象就可以作为同步对象。但是该对象必须是一个引用类型的对象(这是必须满足的条件)。同步对象通常是私有的(因为这样便于封装锁逻辑),而且一般是实例字段或者静态字段。
三、Monitor.Enter and Monitor.Exit
C#的lock语句是包裹在try/finally语句块中的Monitor.Enter和Monitor.Exit语法糖。调用 Monitor.Exit 而不先调用同一对象上的 Monitor.Enter 会引发异常。
Monitor.Enter (_locker);
try
{
if (_para2 != 0) Console.WriteLine (_para1 / _para2);
_para2 = 0;
}
finally { Monitor.Exit (_locker); }
如果上述try方法块内出现异常,则锁的状态是不确定的。所以后面语言版本更新后推出了更加健壮的版本,lock语句将会翻译为以下模式。
bool lockTaken = false;
try
{
Monitor.Enter (_locker, ref lockTaken);
// Do your stuff...
}
finally { if (lockTaken) Monitor.Exit (_locker); }
TryEnter
Monitor还提供了TryEnter方法来指定一个超时时间(以毫秒为单位的整数或者一个TimeSpan值)。如果在指定时间内获得了锁,则该方法返回true,如果超时并且没有获得锁,该方法返回false。如果不给TryEnter方法提供任何参数,且当前无法获得锁,则该方法会立即超时。和Enter方法一样,TryEnter方法也在CLR 4.0中进行了重载,并在重载中接受lockTaken参数。
四、什么时候使用锁
使用锁的基本原则是:若需要访问可写的共享字段,则需要在其周围加锁。即便对于最简单的情况(例如对某个字段进行赋值),也必须考虑进行同步。
非线程安全
class ThreadUnsafe
{
static int _x;
static void AddM() { _x++; }
static void SetM() { _x = 123; }
}
线程安全
class ThreadSafe
{
static readonly object _locker = new object();
static int _x;
static void AddM() { lock (_locker) _x++; }
static void SetM() { lock (_locker) _x = 123; }
}
五、原子性
如果使用同一个锁对一组变量的读写操作进行保护,那么可以认为这些变量的读写操作是原子的(atomically)。假设我们只在locker锁中对x和y字段进行读写:
lock (locker) { if (x != 0) y /= x; }
则可以称x和y是以原子方式访问的。因为上述代码块是无法分割执行的,也不可能被其他能够更改x和y的值,或是破坏其输出结果的线程抢占。因此只要x和y永远在相同的排它锁中进行访问,那么上述代码就永远不会发生除数为零的错误。
六、嵌套锁
线程可以用嵌套(重入)的方式重复锁住同一个对象。
lock (locker)
lock (locker)
lock (locker)
{
// Do something...
}
Monitor.Enter (locker); Monitor.Enter (locker); Monitor.Enter (locker);
// Do something...
Monitor.Exit (locker); Monitor.Exit (locker); Monitor.Exit (locker);
在这些情况下,只有当最外层的 lock 语句退出或匹配数量的 Monitor.Exit 语句已执行时,对象才会解锁。
当一个方法在锁中调用另一个方法时,嵌套锁定很有用:
static readonly object _locker = new object();
static void Method1()
{
lock (_locker)
{
Method2();
// 我们仍然有锁——因为锁是可重入的。
}
}
static void Method2()
{
lock (_locker) { Console.WriteLine ("method2"); }
}
七、死锁
两个线程互相等待对方占用的资源就会使双方都无法继续执行,从而形成死锁。演示死锁的最简单的方法是使用两个锁。
object locker1 = new object();
object locker2 = new object();
new Thread (() => {
lock (locker1)
{
Thread.Sleep (1000);
lock (locker2); // Deadlock
}
}).Start();
lock (locker2)
{
Thread.Sleep (1000);
lock (locker1); // Deadlock
}
标准托管环境下的CLR和SQL Server不同,它不会自动检查和处理死锁(强制终止其中一个线程)。除非指定超时时间,否则线程死锁将致使线程永久阻塞。(SQL Server的CLR集成托管环境则不同,它会自动检测死锁,然后在其中的一个线程抛出一个可捕获的异常。)。
为了尽可能避免死锁,最常见的建议是“使用一致的顺序锁定对象以避免死锁”。另一个更好的方式是,当锁定一个对象的方法调用时,务必警惕该对象是否持有当前对象的引用。
使用更高级的同步手段,例如任务的延续/组合器、数据并行、不可变类型都可以减少对锁的依赖。