多线程-线程同步

前言

关于.net中线程的基本应用可以参考《C# 3.0核心技术》中多线程的第一部分,但线程同步方面的最好参考仍然是Jeffrey的《Windows核心编程》,其中不仅有详细的介绍,还有经典范例代码。《核心》是基于Win32API的,因此在学习.net线程同步之前,有必要对Win32API与.net的线程同步作一个概述。

1、首先要明白什么是原子操作,例如g++拆解,这部分的知识参考《核心》P173

2、了解用户方式中的线程中同步、线程与内核对象的同步这两个概念,这实际上也是《核心》中关于线程同步三章的前两章名称

关键代码段CriticalSection属于用户方式对象(这种说法并不是百分之百的正确,当一个线程因争用关键代码段而处于等待状态时,一个内核对象会被创建,线程也会由用户方式转入内核方式)。在.net中关键代码段对应Monitor类,相对Win32API,C#的Monitor增加了Wait和Paluse机制。

ReaderWriterLock是与Monitor类似的用户方式同步对象,但它是.net特有的,没有API原型,它的超时捕获也比较特殊,是通过捕获异常来完成的

内核对象,在Win32API中,进程、线程、文件句柄以及事件、信号量、可等待定时器,互斥对象等专用线程同步对象等均属于内核对象,只要是内核对象,都可以用于同步目的,它们被交给WaitForSingleObject、WatiForMultipleObjects、SingleObjectAndWait等API函数作同步操作,内核对象总是处于两种状态:有信号和无信号,此外,内核对象可设置名称以跨进程同部。在.net中Mutex类对应互斥对象、Semaphore类对应信号量、EventWaitHandle类(一般使用它的子类AutoResetEvent和ManualResetEvent)对应事件,Timer类对应等待计时器,这些类的实例成员方法WaitOne对应API WaitForSingleObject,而类静态成员方法WaitAll和WaitAny对应WaitForMultipleObjects(该API接收一个bool参数以指示等待直至内核对象数组中的任一个变为有信号还是所有变为有信号),类静态成员方法SingleAndWait对应SingleObjectAndWait。

4、等待的超时设置除了一般意义外还具有对线程结束标志作出及时响应的作用(核心P221)

 

static void TProc()
{
    int i = 0;
    while (isRun)
    {
        aGoEvents[0].WaitOne(3000, false)
        Console.WriteLine(i.ToString());
    }
}

 

isRun是一个bool型的线程结束标志,如果WaitOne没有设置超时时间,那么线程将无法对结束标志作出有效的响应

5、公平问题,当等待的线程超该唤醒的线程数时,系统会“公平”的选择线程,“公平”意味着算法是未知的,并非先进先出或后进先出这样固定的算法(至少内核方式的同步是这样的)。

 

volatile 关键字

先看一下以下代码:

static bool bStop;
static void Main(string[] args)
{
    Thread t = new Thread(TProc);
    t.Start();
    Thread.Sleep(3000);
    bStop = true;
    Console.WriteLine("bStop is true");
    t.Join();
    Console.WriteLine("thread is over");
    Console.ReadLine();
}

static void TProc()
{
    while (!bStop)
    {
        Console.WriteLine("is running...");
        Thread.Sleep(1000);
    }
}

这是一种常用的由主线程控制工作线程结束的方式,但这里存在一个问题,即在主线程中把bStop设置成true后,工作线程仍然可能会输出“is running”,原因就是bStop可能被缓冲到CPU寄存器中来提升访问性能,由于工作线程中对bStop的循环访问,使得编译器优化了这段代码,将bStop放入寄存器,然后再对改寄存器中的值进行循环测试,而主线程对bStop的修改在内存中进行,并没有及时反应到寄存器中。

volatile关键字告诉编译器,不要作这种优化,或者说它表示这个字段是易变的,这样任何时侯对该字段的访问都能得到最新的值

volatile 关键字可应用于以下类型(其它的类型不缓存在CPU寄存器上):

  • 引用类型。

  • 指针类型(在不安全的上下文中)。

  • 整型,如 sbyte、byte、short、ushort、int、uint、char、float 和 bool。

  • 具有整数基类型的枚举类型。

  • 已知为引用类型的泛型类型参数。

  • IntPtr 和 UIntPtr。

所涉及的类型必须是类或结构的字段。不能将局部变量声明为volatile

 

Interlocker类

看以下代码:

static int g;
static void Main(string[] args)
{
    Thread t1 = new Thread(TProc);
    Thread t2 = new Thread(TProc);
    t1.Start();
    t2.Start();
    t1.Join();
    t2.Join();
    Console.WriteLine("{0}", g);
    Console.ReadLine();
}

static void TProc()
{
    for (int i = 0; i < 10000; i++)
    {
        g++;
    }
}

