欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

C#实现多线程的同步方法实例分析

程序员文章站 2023-12-11 18:20:28
本文主要描述在c#中线程同步的方法。线程的基本概念网上资料也很多就不再赘述了。直接接入 主题,在多线程开发的应用中,线程同步是不可避免的。在.net框架中,实现线程同步主要...

本文主要描述在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!"); 
  } 
}

如果你想在读的时候插入写操作请使用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="http://blog.csdn.net/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("waiting...");
  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("signaling worker...");
  starter.set(); 
  console.readline();
 }
 public static void go(object data, bool timedout) 
 {
  console.writeline("started " + data); // perform task... 
 }
}

对于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 to complete..."); 
  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); });

其他的方法可以使用异步委托。

希望本文所述对大家的c#程序设计有所帮助。

上一篇:

下一篇: