.net core底层入门学习笔记(七-多线程)

.net core底层入门学习笔记(七)

本篇开始记录.net中多线程的实现原理



前言

多线程机制,解决如何在有限的CPU核心上执行多个任务的一种机制。
由操作系统管理的现成为原生线程,.net基于原生线程搭建了一套线程模型,托管代码在这套线程模型上运行,称为托管线程。


一、原生线程

主流操作系统通过多线程机制实现在计算机上同时运行多个任务。操作系统负责安排线程的创建、运行、切换和终止,称为原生线程。任务在执行时,需要访问一些资源,操作系统使用进程管理这些资源,属于同一个进程的线程之间共享进程中的资源。
每个逻辑核心都包含一组寄存器,每个核心都根据自己程序计数器从内存读取指令并执行,单个逻辑核心同一时间只能执行一个线程对应的任务(指令)。为实现多个线程同时运行,线程需要在逻辑核心上轮流运行,对应的切换方式:
1.主动切换,线程任务主动要求暂停,比如执行读取文件,网络请求等IO操作。
2.被动切换,线程运行一段时间后,操作系统强制切换到下一个线程,称为抢占,线程运行最大时间称为时间片,抢占机制的实现依赖硬件计时器。

一、上下文切换

在操作系统中,保存某一时间点上CPU各个寄存器状态的值的数据结构称为上下文,线程之间切换,需要对上下文进行切换。因为寄存器内保存了之前线程运行的各种状态(比如最重要的程序计数器,CPU通过这个计数器,才知道该线程运行到哪个指令,接下来从哪读取指令继续执行,其他用于保存计算结果等寄存器也需要保存用于还原)。

  • 当CPU收到硬件计时器,发出的中断时,调用中断处理器
  • 操作系统检查是否应该抢占线程,如果抢占继续
  • 保存当前逻辑核心寄存器到当前运行线程关联的上下文数据结构中
  • 获取下一个可运行线程
  • 设置硬件计时器
  • 从下一个可运行线程对应关联的上下文数据结构中,读取数据恢复到当前逻辑核心的各个CPU中
  • 执行对应指令继续执行
  • …循环往复

上下文的切换成本:显示成本:保存寄存器值到内存,从内存恢复值到寄存器;隐形成本:CPU缓存丢失,读取内存地址数据变慢。
同一进程切换上下文,只有显示成本(因为虚拟内存空间对应同一块,不需要切换内存页表,CPU缓存还存在有用),不同进程则两个成本皆有。
如何优化:自旋锁,异步操作等。

二、线程调度

线程调度机制,负责安排线程如何切换如何等待。
具体方式:1.安排等待运行队列中的线程在逻辑核心上轮流运行(抢占,或自动切换);2.线程处于等待不可用资源时,放入对应资源的等待线程队列;3.资源可用后,将等待资源线程放入等待运行队列中,使得他们可以继续轮流运行。

三、栈空间

每个原生线程都需要在内存中使用一块空间作为栈空间,用于实现调用函数之间的数据交换,以及调用链跟踪等机制,前面的笔记有记录。栈空间在线程创建时,由操作系统分配,结束后,由操作系统回收。

二、托管线程

由于多线程机制,在不同操作系统上实现不同,为了实现跨平台,.net基于原生线程搭建了一套线程模型,是的托管代码可在不同操作平台上基于相同的线程模型运行。
托管线程与原生线程,可以是多对多的关系,目前只实现了1对1的关系,没有支持在同一个原生线程上跑多个托管线程,或者多个托管线程跑在同一个原生线程上。
托管线程在.net运行时内部或托管代码创建,每个托管线程对应一个托管对象,托管线程对象与原生线程使用托管线程关联。

一、托管线程对象

托管线程对象会在以下情况创建,且与原生线程关联的方式不同:
1.托管代码新建Thread类型对象:调用start方法后创建新的原生线程,关联托管对象
2…net运行时内部创建托管线程:同时创建原生线程,关联托管对象
3.非托管代码在原生线程上首次调用托管代码:创建新的托管线程对象,并关联到此原生线程(即非托管代码所在线程,注意是首次调用)
4…net程序运行,并在主线程调用Main函数:创建新的托管线程对象,并关联到此原生线程(即主线程)

