C#进阶C# 多线程

Posted 哈桑c

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C#进阶C# 多线程相关的知识,希望对你有一定的参考价值。

序号系列文章
19【C#进阶】C# 集合类
20【C#进阶】C# 泛型
21【C#进阶】C# 匿名方法

文章目录

前言

🐪 hello大家好,我是哈桑c,本文为大家介绍 C# 中的多线程。


1、线程与多线程的基本概念

线程是操作系统能够进行运算调度的最小单位,它被包含在进程1之中,是进程中的实际运行单位。一个线程指的是进程中一个单一顺序的控制流,一个进程可以并发2多个线程,每个线程并行执行不同的任务。

多线程是指在软件或者硬件上实现多个线程并发执行的技术。具有多线程能力的计算机因有硬件支持而能够在同一时间执行多个线程,进而提升整体处理性能的能力。

在 C# 中,实现多线程技术最常使用的类就是包含在 System.Threading 命名空间中的 Thread 类。Thread 类是一个定义创建和控制线程,设置其优先级并获取其状态的类。以一个简单的程序演示 Thread 类的用法,借此讨论多线程的使用。

代码示例:

using System;
using System.Threading;

// 简单的线程场景:启动一个静态方法运行在第二个线程上。
public class ThreadExample

    // 被运行的子线程
    public static void ThreadProc()
    
        for (int i = 0; i < 10; i++)
        
            Console.WriteLine("子线程: 0", i);
            Thread.Sleep(0);
        
    

    public static void Main()
    
        Console.WriteLine("主线程:开启第二个线程。");
        
        Thread t = new Thread(new ThreadStart(ThreadProc));
        t.Start();      // 开启子线程的工作
        
        // 同时开启主线程的工作
        for (int i = 0; i < 4; i++)
        
            Console.WriteLine("主线程:开始工作");
            Thread.Sleep(0);
        

        Console.WriteLine("主线程:调用Join(),等待子线程结束。");
        t.Join();
        Console.WriteLine("主线程:子线程已经 Join 回来了。按Enter键结束程序。");
        Console.ReadLine();
    

运行结果:

在上例中可以看出,使用 Thread 类我们不仅可以执行主线程3的内容,还可以创建新的 Thread 对象以此来运行子线程4的内容。这样我们就成功实现了一个多线程程序。

2、创建并使用线程

在 Thread 类中,创建子线程可以通过 Thread t = new Thread(); 调用构造方法的方式来实现。扩展的 Thread 类调用 Start() 方法来开始子线程的执行。

代码示例:

using System;
using System.Diagnostics;
using System.Threading;

public class Example

    public static void Main()
    
        var th = new Thread(ExecuteInForeground);
        th.Start(4500);
        Thread.Sleep(1000);
        Console.WriteLine("主线程 (0) 等待...",
                          Thread.CurrentThread.ManagedThreadId);
    

    private static void ExecuteInForeground(Object obj)
    
        int interval;
        try
        
            interval = (int)obj;
        
        catch (InvalidCastException)
        
            interval = 5000;
        

        var sw = Stopwatch.StartNew();
        Console.WriteLine("线程 0: 1, 属性 2",
                          Thread.CurrentThread.ManagedThreadId,
                          Thread.CurrentThread.ThreadState,
                          Thread.CurrentThread.Priority);
        do
        
            Console.WriteLine("线程 0: 运行 1:N2 描述",
                              Thread.CurrentThread.ManagedThreadId,
                              sw.ElapsedMilliseconds / 1000.0);
            Thread.Sleep(500);
         while (sw.ElapsedMilliseconds <= interval);
        sw.Stop();
    

运行结果:

在上例中,我们使用 var th = new Thread(ExecuteInForeground); 的方法并传入了 ExecuteInForeground 的构造方法名成功的创建了一个子线程

同时我们也可以使用 Thread 类中 start 方法来启动子线程的运行。注意如果方法需要有参数的话,那么这时就需要在 start 方法上传入参数,如果没有则反之。

3、检索线程对象

在使用 Thread 类时,如果想要获取线程的信息,可以从正在执行的代码中使用 Thread 类的属性来检索当前正在运行的线程的引用对象

代码示例:

using System;
using System.Threading;

public class ExampleThread

    // 创建一个obj对象便于用于锁机制
    static Object obj = new Object();

    public static void Main()
    
        ThreadPool.QueueUserWorkItem(ShowThreadInformation);    // 将方法排入队列以便执行。 
        var th1 = new Thread(ShowThreadInformation);
        th1.Start();
        var th2 = new Thread(ShowThreadInformation);
        th2.IsBackground = true;
        th2.Start();
        Thread.Sleep(500);
        ShowThreadInformation(null);
    

    private static void ShowThreadInformation(Object state)
    
        lock (obj)      // 为保证线程之间的执行顺序,在这里加上一个锁
        
            var th = Thread.CurrentThread;
            Console.WriteLine("托管线程 #0: ", th.ManagedThreadId);
            Console.WriteLine("后台线程: 0", th.IsBackground);
            Console.WriteLine("线程池线程: 0", th.IsThreadPoolThread);
            Console.WriteLine("优先级: 0", th.Priority);
            Console.WriteLine("文化: 0", th.CurrentCulture.Name);
            Console.WriteLine("UI文化: 0", th.CurrentUICulture.Name);
            Console.WriteLine();
        
    

运行结果:

从上例中可以看到,我们不仅创建了托管线程,还创建了前台线程、后台线程以及线程池的对象。在这里我们使用了 ManagedThreadId 等属性输出了线程的 Id 值、是否为后台线程或线程池5、优先级和线程名等线程信息。

4、前台线程和后台线程

在 .NET 框架的公用语言运行时(Common Language Runtime,CLR)中能区分两种不同类型的线程:前台线程和后台线程。

前台线程和后台线程的区别:

  • 如果所有前台线程已终止,后台线程不会使进程保持运行。
  • 停止所有前台线程后,运行时将停止所有后台线程并关闭。

前台线程和后台线程分别的应用范围。

默认情况下,以下线程在前台执行:

  • 主线程: 常见的主应用程序线程均为前台线程。
  • 子线程: 通过调用类构造函数创建 Thread 的所有线程。

默认情况下,以下线程在后台执行:

  • 线程池线程: 由运行时维护的工作线程池。 可以使用 类配置线程池并计划线程池线程 ThreadPool 上的工作。
  • 非托管到托管的线程: 从非托管代码进入托管执行环境的所有线程。

在 Thread 类中,可以通过设置 IsBackground 的属性来更改在后台执行的线程。后台线程适用于只要应用程序正在运行就应继续执行但不阻止应用程序终止的任何操作,例如监视文件系统更改或传入套接字连接。

代码示例:

using System;
using System.Diagnostics;
using System.Threading;

public class BackgroundThreadExample

    public static void Main()
    
        var th = new Thread(ExecuteInForeground);
        th.IsBackground = true;
        th.Start();
        Thread.Sleep(1000);
        Console.WriteLine("主线程 (0) 等待...", 
                        Thread.CurrentThread.ManagedThreadId); 
    

    private static void ExecuteInForeground()
    
        var sw = Stopwatch.StartNew();
        Console.WriteLine("线程 0: 1, 优先级 2",
                          Thread.CurrentThread.ManagedThreadId,
                          Thread.CurrentThread.ThreadState,
                          Thread.CurrentThread.Priority);
        do
        
            Console.WriteLine("Thread 0: Elapsed 1:N2 seconds",
                              Thread.CurrentThread.ManagedThreadId,
                              sw.ElapsedMilliseconds / 1000.0);
            Thread.Sleep(500);
         while (sw.ElapsedMilliseconds <= 5000);
        sw.Stop();
    

运行结果:

从上例中可以看出,我们使用了 Thread 类对象的 IsBackground 属性将 th 线程对象设置为了后台线程。不像前面的示例一样 th 对象可以顺利执行不超过五秒的内容,在上例中可以看到 th 对象只执行了 0.51 秒(并不固定)。这是因为停止所有前台线程后,运行时将停止所有后台线程并关闭。所以在主线程(前台线程)输出"主线程 (1) 等待…"之后就表示主线程停止了,后台线程也会停止并关闭,并不会执行不超过五秒的内容。

5、Thread 类的属性和方法

以一个表格展示 Thread 类常用的属性和方法。

Thread 类常用的属性:

