[TOC]
多线程访问共享数据时就会产生线程同步问题,.NET 为解决线程同步问题提供了很多种方法,下面对一些常用的方法做个总结:
- lock 语句 & Monitor
- Interlocked
- AutoResetEvent & ManualResetEvent
- SpinLock
- Mutex & Semaphore
- ReaderWriterLockSlim
lock & Monitor
lock 语句是常用的一个线程同步方法,语法:
lock(obj){
// synchronized region
}
注意:obj 必须是引用类型,你可以理解为如果是值类型,lock 的是值类型的副本,没有任何意义。
看一个示例:
启动 4 个线程,每个线程多静态变量循环自增 1000000 次,那么结果应该是 4 x 1000000 = 4000000
using System;
using System.Threading;
class Example
{
// volatile 是为了防止编译器优化,多个线程共享 num 变量
private static volatile int num = 0;
//private static object lockobj = new object();
public static void Main()
{
Thread[] ts = new Thread[4];
for(int i = 0; i < 4; i++)
{
Thread t = new Thread(new ThreadStart(ThreadPro));
t.Start();
ts[i] = t;
}
for (int i = 0; i < 4; i++)
{
ts[i].Join();
}
Console.WriteLine(num.ToString());
}
public static void ThreadPro()
{
for (int i = 0; i < 1000000; i++ )
{
//lock (lockobj)
//{
num++;
//}
}
Console.WriteLine("Thread: {0} end", Thread.CurrentThread.ManagedThreadId);
}
}
/* 运行结果
Thread: 3 end
Thread: 4 end
Thread: 6 end
Thread: 5 end
2568336
请按任意键继续. . .
*/
我们发现运行结果每次都不一样,且循环的数值越大,结果相差越大。这是为什么呢?因为操作 num++ 不是线程安全的。什么意思呢? 我们看一下 num++ 的做了什么就知道了:
1. 把 num 值复制到寄存器
2. 寄存器值 +1
3. 寄存器值拷贝到 num 内存
示例程序启动了 4 个线程,假设某个时间点 num 的值为 100, 假设 1 号线程在执行 num++ 的第一个步骤时后时间片就结束了,1 号线程被线程调度器强制中断,调度器把 CUP 时间分配给 2 号线程,2 号线程开始执行第一个步骤,但注意 2 号线程取到值和 1 号线程的值是一样的,都是 100。于是 2 号进程接着执行步骤 2 , 3 后值为 101,假设时间片结束分配给 1 号线程执行步骤 2,3 结果也为 101,那么久相当于少了一次自增 1 的操作。
这样计算结果我们就不可预期了,也没有任何意义。解决办法就是使 num++ 操作原子化,也就是说 num++ 执行他的三个步骤时只允许一个线程访问。
把上面的注释代码去掉,就是使用 lock 语句线程同步了。同步以后每次的结果都是 4000000.
lock 语句经过编译器后会翻译成:
Monitor.Enter(obj)
try{
// synchronized region for obj
}finally{
Monitor.Exit(obj);
}
因为这样写起来麻烦,而这种结构又经常用到,所以就用 lock 语句来简化罢了。
但 Monitor 的用法更丰富一些。它的 TryEnter() 方法可以传递一个超时值,锁定时间超过这个超时值后就吧传出参数置为 false 线程就不再等待。
bool lockToken = false;
Monitor.TryEnter(obj, 500, ref lockToken);
if(lockToken){
try{
// synchronized region for obj
}
finally{
Monitor.Exit(obj);
}
}
Interlocked
此类的方法可以防止可能在下列情况发生的错误:计划程序在某个线程正在更新可由其他线程访问的变量时切换上下文;或者当两个线程在不同的处理器上并发执行时。 此类的成员不引发异常。
Interlocked 主要是为了结局变量的并发访问问题。就如 lock 语句中的示例一样自增操作可以使用 Increment (Decrement) 方法。但是 Interlocked 要比其他同步方式要快。
下面列一些 Interlocked 的常用方法:
名称 | 说明 |
---|---|
Add(Int32, Int32) | 对两个 32 位整数进行求和并用和替换第一个整数,上述操作作为一个原子操作完成。 |
Add(Int64, Int64) | 对两个 64 位整数进行求和并用和替换第一个整数,上述操作作为一个原子操作完成。 |
CompareExchange(Double, Double, Double) | 比较两个双精度浮点数是否相等,如果相等,则替换其中一个值。 |
CompareExchange(Int32, Int32, Int32) | 比较两个 32 位有符号整数是否相等,如果相等,则替换其中一个值。 |
CompareExchange(Int64, Int64, Int64) | 比较两个 64 位有符号整数是否相等,如果相等,则替换其中一个值。 |
CompareExchange(IntPtr, IntPtr, IntPtr) | 比较两个平台特定的句柄或指针是否相等,如果相等,则替换其中一个。 |
CompareExchange(Object, Object, Object) | 比较两个对象是否相等,如果相等,则替换其中一个对象。 |
CompareExchange(Single, Single, Single) | 比较两个单精度浮点数是否相等,如果相等,则替换其中一个值。 |
CompareExchange(T, T, T) | 比较指定的引用类型 T 的两个实例是否相等,如果相等,则替换其中一个。 |
Decrement(Int32) | 以原子操作的形式递减指定变量的值并存储结果。 |
Decrement(Int64) | 以原子操作的形式递减指定变量的值并存储结果。 |
Exchange(Double, Double) | 以原子操作的形式,将双精度浮点数设置为指定的值并返回原始值。 |
Exchange(Int32, Int32) | 以原子操作的形式,将 32 位有符号整数设置为指定的值并返回原始值。 |
Exchange(Int64, Int64) | 将 64 位有符号整数设置为指定的值并返回原始值,上述操作作为一个原子操作完成。 |
Exchange(IntPtr, IntPtr) | 以原子操作的形式,将平台特定的句柄或指针设置为指定的值并返回原始值。 |
Exchange(Object, Object) | 以原子操作的形式,将对象设置为指定的值并返回对原始对象的引用。 |
Exchange(Single, Single) | 以原子操作的形式,将单精度浮点数设置为指定的值并返回原始值。 |
Exchange(T, T) | 以原子操作的形式,将指定类型 T 的变量设置为指定的值并返回原始值。 |
Increment(Int32) | 以原子操作的形式递增指定变量的值并存储结果。 |
Increment(Int64) | 以原子操作的形式递增指定变量的值并存储结果。 |
MemoryBarrier | 同步内存存取如下所示:当前执行线程的处理器不能重新排序命令,在内存存取,在对 MemoryBarrier 的调用之后调用 MemoryBarrier的内存存取后之前执行。 |
Read | 返回一个以原子操作形式加载的 64 位值。 |
AutoResetEvent & ManualResetEvent
AutoResetEvent 允许线程通过发信号互相通信。 通常,当线程需要独占访问资源时使用该类。
线程通过调用 AutoResetEvent 上的 WaitOne 来等待信号。 如果 AutoResetEvent 为 non-signaled 状态,则线程会被阻止,并等待当前控制资源的线程通过调用 Set 来通知资源可用。
调用 Set 向 AutoResetEvent 发信号以释放等待线程。 AutoResetEvent 将保持 signaled 状态,直到一个正在等待的线程被释放,然后自动返回 non-singaled 状态。 如果没有任何线程在等待,则状态将无限期地保持为 signaled 状态。
如果当 AutoResetEvent 为 signaled 状态时线程调用 WaitOne,则线程不会被阻止。 AutoResetEvent 将立即释放线程并返回到未触发状态。
上面三段话摘自 MSDN none-signaled 状态我没有翻译,因为 MSDN 把他翻译为非终止状态我实在不理解。。。
根据我的理解打个比方,AutoResetEvent 的实例对象就想是一个通行证,但是这个通行证每次只能使用一次,用完一次(WaitOne 方法)就又要重新充值(调用 Set 方法),而 AutoResetEvent 的构造函数可以传一个布尔值指示这个通行证刚开始充值了没有。
来看个示例代码:
using System;
using System.Threading;
public class Example
{
// 构造函数传 true false 结果不一样
private static AutoResetEvent autoevent = new AutoResetEvent(false);
public static void Main()
{
Thread t = new Thread(new ThreadStart(ThreadPro));
Console.WriteLine("Thread start...");
t.Start();
while(true)
{
Console.WriteLine("enter to set a signal");
Console.ReadLine();
autoevent.Set();
}
}
private static void ThreadPro()
{
while(true)
{
Console.WriteLine("ThreadPro waiting signal...");
autoevent.WaitOne();
Console.WriteLine("ThreadPro get a signal...");
}
}
}
示例中每键入一次 Enter 就会给 autoevent 对象一次(注意不是线程)一个信号(充值一次),线程中的循环就执行一次。如果初始化时 autoenvent 传的是 true 那么程序一启动线程中的循环就会执行一次,因为通行证里已经有钱了 ^_^
细心的人可能会发现了这个示例只有一个线程,如果是多个线程呢?主线程 Set 一次那个线程会执行呢? autoevent.Set() 是给 autoevent 发信号,而每个线程使用的是同一个 autoevent 对象,所以调用 Set() 方法后哪个线程先取得了 CUP 时间那个线程就执行一次(刷完一次卡,卡里有没钱了),其他线程继续等待。所以是抢占式的。
说到这里就可以提 ManualResetEvent 了,ManualResetEvent 就像是一个可以无限刷的通行证,调用 Set() 方法后就可以无限刷了,直到调用 Reset() 方法后,WaitOne 阻塞等待重新 Set(),所以称之为手动的 ^_^。
ManualResetEventSlim
.NET Framework 4 使用此 System.Threading.ManualResetEventSlim 类可以获得更好的性能,当等待时间预计非常短时以及当事件不会跨越进程边界时。 它在等待事件变为终止状态时,ManualResetEventSlim 使用繁忙旋转短的时间段。 当等待时间很短时,旋转的开销相对于使用等待句柄来进行等待的开销会少很多。 但是,如果事件在某个时间段内没有变为已发出信号状态,则 ManualResetEventSlim 会采用常规的事件处理等待。
SpinLock
SpinLock 是 .NET 4.0新特性。它在短时间内会使用循环的方式阻塞线程,这样可以提高效率,但是超过一定时间他会使用等待句柄阻塞线程,也就是说和 lock 一样了。
注意 SpinLock 的 Exit() 方法:
public void Exit(
bool useMemoryBarrier
)
将 useMemoryBarrier 参数设置为 true 时调用 Exit 会提高锁定的公正性,但是会牺牲一些性能。 默认 Exit 重载的行为与为 useMemoryBarrier 指定 true 时一样。
Mutex & Semaphore
一个同步基元,也可用于进程间同步。
当两个或更多线程需要同时访问一个共享资源时,系统需要使用同步机制来确保一次只有一个线程使用该资源。 Mutex 是同步基元,它只向一个线程授予对共享资源的独占访问权。 如果一个线程获取了互斥体,则要获取该互斥体的第二个线程将被挂起,直到第一个线程释放该互斥体。
可以使用 WaitHandle.WaitOne 方法请求互斥体的所属权。 拥有互斥体的线程可以在对 WaitOne 的重复调用中请求相同的互斥体而不会阻止其执行。 但线程必须调用 ReleaseMutex 方法同样多的次数以释放互斥体的所属权。 Mutex 类强制线程标识,因此互斥体只能由获得它的线程释放。 相反,Semaphore 类不强制线程标识。
如果线程在拥有互斥体时终止,则称此互斥体被放弃。 将此 mutex 的状态设置为收到信号,下一个等待线程将获得所有权。 从 .NET Framework 2.0 版开始,在获取被放弃 mutex 的下一个线程中将引发 AbandonedMutexException。 在 .NET Framework 2.0 版之前,这样不会引发任何异常。
对于系统范围的 mutex,被放弃的 mutex 可能指示应用程序已突然终止(例如,通过使用 Windows 任务管理器终止)。
Mutex 有两种类型:未命名的局部 mutex 和已命名的系统 mutex。 本地 mutex 仅存在于进程当中。 您的进程中任何引用表示 mutex 的 Mutex 对象的线程都可以使用它。 每个未命名的 Mutex 对象都表示一个单独的局部 mutex。
已命名的系统互斥体在整个操作系统中都可见,可用于同步进程活动。 您可以使用接受名称的构造函数创建表示已命名系统 mutex 的 Mutex 对象。 同时也可以创建操作系统对象,或者它在创建 Mutex 对象之前就已存在。 您可以创建多个 Mutex 对象来表示同一命名系统 mutex,而且您可以使用 OpenExisting 方法打开现有的命名系统 mutex。
ReaderWriterLockSlim
使用 ReaderWriterLockSlim 来保护由多个线程读取但每次只采用一个线程写入的资源。 ReaderWriterLockSlim 允许多个线程均处于读取模式,允许一个线程处于写入模式并独占锁定状态,同时还允许一个具有读取权限的线程处于可升级的读取模式,在此模式下线程无需放弃对资源的读取权限即可升级为写入模式。
ReaderWriterLockSlim 类似于 ReaderWriterLock,但简化了递归规则以及升级和降级锁定状态的规则。 ReaderWriterLockSlim 可避免多种潜在的死锁情况。 此外,ReaderWriterLockSlim 的性能明显优于 ReaderWriterLock。 对于所有新的开发建议使用 ReaderWriterLockSlim。
升级和降低锁定
可升级模式适用于线程通常读取受保护资源的内容,但在某些条件满足时可能需要写入的情况。 已进入可升级模式下的 ReaderWriterLockSlim 的线程对受保护资源具有读取权限,并且可以通过调用 EnterWriteLock 或 TryEnterWriteLock 方法升级为写入模式。 因为每次只能有一个线程处于可升级模式,在不允许递归的情况下升级为写入模式不会死锁,这是默认策略。
如果还有其他线程也处于读取模式,正在升级的线程将受到阻塞。 当此线程受到阻塞时,其他尝试进入读取模式的线程也将受到阻塞。 当所有线程都退出读取模式时,受到阻塞的可升级线程将进入写入模式。 如果有其他线程正在等待进入写入模式,它们仍将处于阻塞状态,因为处在可升级模式下的那个线程将阻止它们获取对资源的独占访问权。
读写锁常用于缓存,下面是 MSDN 的经典实例:
using System;
using System.Threading;
using System.Collections.Generic;
public class SynchronizedCache
{
private ReaderWriterLockSlim cacheLock = new ReaderWriterLockSlim();
private Dictionary<int, string> innerCache = new Dictionary<int, string>();
public string Read(int key)
{
cacheLock.EnterReadLock();
try
{
return innerCache[key];
}
finally
{
cacheLock.ExitReadLock();
}
}
public void Add(int key, string value)
{
cacheLock.EnterWriteLock();
try
{
innerCache.Add(key, value);
}
finally
{
cacheLock.ExitWriteLock();
}
}
public bool AddWithTimeout(int key, string value, int timeout)
{
if (cacheLock.TryEnterWriteLock(timeout))
{
try
{
innerCache.Add(key, value);
}
finally
{
cacheLock.ExitWriteLock();
}
return true;
}
else
{
return false;
}
}
public AddOrUpdateStatus AddOrUpdate(int key, string value)
{
cacheLock.EnterUpgradeableReadLock();
try
{
string result = null;
if (innerCache.TryGetValue(key, out result))
{
if (result == value)
{
return AddOrUpdateStatus.Unchanged;
}
else
{
cacheLock.EnterWriteLock();
try
{
innerCache[key] = value;
}
finally
{
cacheLock.ExitWriteLock();
}
return AddOrUpdateStatus.Updated;
}
}
else
{
cacheLock.EnterWriteLock();
try
{
innerCache.Add(key, value);
}
finally
{
cacheLock.ExitWriteLock();
}
return AddOrUpdateStatus.Added;
}
}
finally
{
cacheLock.ExitUpgradeableReadLock();
}
}
public void Delete(int key)
{
cacheLock.EnterWriteLock();
try
{
innerCache.Remove(key);
}
finally
{
cacheLock.ExitWriteLock();
}
}
public enum AddOrUpdateStatus
{
Added,
Updated,
Unchanged
};
}