托管线程对象包含以下数据结构:线程本地存储、托管与非托管函数切换记录(用于跟踪链)、分配上下文、执行上下文、同步上下文。托管代码依赖这些数据结构,所以托管代码必须要在托管线程上运行。托管线程对象会被.net运行时记录到一个内部列表,利用这个列表可枚举所有托管线程,GC需要依赖此结构。

二、前台线程与后台线程

这里都指托管线程,当前.net程序退出前会等待所有前台线程结束后再退出,默认托管代码创建的都是前台线程,可使用Thread.isBackground属性修改,需要在start之前(即线程运行前修改)。

三、抢占模式与合作模式

注意区别,原生线程中的抢占模式。这里特指的.net中实现的托管线程的模式。
.net中区分两种模式,主要是为了GC的实现
合作模式:处于合作模式的线程可以自由访问托管堆中的对象,处于抢占模式线程不能访问托管堆上的对象,如果要访问,必须切换为合作模式,而切换回合作模式,需要等待GC结束。
托管代码随时需要访问托管对象,托管代码必须在合作模式下运行。如果当前线程处于抢占模式,托管代码会暂停运行,非托管代码可以继续做与.net对象无关的处理,一旦非托管代码需要访问托管堆对象了(即切换回托管代码),那么此时这个线程会请求切换为合作模式,由于GC在运行,会阻塞等待GC运行完毕再切换回来。
1.切换模式的实现
抢占模式与合作模式之间切换分为主动切换与被动切换
主动切换:合作模式切换到抢占模式,修改托管线程对应标记;抢占切换到合作,检查GC是否运行的全局变量,如果正在运行,则休眠到GC结束后再切换。
被动切换:合作模式切换抢占:线程需要停留在GC安全点

GC安全点:编译器再生成指令的时候,会同时生成元数据,元数据中包含了GC信息,即哪些位置会有引用对象(这样GC可以根据这些引用对象进行跟踪标记扫描),又不可能每条指令都生成GC信息,所以会选指令中的一些特定地方生成GC信息。这些指定的地方就是GC安全点,有了安全点之后,如果线程暂停时都停留在安全点,那么根据此时的这些GC信息,获得的实时对象跟踪就是安全的(因为此时整个引用对象都很明确的能被跟踪标记到,其他地方没有GC信息,运行到这些地方停留下来会导致引用对象没有被记录,或者改了引用之类的)

GC线程切换其他线程流程:

  1. 暂停线程运行,通过SuspendThread函数暂停运行,通过GetThreadContext获取上下文信息
  2. 分析线程是否在GC安全点,通过寄存器(程序计数器,上下文中)定位到托管函数,分析元数据,判定位置是否为GC安全点
  3. 如果是,则重定向到内部函数,内部函数处理:切换线程为抢占模式(此时GC安全,切换没有问题),再将其切换到合作模式(此时由于处于GC状态,所以会等待GC结束才能切换完毕),最后恢复运行。
  4. 如果不是GC安全点,使用返回地址劫持,对调用链进行跟踪,找到最近的返回地址,将其替换为内部函数指令地址,恢复线程运行,当线程从当前函数中返回时,会回到替换后的地址。

注意点:返回地址劫持,部分函数可能运行时间过长,导致GC时间过长,JIT编译器会检测代码中的循环,插入检查GC状态代码;若是因为运行非托管代码导致GC时间过长,则规定非托管函数主动切换到合作模式后,必须在短时间内切换回来。

四、线程本地存储

按线程隔离使用线程本地变量。
原生实现:使用分段寄存器指向原生线程数据地址,切换上下文都会跟着一起切换,使得每个线程都能访问独立的原生线程数据。
.net运行时实现,利用原生线程本地变量,保存当前原生线程对应的托管线程对象,每个托管线程关联一块内存空间用于.net运行时的本地存储。

TheadStatic Attribute属性:标记了的全局变量就是托管线程本地变量。
每个托管线程对象关联一个TLB表,TLB表以AppDomain ID为索引保存TLM表,TLM表再以模块ID为索引保存托管线程本地变量。
加载程序集时,枚举模块中的全局变量,按是否线程本地变量划分,非线程本地变量,放在对应的AppDomain对应的高频堆中,线程本地变量计算偏移值,首次访问再分配空间。