属性名描述
ApartmentState获取或设置此线程的单元状态。
CurrentCulture获取或设置当前线程的区域性。
CurrentPrincipal获取或设置线程的当前负责人(对基于角色的安全性而言)。
CurrentThread获取当前正在运行的线程。
CurrentUICulture获取或设置资源管理器使用的当前区域性以便在运行时查找区域性特定的资源。
ExecutionContext获取 ExecutionContext 对象,该对象包含有关当前线程的各种上下文的信息。
IsAlive获取指示当前线程的执行状态的值。
IsBackground获取或设置一个值,该值指示某个线程是否为后台线程。
IsThreadPoolThread获取指示线程是否属于托管线程池的值。
ManagedThreadId获取当前托管线程的唯一标识符。
Name获取或设置线程的名称。
Priority获取或设置指示线程的调度优先级的值。
ThreadState获取一个值,该值包含当前线程的状态。

Thread 类常用的方法:

方法名描述
Abort()在调用此方法的线程上引发 ThreadAbortException,以开始终止此线程的过程。 调用此方法通常会终止线程。
Equals(Object)确定指定对象是否等于当前对象。(继承自 Object)
Finalize()确保垃圾回收器回收 Thread 对象时释放资源并执行其他清理操作。
GetApartmentState()返回表示单元状态的 ApartmentState 值。
GetCompressedStack()返回 CompressedStack 对象,此对象可用于获取当前线程的堆栈。
GetCurrentProcessorId()获取用于指示当前线程正在哪个处理器上执行的 ID。
GetDomain()返回当前线程正在其中运行的当前域。
GetDomainID()返回唯一的应用程序域标识符。
GetHashCode()返回当前线程的哈希代码。
GetType()获取当前实例的 Type。(继承自 Object)
Interrupt()中断处于 WaitSleepJoin 线程状态的线程。
Join()在继续执行标准的 COM 和 SendMessage 消息泵处理期间,阻止调用线程,直到由该实例表示的线程终止。
MemberwiseClone()创建当前 Object 的浅表副本。(继承自 Object)
ResetAbort()取消当前线程所请求的 Abort(Object)。
Resume()继续已挂起的线程。
SetApartmentState(ApartmentState)在线程启动前设置其单元状态。
SetCompressedStack(CompressedStack)将捕获的 CompressedStack 应用到当前线程。
Sleep(Int32)将当前线程挂起指定的毫秒数。
SpinWait(Int32)导致线程等待由 iterations 参数定义的时间量。
Start()导致操作系统将当前实例的状态更改为 Running。
ToString()返回表示当前对象的字符串。(继承自 Object)
TrySetApartmentState(ApartmentState)在线程启动前设置其单元状态。
UnsafeStart()导致操作系统将当前实例的状态更改为 Running。
VolatileRead(Byte)向字段中读取值。
VolatileWrite(Byte, Byte)向字段中写入值。

点击了解更多多线程的使用。


结语

🐆 以上就是 C# 多线程的介绍啦,希望对大家有所帮助。感谢大家的支持。


  1. 进程: 计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配的基本单位,是操作系统结构的基础。 ↩︎

  2. 并发: 指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。 ↩︎

  3. 主线程: 进程中第一个被执行的线程称为主线程。 ↩︎

  4. 子线程: 除了主线程外,在 C# 中使用 Thread t = new Thread(); 方式创建的线程均为子线程。 ↩︎

  5. 线程池: 是一种多线程处理形式,处理过程中将任务添加到队列,然后在创建线程后自动启动这些任务。 ↩︎

C#进阶系列28 基元线程同步构造

多个线程同时访问共享数据时,线程同步能防止数据损坏。之所以要强调同时,是因为线程同步问题实际上就是计时问题。

不需要线程同步是最理想的情况,因为线程同步一般很繁琐,涉及到线程同步锁的获取和释放,容易遗漏,而且锁会损耗性能,获取和释放锁都需要时间,最后锁的玩法就在于一次只能让一个线程访问数据,那么就会阻塞线程,阻塞线程就会让额外的线程产生,阻塞越多,线程越多,线程过多的坏处就不谈了。

所以可以避免线程同步的话就应该去避免,尽量不要去使用静态字段这样的共享数据。

类库和线程安全

.net类库保证了所有静态方法都是线程安全的,也就是说两个线程同时调用一个静态方法,不会发生数据被破坏的情况。

并不能保证所有实例方法线程安全。因为一般情况下实例创建后只有创建的线程能访问到,除非后来将实例的引用传给了一个静态变量,或者将引用传给了线程池的队列或者任务,那么此时可能就要考虑用线程同步了。

Console类包含一个静态字段,类的许多方法都要获取和释放这个对象上的锁,确保只有一个线程访问控制台。

