线程与同步异步

一、线程

1、什么是线程?什么是进程?两者有什么关系?

进程(Process):进程代表了操作系统上运行着的一个应用程序,每个进程都有自己独立的边界,进程与进程之间不能共享资源,一个进程可以包含一个或多个线程;

线程(Thread):线程是被操作系统调度的基本单元,同一进程内的所有线程共享内存和资源,并且一个线程可以对同一进程内的其他线程进行访问或结束等操作;

关系:它们是一个包含的关系,进程就像是线程的容器,且至少包含一个线程

为了更形象的理解该部分内容,可以参考阮一峰的 进程与线程的一个简单解释

2、系统是如何调用线程的?

抢占式调度:所有的线程都在被不停地快速切换运行,使得用户感觉所有的线程都在并行运行;

非抢占式调度:某个线程在运行时不会被操作系统强制暂停,它可以持续地运行直到运行告一段落并主动交出运行权;

通常情况下,一些系统级别的线程采用的是非抢占式调度,而普通线程采用的是抢占式调度

3、调用会不会存在问题?

对于单核CPU的操作系统来说,线程在不停的切换,而每次切换线程内的数据也在被不停的搬入搬出,会在一定程度上影响性能开销;多核CPU的操作系统则可以并行的运行多个线程,理论上性能会成倍的提高。所以衍生出了多线程操作的概念

4、.NET中常见的线程对象

4 .1 多线程操作之Thread对象

从.NET1.0开始,我们就可以就通过Thread对象创建、控制个线程;简单示例如下,我们创建了10个进程并调用,从结果上来看它们是多个线程并行运行的,且执行线程Id和结束的线程Id是可以对应上的;

4.2 多线程操作之ThreadPool对象

上面可以看到,每次我们需要调用线程进行操作,都需要手动创建一个线程对象,执行完成后再交由GC去销毁,一定程度上会影响性能开销,而且使用起来不是很方便,所以CLR提供了一个叫“线程池(ThreadPool)”的对象

线程池有以下特性:①当一个线程被使用完毕后并不会立刻被销毁,而是放入线程池中等待下一次使用;当应用程序需要一个新的线程时,就可以从线程池中直接获取一个已经存在的线程;②当线程池中的线程数小于线程池设置的下限时,线程池会创建新的线程;而当线程池中的线程数大于线程池设置的上限时,线程池将销毁多余的线程;

那么我们怎么操作线程池呢,ThreadPool对象提供了几个静态方法,我们使用一个简单的,示例如下,创建一个线程池后,做与上一个示例相同的操作,可以看到3号线程被重复调用了两次(随机事件)

4.3 多线程操作之Task对象

上面的示例可以看到,线程池的使用可以复用线程,一定程度上可以减少系统的开销。但是却有几个缺陷,比如:①不支持线程的挂起、取消等操作;②不支持线程的优先级设置;

所以在.NET4.0又出现了Task对象,它是基于线程池实现的,同时弥补了线程池功能上的一些不足,比如可以获取线程的状态,有完全的控制权等等,我们同样使用Task,做一个简单的示例,可以看到,任务2可以等待任务1执行完成后再执行自己的逻辑

4.4 多线程操作之Parallel对象

并行Paralle内部使用的是Task对象,它提供了Parallel.Invoke, Parallel.For, Parallel.Forecah 三个方法。需要注意的是所有并行任务完成后才会返回结果,所以少量短时间任务建议不要使用Parallel。通常情况下比较适合处理密集计算的场合。我没用过就不写例子了,感兴趣的可以用Paralle对象的方法和for/foreach循环对比下看看。

5、什么是前台线程?什么是后台线程?

前面说明了一部分线程的概念与线程的操作,接下来我们来看看什么是前台线程,什么是后台线程。

默认情况下,主应用程序线程和Thread对象创建的线程会在前台执行;而线程池线程和从非托管代码进入托管执行环境中的线程会在后台执行,所以ThreadPool、Task和Parallel都为后台线程。如果所有前台线程均已终止,后台线程不会保持运行, 即所有前台线程停止后,CLR将停止所有后台线程并关闭。示例如下:

二、线程的数据

1、线程的”私有”参数

上面提到线程的一个特点是可以共享数据或资源,那么我们能定义一个参数,只供线程自己使用吗?答案是肯定的,我们可以使用线程本地存储(Thread Local Storage简称TLS)来达到这个目的。TLS是线程内部的一个结构,可以存放自己独享的数据,我们可以使用Thread.GetData和Thread.SetData来获取或设置数据。

此外.NET还封装了一个叫ThreadStaticAttribute的对象,本质上它也是基于TLS实现的。

2、线程的执行上下文

什么是线程执行上下文?它的英文是ExecutionContext,指线程执行过程中的上下文信息。每当新建一个线程,该对象就会从当前创建的线程传递至被创建的线程,以保证被创建的线程与创建的线程有部分相同的设置信息。

若希望手动阻止上下文的流动,可以使用ExecutionContext类中的SuppressFlow方法

三、多线程同步

1、什么是多线程同步?

这里的同步是指数据上的同步而非线程操作上的同步,当多个线程同一时间去访问一个数据时,如何保证该数据的准确即是多线程中的重点问题之一。其实现方式都是基于锁实现的,简单来说就是当一个线程访问时,将数据锁定,不允许其他线程访问,下面我们介绍一下几种类型的锁

1.1、用户模式构造的锁

它使用CPU指令来协调线程,速度很快。它是怎么实现同步的呢?举个例子:线程1访问资源,使用用户构造模式的锁,线程2访问发现有锁后会进行等待,等待过程中会不停的去查看资源是否可用,直到资源可用为止。

它的优点是速度快,一旦发现资源被释放了,就立即去访问资源;缺点就是因为它需要不停的去确认资源的状态,所以会一直占用CPU的资源,影响性能。综上,它适用于对资源占用时间短的线程同步场景。

.NET中提供了两种用户模式锁:①Threading.Interlocked;②Thread.VolatileRead 和 Thread.VolatileWrite,它们都可以在简单数据类型上进行读写操作

1.2、内核模式构造的锁

它是对于用户模式的一个补充。它是怎么实现同步的呢?举个例子:线程1访问资源,使用内核模式构造的锁,线程2访问资源发现有锁后会被系统要求进行睡眠,线程1使用完资源后通知系统,系统再唤醒线程2。

它的优点是解决了不停去访问资源的情况,不会占用CPU的资源;缺点是存在用户模式下的托管代码和内核代码相互转换的过程,导致会延长处理时间。综上,它适合于需要长时间占用资源的线程同步场景。

.NET中提供了两种内核模式锁:①基于事件的,如AutoResetEvent和ManualResetEvent;②基于信号量的,如Semaphore

1.3、混合锁及其原理⭐️

混合锁是基于两者的优点实现的,线程使用资源的时间很短,就使用用户模式构造同步,否则就升级到内核模式构造同步。常见的混合锁有SemaphoreSlim、ReadWriteLockSlim和Monitor,它们有各自适用的应用场景。

下面我们看下经常使用的lock锁,它的本质是Monitor,微软为了开发者使用方便进行了简单的包装,即所谓的语法糖,lock方法对应的主要是 Monitor的Enter和Exit方法。那么lock是怎么实现同步的呢?我们分三步看。

①.NET在加载时就会新建一个同步块数组,当对象需要被同步时,.NET会为其分配一个同步块

②.NET在新建堆对象(即引用类型对象实例)时会分配一个名为同步索引的地址指针,初始值为-1不指向任何地址;

③使用lock时, Monitor.Enter会创建或使用一个空闲的同步索引块,内部结构为混合锁结构,同步索引会指向同步块数组为其分配的同步块;Monitor.Exit时,会将对象的同步索引重置为-1如下图:

再来看看经常讨论的两个问题:

①为什么值类型不能为lock的对象?

值类型是在栈上创建的,即使装箱后变为引用类型,因每次装箱后地址不同,所以无法lock;

②可以lock当前对象this吗?

this为执行代码的当前对象,可以被任何人访问,会导致类型的使用者加入同步块队伍中,进而增加开销;

综上:对于实例方法的同步,一般采用私有的引用对象成员private object 名称= new object();对于静态方法的同步,一般采用静态私有的引用对象成员private static object 名称= new object();

2、互斥体Mutex和信号量Semaphore

2.1什么是互斥体?

它是指某些代码片段在任意时间内只允许一个线程进入。.NET中的Mutex类则是封装好的互斥体对象。

看上去似乎和Monitor差不多,不同的是Mutex使用的是操作系统内核对象,而Monitor是在.NET下实现的,所以执行效率上Mutex会高一些;此外,Mutex可以跨应用程序域和进程,而Monitor只能同步同一应用程序域下面的线程。

2.2、什么是信号量

信号量允许指定数量的线程同时访问资源,超出数量后会进行排队,知道之前的线程退出;如果Mutex是其n=1的版本,那么信号量就是n的版本。

信号量适用于Web服务器高并发的场景,它接收两个参数,第一个为允许多少条线程进入(总数量),第二个为指定多少个线程同时进入(一次进入多少个);另外它不需要锁的持有者,所以一般声明为静态类型,比如static Semaphore sem = new Semaphore(10, 2);

3、开发中的多线程问题

3.1、控件不允许跨线程访问

WinForm的开发者在开发过程中使用多线程访问控件时,经常会遇到控件不允许跨线程访问的问题,如下图:

那么是什么原因导致的呢?那是因为为了保证UI的线程安全,微软在GUI应用中引入了一个特殊的线程处理模型,导致控件只能访问由创建它的线程进行访问或修改。

3.2、UI界面假死

在UI线程中执行耗时的计算操作,会导致UI的假死,出现该问题的原因要追溯到Windows的消息机制。

Windows是基于消息机制的,GUI内部就好比是一个消息队列,GUI线程不断的循环处理消息,更新UI进行呈现,如果去处理耗时操作,GUI线程就无法处理队列中的其他消息,UI界面会处于假死状态。

如下图,点击按钮2时因网络原因无法获取到对应的信息,主线程被阻塞,我们是无法点击按钮1的

3.3、如何解决

不难想到的是可以使用线程,但是线程会有更新UI的问题阿,比如上面的例子我们改成可访问的网址又会出现不允许跨线程的问题,又该怎么办呢?

其实系统已经提供了很多处理此类问题的对象,如Invoke,BeginInvoke,BackgroundWorker等,我们这边使用BeginInvoke进行示例,点击按钮2后仍然可以点击按钮1,如下图:

关于其他对象的使用可以看看五维思考的文章 多线程总结(结合进度条)

四、同步异步与多线程

1、什么是同步?什么是异步?两者的差异是什么?

同步是执行或调用一个方法时,每次都需要拿到对应的结果才会继续往后执行;异步与同步相反,它会在执行或调用一个方法后就继续往后执行,不会等待获取执行结果。二者的区别就是处理请求发出后,是否需要等待请求结果,再去继续执行其他操作。

以下图为例,红色线条为主线程,其他线条为调用的方法,上面的为同步,下面的为异步。

​ (图片来源为的刘铁猛的视频—C#语言入门详解)

2、阻塞非阻塞是什么意思?和同步异步有关系吗?

阻塞的概念通常会伴随着线程。阻塞是指当前执行的线程调用一个方法,在该方法没有返回值之前,当前执行的线程会被挂起,无法继续进行其他操作。非阻塞是指当前执行的线程调用一个方法,当前执行的线程不受该方法的影响,可以继续进行其他操作。

看完上面的说明,再对照同步异步的说明,这不是一个意思吗?但是它们的侧重点是不同的,同步异步强调的是是否需要等待获取结果,而阻塞非阻塞强调的是是否会影响当前线程的后续操作

3、各个组合的效果

同步/异步与阻塞/非阻塞,一共有四种组合方式,知乎上有个例子举得很贴切,我截了张图,原帖地址如下:

什么是阻塞,非阻塞,同步,异步?

4、异步与多线程有关系吗?

多线程是实现异步常用的一种方式,异步是目的,多线程是其实现方式之一。

下面我们通过一个使用Task线程实现异步的例子,了解一下异步的执行流程:

可以看到主方法的执行并没有受到AsyncMethod方法的影响,而是继续往下执行了,实现了异步的效果。

5、异步编程与async/await

异步编程的模式在使用恰当的情况下,会带来不小的性能提升,微软在不同时期一共推出了三种异步编程模式,分别为APM、EAP和TAP,async/await正是基于TAP模式下实现的。

5.1、async是什么?

async 是上下文关键字,用来标记异步方法,async标记方法的返回值必须是Task、Task 、void之一。从C# 7.0开始,任何具有可访问的GetAwaiter方法的类型也是可以标记的。

5.2、await是什么?

1、 await 用于等待异步方法的结果,await关键字可以用在async方法和Task、Task 之前,用于等待异步任务执行结束;

2、 await 并不是针对于async的方法,而是针对async方法所返回给我们的Task;

3、 await 不会开启新的线程,直到遇到Async方法或自己创建Task,才会真正的去创建线程

5.3、相关说明

1、异步方法缺少 await 不会导致编译器错误,但是异步方法会作为同步方法执行;

2、 await 无法等待具有 void 返回类型的异步方法;

③异步方法中无法声明 in、ref 或 out 参数

5.4、了解await的基本实现

1、通过resharp可以确认,await是一个TaskAwaiter对象,而它是怎么来的呢?原来Task类在GetAwaiter方法中创建了一个TaskAwaiter对象,并将this传递,如下图:

2、接下来我们再分三个部分看:

①await如何确认后面的异步方法执行完成了?

TaskAwaiter对象存在一个OnCompleted的方法,会等待操作完成时会执行,如下图:

②await是怎么让主线程等待其获取异步方法结果的?

TaskAwaiter对象存在一个GetAwaiter的方法,会在操作完成时通知等待的对象(主线程),如下图:

③await是怎么返回结果的?

TaskAwaiter对象存在一个GetResult的方法,会结束等待并返回结果,如下图:

结论:Task通过增加一个GetAwatier()函数,同时将自身传递给TaskAwaiter类来实现了await语法糖的支持

说明:多线程同步异步的知识点很多,本文只是针对该部分的一个简单小结,若想深究其原理,请查看专业书籍。

本人知识点有限,若文中有错误的地方请及时指正,方便大家更好的学习和交流。
本文参考了多篇优秀的博客内容,感兴趣的朋友可以看下,地址如下:

梦里花落知多少,.NET面试题解析(07)-多线程编程与线程同步

Edison Zhou,.NET基础拾遗(5)多线程开发基础

腾飞(Jesse),async & await 的前世今生

Jonins,异步编程

© 版权声明
THE END
喜欢就支持一下吧!
点赞0
分享