ThreadLocal:这个类用于包装实现托管线程本地变量。使用ts_slotArray数组,存储在各个线程中的值,索引由一个IdManager管理,且数组存储的具体类型为LinkedSlotVolatile。

五、原子操作

多个线程访问同一个资源,使用原子操作机制,保证访问不冲突。不同平台实现原子操作,不同实现方式。
x86实现,将多个CPU操作合并为一个指令,比如add:读取内存值到寄存器,加法器增加寄存器值,寄存器值保存到内存。
单核心使用单指令方式,可保证原子操作,多核心无法保证。因为实际上内核操作依然分为多个实际步骤。可使用带lock前缀的指令,保证操作同一内存地址指令,不能在多个逻辑核心上同时执行。lock实现了保持对系统总线的控制,直到整条指令执行完毕。具体原理:物理表现

.net中的原子操作由类System.Threading.Interlocked提供,各种对单一引用的原子操作(Increment、Decrement、Add、Exchange、CompareExchange),除了基本的一些运算,比较,修改等原子操作外,此类还提供了MemoryBarrier函数,在读写内存指令之间,增加了内存屏障,保证了指令之间的顺序执行(乱序是CPU为了提高效率做的,比如读取内存操作,需要等待,CPU会直接执行下个步骤)

无锁算法:把多个计算,包装到一个类中,然后通过类引用方式,使用System.Threading.Interlocked保证原子操作。编写此类算法有难度,.NET提供了一些线程安全数据类型(使用无所算法):ConcurrentBag,集合类,ConcurrentDictionary字典类,ConcurrentQueue有序集合类(队列),ConcurrentStack有序集合类(栈)

六、自旋锁

为了避免多个线程访问同一个资源发生冲突,根据访问复杂度选用不同措施。原子操作适用于简单的单个操作,无锁算法适用于简单的一连串操作,线程锁适用于复杂的一连串操作。
线程锁分为获取锁,释放锁两个操作,在获取锁之后,释放锁之前保证只有一个线程执行。
自旋锁:顾名思义,自我旋转的锁。,基于前面的原子操作实现。用一个数值表示锁是否已被获取,对这个数值进行原子操作,即可实现自旋锁:

private static int _lock = 0;//用于检测是否获得锁的数值
private static int _countA = 0;
private static int _countB = 0;

public static void IncreamentCounters(){
	while(Interlocked.Exchange(ref _lock,1)!= 0){
		Thread.SpinWait(1);
	}
	//保护开始
	//保护结束
	
	Interlocked.Exchange(ref _lock,0);
}

其中Thread.SpinWait函数用于提示CPU当前正在处于自旋锁循环中,避免一直执行循环体消耗性能。
上面实现也可以不使用Interlocked.Exchange,可以换为Interlocked.MemoryBarrier函数,或者使用valatile,都是使用内存屏障的方式,保证了_lock的读取与赋值是有有序的。

自旋锁的问题:1.自旋锁保护的代码应该在非常短的时间内运行,避免其他线程反复获取锁影响性能。2.如果只有1个核心,则需要在获取锁失败时调用Thread.Yield提示操作系统切换线程,否则会导致当前线程会一直运行,因为无法释放锁;3.未考虑公平性,即无法保证先申请锁的线程会在锁释放后先获得锁,公平性的自旋锁:排号自旋锁,队列自旋锁。

.net中其他自旋锁实现方式:System.Threading.SpinWait类,System.Threading.SpinLock类。
Thread.Sleep(0)与Thread.Yield区别:前者会切换到任意逻辑核心关联的待运行线程,后者只会切换到当前逻辑核心关联的待运行线程。

自旋锁使用pause指令,能够抵消判断循环失败导致的CPU预测失准问题。

七、互斥锁

自旋锁不适用于长时间运行的代码,互斥锁基于原子操作与线程调度,原理:使用一个数值表示是否获取锁成功,失败时不会反复重试,安排获取锁的线程进入等待状态,添加到关于锁的队列中,锁被释放时,检查这个关联的队列,如果有现成对象,则通知操作系统唤醒该线程。
.net提供System.Threading.Mutex类实现互斥锁,可重入,也就是获取锁后再次获取同样的锁,它基于计数机制获取和释放锁,意味着获取多少次,则释放同等次数才能真正释放掉锁。

