1 同步概要
在第 1 部分:基础知识中,我们描述了如何在线程上启动任务、配置线程以及双向传递数据。同时也说明了局部变量对于线程来说是私有的,以及引用是如何在线程之间共享,允许其通过公共字段进行通信。
下一步是同步(synchronization
):为期望的结果协调线程的行为。当多个线程访问同一个数据时,同步尤其重要,但是这是一件非常容易搞砸的事情。
同步构造可以分为以下四类:
简单的阻塞方法
这些方法会使当前线程等待另一个线程结束或是自己等待一段时间。Sleep
、Join
与Task.Wait
都是简单的阻塞方法。
锁构造
锁构造能够限制每次可以执行某些动作或是执行某段代码的线程数量。排它锁构造是最常见的,它每次只允许一个线程执行,从而可以使得参与竞争的线程在访问公共数据时不会彼此干扰。标准的排它锁构造是lock(Monitor.Enter/Monitor.Exit)
、Mutex
与 SpinLock
。非排它锁构造是Semaphore
、SemaphoreSlim
以及读写锁。
信号构造
信号构造可以使一个线程暂停,直到接收到另一个线程的通知,避免了低效的轮询 。有两种经常使用的信号设施:事件等待句柄(event wait handle )
和Monitor
类的Wait / Pluse
方法。Framework 4.0 加入了CountdownEvent
与Barrier
类。
非阻塞同步构造
非阻塞同步构造通过调用处理器指令来保护对公共字段的访问。CLR 与 C# 提供了下列非阻塞构造:Thread.MemoryBarrier
、Thread.VolatileRead
、Thread.VolatileWrite
、volatile
关键字以及Interlocked
类。
阻塞这个概念对于前三类来说都非常重要,接下来我们简要的剖析下它。
1.1 阻塞
当线程的执行由于某些原因被暂停,比如调用Sleep
等待一段时间,或者通过Join
或EndInvoke
方法等待其它线程结束时,则认为此线程被阻塞(blocked
)。被阻塞的线程会立即出让(yields
)其处理器时间片,之后不再消耗处理器时间,直到阻塞条件被满足。可以通过线程的ThreadState属性来检查一个线程是否被阻塞:bool blocked = (someThread.ThreadState & ThreadState.WaitSleepJoin) != 0;
(上面例子中线程状态可能在进行状态判断和依据状态进行操作之间发生改变,因此这段代码仅可用于调试诊断的场景。)
当一个线程被阻塞或是解除阻塞时,操作系统会进行上下文切换(context switch),这会带来几微秒的额外时间开销。
阻塞会在以下 4 种情况下解除(电源按钮可不能算╮(╯▽╰)╭):
- 阻塞条件被满足
- 操作超时(如果指定了超时时间)
- 通过
Thread.Interrupt
中断 - 通过
Thread.Abort
中止
通过Suspend
方法(已过时,不应该再使用)暂停线程的执行不被认为是阻塞。
1.2 阻塞和自旋
有时线程必须暂停,直到特定条件被满足。信号构造和锁构造可以通过在条件被满足前阻塞线程来实现。但是还有一种更为简单的方法:线程可以通过自旋(spinning
)来等待条件被满足。例如:
while (!proceed);
// 或者:
while (DateTime.Now < nextStartTime);
一般来说,这会非常浪费处理器时间:因为对 CLR 和操作系统来说,**这个线程正在执行重要的计算,就给它分配了相应的资源**。<br />有时会组合使用阻塞与自旋:
while (!proceed) Thread.Sleep (10);
尽管并不优雅,但是这比仅使用自旋更高效(一般来说)。然而这样也可能会出现问题,这是由`proceed`标识上的并发问题引起的。正确的使用和**锁构造**和**信号构造**可以避免这个问题。
自旋在等待的条件很快(大致几微秒)就能被满足的情况下更高效,因为它避免了上下文切换带来的额外开销。.NET Framework 提供了专门的方法和类型来辅助实现自旋,在第 5 部分会讲到。
1.3 线程状态
可以通过线程的ThreadState
属性来查询线程状态,它会返回一个ThreadState
类型的按位方式组合的枚举值,其中包含了三“层”信息。然而大多数值都是冗余的、无用的或者过时不建议使用的。下图是其中一“层”信息:
下面的代码可以提取线程状态中最有用的 4 个值: Unstarted
、Running
、WaitSleepJoin
和Stopped
:
public static ThreadState SimpleThreadState (ThreadState ts)
{
return ts & (ThreadState.Unstarted |
ThreadState.WaitSleepJoin |
ThreadState.Stopped);
}
`ThreadState`属性在进行调试诊断时有用,但不适合用来进行同步,因为线程状态可能在判断状态和依据状态进行操作之间发生改变。
2 锁
排它锁用于确保同一时间只允许一个线程执行指定的代码段。主要的两个排它锁构造是`**lock**`和`**Mutex(互斥体)**`。其中`**lock**`更快,使用也更方便。**而**`**Mutex**`**的优势是它可以跨进程的使用。**<br />在这一节里,我们从介绍`**lock**`构造开始,然后介绍`**Mutex**`和**信号量(**`**semaphore**`**)**(**用于非排它场景**)。稍后在第 4 部分会介绍**读写锁(reader / writer lock)**。
Framework 4.0 加入了SpinLock结构体,可以用于高并发场景。
让我们从下边这个类开始:
class ThreadUnsafe
{
static int _val1 = 1, _val2 = 1;
public static void Go()
{
if (_val2 != 0)
Console.WriteLine(_val1 / _val2);
_val2 = 0;
}
}
这个类不是线程安全的:如果Go方法同时被两个线程调用,可能会产生除数为零错误,因为可能在一个线程刚好执行完if的判断语句但还没执行Console.WriteLine语句时,_val2就被另一个线程设置为零。<br />下边使用lock解决这个问题:
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;
}
}
}
同一时间只有一个线程可以锁定同步对象(这里指_locker),并且其它竞争锁的线程会被阻塞,直到锁被释放。如果有多个线程在竞争锁,它们会在一个“就绪队列(ready queue)”中排队,并且遵循先到先得的规则(需要说明的是,Windows 系统和 CLR 的差别可能导致这个队列在有时会不遵循这个规则)。因为一个线程的访问不能与另一个线程相重叠,排它锁有时也被这样描述:它强制对锁保护的内容进行顺序(serialized)访问。在这个例子中,我们保护的是Go方法的内部逻辑,还有_val1与_val2字段。
在竞争锁时被阻塞的线程,它的线程状态是**WaitSleepJoin**
。在中断与中止中,我们会描述如何通过其它线程强制释放被阻塞的线程,这是一种可以用于结束线程的重型技术(译者注:这里指它们应该被作为在没有其它更为优雅的办法时的最后手段)。
锁构造比较 | |||
---|---|---|---|
构造 | 用途 | 跨进程 | 开销* |
lock (Monitor.Enter / Monitor.Exit) |
确保同一时间只有一个线程可以访问资源或代码 | - | 20ns |
Mutex | √ | 1000ns | |
SemaphoreSlim (Framework 4.0 中加入) |
确保只有不超过指定数量的线程可以并发访问资源或代码 | - | 200ns |
Semaphore | √ | 1000ns | |
ReaderWriterLockSlim (Framework 3.5 中加入) |
允许多个读线程和一个写线程共存 | - | 40ns |
ReaderWriterLock (已过时) |
- | 100ns |
- 时间代表在同一线程上一次进行加锁和释放锁(假设没有阻塞)的开销,在 Intel Core i7 860 上测得。
2.1 Monitor.Enter 与 Monitor.Exit
C# 的lock语句是一个语法糖,它其实就是使用了try / finally
来调用Monitor.Enter
与Monitor.Exit
方法。下面是在之前示例中的Go方法内部所发生的事情(简化的版本):
static object _locker = new object();
void Main()
{
Monitor.Enter(_locker);
try
{
// TODO something
}
finally
{
Monitor.Exit(_locker);
}
}
如果在同一个对象上没有先调用Monitor.Enter
就调用Monitor.Exit
会抛出一个异常。
lockTaken 重载
刚刚所描述的就是 C# 1.0、2.0 和 3.0 的编译器翻译lock
语句产生的代码。
然而它有一个潜在的缺陷。考虑这样的情况:在Monitor.Enter
的实现内部或者在Monitor.Enter
与try
中间有异常被抛出(可能是因为在线程上调用了Abort
,或者有OutOfMemoryException
异常被抛出),这时不一定能够获得锁。如果获得了锁,那么该锁就不会被释放,因为不可能执行到try / finally
内,这会导致锁泄漏。
为了避免这种危险,CLR 4.0 的设计者为Monitor.Enter
添加了下面的重载: public static void Enter(object obj, ref bool lockTaken);
当(且仅当)Enter
方法抛出异常,锁没有能够获得时,lockTaken
为false
。
下边是正确的使用方式(这就是 C# 4.0 对于lock
语句的翻译):
bool lockTaken = false;
try
{
Monitor.Enter (_locker, ref lockTaken);
// 你的代码...
}
finally { if (lockTaken) Monitor.Exit (_locker); }
TryEnterMonitor
还提供了一个TryEnter
方法,允许以毫秒或是TimeSpan
方式指定超时时间。如果获得了锁,该方法会返回true
,而如果由于超时没有获得锁,则会返回false
。TryEnter
也可以以无参数的形式进行调用,这是对锁进行“测试”,如果不能立即获得锁就会立即返回false
。
类似于Enter
方法,该方法在 CLR 4.0 中也被重载来接受lockTaken
参数。
2.2 选择同步对象
对所有参与同步的线程,可见的任何对象都可以被当作同步对象使用,但有一个硬性规定:同步对象必须为引用类型。同步对象一般是私有的(因为这有助于封装锁逻辑),并且一般是一个实例或静态字段。同步对象也可以就是其要保护的对象,如下面例子中的_list
字段:
class ThreadSafe
{
List<string> _list = new List<string>();
void Test()
{
lock (_list)
{
_list.Add("Item 1");
// ...
一个只被用来加锁的字段(例如前面例子中的_locker
)可以精确控制锁的作用域与粒度。对象自己(this
),甚至是其类型都可以被当作同步对象来使用:
lock (this) { ... }
// 或者:
lock (typeof (Widget)) { ... } // 保护对静态资源的访问
这种方式的缺点在于并没有对锁逻辑进行封装,从而很难避免**死锁**与过多的**阻塞**。同时类型上的锁也可能会跨越应用程序域(application domain)边界(在同一进程内)。<br />你也可以在被 lambda 表达式或匿名方法所捕获的局部变量上加锁。
锁在任何情况下都不会限制对同步对象本身的访问。换句话说,**x.ToString()**
不会因为其它线程调用**lock(x)**
而阻塞,两个线程都要调用**lock(x)**
才能使阻塞发生。
2.3 何时加锁
简单的原则是,需要在访问任意可写的共享字段(any writable shared field)时加锁。即使是最简单的操作,例如对一个字段的赋值操作,都必须考虑同步。在下面的类中,Increment
与Assign
方法都不是线程安全的:
class ThreadUnsafe
{
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; }
}
在**非阻塞同步**`**(nonblocking synchronization)**`中,我们会解释这种需求是如何产生的,以及在这些场景下内存屏障(memory barrier,内存栅栏,内存栅障)和`**Interlocked**`类如何提供替代方法进行锁定。
2.4 锁与原子性
如果一组变量总是在相同的锁内进行读写,就可以称为原子的(atomically)读写。假定字段x
与y
总是在对locker
对象的lock
内进行读取与赋值:lock(locker) { if (x != 0) y /= x; }
可以说x
和y
是被原子的访问的,因为上面的代码块无法被其它的线程分割或抢占。如果被其它线程分割或抢占,x
和y
就可能被别的线程修改导致计算结果无效。而现在 x
和y
总是在相同的排它锁中进行访问,因此不会出现除数为零的错误。
在lock锁内抛出异常将打破锁的原子性,考虑如下代码:
decimal _savingsBalance, _checkBalance;
void Transfer(decimal amount)
{
lock (_locker)
{
_savingsBalance += amount;
_checkBalance -= amount + GetBankFee();
}
}
如果GetBankFee()方法内抛出异常,银行可能就要损失钱财了。在这个例子中,我们可以通过更早的调用GetBankFee()来避免这个问题。对于更复杂情况,解决方案是在**catch**
或**finally**
中实现“回滚(**rollback**
)”逻辑。
指令原子性是一个相似但不同的概念: 如果一条指令可以在 CPU 上不可分割地执行,那么它就是原子的。(见非阻塞同步)
2.5 嵌套锁
线程可以用嵌套(重入)的方式重对相同的对象进行加锁:
lock (locker)
lock (locker)
lock (locker)
{
// ...
}
或者:
Monitor.Enter (locker); Monitor.Enter (locker); Monitor.Enter (locker);
// ...
Monitor.Exit (locker); Monitor.Exit (locker); Monitor.Exit (locker);
在这样的场景中,只有当最外层的`lock`语句退出或是执行了匹配数目的`Monitor.Exit`语句时,对象才会被解锁。<br />嵌套锁可以用于在锁中调用另一个方法(也使用了同一对象来锁定):
static readonly object _locker = new object();
static void Main()
{
lock (_locker)
{
AnotherMethod();
// 这里依然拥有锁,因为锁是可重入的
}
}
static void AnotherMethod()
{
lock (_locker) { Console.WriteLine("Another method"); }
}
线程只会在第一个(最外层)`lock`处**阻塞**。
2.6 死锁
当两个线程等待的资源都被对方占用时,它们都无法执行,这就产生了死锁。演示死锁最简单的方法就是使用两个锁:
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); // 死锁
}
更复杂的死锁链可能由三个或更多的线程创建。
在标准环境下,CLR 不会像SQL Server一样自动检测和解决死锁。除非你指定了锁定的超时时间,否则死锁会造成参与的线程无限阻塞。(在SQL CLR 集成宿主环境中,死锁能够被自动检测,并在其中一个线程上抛出可捕获的异常。)
死锁是多线程中最难解决的问题之一,尤其是在有很多关联对象的时候。这个困难在根本上在于无法确定调用方(caller)已经拥有了哪些锁。
你可能会锁定类x
中的私有字段a
,而并不知道调用方(或者调用方的调用方)已经锁住了类y
中的字段b
。同时,另一个线程正在执行顺序相反的操作,这样就创建了死锁。讽刺的是,这个问题会由于(良好的)面向对象的设计模式而加剧,因为这类模式建立的调用链直到运行时才能确定。
流行的建议:“以一致的顺序对对象加锁以避免死锁”,尽管它对于我们最初的例子有帮助,但是很难应用到刚才所描述的场景。更好的策略是:如果发现在锁区域中的对其它类的方法调用最终会引用回当前对象,就应该小心,同时考虑是否真的需要对其它类的方法调用加锁(往往是需要的,但是有时也会有其它选择)。更多的依靠[**声明方式(declarative)**](https://blog.gkarch.com/threading/part5.html#plinq)
与[**数据并行(data parallelism)**](https://blog.gkarch.com/threading/part5.html#the-parallel-class)
、[**不可变类型(immutable types)**](https://blog.gkarch.com/threading/part2.html#immutable-objects)
与[**非阻塞同步构造( nonblocking synchronization constructs)**](https://blog.gkarch.com/threading/part4.html#nonblocking-synchronization)
,可以减少对锁的需要。
有另一种思路来帮助理解这个问题:当你在拥有锁的情况下访问其它类的代码,对于锁的封装就存在潜在的泄露。这不是 CLR 或 .NET Framework 的问题,而是因为锁本身的局限性。锁的问题在许多研究项目中被分析,包括软件事务内存(Software Transactional Memory)。
另一个死锁的场景是:如果已拥有一个锁,在调用Dispatcher.Invoke
(在 WPF 程序中)或是Control.Invoke
(在 Windows Forms 程序中)时,如果 UI 恰好要运行等待同一个锁的另一个方法,就会在这里发生死锁。这通常可以通过调用BeginInvoke
而不是Invoke
来简单的修复。或者,可以在调用Invoke
之前释放锁,但是如果是调用方获得的锁,那么这种方法可能并不会起作用。我们在富客户端应用与线程亲和中来解释Invoke
和BeginInvoke
。(.Net Fx版本,Task后在不必要的情况下使用ConfigureAwait(false)
)
2.7 性能
锁是非常快的,在一个 2010 时代的计算机上,没有竞争的情况下获取并释放锁一般只需 20 纳秒。如果存在竞争,产生的上下文切换会把开销增加到微秒的级别,并且线程被重新调度前可能还会等待更久的时间。如果需要锁定的时间很短,那么可以使用自旋锁(SpinLock)来避免上下文切换的开销。
如果获取锁后保持的时间太长而不释放,就会降低并发度,同时也会加大死锁的风险。
2.8 互斥体(Mutex)
互斥体类似于 C# 的lock
,不同在于它是可以跨越多个进程工作。换句话说,Mutex
可以是机器范围(computer-wide)的,也可以是程序范围(application-wide)的。
没有竞争的情况下,获取并释放Mutex需要几微秒的时间,大约比lock慢 50 倍。
使用Mutex类时,可以调用WaitOne方法来加锁,调用ReleaseMutex方法来解锁。关闭或销毁Mutex会自动释放锁。与lock语句一样,Mutex只能被获得该锁的线程释放。