当代码运行完毕后g值是20000吗,这不确定,因为在大多数计算机上(跟32或64位系统及操作类型有关),增加变量操作不是一个原子操作,需要执行下列步骤:

  1. 将实例变量中的值加载到寄存器中。

  2. 增加或减少该值。

  3. 在实例变量中存储该值。

在多线程情况下,可能会出现这样的情况,即一个线程在执行完前两个步骤后被抢先。然后由另一个线程执行所有三个步骤。当第一个线程重新开始执行时,它改写实例变量中的值,造成第二个线程执行增减操作的结果丢失。

Interlocker类为多个线程共享的变量提供原子操作,它是一个静态类,主要的成员方法如下:

  • Add:以原子操作的形式,添加两个整数并用两者的和替换第一个整数。
  • Exchange:以原子操作的形式将变量设置为指定的值,并返回先前值
  • CompareExchange:比较两个值是否相等,如果相等,则替换其中一个值
  • Equals:确定两个Object 实例是否相等
  • Increment:以原子操作的形式递增指定变量的值并存储结果
  • Decrement:以原子操作的形式递减指定变量的值并存储结果
  • Read:返回一个以原子操作形式加载的 64 位值

Interlocker的另一个应用是模拟锁或者称为循环锁,以下是一个非阻止(拥塞)锁的范例代码:

 

 

static int locker;
static void Main(string[] args)
{
    Thread t = null;
    for (int i = 0; i < 3; i++)
    {
        t = new Thread(TProc);
        t.Name = "thread" + i.ToString();
        t.Start();
    }
    Console.ReadLine();
}

static void TProc()
{
    for (int i = 0; i < 5; i++)
    {
        if (Interlocked.Exchange(ref locker, 1) == 0)
        {
            Console.WriteLine(Thread.CurrentThread.Name + ":enter code");
            Thread.Sleep(500);
            Console.WriteLine(Thread.CurrentThread.Name + ":exit code");
            Interlocked.Exchange(ref locker, 0);
        }
        else
        {
            Console.WriteLine(Thread.CurrentThread.Name + ":is locked");
        }
        Thread.Sleep(500);
    }
}

 

 

 

Monitor 类

Monitor又称为监视器,通过向单个线程授予对象锁来控制对对象的访问。对象锁提供限制访问代码段(通常称为临界区)的能力。当一个线程拥有对象的锁时,其他任何线程都不能获取该锁,从而确保不会允许其他任何线程访问正在由锁的所有者执行的应用程序代码段,即保证了任一时刻,重要的代码段(临界区)只允许一个线程访问。

Monitor属于用户方式同步对象,当然,这不是绝对的,当一个线程因争用关键代码段而处于等待状态时,一个内核对象会被创建,线程也会由用户方式转入内核方式,此外,在API中,可以设置在EenterCriticalSection函数被调用前,是否使用循环锁及循环锁的循环次数,.net的Monitor类虽然相关设置,但应该也内置了循环锁的调用。

Monitor是一个静态类,它需要与某个object对象相关联,与之关联的object对象也被称为同步对象或锁定对象,锁定对象的选择请参考lock一节

Monitor为每个锁定对象维护以下信息:

  • 对当前持有锁的线程的引用。
  • 等待队列的引用,它包含正在等待锁定对象状态变化通知的线程。
  • 就绪队列的引用,它包含准备获取锁的线程。

Enter方法用于获取对象锁即进入临界区,如果其他线程已对该对象执行了 Enter,但尚未执行对应的 Exit,则当前线程将阻止,直到对方线程释放该对象。Exit用于释放对象上的锁,即退出临界区。示例代码如下:

static object locker = new object();
static void Main(string[] args)
{
    Thread t = null;
    for (int i = 0; i < 3; i++)
    {
        t = new Thread(TProc);
        t.Name = i.ToString();
        t.Start();
    }
    Console.ReadLine();
    
}

static void TProc()
{
    Monitor.Enter(locker);
    try
    {
        Console.WriteLine("thread:" + Thread.CurrentThread.Name + " enter");
        Thread.Sleep(0);
        Console.WriteLine("thread:" + Thread.CurrentThread.Name + " exit");
        
    }
    finally
    {
        Monitor.Exit(locker);
    }
}

注意,Enter调用具有累加性,即同一线程对同一锁定对象多次调用 Enter 是合法的,但必须调用相同数目的Exit以正确释放对象上的锁,同时,对同一锁定对象的Enter和Exit的调用必须配对出现,即Exit能正确执行的前提是该锁定对象已被当前线程拥有,否则将触发异常。此外上述代码使用了try…finally结构,以确保Monitor的正确释放。