互斥锁问题:让线程进入等待,与唤醒,通过操作系统调度的性能影响较大。

八、混合锁

.net提供了更为通用且高性能的混合锁,System.Threading.Monitor类的函数Enter与Exit函数。能以任何对象为此函数的对象来代表锁。

private static object _lock = new object();//用于锁的对象
private static int _countA = 0;
private static int _countB = 0;

public static void IncreamentCounters(){
	object lockObj = _lock;
	bool lockTaken = false;
	try{
		Monitor.Enter(lockObj,lockTaken);

		//保护开始
		_countA++;
		_countB++;
		//保护结束
		}
		finally{
		if(lockTaken){
				Monitor.Exit(lockObj);
					}
		}
}

混合锁的特点:先自旋转一定次数,超过一定次数,再安排线程进入等待状态,即融合了自旋锁与互斥锁的两者。

原理:引用类型对象有一个对象头,对象头位置在对象地址之前,32位对象头中,低26位根据标志,可存储:1.当前获取该锁的线程ID和进入次数;2.存储同步块索引。
同步块索引包含:所属线程对象,进入次数,事件对象的数据结构。其中事件对象可让线程进入等待和唤醒。同步块按需创建并自动释放,.net运行时会内部维护一个同步块数组,每个引用对象包含的同步块索引就是这个数组的索引。
混合锁获取锁,都是会检查这个对象头,先自旋,标记为存储上面1号内容(只有次数与线程对象ID),进行自旋;超过次数后,存储变为2号内容(同步块索引),然后等待事件对象发生。
释放锁的逻辑与上面类似,都是先获取锁,判定对象头的内容是1号内容,还是2号内容,操作其中的次数,判定线程ID等。

Monitor.Enter放入Try内,用于保证线程中止安全,线程中止由.net Framework中加入,.net core不再支持线程中止功能。

.net提供lock语言简化Monitor的使用:lock(obj){}

九、信号量

信号量是具有特殊用途的线程同步对象。信号量内部使用一个数值记录可用数量,各个线程都可以对信号量进行增加与减少操作。通过现有数量判定,执行减少操作的线程是否需要进入等待状态。互斥锁线程获取与释放,必须是同一个线程。而信号量增加与减少操作可来源于不同线程。
.net使用System.Threading.Semaphore类封装了操作系统提供的信号量实现(在windows上操作系统实现,在类Unix系统上,则由.net core内部结合操作系统模拟实现)
信号量的生产者-消费者模式:存在一个队列,部分线程向这个队列添加任务,部分线程从这个队列取出任务并执行。队列本身的存取,需要使用混合锁方式避免队列冲突,同时使用信号量的方式,控制各个线程对队列内容的获取。如果纸质用混合锁,意味着只有一个放入,另外一个获取,交替进行。而使用信号量后,可由多个线程同时放入(由于队列有锁,实际只有一个放入),多个线程同时获取并执行。

混合锁Monitor类的条件变量功能,也能实现生产者消费者模式,不过条件变量功能需要先获取锁,虽然线程获取锁失败,会进入等待状态,大部分场景还是应该使用Monitor类实现线程同步。

十、读写锁

读写锁也是具有特殊用途的线程锁,适用于频繁读取且读取耗时的场景。普通互斥锁,读取与修改操作无法同时执行,而读写锁可以让多个进程同时读取。
读写锁分为读取锁,写入锁。读取锁可以被多个线程同时获取,写入锁不可以被多个线程同时获取,且读取锁与写入锁不可以被不同线程同时获取。
上面的原则,实现了,同时只会有一个线程对资源进行写入,但是可以有多个线程(在没有其他线程获取写入锁时)对同一个资源进行读取。.net中实现的类:System.Threading.ReaderWriterLockSlim类。

总结

本篇主要记录,原生线程,与.net实现的托管线程的关系,以及线程之间如何处理冲突,如何进行线程间资源同步使用的各种.net实现机制,下一篇主要记录更为重要的异步操作。

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值