目录
[3.5 死锁](#3.5 死锁)
[3.6 性能](#3.6 性能)
1.lock
让我们先看一段代码:
cs
class ThreadUnsafe
{
static int _val1 = 1, _val2 = 1;
static void Go()
{
if (_val2 != 0) Console.WriteLine (_val1 / _val2);
_val2 = 0;
}
}
这段代码在单线程运行时是安全的,但是多线程运行就会出现问题,即有可能在做除法时,出现**_val2**=0 的情况,这是由于在执行打印时,另一个线程可能会去修改_val2的值。
我们可以通过加锁来解决这个问题:
cs
class ThreadSafe
{
static readonly object _locker = new object();
static int _val1, _val2;
static void Go()
{
lock (_locker)
{
if (_val2 != 0) Console.WriteLine (_val1 / _val2);
_val2 = 0;
}
}
}
关键字lock 保证任何时候,只有一个线程可以访问变量**_val1** 和**_val2**。
2.Monitor
关键字lock的机制是靠Monitor类实现的。lock可以认为是利用try-finally 结构对Monitor.Enter和Monitor.Exit 函数进行封装。比如上面使用lock关键字的代码用Monitor类实现如下:
cs
Monitor.Enter (_locker);
try
{
if (_val2 != 0) Console.WriteLine (_val1 / _val2);
_val2
= 0;
}
finally { Monitor.Exit (_locker); }
(ps:在没有调用Monitor.Enter函数时,调用Monitor.Exit 会抛出异常)
然而这段代码存在一个微妙的漏洞。试想这样一种(不太可能的)情况:当Monitor.Enter方法内部抛出异常,或是在调用Monitor.Enter之后、进入try代码块之前发生异常(例如线程被强制中止Abort,或是抛出内存耗尽异常OutOfMemoryException)。此时锁可能被获取,也可能未被获取。如果锁已被获取,它将永远不会被释放------因为我们无法进入try/finally代码块,最终导致锁泄漏。 为避免这种风险,CLR 4.0的设计者为Monitor.Enter添加了以下重载方法:
cs
public static void Enter (object obj, ref bool lockTaken);
当(且仅当)Enter方法抛出异常且未成功获取锁时,该方法执行后lockTaken参数值为false。以下是正确的使用模式(这也正是C# 4.0编译lock语句时生成的代码逻辑):
cs
bool lockTaken = false;
try
{
Monitor
.Enter (_locker, ref lockTaken);
// Do your stuff...
}
finally { if (lockTaken) Monitor.Exit (_locker); }
这样一来,即使在Enter函数出现异常,也不会去执行Monitor .Exit函数了。
Monitor 类还提供了 TryEnter
方法,允许指定超时时间(以毫秒或 TimeSpan
形式)。如果成功获取锁,该方法返回 true
;如果因超时未能获取锁,则返回 false
。**TryEnter
**也可以不带参数调用,此时它会立即"测试"锁的状态,如果无法立即获取锁,则立刻超时返回。
与 Enter
方法类似,在 CLR 4.0 中,TryEnter
也提供了接受 lockTaken
参数的重载版本。
这里就不过多介绍了,感兴趣的可以去看官方链接
3.锁的其它要注意的问题
3.1同步对象的选择
任何对参与线程可见的对象都可以作为同步对象,但必须遵循一个硬性规则:同步对象必须是引用类型。同步对象通常声明为 private(这有助于封装锁逻辑),并且通常是实例字段或静态字段。同步对象可以同时作为被保护对象本身,如下例中的 _list 字段所示:
cs
class ThreadSafe
{
List
<string> _list = new List <string>();
void Test()
{
lock (_list)
{
_list
.Add ("Item 1");
...
专门用于加锁的字段(如前例中的 _locker
)能够精确控制锁的作用范围和粒度。此外,包含对象本身(this
)或其类型(typeof(ClassName)
)也可作为同步对象使用:
cs
lock (this) { ... }
cs
lock (typeof (Widget)) { ... } // For protecting access to statics
虽然上面两个锁对象都是合理的,却是不建议的:
使用this作为锁对象会造成:
- 外部代码可能锁定你的对象实例,导致死锁。
- 破坏了面向对象的封装原则。
使用类类型作为锁对象则更糟糕:typeof(ClassName) 返回的是类的 Type 对象,该对象在 AppDomain 范围内是唯一的。所有线程中任何使用 lock(typeof(ClassName)) 的代码都会竞争同一个锁,导致: ◦ 性能瓶颈:无关代码因共享同一个锁而阻塞。 ◦ 死锁风险:第三方库或框架若恰好也锁定了该类型,可能引发不可预料的死锁。
3.2什么时候该上锁
首先,如果你确定你的程序是单线程的,那任何时候都不需要上锁。否则,上锁基本原则是:任何对可写共享字段的访问都需要加锁。**即使是最简单的单字段赋值操作,也必须考虑同步问题。**例如以下类中,无论是Increment还是Assign方法都不是线程安全的:
cs
class ThreadUnsafe
{
static int _x;
static void Increment() { _x++; }
static void Assign() { _x = 123; }
}
其线程安全的标准应该为:
cs
class ThreadSafe
{
static readonly object _locker = new object();
static int _x;
static void Increment() { lock (_locker) _x++; }
static void Assign() { lock (_locker) _x = 123; }
}
3.3锁和原子性
如果一组变量总是在同一个锁内进行读写,那么可以认为这些变量的读写操作是原子性的。假设字段 x 和 y 始终在对 locker 对象加锁的情况下进行读写:
cs
lock (locker) { if (x != 0) y /= x; }
那么我们可以说 x 和 y 的访问是原子性的,因为这段代码块不会被其他线程的操作分割或抢占,从而避免 x 或 y 被意外修改而导致结果失效。只要 x 和 y 始终在同一个独占锁内访问,就永远不会发生除零错误。
3.4嵌套锁
一个线程可以反复的对一个对象添加锁:
cs
lock (locker)
lock (locker)
lock (locker)
{
// Do something...
}
或者改用Monitor类:
cs
Monitor.Enter (locker); Monitor.Enter (locker); Monitor.Enter (locker);
// Do something...
Monitor
.Exit (locker); Monitor.Exit (locker); Monitor.Exit (locker);
在这种情况下,只有当最外层的 lock 语句执行完毕退出时 - 或者执行了对应数量的 Monitor.Exit 语句后 - 对象才会被解锁。 嵌套锁在方法内部调用另一个加锁方法时特别有用:
cs
static readonly object _locker = new object();
static void Main()
{
lock (_locker)
{
AnotherMethod();
// We still have the lock - because locks are reentrant.
}
}
static void AnotherMethod()
{
lock (_locker) { Console.WriteLine ("Another method"); }
}
3.5 死锁
死锁在多线程编程是比较常见的。下面这个代码就会触发死锁:
cs
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
}
在多线程编程中,死锁是最棘手的难题之一------尤其是当存在大量相互关联的对象时。究其根本,难点在于你永远无法确定调用方已经获取了哪些锁。 设想这样一个场景:你可能在类X中无意识地锁定了私有字段a,却不知道调用方(或调用方的调用方)已经在类Y中锁定了字段b。与此同时,另一个线程正以相反的顺序执行锁定------这就形成了死锁。
颇具讽刺意味的是,这种问题反而会因(良好的)面向对象设计模式而加剧,因为这些模式创建的调用链直到运行时才能确定。 虽然"按固定顺序锁定对象以避免死锁"的建议在我们最初的示例中很有帮助,但很难适用于上述场景。
更明智的策略是:当持有锁的情况下调用可能反向引用自身对象的方法时要格外谨慎。同时,需要审慎评估是否真的有必要在调用其他类的方法时保持锁定(虽然很多时候确实需要------我们稍后会讨论------但有时存在其他选择)。更多地依赖声明式编程、数据并行、不可变类型以及非阻塞同步结构,可以减少对锁定的依赖。
这个问题还可以换个角度理解:当持有锁时调用外部代码,锁的封装性就会在无形中被破坏。这不是CLR或.NET框架的缺陷,而是锁机制与生俱来的局限性。目前包括软件事务内存(Software Transactional Memory)在内的多个研究项目正在尝试解决锁机制带来的各种问题。
另一个典型的死锁场景发生在WPF应用程序调用Dispatcher.Invoke或Windows Forms应用程序调用Control.Invoke时------如果此时恰好持有锁,而UI线程正在执行另一个等待同一锁的方法,就会立即引发死锁。通常只需改用BeginInvoke而非Invoke即可解决。当然,也可以在调用Invoke前释放锁,不过如果锁是由调用方获取的,这个方法就不适用了。我们将在"富客户端应用与线程关联性"章节详细解释Invoke和BeginInvoke的机制。
3.6 性能
加锁操作本身非常高效:在2010年代的计算机上,如果锁未被争用,获取和释放一个锁最快仅需20纳秒。但当锁出现争用时,随之而来的上下文切换会使开销激增至微秒级别------如果线程需要重新调度,等待时间可能更长。对于极短时间的锁定,使用SpinLock类可以避免上下文切换的开销。 需要注意的是,如果锁持有时间过长,不仅会降低并发性能,还会显著增加死锁风险。锁的争用会引发线程阻塞,当多个线程相互等待对方释放锁时,系统吞吐量将急剧下降。因此,开发者需要在保证线程安全的前提下,尽量缩小临界区范围,并考虑使用读写锁(ReaderWriterLockSlim)等更细粒度的同步机制来提升并发性。对于高并发场景,无锁编程(lock-free programming)或不可变数据结构往往是更好的选择。
4.Mutex
互斥锁(Mutex)类似于 C# 的 lock 语句,但它的作用范围可以跨越多个进程。也就是说,Mutex 既可以是应用程序级别的,也可以是计算机全局范围的。( 获取和释放一个无竞争的 Mutex 需要几微秒时间------这比 lock 语句慢了约 50 倍。)
使用 Mutex 类时,你需要调用 WaitOne 方法来加锁,调用 ReleaseMutex 方法来解锁。关闭或释放 Mutex 会自动解除锁定。与 lock 语句一样,Mutex 只能由获取它的同一个线程来释放。跨进程 Mutex 的一个常见用途是确保同一时间只能运行一个程序实例。具体实现如下:
cs
class OneAtATimePlease
{
static void Main()
{
// Naming a Mutex makes it available computer-wide. Use a name that's
// unique to your company and application (e.g., include your URL).
using (var mutex = new Mutex (false, "oreilly.com OneAtATimeDemo"))
{
// Wait a few seconds if contended, in case another instance
// of the program is still in the process of shutting down.
if (!mutex.WaitOne (TimeSpan.FromSeconds (3), false))
{
Console.WriteLine ("Another app instance is running. Bye!");
return;
}
RunProgram();
}
}
static void RunProgram()
{
Console.WriteLine ("Running. Press Enter to exit");
Console.ReadLine();
}
}
当然也可以这样实现:
cs
static void Main()
{
using var mutex = new Mutex(true, "Global\\MyApp", out bool createdNew);
if (!createdNew)
{
Console.WriteLine("程序已在运行中!");
return;
}
// 主程序逻辑
Console.WriteLine("程序启动...");
Console.ReadLine();
}
一般而言,mutex在多线程编程中使用的不多,lock是更常见的选择。但涉及到跨进程时,lock可能就无能为力了,这是可以考虑mutex.
5.Semaphore
信号量(Semaphore)就像一家夜总会:它有一定的容量限制,由门口的保安严格执行。一旦满员,其他人就无法进入,只能在门外排队等候。每当有一个人离开,队首的一个人就能进入。它的构造函数至少需要两个参数:当前夜总会内的空位数,以及夜总会的总容量。
容量为1的信号量与互斥锁(Mutex)或lock类似,但关键区别在于信号量没有"所有者"------它对线程是透明的。任何线程都可以调用信号量的Release方法,而Mutex和lock只能由获取锁的线程来释放。
(这个类有两个功能相似的版本:Semaphore和SemaphoreSlim。后者是在.NET Framework 4.0中引入的,针对并行编程的低延迟需求进行了优化。它在传统多线程编程中也很有用,因为它允许在等待时指定取消令牌。不过,它不能用于进程间通信。 Semaphore执行WaitOne或Release大约需要1微秒;而SemaphoreSlim只需要前者的四分之一时间。 )
信号量在限制并发度方面非常有用------可以防止过多线程同时执行某段代码。在下面的例子中,五个线程试图进入一家同时只允许三个线程进入的"夜总会":
cs
class TheClub // No door lists!
{
static SemaphoreSlim _sem = new SemaphoreSlim (3); // Capacity of 3
static void Main()
{
for (int i = 1; i <= 5; i++) new Thread (Enter).Start (i);
}
static void Enter (object id)
{
Console.WriteLine (id + " wants to enter");
_sem.Wait();
Console.WriteLine (id + " is in!"); // Only three threads
Thread.Sleep (1000 * (int) id); // can be here at
Console.WriteLine (id + " is leaving"); // a time.
_sem.Release();
}
}
执行结果如下:
1 wants to enter 1 is in! 2 wants to enter 2 is in! 3 wants to enter 3 is in! 4 wants to enter 5 wants to enter 1 is leaving 4 is in! 2 is leaving 5 is in!
如果将 Sleep 语句替换为密集的磁盘 I/O 操作,信号量(Semaphore)通过限制过多的并发硬盘访问,反而能够提升整体性能。 如果给信号量命名,它就能像互斥锁(Mutex)一样实现跨进程同步。
本小节就介绍到这里,下面一节将介绍线程安全的一些实现准则。