最新要闻

广告

手机

iphone11大小尺寸是多少?苹果iPhone11和iPhone13的区别是什么?

iphone11大小尺寸是多少?苹果iPhone11和iPhone13的区别是什么?

警方通报辅警执法直播中被撞飞:犯罪嫌疑人已投案

警方通报辅警执法直播中被撞飞:犯罪嫌疑人已投案

家电

金三银四每天一个.NET基础知识巩固(一)

来源:博客园

一. 什么是线程安全?

正式一点的说法是:指某个函数、函数库在多线程环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。 talk is cheap,show me the code.直接上代码理解:

int i = 10, number = 99;while (i>0){    Console.WriteLine(++number);    i--;}

结果会输出什么?我们刚接触编程语言的同学都能看出来,number变量会依次+1并且打印:


(资料图片)

那如果我们使用多线程来对共享变量number进行累加输出呢?

int i = 10, number = 99;while (i > 0){    Task.Run(() =>    {        Console.WriteLine(++number);    });    i--;}复制代码

结果会输出什么?

看起来毫无规律可循,为什么会发生这种情况?

这是因为++number的指令在实际执行的过程中不是原子性的,而是要分为读、改、写三步来进行;即先从内存中读出number的值,然后执行+1操作,再将结果写回内存中。也就是说,可能线程一在读取number的值之后,还没来得及改,CPU切换到线程二读取了number的值,并且修改后写入内存中,这个时候如果再切回线程一,继续修改写入,那么这两次操作得到的值就是一样的,和我们预期逻辑不符。

那么,我们该如何避免这种情况,来保证线程安全呢?

1.原子操作

现在一般的CPU可以使用总线锁和缓存锁来实现: CPU在硬件上可以通过拉低引线来实现总线锁,当需要进行一个原子操作时,CPU就可以拉低引线,将总线锁住,实现其他CPU与内存之间的通讯隔离,这样,其他的CPU就不能够从内存中读取数据了。 由于总线锁会中断其他CPU与内存之间的通讯,导致在这段时间内其他CPU访问不到内存,效率降低,因此总线锁的开销是很大的,于是又出现了一种新的方式,缓存锁。

缓存锁不会阻断CPU与内存之间的通讯,内存中的一个变量可以被多个核心访问,其缓存行有被修改的、独享的、共享的、无效的这四种状态,简单的来说,在某个CPU修改变量时,会将其他CPU中的缓存置为无效的,从而保证在修改时,每个CPU核心中的值都是最新的。

C#中的原子操作在C#中,提供了Interlocked类来实现原子操作,其方法有Add、Increment、Decrement、Exchange、CompareExchange等,可以使用原子操作进行加法、加一、减一、替换、比较替换等操作。 那么我们优化一下刚才的代码,是不是就打印正常了呢?

int i = 10, number = 99;while (i > 0){    Task.Run(() =>    {        Interlocked.Increment(ref number);        Console.WriteLine(++number);    });    i--;}复制代码

答案是,打印依旧是没有规律的,这是为什么? 因为在我们Task的匿名委托里面,我们需要执行的是两个方法,我们使用Interlocked类确实保证了之前++number这个操作的原子性,但是Console.WriteLine方法我们并没有保证,所以会出现我们执行完number的修改后,还没有输出,线程却被挂起,被其他线程抢先进行了输出,但我们可以发现,我们输出的十个数没有重复的,这并不是巧合,恰恰是因为我们使用了Interlocked保证了number变量读取修改写入的原子性。

C#的线程安全类型

C#提供了一些线程安全的数据类型,这些数据类型大量应用了无锁算法来提升访问速度,如:System.Collections.Consurrent.CurrentBag

System.Collections.Consurrent.CurrentDictionary

System.Collections.Consurrent.CurrentQueue

System.Collections.Consurrent.CurrentStack

但是如果我就是想要task里的全部操作都是线程安全的,那怎么办呢?这就需要线程锁了。

C#的线程锁

自旋锁

自旋锁(Spinlock)是最简单的线程锁,基于原子操作实现。它使用一个数值来表示锁是否已经被获取,0表示未被获取,1表示已经获取。获取锁时会先使用原子操作设置数值为1,然后检查修改前的值是否为0,如果为0则代表获取成功,否则继续重试直到成功为止。 释放锁时会设置数值为0,其他正在获取锁的线程会在下一次重试时成功获取。 使用原子操作的原因是,它可以保证多个线程同时把数值0修改到1时,只有一个线程可以观察到修改前的值为0,其他线程观察到修改前的值为1。

.NET 可以使用以下的类实现自旋锁:

System.Threading.Thread.SpinWait

System.Threading.SpinWait

System.Threading.SpinLock

使用自旋锁有个需要注意的问题,自旋锁保护的代码应该在非常短的时间内执行完毕,如果代码长时间运行则其他需要获取锁的线程会不断重试并占用逻辑核心,影响其他线程运行。

此外,如果 CPU 只有一个逻辑核心,自旋锁在获取失败时应该立刻调用 Thread.Yield 函数提示操作系统切换到其他线程,因为一个逻辑核心同一时间只能运行一个线程,在切换线程之前其他线程没有机会运行,也就是切换线程之前自旋锁没有机会被释放。 我们使用System.Threading.SpinLock来优化一下刚才的代码:

