多个线程同时使用共享对象,这种情形被称为竞争条件(Race Condition),竞争条件是多线程环境中非常常见的导致错误的原因,同步这些线程使得对共享对象的操作能够以正确的顺序执行是非常重要的。在多线程中使用共享资源的常用技术,被称为线程同步。
性能考量
锁定本身是非常快的,一个锁在没有堵塞的情况下一般只需几十纳秒(十亿分之一秒)。如果发生堵塞,任务切换带来的开销接近于数微秒(百万分之一秒)的范围内,尽管在线程重组实际的安排时间之前它可能花费数毫秒(千分之一秒)。而相反,与此相形见绌的是该使用锁而没使用的结果就是带来数小时的时间,甚至超时。
如果耗尽并发,锁定会带来反作用,死锁和争用锁,耗尽并发由于太多的代码被放置到锁语句中了,引起其它线程不必要的被阻止。死锁是两线程彼此等待被锁定的内容,导致两者都无法继续下去。争用锁是两个线程任一个都可以锁定某个内容,如果“错误”的线程获取了锁,则导致程序错误。
对于太多的同步对象死锁是非常容易出现的症状,一个好的规则是开始于较少的锁,在一个可信的情况下涉及过多的阻止出现时,增加锁的粒度。
上下文切换(context switch):是指操作系统的线程调度器会保存等待线程的状态,并切换到另一个线程,依次恢复等待的线程的状态。
内核模式(kernel mode):将等待的线程置于阻塞状态。当线程处于阻塞状态时,会占用尽可能少的CPU时间;只有操作系统的内核才能阻止线程使用CPU时间。
用户模式(user mode):线程只等待一小段时间,而不用将线程切换到阻塞状态。虽然线程等待时会浪费CPU时间,但我们节省了上下文切换耗费的CPU时间;该方式非常轻量,速度很快,但如果线程需要等待较长时间则会浪费大量的CPU时间。
混合模式(hybrid):先尝试使用用户模式等待,如果线程等待了足够长的时间,则会切换到阻塞状态以节省CPU资源。
1、volatile
volatile 是最简单的一种同步方法,当然简单是要付出代价的。它只能在变量一级做同步,volatile 的含义就是告诉处理器,不要将我放入工作内存, 请直接在主存操作我。因此,当多线程同时访问该变量时,都将直接操作主存,从本质上做到了变量共享。
但 volatile 并不能实现真正的同步,因为它的操作级别只停留在变量级别,而不是原子级别。如果是在单处理器系统中,是没有任何问题的,变量在主存中没有机会被其他人修改,因为只有一个处理器,这就叫作 processor Self-Consistency。但在多处理器系统中,可能就会有问题。 每个处理器都有自己的data cache,而且被更新的数据也不一定会立即写回到主存。所以可能会造成不同步,但这种情况很难发生,因为 cache 的读写速度相当快,flush 的频率也相当高,只有在压力测试的时候才有可能发生,而且几率非常非常小。
简单来说 volatile 关键字是告诉C#编译器和JIT编译器,不对 volatile 标记的字段做任何的缓存。确保字段读写都是原子操作,最新值。
从功能上看起到锁的作用,但它不是锁, 它的原子操作是基于CPU本身的,非阻塞的。 因为32位CPU执行赋值指令,数据传输最大宽度4个字节。
所以只要在4个字节以下读写操作的,32位CPU都是原子操作,volatile 是利用这个特性来保证其原子操作的。
这样的目的是为了提高JIT性能效率,对有些数据进行缓存了(多线程下)。
//正确
public volatile Int32 score1 = 1;
//报错
public volatile Int64 score2 = 1;
如上,我们尝试定义了8个字节长度score2,会抛出异常。 因为8个字节32位CPU就分成2个指令执行了,所以就无法保证其原子操作了。
如果把编译平台改成64位,同样不可以使用,C#限制4个字节以下的类型字段才能用volatile。
一种方法是使用特定平台的整数类型 IntPtr,这样 volatile 即可作用到64位上了。
volatile 多数情况下很有用处的,毕竟锁的性能开销还是很大的。可以把当成轻量级的锁,根据具体场景合理使用,能提高不少程序性能。
线程中的 Thread.VolatileRead 和 Thread.VolatileWrite 是 volatile 以前的版本。
2、Interlocked(自由锁)
Interlocked对对象执行基本的原子操作,从而不用阻塞线程就可避免竞争条件。
对于整数数据类型的简单操作,可以用 Interlocked 类的成员来实现线程同步。Interlocked 提供了 Increment 、Decrement 、Add、Exchange 和CompareExchange 等基本数学操作的原子方法。使用 Increment 和 Decrement 可以保证对一个整数的加减为一个原子操作。Exchange 方法自动交换指定变量的值。CompareExchange 方法组合了两个操作:比较两个值以及根据比较的结果将第三个值存储在其中一个变量中。比较和交换操作也是按原子操作执行的。
MSDN 描述:为多个线程共享的变量提供原子操作。主要函数如下:
Interlocked.Increment 原子操作,递增指定变量的值并存储结果。
Interlocked.Decrement 原子操作,递减指定变量的值并存储结果。
Interlocked.Add 原子操作,添加两个整数并用两者的和替换第一个整数。
Interlocked.CompareExchange(ref a, b, c); 原子操作,a参数和c参数比较,相等b替换a,不相等不替换。
3、lock关键字(互斥锁)
lock 关键字确保当一个线程使用某些资源时,同时其他线程无法使用该资源。
lock 只能在进程内锁,不能跨进程,内部走的是混合构造,先自旋再转成内核构造。
lock 是一种比较好用的简单的线程同步方式,它是通过为给定对象获取互斥锁来实现同步的。它如果锁定了一个对象,需要访问该对象的所有的其他线程则会处于阻塞状态,并等待直到该对象解除锁定,也就是说线程出了临界区。用法:
privatestaticreadonlyobjectobj =newobject();
lock(obj)
{
}
lock 的参数必须是基于引用类型的对象,不要是基本类型像 bool,int 什么的,这样根本不能同步,原因是 lock 的参数要求是对象,如果传入 int,势必要发生装箱操作,这样每次 lock 的都将是一个新的不同的对象。最好避免使用 public 类型或不受程序控制的对象实例,因为这样很可能导致死锁。特别是不要使用字符串作为 lock 的参数,因为字符串被CLR”暂留“,就是说整个应用程序中给定的字符串都只有一个实例,因此更容易造成死锁现象。建议使用不被“暂留”的私有或受保护成员作为参数。其实某些类已经提供了专门用于被锁的成员,比如 Array 类型提供 SyncRoot,许多其它集合类型也都提供了SyncRoot 。
所以,使用 lock 应该注意以下几点:
1、如果一个类的实例是 public 的,最好不要 lock(this)。因为使用你的类的人也许不知道你用了 lock,如果他 new 了一个实例,并且对这个实例上锁,就很容易造成死锁。
2、如果 MyType 是 public 的,不要 lock(typeof(MyType))。
3、永远不要 lock 一个字符串。
关于对type类型的锁
第一,在同进程同域,不同线程下,锁type int,其实锁的是同一个 int 对象,所以要慎用。
第二,这里就简单说下:
A:CLR启动时,会创建系统域(System Domain)和共享域(Shared Domain), 默认程序域(Default AppDomain)。 系统域和共享域是单例的。程序域可以有多个,我们可以使用 AppDomain.CreateDomain 方法创建的。
B:按正常来说,每个程序域的代码都是隔离,互不影响的。但对于一些基础类型来说,每个程序域都重新加载一份,就显得有点浪费,带来额外的损耗压力。聪明的 CLR 会把一些基本类型Object,ValueType,Array,Enum,String and Delegate等所在的程序集MSCorLib.dll,在 CLR 启动过程中都会加载到共享域。 每个程序域都会使用共享域的基础类型实例。
C:而每个程序域都有属于自己的托管堆。托管堆中最重要的是 GC heap 和 Loader heap 。GC heap 用于引用类型实例的存储,生命周期管理和垃圾回收。Loader heap 保存类型系统,如MethodTable,数据结构等,Loader heap 生命周期不受GC管理,跟程序域卸载有关。
所以共享域中Loader heap MSCorLib.dll中的 int 实例会一直保留着,直到进程结束。单个程序域卸载也不受影响。锁 int 实例是跨程序域的,MSCorLib中的基础类型都是这样, 极容易造成死锁。 而自定义类型则会加载到自己的程序域,不会影响其他。
第三,string 在C#是个特殊对象,值是不变的,每次变动都是一个新对象值,这也是推荐 StringBuilder 原因。
正是由于C#中字符串的这种特性,所以字符串在多线程下是不会被修改的,只读的。它存在于 System Domain 域中 Managed Heap 中的一个Hashtable中。其中Key为string本身,Value为string对象的地址。
当程序域需要一个string的时候,CLR首先在这个 Hashtable 根据这个string 的 hash code 试着找对应的 Item。如果成功找到,则直接把对应的引用返回,否则就在 System Domain 对应的 Managed Heap 中创建该 string,并加入到 Hashtable 中,并把引用返回。所以说字符串的生命周期是基于整个进程的,也是跨 AppDomain。
4、Monitor(监视器)
Monitor 类提供了与 lock 类似的功能,不过与 lock 不同的是,它能更好的控制同步块,当调用了 Monitor 的 Enter(Object o) 方法时,会获取o的独占权,直到调用 Exit(Object o) 方法时,才会释放对o的独占权;可以多次调用 Enter(Object o) 方法,只需要调用同样次数的 Exit(Object o) 方法即可,Monitor 类同时提供了 TryEnter(Object o,[int]) 的一个重载方法,该方法尝试获取o对象的独占权,当获取独占权失败时,将返回false。
Monitor提供的方法很少就只有获取锁的方法Enter,TryEnter;释放锁的方法Wait,Exit;还有消息通知方法Pulse,PulseAll。
Monitor提供了三个静态方法 Monitor.Pulse(Object o),Monitor.PulseAll(Object o) 和 Monitor.Wait(Object o ) ,用来实现一种唤醒机制的同步。
Monitor.Enter(obj); //在指定对象上获取排他锁
Monitor.Wait(obj); //释放对象上的锁并阻止当前线程,直到它重新获取该锁
Monitor.Exit(obj); //释放指定对象上的排他锁
Monitor.Enter(obj);
Monitor.Pulse(obj); //通知等待队列中的线程锁定对象状态的更改
Monitor.Exit(obj);
Monitor.Enter(obj);
Monitor.PulseAll(obj);//通知所有的等待线程对象状态的更改
Monitor.Exit(obj);
但使用 lock 通常比直接使用 Monitor 更可取,一方面是因为 lock 更简洁,另一方面是因为 lock 确保了即使受保护的代码引发异常,也可以释放基础监视器。Monitor 类是通过在 finally 中调用 Exit 来实现的。实际上,lock 关键字是 Monitor 类用例的一个语法糖,Lock 在IL会生成 Monitor。
private staticreadonlyobjectobj =newobject();
lock(obj)
{
DoSomething();
}
//lock IL会编译成如下写法
boollockTaken=false;
Monitor.Enter(obj,reflockTaken);
try
{
DoSomething();
}
finally
{
if(lockTaken)
{
Monitor.Exit(obj);
}
}
5、Mutex(互斥体)
Mutex 是一种原始的同步方式,其只对一个线程授予对共享资源的独占访问。(一个同步基元,也可用于进程间同步)
请注意具名的互斥量是全局的操作系统对象!请务必正确关闭互斥量。最好是使用 using 代码块来包裹互斥量对象。
在使用上,Mutex 与上述的 Monitor 比较接近,不过 Mutex 不具备 Wait,Pulse,PulseAll 的功能,因此,我们不能使用 Mutex 实现类似的唤醒的功能。不过 Mutex 有一个比较大的特点,Mutex 是跨进程的,因此我们可以在同一台机器甚至远程的机器上的多个进程上使用同一个互斥体。尽管 Mutex 也可以实现进程内的线程同步,而且功能也更强大,但这种情况下,还是推荐使用 Monitor,因为 Mutex 类是系统内核对象封装了 win32 的一个内核结构来实现互斥,并且互斥操作需要请求中断来完成 ,所以它所需要的互操作转换更耗资源。
典型的使用Mutex同步需要完成三个步骤的操作:
1.打开或者创建一个 Mutex 实例;
2.调用 WaitOne 来请求互斥对象;
3.最后调用 ReleaseMutex 来释放互斥对象。
需要注意的是,WaitOne 和 ReleaseMutex 必须成对出现,否则会导致进程死锁的发生,这时系统(.Net2.0)框架会抛出AbandonedMutexException异常。Mutex 只能从获取互斥锁的这个线程上被释放;Mutex 有个好的特性是,如果程序结束时而互斥锁没通过 ReleaseMutex 首先被释放,CLR将自动地释放Mutex 。
staticvoidMain(string[] args)
{
conststringMutexName ="CSharpThreadingCookbook";
using(varm =newMutex(false, MutexName))
{
if(!m.WaitOne(TimeSpan.FromSeconds(5),false))
{
Console.WriteLine("Second instance is running!");
}
else
{
Console.WriteLine("Running!");
Console.ReadLine();
m.ReleaseMutex();
}
}
}
6、SemaphoreSlim(信号量)
SemaphoreSlim 类是 Semaphore 类的轻量级版本,该类限制了同时访问同一个资源的线程数量。
信号量就像一个夜总会:它有确切的容量,并被保镖控制。一旦满员,就没有人能再进入,其他人必须在外面排队。那么在里面离开一个人后,队头的人就可以进入。
SemaphoreSlim_semaphore =newSemaphoreSlim(4);
_semaphore.Wait(); //阻止当前线程,直至它可进入 SemaphoreSlim 为止
_semaphore.Release(); //退出 SemaphoreSlim 一次
SemaphoreSlim 使用了混合模式,其允许我们在等待时间很短的情况下无需使用上下文切换。然而,有一个叫做 Semaphore 的 SemaphoreSlim 类的老版本,该版本使用纯粹的内核时间(kernel-time)方式,一般没必要使用它,除非是非常重要的场景。我们可以创建一个具名的 semaphore,就像一个具名的 mutex 一样,从而在不同的程序中同步线程;mutex 对一个资源进行锁,semaphore 则是对多个资源进行加锁;semaphore 是由 windows 内核维持一个 int32 变量的线程计数器,线程每调用一次,计数器减一,释放后对应加一,超出的线程则排队等候。
Mutex、Semaphore 需要先把托管代码转成本地用户模式代码、再转换成本地内核代码。
当释放后需要重新转换成托管代码,性能会有一定的损耗,所以尽量在需要跨进程的场景再使用。
SemaphoreSlim 并不使用 Windows 内核信号量,而且不支持进程间同步。所以在跨进程同步的场景可以使用 Semaphore。
7、同步事件和等待句柄
用 lock 和 Monitor 可以很好地起到线程同步的作用,但它们无法实现线程之间传递事件。如果要实现线程同步的同时,线程之间还要有交互,就要用到同步事件。同步事件是有两个状态(终止和非终止)的对象,它可以用来激活和挂起线程。
//创建 AutoResetEvent 的实例,参数false表示初始状态为非终止(unsignaled ),如果是true的话,初始状态则为终止(signaled )。
AutoResetEvent 类用来从一个线程向另一个线程发送通知,它可以通知等待的线程有某事件发生。向 AutoResetEvent 构造方法传入false,定义了一个实例的初始状态为 unsignaled(非终止),这意味着任何线程调用这个对象的 WaitOne 方法将会被阻塞,直到我们调用了 Set 方法。如果事件初始状态为 true,那么AutoRestEvent 实例的状态为 signaled(终止),如果线程调用了 WaitOne 方法则会被立即处理,然后事件状态自动变为 unsignaled,所以需要再对该实例调用一次 Set 方法,以便让其他的线程对该实例调用 WaitOne 方法从而继续执行。
AutoResetEvent 类采用的是内核模式,所以等待的时间不能太长。ManualResetEventSlim 类更好,因为它使用的混合模式,可以在线程间以更灵活的方式传递信号。
AutoResetEvent 事件像一个旋转门,一次只允许一人通过。ManualResetEventSlim 的整个工作方式有点像人群通过大门。
ManualResetEventSlim 是 ManualResetEvent 的混合版本,一直保持大门敞开直到手动调用 Reset 方法(相当于关闭了大门),当调用 Set 方法时,相当于打开了大门从而允许准备好的线程接收信号并继续工作。
如果我们需要全局事件,则可以使用 EventWaitHandle 类,其是 AutoResetEvent 和 ManualResetEvent 类的基类。
可以调用 WaitOne、WaitAny 或 WaitAll 来使线程等待事件。它们之间的区别可以查看MSDN。当调用事件的 Set 方法时,事件将变为终止状态,等待的线程被唤醒。
一个AutoResetEvent象是一个"检票轮盘":插入一张通行证然后让一个 人通过。"auto"的意思就是这个"轮盘"自动关闭或者打开让某人通过。线程将在调用WaitOne后进行等待或者是阻塞,并且通过调用Set操作来插入线程。如果一堆线程调用了WaitOne操作,那么"轮盘"就会建立一个等待队列。一个通行证可以来自任意一个线程,换句话说任意一个线程都可以通过访问AutoResetEvent对象并调用Set来释放一个阻塞的线程。
如果在Set被调用的时候没有线程等待,那么句柄就会一直处于打开状态直到有线程调用了WaitOne操作。这种行为避免了竞争条件--当一个线程还没来得急释放而另一个线程就开始进入的情况。因此重复的调用Set操作一个"轮盘"哪怕是没有等待线程也不会一次性的让所有线程进入。
WaitOne操作接受一个超时参数-当发生等待超时的时候,这个方法会返回一个 false。当已有一个线程在等待的时候,WaitOne操作可以指定等待还是退出当前同步上下文。Reset操作提供了关闭"轮盘"的操作。 AutoResetEvent能够通过两个方法来创建: 1.调用构造函数 EventWaitHandle wh = new AutoResetEvent (false); 如果boolean值为true,那么句柄的Set操作将在创建后自动被调用 ;2. 通过基类EventWaitHandle方式 EventWaitHandle wh = new EventWaitHandle (false, EventResetMode.Auto); EventWaitHandle构造函数允许创建一个ManualResetEvent。人们应该通过调用Close来释放一个Wait Handle在它不再使用的时候。当在应用程序的生存期内Wait handle继续被使用,那么如果遗漏了Close这步,在应用程序关闭的时候也会被自动释放。
8、CountdownEvent
使用 CountdownEvent 信号类来等待直到一定数量的操作完成。针对需要等待多个异步操作完成的情形,使用该方式是非常便利的。
然而这有一个重大的缺点,如果调用 Signal 没达到指定的次数,那么 Wait 将一直等待,请确保使用 CountdownEvent 时,所有线程完成后都要调用 Signal 方法。
//表示在计数变为零时处于有信号状态的同步基元
staticCountdownEvent_count =newCountdownEvent(3);
staticvoidRun_CountdownEvent()
{
Task.Factory.StartNew(() =>
{
Thread.Sleep(2000);
Console.WriteLine("thread 1 complete");
//向 CountdownEvent 注册信号,同时减小 CountdownEvent.CurrentCount的值
_count.Signal();
});
Task.Factory.StartNew(() =>
{
Thread.Sleep(5000);
Console.WriteLine("thread 2 complete");
_count.Signal();
});
Task.Factory.StartNew(() =>
{
Thread.Sleep(3000);
Console.WriteLine("thread 3 complete");
_count.Signal();
});
Console.WriteLine("waiting tasks....");
//阻止当前线程,直到设置了CountdownEvent 为止
_count.Wait();
Console.WriteLine("all task completed");
}
//使用Task的 WaitAll 可以达到同样的效果
staticvoidRun_Task()
{
vart1 =Task.Factory.StartNew(() =>
{
Thread.Sleep(2000);
Console.WriteLine("thread 1 complete");
});
vart2 =Task.Factory.StartNew(() =>
{
Thread.Sleep(5000);
Console.WriteLine("thread 2 complete");
});
vart3 =Task.Factory.StartNew(() =>
{
Thread.Sleep(3000);
Console.WriteLine("thread 3 complete");
});
Console.WriteLine("waiting tasks....");
Task.WaitAll(t1, t2, t3);
Console.WriteLine("all task completed");
}
9、Barrier(屏障)
Barrier 类用于组织多个线程及时在某个时刻碰面,其提供了一个回调函数,所有参与者线程调用了 SignalAndWait 方法后该回调函数会被执行。这在多线程迭代运算中非常有用,可以在每个迭代结束前执行一些计算,当最后一个线程调用 SignalAndWait 方法时可以在迭代结束时进行交互。
//使多个任务能够采用并行方式依据某种算法在多个阶段中协同工作
staticBarrier_barrier =newBarrier(2,
b =>Console.WriteLine("End of phase {0}", b.CurrentPhaseNumber + 1));
//发出参与者已达到屏障并等待所有其他参与者也达到屏障
_barrier.SignalAndWait();
10、SpinWait SpinLock
11、MethodImplAttribute
如果临界区是跨越整个方法的,也就是说,整个方法内部的代码都需要上锁的话,使用MethodImplAttribute属性会更简单一些。这样就不用在方法内部加锁了,只需要在方法上面加上 [MethodImpl(MethodImplOptions.Synchronized)] 就可以了,MehthodImpl 和 MethodImplOptions 都在命名空间System.Runtime.CompilerServices 里面。但要注意这个属性会使整个方法加锁,直到方法返回,才释放锁。因此,使用上不太灵活。如果要提前释放锁,则应该使用 lock 或 Monitor 。
12、SynchronizationAttribute
当我们确定某个类的实例在同一时刻只能被一个线程访问时,我们可以直接将类标识成 Synchronization 的,这样,CLR会自动对这个类实施同步机制,实际上,这里面涉及到同步域的概念,当类按如下设计时,我们可以确保类的实例无法被多个线程同时访问。
1. 在类的声明中,添加 System.Runtime.Remoting.Contexts.SynchronizationAttribute 属性。
2. 继承至 System.ContextBoundObject
需要注意的是,要实现上述机制,类必须继承至System.ContextBoundObject,换句话说,类必须是上下文绑定的。
[System.Runtime.Remoting.Contexts.Synchronization]
publicclassSynchronizedClass: System.ContextBoundObject
{
}
常量含义
NOT_SUPPORTED相当于不使用同步特性
SUPPORTED如果从另一个同步对象被实例化,则合并已存在的同步环境,否则只剩下非同步
REQUIRED(默认)如果从另一个同步对象被实例化,则合并已存在的同步环境,否则创建一个新的同步环境
REQUIRES_NEW总是创建新的同步环境