Interrupt 可以中断正在等待的线程,这将在线程中引发 ThreadInterruptedException异常。

Enter方法不具备超时设置的能力,TryEnter用于进入临界区,同时可以设置一个等待超时时间,在超时时间范围内获取对象锁则返回true,否则返回false,如过超时时间为0,则TryEnter尝试获取锁并立即返回,示例代码如下:

if (Monitor.TryEnter(locker, 3000))
{
    try
    {
        Console.WriteLine("thread:" + Thread.CurrentThread.Name + " enter");
        Thread.Sleep(0);
        Console.WriteLine("thread:" + Thread.CurrentThread.Name + " exit");
    }
    finally
    {
        Monitor.Exit(locker);
    }
}
else
{
    Console.WriteLine("thread:" + Thread.CurrentThread.Name + " time out");
}

 

Wait和Pluse

Wait和Pluse是.net独有,在Win32 API中并没有这个功能

Wait和Pluse机制用于线程间的交互,Wait和Pulse的目标是提供一种简单的信号模式:Wait阻止直到收到其它线程的通知;而Pulse用于触发这个通知,使用这种轻量级的信号模式就可以实现AutoResetEvent, ManualResetEvent和Semaphore的功能。

为了信号系统正常工作,Wait必须在Pulse之前执行。如果 Pulse先执行了,它的pulse就会丢失,之后的wait必须等待一个新的pulse,否则它将永远被阻止。这和AutoResetEvent不同,AutoResetEvent的Set方法有一种“锁存”效果,当它先于WaitOne调用时也同样有效。

在调用Wait或Pulse的时候,你必须定义个同步对象 ,两个线程使用相同的对象,它们才能彼此发信号。在调用Wait或Pulse之前同步对象必须被lock。

Wait

为了完成工作,在等待的时候Monitor.Wait临时的释放或切换当前的锁,所以另一个线程(比如执行Pulse的这个)可以获得它。Wait方法可以被想象扩充为下面的伪代码。

 

Monitor.Exit (x);             // 释放锁
//等待到x发的信号后
Monitor.Enter (x);            // 收回锁

 

因此一个Wait阻止两次:一次是等待信号,另一次是重新获取排它锁。这也意味着 Pulse本身不同完全解锁:只有当用Pulse发信号的线程退出它的锁语句的时候 ,等待的线程实际上才能继续运行。

Wait的锁切换对 嵌套锁也是有效的,如果Wait在两个嵌套的lock语句中被调用:

 

lock (x)  
    lock (x)    
        Monitor.Wait (x);

 

那么Wait逻辑上展开如下:

 

Monitor.Exit (x); Monitor.Exit (x);    // Exit两次来释放锁
//wait for a pulse on x
Monitor.Enter (x); Monitor.Enter (x);  //还原之前的排它锁

 

Wait提供了超时设置功能,它返回一个bool值以说明是否超时

Pluse

多于一个线程Enter相同的对象,那么就在同步对象上形成了“就绪队列”,而当多于一个线程同时Wait相同的对象,就在同步对象上形成了“等待队列”,每个Pluse释放等待队列头上的单个线程,使它们可以进入就绪队列重新得到锁,要注意,Pluse只是使等待队列中的线程进入就绪队列,如果Pluse的调用线程没有退出它的锁语句(Exit或Wait),那么进入就绪队列仍然没有办法拥有锁。

Pluse是一个单向通讯,以异步方式进行,不返回任何值来指示这个脉冲是否被接收到了,如果发出脉冲时没有线程在等待队列中,则脉冲会被忽略

提供了PluseAll方法,用于在一刹那之间释放整个等待队列里的线程。

示例代码如下:

static object locker = new object();
static bool isHave = false;
static void Main(string[] args)
{
    new Thread(Produce).Start();
    new Thread(Consume).Start();
}

static void Produce()
{
    lock (locker)
    {
        while (true)
        {
            //如果已有产品,则等待消费完成
            if (isHave)
                Monitor.Wait(locker);
            Console.WriteLine("生产一个");
            Thread.Sleep(2000);
            isHave = true;
            Monitor.Pulse(locker);
        }
    }
}

static void Consume()
{
    lock(locker)
    {
        while(true)
        {
            //如果没有产品,则等待生产完成
            if(!isHave)
                Monitor.Wait(locker);
            Console.WriteLine("消费一个");
            Thread.Sleep(2000);
            isHave = false;
            Monitor.Pulse(locker);
        }
    }
}

这里使用了一个标志isHave,Wait和Pluse通常都需要与一个自定义确认标志配合使用,可以试试不用isHave,不过好像很难

注意lock和while的包含顺序,看看以下代码:

 

static void Produce()
{
    while(true)
    {
        lock(locker)
        {
            if(isHave)
                Monitor.Wait(locker);
            Console.WriteLine("生产一个");
            Thread.Sleep(2000);
            isHave = true;
            Monitor.Pulse(locker);
        }
    }
}

static void Consume()
{
    while(true)
    {
        lock(locker)
        {
            if (!isHave)
                Monitor.Wait(locker);
            Console.WriteLine("消费一个");
            Thread.Sleep(2000);
            isHave = false;
            Monitor.Pulse(locker);
        }
    }
}

 

上述代码也能达到同样的效果,虽然我也不太明白两段代码到底有什么区别,但MSDN中Pluse方法里的例子是lock包住while,所以还是推荐用第一种代码结构。

更多Wait和Pluse知识请参考《C#中的多线程》,总体感觉是Wait和Pluse机制不太容易使用。

 

lock 语句

lock语句可以作为Monitor类的一个替代用法,用lock 关键字通常比直接使用 Monitor 类更可取,一方面是因为 lock 更简洁,另一方面是因为 lock 确保了即使受保护的代码引发异常,也可以释放基础监视器。这是通过 finally 关键字来实现的,无论是否引发异常它都执行关联的代码块。实际上lock与Monitor的关系与using和try的关系是类似的

 

//代码段一
lock(locker)
{
  //do something
}

//代码段二
Monitor.Enter(locker);
try
{
    // do something
}
finally
{
    Monitor.Exit(locker);
}

 

以上两段代码是等效的,或者说代码段一会被编译器编译成代码段二的形式,通过IL Dasm可以看到这一点

锁定对象(适用用Monitor和lock)

用于锁定的对象必须是引用类型而不是值类型,将值类型变量传递给Enter时,它被装箱为一个对象。如果再次将相同的变量传递给Enter,则它被装箱成另一个单独对象,因而线程将不会阻止,Monitor本应保护的代码未受保存,这也就达不到同步的目的了。最重要的是,将变量传递给Exit时,也将常见另一个单独的对象。因为传递给Exit的对象和传递给Enter的对象不同,Monitor将引发SynchronizationLockException异常。

通常,应避免锁定 public 类型,否则实例将超出代码的控制范围。常见的结构 lock (this)、lock (typeof (MyType)) 和 lock ("myLock") 违反此准则:

  • lock (this) 问题:如果该实例可以被公开访问,则 lock(this) 可能会有问题,因为不受控制的代码也可能会锁定该对象。这可能导致死锁,即两个或更多个线程等待释放同一对象。
  • lock (typeof (MyType)) 问题:基于相同的原因,如果MyType可以被公共访问,锁定公共数据类型也可能导致问题。
  • lock ("myLock") 问题:锁定字符串尤其危险,因为字符串被公共语言运行库 (CLR)“暂留”。这意味着整个程序中任何给定字符串都只有一个实例,就是这同一个对象表示了所有运行的应用程序域的所有线程中的该文本。因此,只要在应用程序进程中的任何位置处具有相同内容的字符串上放置了锁,就将锁定应用程序中该字符串的所有实例。

因此,最好锁定不会被暂留的私有或受保护成员,最佳做法是定义 private 对象来锁定, 或 private shared 对象变量来保护所有实例所共有的数据。

同步对象可以兼对象和保护两种作用。比如下面List

 

class ThreadSafe
{
    List<string> list = new List<string>();
    void Test()
    {
        lock (list)
        {
            list.Add("Item 1");
            // do something
        }
    }
}

 

某些类提供专门用于锁定的成员。例如,Array 类型提供 SyncRoot。许多集合类型也提供 SyncRoot

更一般的作法是是创建一个专门的对象用于锁定,因为它可以精确控制锁的范围和粒度:

 

 

class ThreadSafe
{
    //创建一个锁定对象
    static object locker = new object();
    void Test()
    {
        lock (locker)
        {
            // do something
        }
    }
}

 

 

 

Mutex 类

Mutex也称为互斥体,用于确保线程拥有对单个资源的互斥访问权,Mutex的行为特性与Monitor相同,但在API层面,互斥对象是内核对象,而Monitor则属于用户方式对象,并且.net中Mutex类是Win32封装的,所以它所需要的互操作转换更耗资源这些意味着Mutex的速度要比Monitor慢得多,因为Mutex是内核对象,这也意味着它可以使不同进程中的多个线程能够访问同一个互斥对象,也就是说命名的Mutex可以用于进程同步,这是它相对Monitor的一个优势。

像所有内核对象一样,互斥体有两种类型:局部互斥体已命名的系统互斥体。如果使用接受名称的构造函数创建 Mutex 对象,则该对象与具有该名称的操作系统对象关联。已命名的系统互斥体在整个操作系统中都可见,可用于同步进程活动。您可以创建多个 Mutex 对象来表示同一个已命名的系统互斥体,也可以使用 OpenExisting 方法打开现有的已命名系统互斥体