基元用户模式和内核模式构造(这一部分看不明白可以先看看后面的用户模式和内核模式的讲解,就会清楚了)

基元是指可以在代码中使用的最简单的构造。

有两种基元构造:用户模式和内核模式。应尽量使用基元用户模式构造,它们的速度显著高于内核模式的构造。

这是因为它们使用特殊的CPU指令来协调线程,意味着协调是在硬件上发生的,也意味着操作系统永远检测不到一个线程在基元用户模式的构造上阻塞了。

只有操作系统内核才能停止一个线程的运行。

所以在用户模式下运行的线程可能被系统抢占。

所以也可以用内核模式构造,因为线程通过内核模式的构造获取其它线程拥有的资源时,Windows会阻塞线程以避免它浪费CPU时间。当资源变得可用时,Windows会恢复线程,允许它访问资源。

然而线程从用户模式切换到内核模式(或相反)会招致巨大的性能损失。

对于在一个构造上等待的线程,如果占有构造的这个线程不释放它,前者就可能一直阻塞。构造是用户模式的构造情况下,线程会一直在一个CPU上运行,称为“活锁”。如果是内核模式的构造,线程会一直阻塞,称为“死锁”。

死锁优于活锁,因为活锁既浪费CPU时间,又浪费内存,而死锁只浪费内存。

而混合构造兼具两者之长,在没有竞争的情况下,这个构造很快且不会阻塞(就像用户模式的构造),在存在对构造的竞争的情况下,它会被操作系统内核阻塞。(下一章讲)

用户模式构造

CLR保证对以下数据类型的变量的读写是原子性的:Boolean,Char,S(Byte),U(Int16),U(Int32),U(IntPtr),Single以及引用类型。

这意味着变量中的所有字节都是一次性读取或写入。(举个反例,对于一个Int64静态变量初始化为0,一个线程写它的时候只写了一半,另一个线程读取的时候读取到的是中间状态。不过话说回来,貌似64位机器一次性读取64位,是不是在这个时候Int64也会编程原子性呢,未验证,不过不影响我们理解。)

本章讲解的基元用户模式构造就在于规划好这些原子性数据的读取/写入时间。

实际上这些构造也可以强制为Int32和Double这些类型数据进行原子性的规划好时间的访问。

有两种基元用户模式线程同步构造

  • 易变构造
  • 互锁构造

所有易变和互锁构造都要求传递对包含简单数据类型的一个变量的引用(内存地址)。

易变构造

在讲易变构造之前,得先讲一个问题,就是代码优化的问题。

之前我们讲过C#编译器,JIT编译器,CPU都可能会优化代码,典型的例子就是Timer的应用,一个Timer对象在后续没有使用的情况下,可能直接被优化掉了,根本不会定时执行回调函数。

而这些优化效果是很难在调试的时候看出来,因为调试的时候并没有对代码进行优化。

而多线程也会导致这样的问题,比如一个线程回调函数用到某个静态变量后,且并不改变这个变量,那么可能就会进行优化,认为这个变量的值不变,让其直接优化成固定的值。而你本来的目的实在另一个线程中改变这个静态变量的值,现在你的改变也起不了效果看了。

