之前写过两篇关于线程同步问题的文章(一,二),这篇中将对相关话题进行总结,本文中也对.NET 4.0中新增的一些同步机制进行了介绍。
首先需要说明的是为什么需要线程功能同步。MSDN中有这样一段话很好的解释了这个问题:
当多个线程可以调用单个对象的属性和方法时,对这些调用进行同步处理是非常重要的。否则,一个线程可能会中断另一个线程正在执行的任务,使该对象处于一种无效状态。 |
也就说在默认无同步的情况下,任何线程都可以随时访问任何方法或字段,但一次只能有一个线程访问这些对象。另外,MSDN中也给出定义,成员不受多线程调用中断影响的类即线程安全类。
CLI提供了几种可用来同步对实例和静态成员的访问的策略(前面两边文章介绍了这其中大部分机制):
- 同步代码区域:
可以使用Monitor类或(编译器支持的语法,如C#中的lock关键字)来同步需要安全的接受并发请求的代码段,这种方式比其他等效的同步方法有更好的性能。
lock语句通过Monitor的Enter和Exit方法实现代码段同步,使用try catch finally结构确保锁被释放。当线程执行该代码时,会尝试获取锁。如果该锁已由其他线程获取,则在锁变为可用状态之前,该线程一直处于阻塞状态。当线程退出同步代码块时,锁就会被释放,这与线程的退出方式无关。通常情况下同步一小代码块并且不跨越多个方法的最佳选择是lock 语句,Monitor类功能强大但使用不当容易出现孤立锁与死锁,而由于lock是通过Monitor的Enter和Exit实现的,因此在临界区中可以结合Monitor的其它方法一起使用。
另外可以通过[MethodImpl(MethodImplOptions.Synchronized)]特性标记一个方法是需要被同步的,方法可以是实例方法也可以是静态方法。最终实现的效果与使用lock关键字或Monitor相关方法相同。注意不要在此特性标记的方法内使用lock(this)/lock(typeof(this))(注意,单独使用lock时也不应用对象本身或类型作为锁(应为类型或实例可能被其它机制锁定,如被[MethodImpl(MethodImplOptions.Synchronized)]标记),对于实例方法与静态方法最好分别使用声明新的私有成员或静态私有成员作为锁,避免使用公有成员作为锁)。另外不能对字符串加锁。
- 手动同步:
.NET Framework中提供一些类用于手动进行线程间的访问同步。这些类主要分为3大类别(但正如下文中会看到的这些类别划分并非绝对,某些同步机制在多个类别之间有交叉):
ü 锁定
ü 通知
ü 连锁操作
- 锁定
排他锁 |
独占锁 |
最常见的形式就是C#的lock语句,该语句控制对一个代码块的访问,这个代码块被称作临界区。详见前文xx中对lock的介绍。 |
Monitor类 |
Monitor类提供了许多附加功能,这些功能可以与lock关键字结合使用(在lock的临界区中调用Monitor类的方法)。更多细节见线程同步问题1方法二中的介绍。 |
|
Mutex类 |
Mutex的作用也是创建一个临界区以同步对其中对象的访问,方式类似Monitor类,但最大的不同是Mutex支持跨进程的同步。当然其效率也不如Monitor类,在同一进程内通信应首先考虑使用Monitor。Mutex的介绍详见线程同步问题2方法五中的介绍。 |
|
SpinLock类 |
.NET4.0中新增 当 Monitor 所需的开销会造成性能下降时,可以使用 SpinLock 类。当SpinLock请求进入临界区时,会反复地旋转(执行空循环),直至锁变为可用的。如果请求锁所需时间非常短,则空转可比阻塞提供更好的性能。但是,如果锁保留数十个周期以上,则SpinLock的表现会和Monitor一样,而且将使用更多的CPU周期,降低其他线程或进程的性能。 |
|
其它锁 |
有些时候锁不必独占,可以允许一定数目的线程并发访问某个资源。下面列举的锁即用于这个目的。 |
|
ReaderWriterLock类 |
允许多个线程同时读取一个资源,但在向该资源写入时要求线程等待以获得独占锁。更多细节见线程同步问题1方法三中的介绍。 |
|
Semaphore类 |
Semaphore类允许指定数目的线程访问某个资源。超过这个数目时,请求该资源的其他线程会一直阻塞,直到某个线程释放信号量。更多细节见线程同步问题2方法七中的介绍。 |
|
ReaderWriterLockSlim类 |
.NET4.0中新增 这个类的作用与ReaderWriterLock类完全一致,其拥有更好的性能,在新开发的程序中应当使用ReaderWriterLockSlim而不是ReaderWriterLock。ReaderWriterLockSlim 具有线程关联。 |
|
SemaphoreSlim类 |
.NET4.0中新增 SemaphoreSlim类是用于在单一进程边界内进行同步的轻量信号量。使用方式上与Semaphore一致。 |
- 通知
通知机制是等待另一个线程的信号的所有方法的统称。
Join方法 |
这是等待来自另一个线程信号最简单的方法,解释Join方法最好有一个场景,假如我们有ThreadA,ThreadB两个线程,假如我们在ThreadB执行的方法中调用ThreadA.Join()方法。这将阻塞B线程的执行直到A线程完成。场景中ThreadB可以是主线程也可以是其它子线程。其中也可以调用多个子线程的Join方法。这样ThreadB将阻塞并等待所有这些线程执行完毕后才继续执行。另外如果ThreadA的方法中调用了其它线程的Join方法,这将形成一个队列形式的线程调用,所有这些线程将一个个排队执行。 Join也具有两个接受时间间隔的重载,用于设置阻塞线程等待的最长时间。依然用上面的例子来说,我们在B线程方法中调用ThreadA.Join(5000),当在5秒钟内线程A执行完毕了,则Join方法会立刻返回true,ThreadB继续执行,如果5秒钟线程A未完成,则Join方法在5秒钟到时返回false,ThreadA与ThreadB进入并行交替执行状态。 |
|||
等待句柄 |
等待句柄派生自WaitHandle类,后者又派生自 MarshalByRefObject。从而等待句柄可用于跨应用程序域边界的线程同步。WaitHandle类封装了Win32的同步句柄,用于表示所有允许多个等待操作的同步对象。 通过调用WaitOne实例方法或WaitAll、WaitAny及SignalAndWait中任一个静态方法方法,可以阻塞当前线程以等待WaitHandle发出信号。 WaitHandle的派生类具有不同的线程关联。事件等待句柄(EventWaitHandle、AutoResetEvent 和 ManualResetEvent)以及信号量没有线程关联。任何线程都可以发送事件等待句柄或信号量的信号。另一方面,mutex有线程关联。拥有mutex的线程必须将其释放;而如果在不拥有mutex的线程上调用ReleaseMutex方法,则将引发异常。 |
|||
事件等待句柄 |
事件等待句柄包括EventWaitHandle类及其派生类AutoResetEvent和ManualResetEvent,这些类允许线程通过彼此发送信号和等待彼此的信号来同步活动。当通过调用Set方法或使用SignalAndWait方法通知事件等待句柄时,阻塞线程会从事件等待句柄中释放。 事件等待句柄要么自动重置自身(类似于每次得到信号时只允许一个线程通过的旋转门),要么必须手动重置(类似于一道门,在得到信号前一直关闭,得到信号打开后到其关闭前一直打开)。顾名思义,AutoResetEvent和ManualResetEvent分别表示前者和后者。 |
|||
AutoResetEvent |
派生自EventWaitHandle,表示自动重置的本地事件。详见线程同步问题2方法六的介绍。 |
|||
ManualResetEvent |
派生自EventWaitHandle,表示手动重置的本地事件。详见线程同步问题2方法六的介绍 |
|||
ManualResetEventSlim |
.NET4.0中新增 ManualResetEventSlim类提供了ManualResetEvent的简化版本。其模型与使用方式上与ManualResetEvent一致,主要用于同一进程内线程间的同步。 |
|||
CountdownEvent |
.NET4.0中新增 CountdownEvent的作用与Semaphore相反,Semaphore中设置了最大可用槽数,当计数为0时(即资源不够用时)则阻塞线程。而CountdownEvent用来统计其它线程结束工作的情况,当监听数变为0时,触发信号。本篇文章的最后部分我们详细介绍CountdownEvent类。 |
|||
Mutex类/ Semaphore类 |
这两个类均派生自WaitHandle,所以它们均可与WaitHandle的静态方法一起使用。例如,线程可以使用WaitAll方法/WaitAny方法等待,而以下三个条件均可以使这个线程解除阻塞:EventWaitHandle接收到信号,Mutex被释放,Semaphore被释放。 |
|||
Barrier类 |
.NET4.0中新增 利用 Barrier 类,可以对多个线程进行循环同步,以便它们都在同一个点上阻塞来等待其他线程完成。后文将对这个类进行详细介绍。 |
|||
- 连锁操作
联锁操作是由 Interlocked 类的静态方法对某个内存位置执行的简单原子操作。这些原子操作包括添加、递增和递减、交换、依赖于比较的条件交换,以及 32 位平台上的 64 位值的读取操作。关于Interlocked类详见线程同步问题1方法一。
特别注意,原子性的保证仅限于单个操作;如果必须将多个操作作为一个单元执行,则必须使用更粗粒度的同步机制。
尽管这些操作中没有一个是锁或信号,但它们可用于构造锁和信号。因为它们是Windows操作系统固有的,因此联锁操作的执行速度非常快。如CountdownEvent的实现中就使用了Interlocked类。
最后注意,只要有一个线程避开同步机制直接访问需要同步访问的资源,这种同步机制就是无效的。
- 同步上下文:
可以使用SynchronizationAttribute为ContextBoundObject对象(上下文绑定对象)启用简单的自动同步。介绍详见线程同步问题1方法四中的介绍。
- 线程安全集合:
.NET4.0中新引入的命名空间System.Collections.Concurrent中提供的集合类内置对添加和移除操作的同步机制。多个线程可以在这些集合中安全高效地添加或移除项,而无需用户执行其他同步操作。在编写新代码时,如果遇到多个线程同时写入集合的情况,就应使用并发集合类。如果仅从集合进行(并发)读取,则可使用System.Collections.Generic命名空间中的类。
从.NET发展来看,.NET1.0中提供的集合类(Aarry,Hashtable)通过Synchronized属性支持同步,但不支持泛型,NET2.0种提供了泛型类的集合,但没有内置任何同步机制。.NET4.0开始提供的并发集合类把线程安全与类型安全集合起来。为了提高效率这些并发集合的一部分使用了.NET4.0新增的轻量同步机制,如SpinLock、SpinWait、SemaphoreSlim 和 CountdownEvent,另外ConcurrentQueue<T>和ConcurrentStack<T>类没有使用这些同步机制,而是依赖Interlocked操作来实现线程安全性。
这个新增的命名空间下包含如下类型:
类型 |
说明 |
通过实现IProducerConsumerCollection<T>接口,实现了一个支持生产者消费者模型的数据结构。 |
|
键/值对字典的线程安全实现。 |
|
线程安全的队列实现。 |
|
线程安全的堆栈实现。 |
|
无序的元素集合的线程安全实现。 |
|
BlockingCollection实现的接口。 |
CLR中不同类别可以根据要求以不同的方式进行同步。下表显示了上面列出的几类同步策略为不同类别的字段和方法提供的同步支持。
类别 |
全局字段 |
静态字段 |
静态方法 |
实例字段 |
实例方法 |
特定代码块 |
无同步 |
不同步 |
不同步 |
不同步 |
不同步 |
不同步 |
不同步 |
同步上下文 |
不同步 |
不同步 |
不同步 |
可以同步 |
可以同步 |
不同步 |
同步代码区域 |
不同步 |
不同步 |
当标记时同步 |
不同步 |
当标记时同步 |
当标记时同步 |
手动同步 |
手动 |
手动 |
手动 |
手动 |
手动 |
手动 |
到这,可以发现.NET4.0添加了很多新的同步类(轻量类型),这些类尽可能避免依赖高开销的Win32内核对象(例如等待句柄)来提高性能。通常,当等待时间较短并且只有在尝试了原始同步类型并发现它们并不令人满意时,才应使用这些类型。另外,在需要跨进程通信的方案中不能使用轻量类型。
以下内容来源这篇文章:
CountdownEvent
CountdownEvent,前文中我们提及了CountdownEvent实现的同步效果。这里我们将给出一个CountdownEvent适用的场景及示例代码。如我们可以在主线程中模拟一个线程池,通过CountdownEvent使得主线程可以等待线程池中所有线程结束后才能继续执行(对所有子线程的执行顺序没有要求)。在给出代码之前先介绍一些CountdownEvent中一些主要的属性与方法:
重载的构造函数:CountdownEvent的构造函数接受一个整型值,表示事件句柄最初必须的信号数。
InitialCount属性:这个属性正是构造函数接收的参数所设置的值。
CurrentCount属性:事件解除阻塞所必需的剩余信号数。
AddCount方法:将CurrentCount属性的值加1。
Single方法:给出一个信号,这将是CurrentCount的值减1。
class Program
{
static void Main()
{
var customers = Enumerable.Range(1, 20);
using (var countdown = new CountdownEvent(customers.Count()))
{
foreach (var customer in customers)
{
int currentCustomer = customer;
ThreadPool.QueueUserWorkItem(delegate
{
BuySomeStuff(currentCustomer);
countdown.Signal();
//for test
Console.WriteLine(" CountdownEvent:" + countdown.CurrentCount);
});
}
countdown.Wait();
}
//主线程继续执行
Console.WriteLine("All Customers finished shopping...");
Console.ReadKey();
}
static void BuySomeStuff(int customer)
{
// Fake work
Thread.SpinWait(200000000);
Console.Write("Customer {0} finished", customer);
}
}
代码输出(每次运行子线程执行顺序可能不同):
Customer 1 finished CountdownEvent:19
Customer 2 finished CountdownEvent:18
Customer 3 finished CountdownEvent:17
Customer 4 finished CountdownEvent:16
Customer 5 finished CountdownEvent:15
Customer 6 finished CountdownEvent:14
Customer 7 finished CountdownEvent:13
Customer 8 finished CountdownEvent:12
Customer 9 finished CountdownEvent:11
Customer 10 finished CountdownEvent:10
Customer 11 finished CountdownEvent:9
Customer 12 finished CountdownEvent:8
Customer 13 finished CountdownEvent:7
Customer 14 finished CountdownEvent:6
Customer 15 finished CountdownEvent:5
Customer 16 finished CountdownEvent:4
Customer 17 finished CountdownEvent:3
Customer 18 finished CountdownEvent:2
Customer 20 finished CountdownEvent:1
Customer 19 finished CountdownEvent:0
All Customers finished shopping...
代码中主线程中调用Wait方法来等待子线程完成(即CountdownEvent的CurrentCount属性变为0)。
CountdownEvent内部通过ManualResetEventSlim与Interlocked实现,ManualResetEventSlim用于实现事件等待句柄,而Interlocked用于线程计数。
Barrier
这个类的作用很明确,使用很简单,首先介绍其中几个比较重要的属性与方法,之后直接进入示例:
构造函数:两个重载共同的参数是需要被同步的线程的数量,参数较多的一个重载第二个参数接收一个Action<Barrier>类型对象,表示所有线程达到同一阶段后执行的方法。
ParticipantCount属性:即构造函数中设置的需要被同步的线程的数量。
SignalAndWait方法:发出参与者已达到Barrier的信号,等待所有其他参与者也达到Barrier。
场景如下:Charlie、Mac、Dennis三个人相约在途中的加油站会合后一同前往西雅图。我们用Barrier来模拟这个场景,重要的是在加油站会和这一点进行同步。
代码:
class Program
{
static Barrier sync;
static CancellationToken token;
static void Main(string[] args)
{
var source = new CancellationTokenSource();
token = source.Token;
sync = new Barrier(3);
var charlie = new Thread(() => DriveToBoston("Charlie", TimeSpan.FromSeconds(1)));
charlie.Start();
var mac = new Thread(() => DriveToBoston("Mac", TimeSpan.FromSeconds(2)));
mac.Start();
var dennis = new Thread(() => DriveToBoston("Dennis", TimeSpan.FromSeconds(3)));
dennis.Start();
//source.Cancel();
charlie.Join();
mac.Join();
dennis.Join();
Console.ReadKey();
}
static void DriveToBoston(string name, TimeSpan timeToGasStation)
{
try
{
Console.WriteLine("[{0}] Leaving House", name);
// Perform some work
Thread.Sleep(timeToGasStation);
Console.WriteLine("[{0}] Arrived at Gas Station", name);
// Need to sync here
sync.SignalAndWait(token);
// Perform some more work
Console.WriteLine("[{0}] Leaving for Boston", name);
}
catch (OperationCanceledException)
{
Console.WriteLine("[{0}] Caravan was cancelled! Going home!", name);
}
}
}
执行结果(同样每次运行子线程执行顺序可能不同):
[Charlie] Leaving House
[Mac] Leaving House
[Dennis] Leaving House
[Charlie] Arrived at Gas Station
[Mac] Arrived at Gas Station
[Dennis] Arrived at Gas Station
[Dennis] Leaving for Boston
[Mac] Leaving for Boston
[Charlie] Leaving for Boston
另外可以取消代码中的注释,观察多线程取消的效果。
其它.NET4.0新增的线程类
SpinWait
从.NET Framework 4开始,当线程必须等待发生某个事件发出信号时或需要满足某个条件时,可以使用System.Threading.SpinWait结构,前提是实际等待时间预计会少于通过使用等待句柄或通过其他方式阻塞当前线程所需要的等待时间,否则SpinWait空转导致的CPU开销会影响其它进程。通过使用 SpinWait,可以指定在一个较短的时段内边等待边旋转,然后只有在相应的条件在指定时间内无法得到满足的情况下放弃旋转。
其它小话题:
Thread.Interrupt方法可用于使线程跳出阻塞状态(如等待访问同步代码区域)。Thread.Interrupt 还可用于使线程跳出 Thread.Sleep 等操作。