Mutex有一个线程所有权的概念(与Monitor类似),即Mutex对象内部除内核对象都拥有的使用计数器外还包含一个线程ID和一个递归计数器,如果ID为0,则Mutex对象不被任何对象所拥有,这时它是有信号的,如果ID是非0数字,那么一个线程就拥有互斥对象,这时它是无信号的,在构造Mutex对象时可以指定initiallyOwned参数为True或False,以指示调用线程是否应拥有互斥体的初始所属权

具体细节如下:

调用一个等待方法如WaitOne,方法在内部检查线程的ID,以了解它是否为0,如果ID为0,那么该线程ID被设置为调用线程ID,递归计数器被设置为1,同时调用线程保持可调度状态,如果非0且不等于调用线程ID,那么调用线程便进入等待状态。系统将记住这个情况,并在互斥对象的ID重新设置为0时,将线程ID设置为等待线程的ID,并将递归计数器设置为1,并且允许等待线程再次成为可调度线程,如果线程ID与调用线程的ID相同,即使互斥对象此时是无信号状态(线程ID不为0),系统也允许该线程保持可调度状态(这也是Mutex线程所有权的一个重要体现,其它内核对象不具备这种特性),这时互斥对象的递归计数递增1。与所有情况一样,对互斥对象进行的检查和修改都是以原子操作方式进行的。

在ReleaseMutex方面,该方法将检查调用线程ID是否与互斥对象中的线程ID是否匹配,如果匹配,则递归计数器递减(与Monitor相同,多少个WaitOne调用就应该有多少个ReleaseMutex),当递归计数器到达0时,线程ID也被设置为0,同时该对象变为有信号状态,如果不匹配则什么也不作。如果在释放互斥对象之前,拥有互斥对象的线程终止运行,那么系统自动把该互斥对象视为已放弃。

示例代码如下:

 

static Mutex mut = new Mutex(true);

static void Main(string[] args)
{
    Thread t = new Thread(TProc);
    t.Start();
    Console.WriteLine("Main wait 4s");
    Thread.Sleep(4000);
    Console.WriteLine("go");
    mut.ReleaseMutex();
    t.Join();
    mut.Close();
    Console.ReadLine();
}

static void TProc()
{
    for (int i = 0; i < 3; i++)
    {
        if(mut.WaitOne(3000, false))
        {
            try
            {
                Thread.Sleep(3000);
                Console.WriteLine(i.ToString());
            }
            finally
            {
                mut.ReleaseMutex();
            }
        }
        else
        {
           Console.WriteLine("time out");
        }
    }
}

 

注意Close的调用,内核对象使用完毕后都需要调用显示的Close,其次是WaitOne的返回,与ReaderWriterLock的通过异常来指示等待超时不同,WaitOne通过返回一个bool值以说明收到信号还是等待超时,WaitOne有一个AbandonedMutexException异常,拥有互斥对象的线程在释放对象之前终止运行了,则WaitOne抛出这个异常,核心中的建议是不处理这个异常(P213末尾),因为很少有这种情况发生。

 

Semaphore 类

Semaphore也称为信号量,与Mutex一样属于内核方式的同步对象,同样的,速度上相对用户方式的同步对象慢、命名对象可跨进程同步、存在局部信号量和已命名的系统信号量两种类型

Semaphore对象内部有一个最大资源数(也称为最大并发入口数或最大请求数),和一个当前资源数(也称为保留入口数或当前可用请求数),最大资源数用于标识信号量能控制的资源的最大数量,而当前资源数则用于标识当前可以使用的资源数量,它们是一个带符号的32位值,因此最多可以拥有2147483647个资源。在构造Semaphore对象时需要传递这两个Int32参数以对这两个值进行初始化。

信号量的使用规则如下:

  • 如果当前资源数大于0,则有信号
  • 如果当前资源数为0,则无信号
  • 不允许当前资源数为负数(触发异常)
  • 当前资源数不能大于最大资源数

示例代码如下:

static Semaphore sph = new Semaphore(0, 3);
static void Main(string[] args)
{
    Thread t = null;
    for (int i = 0; i < 2; i++)
    {
        t = new Thread(TProc);
        t.Name = i.ToString();
        t.Start();
    }
    Console.WriteLine("main sleep 4s");
    Thread.Sleep(4000);
    sph.Release(2);
    Console.ReadLine();
    
}

