C#.NET Thread多线程并发编程学习与常见面试题解析-4、基元线程同步构造

前言:上期我们解析了lock,并且得知了lock是对Monitor的封装,并且说了Monitor其实是一个混合锁,我们这期来看一下他到底混合了什么呢。
当然,本节内容的理论知识非常多

一、基元线程同步构造概念

首先基元是什么意思?基元指的是最简单的代码构造,例如

int a=0;

Int32 a = new Int32();

他们生成出来的IL代码都是一样的,所以我们就称int为基元类型。
基元线程同步又分为两种:用户模式内核模式

内核模式:
要求应用程序的线程中调用在操作系统内核中实现的函数。一个线程使用一个内核模式的构造获取一个由其他线程拥有的资源时,系统会阻塞线程,使它不再浪费CPU时间。缺点是调用线程将从托管代码转换为内核代码,再转回来,会浪费大量CPU时间,同时还伴随着线程上下文切换。

用户模式:
基元用户模式比基元内核模式速度要快,它使用特殊的cpu指令来协调线程,在硬件中发生,速度很快。但也因此系统操作系统永远检测不到一个线程在一个用户模式构造上阻塞了。缺点:当资源被占用时会一直询问资源是否可用,长时间占用的话会十分影响性能。

总结:
在短时间占用资源时尽量使用用户模式,这样避免了代码切换和线程上下文切换,在长时间占用资源时尽量使用内核模式,这样避免了长时间在资源前无意义的等待。

二、并发编程三大性质

这里正式介绍一下并发编程的三大性质,其实我们之前的文章都有提到,只是举例而已,现在做一个系统性的总结方便接下来的内容使用。
1、原子性:
原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着“同生共死”的感觉。及时在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。
2、有序性:
如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。
3、可见性:
可见性就是指当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。

三、用户模式

用户模式又包括两种构造,易失构造和互锁构造

1、易失构造

在C#中易失构造也有相对应的关键字volatile,但是这里不太好实验,本来直接上C/C++汇编说明是最方便的,但是又感觉放在本专题上太臃肿,有兴趣的就点击下面引用的链接吧,这里就做一个简单的说明。

在C/C++中代码要编译生成汇编语言,在C#中也要先通过JIT编译器生成IL,但是这个过程呢并不是一一对应的,编译器可能会对我们的代码进行优化,例如他发现你声明过某个变量好像没有用过,那可能就会直接给你优化掉不声明了。

又例如他发现这个复合计算的结果可以直接得到,也许就直接优化成结果了,可能会直接优化成b=1而不是复合计算得到。(又或者可能直接从CPU寄存器读而不是重新从内存中读)

a=0;
b=a+1;

再例如编译器可能会发现两句不关联,或许会优化成b++先执行(只是一种假设,但实际上很多地方确实会做乱序优化)。

a++;
b++;

可以看到优化过后对于读取顺序和执行顺序都是不确定的,这其实对于线程同步来说是十分危险的,所以可以在声明变量的时候使用volatile关键字

public volatile int x;

这里volatile声明的类型是有限制的,例如long和double就不可以,具体的可以看MSDN文档。

声明了volatile之后,编译器对该字段将不会进行以上优化。
(严格意义来说volatile只能保证可见性,不能保证顺序性和原子性,但是实际操作的话现代volatile都有在一定程度上改进过,所以可以在一定程度上对原子性和顺序性保证,但是volatile还是要慎用)

2、互锁构造

.net中为互锁构造提供了System.Threading.Interlocked类,使用这个类中的方法保证都是原子性操作,主要是对Int类型进行操作,这个类的方法使用方式很简单,主要用到以下几个:

Increment: 自增,可以看作是++
Decrement: 递减,可以看作是–
Add:对两个数求和,用结果替换第一个数
Exchange:设置为指定值并返回原始值

这一个类的使用是相当简单的,把我们以前对Int类型的普通操作换成用这个类来操作就具备原子性了。比较有意思的是我们可以使用这个类很方便的实现一个自旋锁,可以用于对一组字段的原子性操作。

    public struct MySpinLock
    {
        private Int32 flag;
        public void Enter()
        {
            while (true)
                if (Interlocked.Exchange(ref flag, 1) == 0)
                    return;
        }

        public void Exit()
        {
            Interlocked.Exchange(ref flag, 0);
        }

    }

我们只要实例化出来这个MySpinLock就可以像我们上一期的Monitor那样去使用,特点就是用户模式的特点,不会阻塞暂停线程,等到可用为止。

当然.net也提供了自己的自旋锁,System.Threading.SpinLock,在特别短的时间内占用资源的时候还是要比lock要快的。

三、内核模式

内核模式使用的是操作系统内核提供的函数,在.NET中System.Threading命名空间提供了一个抽象基类WaitHandle。这个简单的类唯一的作用就是包装一个系统内核对象句柄。WaitHandle有一些派生类,其中不少我们之前已经接触过了,EventWaitHandle,AutoResetEvent,ManualResetEvent,Semaphore。这些我们之前在使用的时候都说过他会阻塞暂停线程,其构造模式就是内核模式构造。