并且以下这样的代码而言可能因为代码的执行顺序不同而出现超出预料的结果。

        static int you = 0;
        static int me = 0;
        private static void Thread1() {
            me = 2;
            you = 2;
        }
        private static void Thread2()
        {
            if (you == 2) {
                Console.WriteLine(me);
            
        }    

像上面的代码,Thread1和Thread2方法分别在两个线程中循环运行。

按照我们预计的结果是,当Thread1运行完了,那么Thread2就会检测到你2了,然后就打印我是2.

然而因为编译器优化的原因,you=2和me=2的顺序完全是可以反过来的,那么当先写了you=2后,me=2这句代码还没执行,此时Thread2已经开始检测到you==2了,那么此时打印的话,会显示我不是2,是0.

或者Thread1中的顺序没有变,而Thread2中的顺序变了,即you读取到数据和me读取到数据的代码也是可以被优化的,编译器在Thread1未运行时,先读了me的值为0,而此时Thread1运行了,虽然给了me为2,但是线程2的寄存器中已经存为0了,所以未读取,那么此时结果依然是你是2,而我不是2;

要解决这个问题就引入了我们的易变构造,这需要了解到一个静态类System.Threading.Volatile,它提供了两个静态方法Write和Read。

这两个方法比较特殊,它们会禁止C#编译器,JIT编译器和CPU平常执行的一些优化。

具体的实现在于,Write方法会保证函数中,所有在Write方法之前执行的数据读写操作都在Write方法写入之前就执行了。

而Read方法会保证函数中,所有在Read方法执行之后的数据读写操作,一定实在Read方法执行后才进行。

修改代码后

        static int you = 0;
        static int me = 0;
        private static void Thread1() {
            me= 2;
            Volatile.Write(ref you,2);
        }
        private static void Thread2()
        {
            if (Volatile.Read(ref you) == 2) {
                Console.WriteLine(me);
            
        }

此时因为Volatile.Write使编译器会保证函数中,所有在Write方法之前执行的数据读写操作都在Write方法写入之前就执行了。

也就是说编译器不会在执行的时候将you=2放在me=2后面了。解决了之前说的第一种情况。

而Volatile.Read保证函数中,所有在Read方法执行之后的数据读写操作,一定实在Read方法执行后才进行。

也就是说me读取肯定在有读取数据的后面,也就解决了之前说的第二种情况。

然而正如你所看到的,这很难理解,关键是自己用到项目中都会觉得真蛋疼,还得百度一下看看是不是Read和Write的保证记混了。

所以为了简化编程,C#编译器提供了volatile关键字,它可以应用于之前提到的那些原子性的简单类型。

volatile声明后,JIT编译器会确保易变字段都是以易变读取和写入的方式进行,不必显示调用Read和Write。(也就是说只要用了volatile,那么me=2的效果就是Volatile.Write(ref me,2),同理读也是一样)

并且volatile会告诉C#编译器和JIT编译器不将字段缓存到CPU寄存器,确保字段的所有读写操作都在内存中进行。

现在再改写之前的代码:

        static volatile int you = 0;
        static int me = 0;
        private static void Thread1() {
            me= 2;
            you=2;
        }
        private static void Thread2()
        {
            if (you == 2) {
                Console.WriteLine(me);
            
        }

然而作者却表示并不喜欢volatile关键字,因为出现上述所说的情况的概率很低,并且volatile禁止优化后对性能会有影响。且C#不支持以传引用的方式传递volatile变量给某个函数。

互锁构造

说道互锁构造,就要说System.Threading.Interlocked类提供的方法。

这个类中的每个方法都执行一次原子性的读或者写操作。

这个类中的所有方法都建立了完整的内存栅栏,也就是说调用某个Interlocked方法之前的任何变量写入都在这个Interlocked方法调用之前执行,而这个调用之后的任何变量读取都在这个调用之后读取。

它的作用就等于之前的Volilate的Read和Write的作用加在一起。

作者推荐使用Interlocked的方法,它们不仅快,而且也能做不少事情,比简单的加(Add),自增(Increment),自减(Decrement),互换(Exchange)。

Interlocked的方法虽然好用,但主要用于操作Int类型。

如果想要原子性地操作类对象中的一组字段,那么可以用以下方法实现:

/// <summary>
    /// 简单的自旋锁
    /// </summary>
    struct SimpleSpinLock {
        private Int32 m_ResourceInUse;//0表示false,1表示true

        public void Enter() {
            while (true) {
                //将资源设为正在使用,Exchange方法的意思是,将m_ResourceInUse赋值为1,并返回原来的m_ResourceInUse的值
                if (Interlocked.Exchange(ref m_ResourceInUse, 1) == 0) return;

            }
        }

        public void Leave() {
            Volatile.Write(ref m_ResourceInUse, 0);
        }
    }
    public class SomeResource {
        private SimpleSpinLock m_sl = new SimpleSpinLock();
        public void AccessResource() {
            m_sl.Enter();
            /*每次只有一个线程能访问到这里的代码*/
            m_sl.Leave();
        }
    }

上面的代码原理就是,当一个线程调用Enter后,那么就会return,并置m_ResourceInUse为1,此时表示资源被占用了。

如果另外一个线程再调用Enter,那么得到的m_ResourceInUse为1,所以不会返回,就不断执行循环,直到第一个线程调用Leave函数,将m_ResourceInUse置为0。

原理很简单,但相信看这个模式的人也应该很清楚了,也就是说只要第一个线程不退出,其它所有的线程都要不断进行循环操作(术语为自旋)。

所以自旋锁应该是用于保护那些会执行得非常快的代码区域。(且不要用在单CPU机器上,因为占有锁的线程不能快速释放锁)

如果占有锁的线程优先级地狱想要获取锁的线程,那么这就造成占有锁的线程可能根本没机会运行,更别提释放锁了。(这就是活锁,前面也提到了)

实际上FCL就提供了一个类似的自旋锁,也就是System.Threading.SpinLock结构,并且还是用了SpinWait结构来增强性能。

由于SpinLock和之前我们自己写的SimpleSpinLock都是结构体,也就是说他们都是值类型,都是轻量级且内存友好的。

然而不要传递它们的实例,因为值类型会复制,而你将失去所有的同步。

事实上Interlocked.CompareExchange本来就可以不仅仅用于操作整数,还可以用来操作其它原子性的基元类型,他还有一个泛型方法。

它的作用是,对比第1个参数和第3个参数,如果两者相等,那么将第2个参数的值赋给第1个参数,并返回第一个参数之前的值。

内核模式构造

内核模式比用户模式慢,这个是可以预见的,因为线程要从托管代码转为本机用户模式代码,再转为内核模式代码,然后原路返回,也就了解为什么慢了。

但是之前也介绍过了,内核模式也具备用户模式所不具备的优点:

  • 内核模式的构造检测到一个资源上的竞争,windows会阻塞输掉的线程,使他不会像之前介绍的用户模式那样“自旋”(也就是那个不断循环的鬼),这样也就不会一直占着一个CPU了,浪费资源。
  • 内核模式的构造可实现本机和托管线程相互之间的同步
  • 内核模式的构造可同步在同一台机器的不同进程中运行的线程。
  • 内核模式的构造可应用安全性设置,防止未经授权的帐户访问它们。
  • 线程可一直阻塞,直到集合中所有内核模式构造可用,或直到集合中的任何内核模式构造可用
  • 在内核模式的构造上阻塞的线程可指定超时值;指定时间内访问不到希望的资源,线程就可以解除阻塞并执行任务。

事件和信号量是两种基元内核模式线程同步构造,至于互斥体什么的则是在这两者基础上建立而来的。

System.Threading命名空间提供了一个抽象基类WaitHandle。这个简单的类唯一的作用就是包装一个Windows内核对象句柄。(它有一些派生类EventWaitHandle,AutoResetEvent,ManualResetEvent,Semaphore,Mutex)

WaitHandle基类内部有一个SafeWaitHandle字段,它容纳一个Win32内核对象句柄。

这个字段在构造一个具体的WaitHandle派生类时初始化。

在一个内核模式的构造上调用的每个方法都代表一个完整的内存栅栏。(之前也说过了,表示调用这个方法之前的任何变量的写入都必须在此方法前完成,调用这个方法之后的任何变量的读取都必须在此方法后完成)。

这个类中的方法就不具体介绍了,基本上这些方法的主要功能呢个就是调用线程等待一个或多个底层内核对象收到信号。

只是要注意在等待多个的方法(即WaitAll和WiatAny这种)中,传递的内核数组参数,数组最大元素数不能超过64,否则会异常。

主要讲一下三个内核构造,也是之前WaitHandle的三个直接继承派生类:

  • EventHandle(Event构造)
    • 事件实际上就是由内核维护的Boolean变量。为false就阻塞,为true就解除阻塞。
    • 有两种事件,即自动重置事件(AutoResetEvent)和手动重置事件(ManualResetEvent)。区别就在于是否在接触一个线程的阻塞后,将事件自动重置为false。
    • 用自动重置事件写个锁示例如下:
        /// <summary>
          /// 简单的阻塞锁
          /// </summary>
          class SimpleWaitLock {
              private readonly AutoResetEvent m_ResourceInUse;
      
              public SimpleWaitLock() {
                  m_ResourceInUse = new AutoResetEvent(true);//初始化事件,表示事件构造可用
              }
      
              public void Enter() {
                  //阻塞内核,直到资源可用
                  m_ResourceInUse.WaitOne();
              }
      
              public void Leave() {
                  //解除当前线程阻塞,让另一个线程访问资源
                  m_ResourceInUse.Set();
              }
              public void Dispose() {
                  m_ResourceInUse.Dispose();
              }
          }

      此示例可以和前面的那个自旋锁相对比,调用方法一模一样。

  • Semaphore(Semaphore构造)
    • Semaphore的英文就是信号量,其实是由内核维护的Int32变量。信号量为0时,在信号量上等待的线程阻塞,信号量大于0时接触阻塞。信号量上等待的线解除阻塞时,信号量自动减1.
    • 同样一个例子来表示,与上面代码对比之后更清晰:(信号量最大值设置为1的话,且释放的时候也只释放一个的话,那么实际上和事件效果一样)
       /// <summary>
          /// 简单的阻塞锁
          /// </summary>
          class SimpleWaitLock {
              private readonly Semaphore m_ResourceInUse;
      
              public SimpleWaitLock(Int32 maxCount) {
                  m_ResourceInUse = new Semaphore(maxCount, maxCount);
              }
      
              public void Enter() {
                  //阻塞内核,直到资源可用
                  m_ResourceInUse.WaitOne();
              }
      
              public void Leave() {
                  //解除当前线程阻塞,让另外2个线程访问资源
                  m_ResourceInUse.Release(2);
              }
              public void Dispose() {
                  m_ResourceInUse.Close();
              }
          }
  • Mutex(Mutex构造)
    • Mutex的中文就是互斥体。代表了一个互斥的锁。
    • 互斥体有一个额外的逻辑,Mutex会记录下线程的ID值,如果释放的时候不是这个线程释放的,那么就不会释放掉,并且还会抛异常。
    • 互斥体实际上在维护一个递归计数,一个线程当前拥有一个Mutex,而后该线程再次在Mutex等待,那么此计数就会递增,而线程调用ReleaseMutex会导致递减,只有计数递减为0,那么这个线程才会解除阻塞。另一个线程才会称为该Mutex的所有者
    • Mutex对象需要额外的内存来容纳那些记录下来的ID值和计数信息,并且锁也会变得更慢了。所以很多人避免用Mutex对象。
    • 通常一个方法在用到一个锁时调用了另一个方法,这个方法也要用到锁,那么就可以考虑用互斥体。因为用事件这种内核构造方法的话,在调用的另一个方法中用到锁就会导致阻塞,从而死锁。例子:
       public class SomeResource {
              private readonly Mutex m_lock = new Mutex();
              public void Method1() {
                  m_lock.WaitOne();
                  Method2();//递归获取锁
                  m_lock.ReleaseMutex();
              }
              public void Method2()
              {
                  m_lock.WaitOne();
                  /*做点什么*/
                  m_lock.ReleaseMutex();
              }
          }

      像以上的这种结构如果简单得用事件来写就会有问题,然而并不是不能用事件去递归实现,而且如果用以下的方法递归实现效果反而会更好:

    • 用事件方式实现递归锁:
      /// <summary>
          /// 事件构造实现的递归锁,效率比Mutex高很多
          /// </summary>
          class ComplexWaitLock:IDisposable {
              private  AutoResetEvent m_lock=new AutoResetEvent(true);
              private Int32 m_owningThreadId = 0;
              private Int32 m_lockCount = 0;
      
              public void Enter() {
                  //获取当前线程ID
                  Int32 currentThreadId = Thread.CurrentThread.ManagedThreadId;
                  //当前线程再次进入就会递增计数
                  if (m_owningThreadId == currentThreadId) {
                      m_lockCount++;
                      return;
                  }
                  m_lock.WaitOne();
                  m_owningThreadId = currentThreadId;
                  m_lockCount = 1;
      
              }
      
              public void Leave() {
                  //获取当前线程ID
                  Int32 currentThreadId = Thread.CurrentThread.ManagedThreadId;
                  if (m_owningThreadId != currentThreadId)
                      throw new InvalidOperationException();
      
                  if (--m_lockCount == 0) {
                      m_owningThreadId = 0;
                      m_lock.Set();
                  } 
              }
              public void Dispose() {
                  m_lock.Dispose();
              }
          }

      上面的代码其实很好搞懂,就是用事件把Mutex的玩法自己实现了。然而上面的代码之所以比Mutex快,是因为这些代码都是用托管代码在实现,而不是像Mutex一样用内核代码,仅仅只有调用事件构造的方法时才会用到内核代码。

以上是关于C#进阶C# 多线程的主要内容,如果未能解决你的问题,请参考以下文章

C# 10 完整特性介绍

C#多线程编程:线程池ThreadPool

C# Winform项目中多线程环境下, 如何跨线程对Window窗体控件进行安全访问?

[转]C#综合揭秘——细说多线程(上)

C#综合揭秘——细说多线程(上)

C#多线程编程