static void TProc()
{
    while (true)
    {
        if (sph.WaitOne(3000, false))
        {
            try
            {
                Console.WriteLine("thread" + Thread.CurrentThread.Name + ":enter");
                Thread.Sleep(3000);
            }
            finally
            {
                //sph.Release();
                Console.WriteLine("thread" + Thread.CurrentThread.Name + ":exit");
            }
        }
        else
        {
            Console.WriteLine("thread" + Thread.CurrentThread.Name + ":time out");
        }
    }
}

注意上面的“sph.Release()”,注释掉该句,两个线程获取资源两次,不注释则两个线程无限的获取资源。前一种方式是较常用的Semaphore应用方式。

与Mutex和Monitor不同,Semaphore没有线程所有权的概念,这就意味着可以在任意线程就不配对的调用WaitOne和Realse或者说任何线程都可以调用Relase,只要对资源数的改动调用符合信号量的使用规则,MSDN中的原话是:"

“Semaphore 类不对 WaitOne 或 Release 调用强制线程标识。程序员负责确保线程释放信号量的次数不能太多”

此外当Wait到资源后,当前可用资源计数器递减,而Relase使当前可用资源数递增或增加指定数量,所以通常是Relase先行,或者在某种程度上Relase不是释放而是增加的意思。

具体细节如下:

调用一个等待方法如WaitOne,方法检查信号量的当前资源数,如果它的值大于0(有信号),那么当前资源计数器递减1,调用线程保持可调度状态,如果等待方法确定信号量的当前资源数是0(无信号),那么线程进入等待状态,当另一个线程对信号量调用Release方法时,当前资源计数器递增1或增加指定数量,系统使相应数量的等待线程再次成为可调度线程(同时相应地减少当前资源数量)

Relase会返回调用Relase方法前信号量当前可用资源数的计数(没有方法可以在不修改当前资源数量的情况下查询当前资源数量值),且Relase的释放或者说是增加数量不能违反信号量的使用规则,否则将触发SemaphoreFullException异常

 

 

 

 

AutoResetEvent、ManualResetEvent 类