这里我们主要再介绍一个内核模式构造锁,Mutex(mutually exclusive)互斥锁,也包含在System.Threading命名空间里。

我们之前已经学习使用过了Seamphore,而Mutex的基本使用方法可以看作是只有一个请求数的Seamphore,但还是有区别。

using System;
using System.Threading;
namespace LeeCarry
{
    public class Test
    {
        private static Semaphore flag=new Semaphore(1,1);
        public static void Main(string[] args)
        {
            
            Thread t = new Thread(()=>
            {
                flag.WaitOne();
                flag.WaitOne();//会暂停在此处
                Console.WriteLine("我是线程我现在正常运行");
                flag.Release();
                flag.Release();
            });
            t.Start();
        }
        
    }
}

以上线程会被不会有任何结果,因为在第一个WaitOne()的时候会扣一个请求数,在第二个WaitOne()的时候由于没有请求数了便会在此处阻塞暂停等待有请求数了再通过(但它再也等不到了)。

那么Mutex有什么不同呢,Mutex释放锁时用的是ReleaseMutex()方法,释放一个请求数。刚刚不是说Mutex可以看作是只有一个请求数的Seamphore吗?为什么这里要强调ReleaseMutex只是释放一个请求数?

因为拥有 mutex 的线程可以在重复调用WaitOne中请求同一互斥体, 而不会阻止其执行。但是,线程必须调用ReleaseMutex方法相同的次数来释放 mutex 的所有权。
以下代码演示的较为简单,实际情况可能在锁内需要去调用别的函数有可能会再次WaitOne/Release等

using System;
using System.Threading;
namespace LeeCarry
{
    public class Test
    {
        private static Mutex flag=new Mutex();
        public static void Main(string[] args)
        {
            
            Thread t = new Thread(()=>
            {
                flag.WaitOne();
                flag.WaitOne();
                Console.WriteLine("我是线程我现在正常运行");
                flag.ReleaseMutex();
                flag.ReleaseMutex();
            });
            t.Start();
        }
        
    }
}

以上程序可以正常输出,但是在实际开发中要注意,要把WaitOne/ReleaseMutex放进try/finally块中保证Mutex有被正常释放。

还有Mutex是可以跨进程的,通常在跨进程使用的时候构造函数会用到三个参数
Mutex(Boolean, String, Boolean)
第一个参数的bool是用来标识,第一次初始化的线程是否拥有该互斥锁。
第二个参数的string是用来标识互斥锁的名字。
第三个参数的bool传递进去一个引用,用来查看该互斥锁是第一次被创建还是已经存在了。

using System;
using System.Threading;
namespace LeeCarry
{
    public class Test
    {

        public static void Main(string[] args)
        {
            bool isCreator;
            Mutex mutex=new Mutex(true,"test",out isCreator);
            
            if(isCreator)
            {
                Console.WriteLine("我是创造者");
                Console.ReadKey();
                mutex.ReleaseMutex();
            }
            else
            {
                Console.WriteLine("这个mutex已经存在了");
            }

        }
        
    }
}

这样两个终端分别运行该程序结果如图所示。
C#.NET Thread多线程并发编程学习与常见面试题解析-4、基元线程同步构造_第1张图片
C#.NET Thread多线程并发编程学习与常见面试题解析-4、基元线程同步构造_第2张图片

四、混合模式

既然内核模式和用户模式都有优缺点,混合构造就是把两者结合,充分利用两者的优点,把性能损失降到最低。大概的思路很好理解,就是如果是在没有资源竞争,或线程使用资源的时间很短,就是用用户模式构造同步,否则就升级到内核模式构造同步,其中最典型的代表就是lock,当然也就是Monitor了。

除了lock以外我们接触过的还有SemaphoreSlim、ManualResetEventSlim,注意他们最后跟了一个Slim,使用方法跟我们之前学的Semaphore,ManualResetEvent一样,只不过他不会先阻塞暂停线程,而是先自旋一段时间,超过一定时间之后再阻塞暂停线程,因此在短时间内抢占资源的话MSDN会建议我们使用这个带Slim结尾的,当然如果你已经预估会长时间占用了可以直接上原生的避免无效自旋(同样的道理如果你已经预估很短时间占用可以直接用自旋锁不用lock)。

总的来说,其实这几个混合锁就够我们解决90%的问题了,但是为了那剩下的问题,我们还是得不断的去深入学习。


引用:
C# 基元类型-Green.Leaf
.NET面试题解析(07)-多线程编程与线程同步-/梦里花落知多少/
基元:用户模式和内核模式构造-HelloWorld.Michael
C#进阶系列 28 基元线程同步构造- 韩子卢
C/C++ Volatile关键词深度剖析-流水灯
三大性质总结:原子性,有序性,可见性-你听__
剖析为什么在多核多线程程序中要慎用volatile关键字?-Guancheng
【C#拾遗】——Mutex对象深入理解-mandy@i
volatile(C# 参考)-MSDN
Interlocked Class-MSDN
Mutex Class-MSDN
ManualResetEventSlim Class-MSDN

你可能感兴趣的:(并发,异步,并行,C#,多线程,dotnet,C#,同步)