int i = 10, number = 99;var sl = new SpinLock();while (i > 0){    Task.Run(() =>    {        var getlock = false;        sl.Enter(ref getlock);        Console.WriteLine(++number);        if (getlock) sl.Exit();    });    i--;}复制代码

结果正常输出:

互斥锁

由于自旋锁不适用于长时间运行,它的使用场景比较有限,更通用的线程锁是操作系统提供的基于原子操作与线程调度实现的互斥锁(Mutex)。与自旋锁一样,操作系统提供的互斥锁内部有一个数值表示是否已经被获取,不同的是当获取锁失败时,它不会反复重试,而是安排获取锁的线程进入等待状态,并把线程对象添加到锁关联的队列中,另一个线程释放锁时会检查队列中是否有线程对象,如果有则通知操作系统唤醒该线程。

因为处于等待状态的线程没有运行,即使长时间不释放也不会消耗 CPU 资源,但让线程进入等待状态与从等待状态唤醒并调度运行可能会花费毫秒级的时间,与自旋锁重试所需的纳秒级时间相比非常的长,所以在笔者看来,单进程内自旋锁和互斥锁的选择就是一种时间与空间的选择。

C#提供了 System.Threading.Mutex 类,这个类包装了操作系统提供的互斥锁,它是可重入的,已经获取锁的线程可以再次执行获取锁的操作,但释放锁的操作也要执行相同的次数,可重入的锁又叫递归锁(Recursive Lock)

递归锁内部使用一个计数器记录进入次数,同一个线程每获取一次就加1,释放一次就减1,减1后如果计数器变为0就执行真正的释放操作,一般用在嵌套调用的多个函数中

Mutex 类的另一个特点是支持跨进程使用,创建时通过构造函数的第二个参数可以传入名称

如果一个进程获取了锁,那么在释放该锁前的另一个进程获取同样名称的锁需要等待;如果进程获取了锁,但是在退出之前没有调用释放锁的方法,那么锁会被操作系统自动释放,其他当前正在等待锁(锁被自动释放前进入等待状态)的进程会收到 AbandonedMutexException 异常

跨进程锁通常用于保护多个进程共享的资源或者防止程序多重启动

我们使用Mutex类优化一下刚才的代码:

int i = 10, number = 99;var mut = new Mutex();while (i > 0){    Task.Run(() =>    {        mut.WaitOne();        Console.WriteLine(++number);        mut.ReleaseMutex();    });    i--;}复制代码

其中可以通过对Mutex构造函数的重载传入互斥锁的锁名,进行跨进程的使用,这里就不演示了,输出的结果和使用自旋锁一样,是正常的。

混合锁

互斥锁 Mutex 使用时必须创建改类型的实例,因为实例包含了非托管的互斥锁对象,开发者必须在不使用锁后尽快调用 Dispose 函数释放非托管资源,并且因为获取锁失败后会立刻安排线程进入等待,总体上性能比较低

.NET 提供了更通用而且更高性能的混合锁(Monitor),任何引用类型的对象都可以作为锁对象,不需要事先创建指定类型的实例,并且涉及的非托管资源由 .NET 运行时自动释放,不需要手动调用释放函数

获取和释放混合锁需要使用 System.Threading.Monitor 类中的函数

C# 提供了 lock 语法糖来简化通过 Monitor 类获取和释放的代码

混合锁的特征是在获取锁失败后像自旋锁一样重试一定的次数,超过一定次数之后(.NET Core 2.1 是30次)再安排当前进程进入等待状态

混合锁的好处是,如果第一次获取锁失败,但其他线程马上释放了锁,当前线程在下一轮重试可以获取成功,不需要执行毫秒级的线程调度处理;而如果其他线程在短时间内没有释放锁,线程会在超过重试次数之后进入等待状态,以避免消耗 CPU 资源,因此混合锁适用于大部分场景。

我们继续优化刚才的代码:

int i = 10, number = 99;var obj = new Object();while (i > 0){    Task.Run(() =>    {        lock(obj)        {            Console.WriteLine(++number);        }    });    i--;}复制代码

结果自然也是正常的,是不是感觉lock语法糖比之前的更加简便?

读写锁

读写锁(ReaderWriterLock)是一个具有特殊用途的线程锁,适用于频繁读取且读取需要一定时间的场景。

共享资源的读取操作通常是可以同时执行的,而普通的互斥锁不管是读取还是修改操作都无法同时执行,如果多个线程为了读取操作而获取互斥锁,那么同一时间只有一个线程可以执行读取操作,在频繁读取的场景下会对吞吐量造成影响。

读写锁分为读取锁和写入锁,线程可以根据对共享资源的操作类型选择获取读写锁还是写入锁,读取锁可以被多个线程同时获取,写入锁不可以被多个线程同时获取,而且读取锁和写入锁不可以被不同的线程同时获取。

C#提供的 System.Threading.ReaderWriterLockSlim 类实现了读写锁,

读写锁也是一个混合锁(Hybird Lock),在获取锁时通过自旋重试一定的次数再进入等待状态

此外,它还支持同一个线程先获取读写锁,然后再升级为写入锁,适用于“需要先获取读写锁,然后读取共享数据判断是否需要修改,需要修改时再获取写入锁”的场景

关键词: