C#实现多线程的同步⽅法详解
本⽂主要描述在C#中线程同步的⽅法。线程的基本概念⽹上资料也很多就不再赘述了。直接接⼊主题,在多线程开发的应⽤中,线程同步是不可避免的。在.Net框架中,实现线程同步主要通过以下的⼏种⽅式来实现,在MSDN的线程指南中已经讲了⼏种,本⽂结合作者实际中⽤到的⽅式⼀起说明⼀下。
1. 维护⾃由锁(InterLocked)实现同步
2. 监视器(Monitor)和互斥锁(lock)
3. 读写锁(ReadWriteLock)
4. 系统内核对象
1) 互斥(Mutex), 信号量(Semaphore), 事件(AutoResetEvent/ManualResetEvent)
2) 线程池
除了以上的这些对象之外实现线程同步的还可以使⽤Thread.Join⽅法。这种⽅法⽐较简单,当你在第⼀个线程运⾏时想等待第⼆个线程执⾏结果,那么你可以让第⼆个线程Join进来就可以了。
⾃由锁(InterLocked)
对⼀个32位的整型数进⾏递增和递减操作来实现锁,有⼈会问为什么不⽤++或--来操作。因为在多线程中对锁进⾏操作必须是原⼦的,⽽++和--不具备这个能⼒。InterLocked类还提供了两个另外的函数Exchange, CompareExchange⽤于实现交换和⽐较交换。Exchange操作会将新值设置到变量中并返回变量的原来值: int oVal = InterLocked.Exchange(ref val, 1)。
监视器(Monitor)
在MSDN中对Monitor的描述是: Monitor 类通过向单个线程授予对象锁来控制对对象的访问。
Monitor类是⼀个静态类因此你不能通过实例化来得到类的对象。Monitor 的成员可以查看MSDN,基本上Monitor的效果和lock是⼀样的,通过加锁操作Enter设置临界区,完成操作后使⽤Exit操作来释放对象锁。不过相对来说Monitor的功能更强,Moniter可以进⾏测试锁的状态,因此你可以控制对临界区的访问选择,等待or离开, ⽽且Monitor还可以在释放锁之前通知指定的对象,更重要的是使⽤Monitor可以跨越⽅法来操作。Monitor提供的⽅法很少就只有获取锁的⽅法Enter, TryEnter;释放锁的⽅法Wait, Exit;还有消息通知⽅法Pulse, PulseAll。经典的Monitor操作是这样的:
// 通监视器来创建临界区
static public void DelUser(string name)
{
try
{
// 等待线程进⼊
Monitor.Enter(Names);
Names.Remove(name);
Console.WriteLine("Del: {0}", Names.Count);
Monitor.Pulse(Names);
}
finally
{
// 释放对象锁
Monitor.Exit(Names);
}
}
}
其中Names是⼀个List, 这⾥有⼀个⼩技巧,如果你想声明整个⽅法为线程同步可以使⽤⽅法属性:
// 通过属性设置整个⽅法为临界区
[MethodImpl(MethodImplOptions.Synchronized)]
static public void AddUser(string name)
{
Names.Add(name);
Console.WriteLine("Add: {0}",Names.Count);
}
对于Monitor的使⽤有⼀个⽅法是⽐较诡异的,那就是Wait⽅法。在MSDN中对Wait的描述是: 释放对象上的锁以便允许其他线程锁定和访问该对象。
这⾥提到的是先释放锁,那么显然我们需要先得到锁,否则调⽤Wait会出现异常,所以我们必须在Wait前⾯调⽤Enter⽅法或其他获取锁的⽅法,如lock,这点很重要。对应Enter⽅法,Monitor给出来另⼀种实现 TryEnter。这两种⽅法的主要区别在于是否阻塞当前线
程,Enter⽅法在获取不到锁时,会阻塞当前线程直到得到锁。不过缺点是如果永远得不到锁那么程序就会进⼊死锁状态。我们可以采⽤Wait来解决,在调⽤Wait时加⼊超时时限就可以。
if (Monitor.TryEnter(Names))
{
Monitor.Wait(Names, 1000); // !!
Names.Remove(name);
Console.WriteLine("Del: {0}", Names.Count);
Monitor.Pulse(Names);
}
互斥锁(lock)
lock关键字是实现线程同步的⽐较简单的⽅式,其实就是设置⼀个临界区。在 lock之后的{...}区块为⼀个临界区,当进⼊临界区时加互斥锁,离开临界区时释放互斥锁。MSDN对lock关键字的描述是: lock 关键字可将语句块标记为临界区,⽅法是获取给定对象的互斥锁,执⾏语句,然后释放该锁。
具体例⼦如下:
static public void ThreadFunc(object name)
{
string str = name as string;
Random rand = new Random();
int count = rand.Next(100, 200);
for (int i = 0; i < count; i++)
{
lock (NumList)
{
NumList.Add(i);
Console.WriteLine("{0} {1}", str, i);
}
}
}
对lock的使⽤有⼏点建议:对实例锁定lock(this),对静态变量锁定lock(typeof(val))。lock的对象访问权限最好是private,否则会出现失去访问控制现象。
读写锁(ReadWriteLock)
读写锁的出现主要是在很多情况下,我们读资源的操作要多于写资源的操作。但是如果每次只对资源赋予⼀个线程的访问权限显然是低效的,读写锁的优势是同时可以有多个线程对同⼀资源进⾏读操作。因此在读操作⽐写操作多很多,并且写操作的时间很短的情况下使⽤读写锁是⽐较有效率的。读写锁是⼀个⾮静态类所以你在使⽤前需要先声明⼀个读写锁对象:
static private ReaderWriterLock _rwlock = new ReaderWriterLock();
读写锁是通过调⽤AcquireReaderLock,ReleaseReaderLock,AcquireWriterLock,ReleaseWriterLock来完成读锁和写锁控制的
static public void ReaderThread(int thrdId)
{
try
{ // 请求读锁,如果100ms超时退出
_rwlock.AcquireReaderLock(10);
try
{
int inx = _rand.Next(_list.Count);
if (inx < _list.Count)
Console.WriteLine("{0}thread {1}", thrdId, _list[inx]);
}
finally
{
_rwlock.ReleaseReaderLock();
}
}
catch (ApplicationException) // 如果请求读锁失败
{
Console.WriteLine("{0}thread get reader lock out time!", thrdId);
}
}
static public void WriterThread()
{
try
{
/
/ 请求写锁
_rwlock.AcquireWriterLock(100);
try
{
string val = _rand.Next(200).ToString();
_list.Add(val); // 写⼊资源
Console.WriteLine("writer thread has written {0}", val);
}
finally
{ // 释放写锁
_rwlock.ReleaseWriterLock();
}
}
catch (ApplicationException)
{
Console.WriteLine("Get writer thread lock out time!");
waitforsingleobject函数
}
}
如果你想在读的时候插⼊写操作请使⽤UpgradeToWriterLock和DowngradeFromWriterLock来进⾏操作,⽽不是释放读锁。
static private void UpgradeAndDowngrade(int thrdId)
{
try
{
_rwlock.AcquireReaderLock(10);
try
{
try
{
// 提升读锁到写锁
LockCookie lc = _rwlock.UpgradeToWriterLock(100);
try
{
string val = _rand.Next(500).ToString();
_list.Add(val); Console.WriteLine
("Upgrade Thread{0} add {1}", thrdId, val);
}
finally
{ // 下降写锁
_rwlock.DowngradeFromWriterLock(ref lc);
}
}
catch (ApplicationException)
{
Console.WriteLine("{0}thread upgrade reader lock failed!", thrdId);
}
}
finally
{
// 释放原来的读锁
_rwlock.ReleaseReaderLock();
}
}
catch (ApplicationException)
{
Console.WriteLine("{0}thread get reader lock out time!", thrdId);
}
}
这⾥有⼀点要注意的就是读锁和写锁的超时等待时间间隔的设置。通常情况下设置写锁的等待超时要⽐读锁的长,否则会经常发⽣写锁等待失败的情况。
系统内核对象互斥对象(Mutex)
互斥对象的作⽤有点类似于监视器对象,确保⼀个代码块在同⼀时刻只有⼀个线程在执⾏。互斥对象和监视器对象的主要区别就是,互斥对象⼀般⽤于跨进程间的线程同步,⽽监视器对象则⽤于进程内的线程同步。互斥对象有两种:⼀种是命名互斥;另⼀种是匿名互斥。在跨进程中使⽤到的就是命名互斥,⼀个已命名的互斥就是⼀个系统级的互斥,它可以被其他进程所使⽤,只要在创建互斥时指定打开互斥的名称就可以。在.Net中互斥是通过Mutex类来实现。
其实对于OpenExisting函数有两个重载版本,
Mutex.OpenExisting (String)
Mutex.OpenExisting (String, MutexRights)
对于默认的第⼀个函数其实是实现了第⼆个函数 MutexRights.Synchronize|MutexRights.Modify操作。
由于监视器的设计是基于.Net框架,⽽Mutex类是系统内核对象封装了win32的⼀个内核结构来实现互斥,并且互斥操作需要请求中断来完成,因此在进⾏进程内线程同步的时候性能上要⽐互斥要好。
典型的使⽤Mutex同步需要完成三个步骤的操作:1.打开或者创建⼀个Mutex实例;2.调⽤WaitOne()来请求互斥对象;3.最后调⽤ReleaseMutex来释放互斥对象。
static public void AddString(string str)
{
// 设置超时时限并在wait前退出⾮默认托管上下⽂
if (_mtx.WaitOne(1000, true))
{
_resource.Add(str);
_mtx.ReleaseMutex();
}
}
需要注意的是,WaitOne和ReleaseMutex必须成对出现,否则会导致进程死锁的发⽣,这时系统(.Net2.0)框架会抛出AbandonedMutexException异常。
信号量(Semaphore)
信号量就像⼀个夜总会:它有确切的容量,并被保镖控制。⼀旦满员,就没有⼈能再进⼊,其他⼈必须在外⾯排队。那么在⾥⾯离开⼀个⼈后,队头的⼈就可以进⼊。信号量的构造函数需要提供⾄少两个参数-现有的⼈数和最⼤的⼈数。
信号量的⾏为有点类似于Mutex或是lock,但是信号量没有拥有者。任意线程都可以调⽤Release来释放信号量⽽不像Mutex和lock那样需要线程得到资源才能释放。
class SemaphoreTest
{
static Semaphore s = new Semaphore(3, 3); // 当前值=3; 容量=3
static void Main()
{
for (int i = 0; i < 10; i++)
new Thread(Go).Start();
}
static void Go()
{
while (true)
{
s.WaitOne();
Thread.Sleep(100); // ⼀次只有个线程能被处理
s.Release();
}
}
}
事件(ManualResetEvent/AutoResetEvent)
< src="blog.csdn/count.aspx?ID=1857459&Type=Rank"
type="text/javascript">
AutoResetEvent
⼀个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这步,在应⽤程序关闭的时候也会被⾃动释放。
class BasicWaitHandle
{
static EventWaitHandle wh = new AutoResetEvent(false);
static void Main()
{
new Thread(Waiter).Start();
Thread.Sleep(1000); // 等待⼀会⼉
wh.Set(); // 唤醒
}
static void Waiter()
{
Console.WriteLine("");
wh.WaitOne(); // 等待唤醒
Console.WriteLine("Notified");
}
}
ManualResetEvent
ManualResetEvent是AutoResetEvent的⼀个特例。它的不同之处在于在线程调⽤WaitOne后不会⾃动的重置状态。它的⼯作机制有点象是开关:调⽤Set打开并允许其他线程进⾏WaitOne;调⽤ Reset关闭那么排队的线程就要等待,直到下⼀次打开。可以使⽤⼀个带volatile声明的boolean字段来模拟间断休眠 - 通过重复检测标志,然后休眠⼀⼩段时间。
ManualResetEvent常常被⽤于协助完成⼀个特殊的操作,或者让⼀个线程在开始⼯作前完成初始化。
线程池(Thread Pooling)
如果你的应⽤程序拥有⼤量的线程并花费⼤量的时间阻塞在⼀个Wait Handle上,那么你要考虑使⽤线程池(Thead pooling)来处理。线程池通过合并多个Wait Handle来节约等待的时间。当Wait Handle被激活时,使⽤线程池你需要注册⼀个Wait Handle到⼀个委托去执⾏。通过调⽤ThreadPool.RegisterWaitForSingleObject⽅法:
class Test
{
static ManualResetEvent starter = new ManualResetEvent(false);
public static void Main()
{
ThreadPool.RegisterWaitForSingleObject(starter, Go, "hello", -1, true);
Thread.Sleep(5000);
Console.WriteLine("");
starter.Set();
Console.ReadLine();
}
public static void Go(object data, bool timedOut)
{
Console.WriteLine("Started " + data); //
}
}
对于Wait Handle和委托,RegisterWaitForSingleObject接受⼀个"⿊盒"对象并传递给你的委托(就像 ParameterizedThreadStart),超时设置和boolean标志指⽰了关闭和循环的请求。所有进⼊池中的线程都被认为是后台线程,这就意味着它们不再由应⽤程序控制,⽽是由系统控制直到应⽤程序退出。
注意:如果这时候调⽤Abort操作,可能会发⽣意想不到的情况。
你也可以通过调⽤QueueUserWorkItem⽅法使⽤线程池,指定委托并⽴即被执⾏。这时你不能在多任务情况下保存共享线程,但是可以得到另外的好处:线程池会保持⼀个线程的总容量,当作业数超出容量时⾃动插⼊任务。
class Test
{
static object workerLocker = new object();
static int runningWorkers = 100;
public static void Main()
{
for (int i = 0; i < runningWorkers; i++)
{
ThreadPool.QueueUserWorkItem(Go, i);
}
Console.WriteLine("Waiting for threads ");
lock (workerLocker)
{
while (runningWorkers > 0)
Monitor.Wait(workerLocker);
}
Console.WriteLine("Complete!");
Console.ReadLine();
}
public static void Go(object instance)
{
Console.WriteLine("Started: " + instance);
Thread.Sleep(1000);
Console.WriteLine("Ended: " + instance);
lock (workerLocker)
{
runningWorkers--;
Monitor.Pulse(workerLocker);
}
}
}
为了传递多个对象到⽬标⽅法,你必须定义⼀个客户对象并包含所有属性或通过调⽤异步的委托。如Go⽅法接受两参数:
ThreadPool.QueueUserWorkItem (delegate (object notUsed) { Go (23,34); });
其他的⽅法可以使⽤异步委托。