在.net中AutoResetEvent和ManualResetEvent都派生自基类EventWaitHandle(且都可由EventWaitHandle创建),统称为事件同步对象,与Mutex和Semaphore一样,事件是内核方式的同步对象,同样的,速度上相对用户方式的同步对象慢、命名对象可跨进程同步、存在局部事件和已命名的系统事件两种类型(要构造已命名系统同步事件,请使用EventWaitHandle类

AutoResetEvent被称为自动重置事件,ManualResetEvent被称为人工重置事件

当一个自动重置事件得到信号时,等待该事件的线程中只有一个线程变为可调度线程,当人工重置对象得到信号时,等待该事件的所有线程均变为可调度线程

AutoResetEvent就像一扇旋转门,插入一张票(Set方法),等待的线程(Wait)中的一个便进入,进入后门又自动被关上,即当线程成功地等待到自动重置事件后,自动重置事件就会自动重置到无信号状态,这意味着一次只能有一个线程进入。ManualResetEvent则像一扇普通的门,插入一张票(Set方法),等待的线程(Wait)便进入,但门不会自动关上,需要调用Reset手工关闭门,这也就意味着在调用Reset前,任意数量的线程可以涌入门

在构造事件对象时需要指定initialState参数是True还是False,以指示事件的初始状态是有信号还是无信号

示例代码如下:

 

//开工信号
static AutoResetEvent[] aGoEvents = new AutoResetEvent[]          
    {
        new AutoResetEvent(false),
        new AutoResetEvent(false)
    };

//完工信号
static AutoResetEvent[] aReadyEvents = new AutoResetEvent[]
    {
        new AutoResetEvent(true),
        new AutoResetEvent(true)
    };

static void Main(string[] args)
{
    new Thread(PaddData).Start();
    new Thread(ProceDataA).Start();
    new Thread(ProceDataB).Start();
    Console.ReadLine();

}

static void PaddData()
{
    while (true)
    {
        //等待完工信号
        AutoResetEvent.WaitAll(aReadyEvents);

        Console.WriteLine("填充开始");
        Thread.Sleep(3000);
        Console.WriteLine("填充结束");

        //发出开工信号
        for (int i = 0; i < aGoEvents.GetLength(0); i++)
        {
            aGoEvents[i].Set();
        }
    }
}

static void ProceDataA()
{
    while (true)
    {
        //等待开工信号
        if (aGoEvents[0].WaitOne(5000, false))
        {
            Console.WriteLine("A处理开始");
            Thread.Sleep(3000);
            Console.WriteLine("A处理结束");

            //发出完工信号
            aReadyEvents[0].Set();
        }
        else
        {
            Console.WriteLine("A thread:time out");
        }
    }
}

static void ProceDataB()
{
    while (true)
    {
        //等待开工信号
        if (aGoEvents[1].WaitOne(5000, false))
        {
            Console.WriteLine("B处理开始");
            Thread.Sleep(3000);
            Console.WriteLine("B处理结束");

            //发出完工信号
            aReadyEvents[1].Set();
        }
        else
        {
            Console.WriteLine("B thread:time out");
        }
    }

}

 

上述代码中使用了两个AutoResetEvent数组,每个数组两个元素,分别用于指示线程A和线程B的开工和完工信号。这里开工信号没有使用单个ManualResetEvent或单个AutoResetEvent,因为线程A和线程B都使用了while(true),单个ManualResetEvent会使它们连续循环,而单个AutoResetEvent会使它们一次仅有一个在工作

上述代码的Main中创建了一个生产线程和两个处理线程,并都while(true),另一种比较合理的作法是处理线程由生产线程在需要时创建(同步结构也应相应改变),即在while(true)中创建,但这里又有另一个问题,即频繁创建处理线程的开销,解决办法是使用线程池。

Set和Reset都返回一个bool值以指示调用是否成功,对一个本身就有信号的事件调用Set或对一个本身就无信号的事件调用Reset并不会有什么问题,也总是返回true。

 

ReaderWriterLock 类

ReaderWriterLock与Monitor类似,是用户方式的同步对象,这也就意味它:

  • 速度较内核方式的同步对象快
  • 不具备跨进程能力,即没有局部和命名的概念
  • 使用ApplicationException异常结构来捕获操时

想象这样一种情况,一块数据区,比如结构体,两个线程负责写入,三个线程进行读取,我们知道,写入是不能同时进行的,读取可以并行进行,但不能既读取又写入,使用一个Monitor吗,显然不行,这可能需要两个以上的同步对象,可以试试不使用ReaderWriterLock类完成上述功能,你会发现编码过程非常繁琐。

ReaderWriterLock 用于同步对资源的访问。在任一特定时刻,它允许多个线程同时进行读访问,或者允许单个线程进行写访问。在资源不经常发生更改的情况下,ReaderWriterLock 所提供的吞吐量比简单的一次只允许一个线程的锁(如 Monitor)更高。

在多数访问为读访问,而写访问频率较低、持续时间也比较短的情况下,ReaderWriterLock 的性能最好。多个读线程与单个写线程交替进行操作,所以读线程和写线程都不会长时间阻止。

递归锁请求会增加锁上的锁计数。

读线程和写线程将分别排入各自的队列。当线程释放写线程锁时,此刻读线程队列中的所有等待线程都将被授予读线程锁;当已释放所有读线程锁时,写线程队列中处于等待状态的下一个线程(如果存在)将被授予写线程锁,依此类推。换句话说,ReaderWriterLock 在一组读线程和一个写线程之间交替进行操作。看一下以下示例代码:

static ReaderWriterLock rwLock = new ReaderWriterLock();
static object locker = new object();
static void Main(string[] args)
{
    Thread t = null;
    for(int i = 0; i < 2; i++)
    {
        t = new Thread(Writer);
        t.Name = i.ToString();
        t.Start();
    }

    for(int i = 0; i<3; i++)
    {
        t = new Thread(Reader);
        t.Name = i.ToString();
        t.Start();
    }

    Console.ReadLine();
}

static void Writer()
{
    while(true)
    {
        try
        {
            rwLock.AcquireWriterLock(3000);
            Console.WriteLine("writer:" + Thread.CurrentThread.Name + " is enter" + " WriterSeqNum:" + rwLock.WriterSeqNum.ToString());
            try
            {
                Thread.Sleep(5000);
            }
            finally
            {
                rwLock.ReleaseWriterLock();
                Console.WriteLine("writer:" + Thread.CurrentThread.Name + " is exit");
            }
        }
        catch(ApplicationException)
        {
            Console.WriteLine("writer:" + Thread.CurrentThread.Name + " wait time out");
        }
    }
}

static void Reader()
{
    while (true)
    {
        rwLock.AcquireReaderLock(-1);
        Console.WriteLine("reader:" + Thread.CurrentThread.Name + " is enter" + " WriterSeqNum:" + rwLock.WriterSeqNum.ToString());
        try
        {
            Thread.Sleep(3000);
        }
        finally
        {
            Console.WriteLine("reader:" + Thread.CurrentThread.Name + " is exit");
            rwLock.ReleaseReaderLock();
        }
    }
}

上述代码中Writer和Reader都使用了finally以安全释放锁。

Writer使用了超时设置,在超时捕获方面,与Monitor和Mutex的通过返回值得判断是否超时不同,AcquireXXXLock没有返回值,如果超时间隔过期并且没有授予锁请求,将引发 ApplicationException 将控制返回给调用线程。线程可以捕捉此异常并确定下一步要进行的操作。

在前面的描述中有这么一句话:“已释放所有读线程锁时,写线程队列中处于等待状态的下一个线程(如果存在)将被授予写线程锁”,观察上述代码的运行结果会发现,三个Reader线程每次都“规矩”的各运行一次,三次Reader输出后,Writer线程就被唤醒了,既然在Reader()中是while(true),为什么不会有四次或五次Reader输出呢,系统是怎么知道“所有的读线程锁”就是三个呢,这是因为ReaderWriterLock有这样一个特性:

当写线程队列中有一个线程在等待活动读线程锁被释放时,请求新的读线程锁的线程会排入读线程队列。即使它们能和现有的阅读器锁持有者共享并发访问,也不会给它们的请求授予权限;这有助于防止编写器被阅读器无限期阻止。

上述代码使用了两个Writer,且在AcruireWriterLock之前没有延时操作,于是Reader就显得很“规矩”,去掉一个Writer,并在RelaseWriterLock()后Sleep一个三倍以上的Reader Sleep时间,你会发现Reader也输出了三次以上,所以ReaderWriterLock并不能绝对保证“在所有读操作结束后再进行下一轮写操作

UpgradeTowriterLock用于将读线程锁升级为写线程锁

DowngradeFromWriterLock用于将线程锁状态还原为调用UpgradeToWriterLock前的状态

示例代码如下:

rwLock.AcquireReaderLock(-1);       
try
{
    Thread.Sleep(5000);
    LockCookie lc = null;
    try
    {
        lc = rwLock.UpgradeToWriterLock(1000);
        try
        {
            Thread.Sleep(3000);
        }
        finally
        {
            rwLock.DowngradeFromWriterLock(ref lc);
        }
    }
    catch (ApplicationException ex)
    {
        Console.WriteLine("time out");
    }
   
}
finally
{
    rwLock.ReleaseReaderLock();
}

 

在线程调用 UpgradeToWriterLock 时,不管锁计数为多少都将释放读线程锁,并且线程将转到写线程锁队列的末尾。因此,在请求升级的线程被授予写线程锁之前,其他线程可以写入资源。

有一种说法是:UpgradeToWriterLock并不是原子的从ReaderLock转换到WriterLock,而是等同于

lock.ReleaseReaderLock();
lock.AcquireWriterLock()

ReleaseLock用于在临时释放锁

RestoreLock用于将线程的锁状态还原为调用 ReleaseLock 前的状态

WriterSeqNum用于获取当前序列号,每当有线程获取写线程锁时,序列号就会增加。

AnyWritersSince用于指示获取序列号之后是否已将写线程锁授予某个线程。

可以使用 WriterSeqNum 和 AnyWritersSince 提高应用程序的性能。例如,线程可以在持有读线程锁的同时缓存它所获得的信息。在先释放再重新获取锁之后,线程可以使用 AnyWritersSince 确定其间是否有其他线程写入资源;如果没有,可以使用缓存信息。如果读取被锁保护的信息的代价比较大,该技术就很有用;例如,运行数据库查询。

示例代码:

static void ReleaseRestore(int timeOut)
{
    int lastWriter;
    try
    {
        rwl.AcquireReaderLock(timeOut);
        try
        {
            int resourceValue = resource;
            Console.WriteLine("reads resource value " + resourceValue); 

            lastWriter = rwl.WriterSeqNum;

            LockCookie lc = rwl.ReleaseLock();

            Thread.Sleep(rnd.Next(250));
            rwl.RestoreLock(ref lc);

            if (rwl.AnyWritersSince(lastWriter))
            {
                resourceValue = resource;
                Console.WriteLine("resource has changed " + resourceValue);
            }
            else
            {
                Console.WriteLine("resource has not changed " + resourceValue);
            }
        }        
        finally
        {
            rwl.ReleaseReaderLock();
        }
    }
    catch (ApplicationException)
    {
        Console.WriteLine("time out");
    }
}

resource代表资源值,它会在写线程中被修改

 

SynchronizationAttribute
当我们确定某个类的实例在同一时刻只能被一个线程访问时,我们可以直接将类标识成Synchronization的,这样,CLR会自动对这个类实施同步机制,实际上,这里面涉及到同步域的概念。

MethodImplAttribute
如果临界区是跨越整个方法的,也就是说,整个方法内部的代码都需要上锁的话,使用MethodImplAttribute属性会更简单一些。这样就不用在方法内部加锁了,只需要在方法上面加上 [MethodImpl(MethodImplOptions.Synchronized)] 就可以了,MehthodImpl和MethodImplOptions都在命名空间System.Runtime.CompilerServices 里面。但要注意这个属性会使整个方法加锁,直到方法返回,才释放锁。

S和M的更多信息请参考MSDN

你可能感兴趣的:(C#)