线程安全

线程安全

多个线程试图同时访问同一个数据时,数据不会遭到破坏

线程同步构造

构造模式分别有用户模式和内核模式两种,其中用户模式构造使用了特殊的CPU指令协调线程(协调是在硬件中发生的事情),所以其构造速度要显著快于内核模式构造,同时用户模式中阻塞的线程池线程永远不会被认为阻塞,所以线程池不会创建新线程替换阻塞线程。在用户模式中运行的线程可能被系统抢占,但线程会以最快的速度再次调度,所以想要获取某一资源又暂时无法取得时,线程会用户模式中一直运行,这并不是一个良好的现象。而内核模式的构造是由Windows操作系统自身提供的,要求在应用程序的线程中调用在操作系统内核中实现的函数,将线程从用户模式切换为内核模式会造成巨大的性能损失。但是也有一个优点:一个线程使用内核模式构造获取一个由其它线程正在访问的资源时,Windows会阻塞线程,使之不再浪费CPU时间,等到资源可用时会恢复线程,允许它访问资源。

用户模式构造
  • 易失构造:在包含一个简单数据类型的变量上执行原子性的读或写操作
  • 互锁构造:在包含一个简单数据类型的变量上执行原子性的读和写操作
原子性

指事务的不可分割性,意味着一个变量的值的读取都是一次性的,如以下代码

class SomeType
{
    public static int x;
}

SomeType.x = 0x01234567;

变量x会一次性从0x00000000变成0x01234567,另一个线程不可能看到一个处于中间值的状态,如0x01234000,这便是原子性。

易失构造

编写好的代码需要被编译器编译成IL代码,再经过JIT编译器转换成本地CPU指令才能被计算机执行。而在这些转换过程中,编译器、JIT编译器、CPU本身可能都会对原先编写好的代码进行优化。如下面这段代码经过编译后将会消失

private static void SomeMethod()
{
    //常量表达式在编译时计算为0
    int value = 100 - (50 * 2);
    //value为0循环永不执行
    for (int i = 0; i < value; i++)
    {
        //永远执行不到,不需要编译循环中的代码
        Console.WriteLine(i);
    }
}

上述代码中,编译器发现value为0,循环永远不会执行,没有必要编译循环中的代码,因此这个方法编译后会被优化掉。如果有一个方法中调用了SomeMethod方法,在对这个方法进行JIT编译的时候,JIT编译器会尝试内联SomeMethod方法的代码,由于没有代码,所以JIT编译器会删除调用SomeMethod方法的代码。

编译器、JIT编译器和CPU对代码进行优化的时候,从单线程的角度看,代码会做我们希望它做的事情,而从多线程来看,代码的意图不一定会得到保留,以下的代码进行了演示:

class SomeType
{
    private int m_Flag = 0;
    private int m_Value = 0;

    public void Thread1()
    {
        this.m_Value = 10;
        this.m_Flag = 1;
    }

    public void Thread2()
    {
        //可能会输出0,与预期不一致
        if(this.m_Flag == 1)
            Console.WriteLine("value = {0}", this.m_Value);
    }
}

static void Main()
{
    ThreadPool.QueueUserWorkItem((o) =>
    {
        someType.Thread1();
    });
    ThreadPool.QueueUserWorkItem((o) =>
    {
        someType.Thread2();
    });
}

上述代码的问题在于假定Thread1方法中的代码按照顺序执行,编译Thread2方法中的代码时,编译器必须生成代码将m_Flag和m_Value 从RAM读入CPU寄存器。RAM可能先传递m_Value的值(此时为0),然后Thread1可能执行,将Thread1改为10,m_Flag改为1。但是Thread2的CPU寄存器没有看到m_Value的值已经被另一个线程修改为10,出现输出结果为0的情况。除此之外Thread1方法中的两行代码在CUP/编译器在解释代码时可能会出现反转,毕竟这样做也不会改变代码的意图,同样可能出现在Thread2中m_Value输出0的情况。

修改代码以修复问题,修改后的代码如下:
class SomeType
{
    private int m_Flag = 0;
    private int m_Value = 0;

    public void Thread1()
    {
        this.m_Value = 10;
        Thread.VolatileWrite(ref this.m_Flag, 1);
    }

    public void Thread2()
    {
        if (Thread.VolatileRead(ref this.m_Flag) == 1)
            Console.WriteLine("value = {0}", this.m_Value);
    }
}

修改后的代码可以看到分别使用了VolatileWrite和VolatileRead来读写数据,Thread1方法调用VolatileWrite可以确保前面的所有数据都写入完成才会将1写入m_Flag;Thread2方法调用VolatileRead可以确保必须先读取m_Flag的值才能读取m_Value的值。

VolatileWrite和VolatileRead
  • VolatileWrite:强迫address中的值在调用时写入,除此之外还必须按照顺序,即所有发生在VolatileWrite之前的加载和存储操作必须先于调用VolatileWrite方法完成
  • VolatileRead:强迫address中的值在调用时读取,除此之外还必须按照顺序,即所有发生在VolatileRead之后的加载和存储操作必须晚于调用VolatileRead方法完成
volatile关键字
class SomeType
{
    private volatile int m_Flag = 0;
    private int m_Value = 0;

    public void Thread1()
    {
        this.m_Value = 10;
        this.m_Flag = 1;
    }

    public void Thread2()
    {
        if (this.m_Flag == 1)
            Console.WriteLine("value = {0}", this.m_Value);
    }
}

使用volatile关键字可以达到和调用VolatileWrite和VolatileRead相同的效果,除此之外volatile关键字告诉C#和JIT编译器不将字段缓存到CPU寄存器中,确保字段的所有读写都在RAM中进行。

调用VolatileWrite方法或VolatileRead方法、使用volatile关键字将会禁用C#编译器、JIT编译器和CPU本身所执行的一些代码优化,如果使用不当反而会损害性能。并且C#不支持以传引用的方式将volatile修饰的字段传递给方法。

自旋锁
struct SpinLock
{
    private int m_ResourceInUse;

    public void Enter()
    {
        //将资源设置为正在使用,并返回m_ResourceInUse的原始值
        while (Interlocked.Exchange(ref this.m_ResourceInUse, 1) != 0) { }
    }

    public void Leave()
    {
        //释放资源
        Thread.VolatileWrite(ref this.m_ResourceInUse, 0);
    }
}

private static SpinLock s_SpinLock = new SpinLock();
private static void DoSomething()
{
    s_SpinLock.Enter();
    //一次只有一个线程才能进入这里执行代码
    s_SpinLock.Leave();
}

现在如果两个线程同时调用Enter,Interlocked.Exchange会确保其中一个线程将m_ResourceInUse从0变到1,并返回m_ResourceInUse的原始值0,然后线程从Enter返回,继续执行后面的代码。另一个线程会将m_ResourceInUse从1变到1,并返回原始值1,发现不是将m_ResourceInUse从0变成1的,所以会一直调用Interlocked.Exchange开始自旋,直到第一个线程调用Leave。第一个线程调用Leave后,会将m_ResourceInUse重新变成0,这时正在自旋的线程调用Interlocked.Exchange能够将m_ResourceInUse从0变成1,于是从Enter返回继续执行后续的代码。

自旋锁的缺点在于处于自旋的线程无法做其它的工作,浪费CPU时间,建议只将自旋锁用于保护执行得非常快的代码块。

内核构造构造
内核模式构造的缺点

由于需要Windows操作系统的自身协作以及内核对象上调用的每个方法都会造成调用线程从托管代码转换成本地用户代码,再转换为本地内核模式代码,这些转换需要大量的CPU时间,如果经常执行可能会对应用程序的性能造成负面影响。

内核模式构造的优点
  • 在资源竞争时,Windows会阻塞输掉的线程,让它不占用CPU从而浪费处理器资源
  • 在内核模式构造上阻塞的线程可以指定超时值,如果指定时间内访问不到希望得到的资源,线程可以解除阻塞执行其它任务
  • 一个线程可以一直阻塞,直到一个集合中的所有内核模式的构造皆可使用或者一个集合中的任何内核模式的构造可用
通过内核构造实现一个单实例应用程序
static void Main()
{
    bool createdNew;
    //创建一个具有指定名称的内核对象
    using (new Semaphore(0, 1, "MyObject", out createdNew))
    {
        if (createdNew)
        {
            //线程创建了内核对象,所以肯定没有这个应用程序的其它实例正在运行
        }
        else
        {
            //线程打开了一个现有的内核对象,说明实例正在被使用,立即退出
        }
    }
}
代码解析

假设进程的两个实例同时启动。每个进程都有自己的线程,两个线程都尝试创建具有相同字符串名称“MyObject”的一个Semaphore。Windows内核确保只有一个线程创建具有指定名称的内核对象。创建对象的线程会将它的createdNew设置为true。

第二个线程,Windows发现具有指定名称的内核对象已经存在了,因此不允许第二个线程创建另一个同名的内核对象,但是却可以访问和第一个进程的线程所访问的一样的内核对象。不同进程的线程便是这样通过一个内核对象互相通信的。在上述代码中第二个线程发现createdNew变量为false,所以知道这个进程的另一个实例正在运行,所以进程的第二个实例立即退出。

Event构造

事件是由内核维护的Boolean变量,如果事件为false,在事件上等待的线程就阻塞,反之解除阻塞。事件分为自动重置事件和手动重置事件,当自动重置事件为true时,只唤醒一个阻塞的线程,因为在解除第一个线程的阻塞后,内核将事件重置回false。当手动重置事件为true时,会解除正在等待的所有线程的阻塞,因为内核不将事件自动重置为false,代码必须将事件手动重置回false。

使用自动同步事件创建线程同步锁
class WaitLock : IDisposable
{ 
    private AutoResetEvent m_Resources = new AutoResetEvent(true);

    public void Enter()
    {
        //在内核中阻塞,等待资源可用然后返回
        this.m_Resources.WaitOne();
    }

    public void Leave()
    {
        //释放资源
        this.m_Resources.Set();
    }

    public void Dispose()
    {
        this.m_Resources.Dispose();
    }
}
SpinLock与WaitLock性能对比
static void Method() { }

static void Main()
{
    var x = 0;
    var iteration = 10000000;

    //x递增1000万需要花费时间
    Stopwatch sw = Stopwatch.StartNew();
    for (int i = 0; i < iteration; i++)
        x++;
    Console.WriteLine("x递增1000万次花费时间: {0}", sw.ElapsedMilliseconds);

    //x递增1000万次加上调用一个空方法需要花费的时间
    sw.Restart();
    for (int i = 0; i < iteration; i++)
    {
        Method();
        x++;
    }
    Console.WriteLine("x递增1000万次加上调用一个空方法需要花费的时间: {0}", sw.ElapsedMilliseconds);

    //x递增1000万次加上一个无竞争的SpinLock需要花费的时间
    SpinLock spinLock = new SpinLock();
    sw.Restart();
    for (int i = 0; i < iteration; i++)
    {
        spinLock.Enter();
        x++;
        spinLock.Leave();
    }
    Console.WriteLine("x递增1000万次加上一个无竞争的SpinLock需要花费的时间: {0}", sw.ElapsedMilliseconds);

    //x递增1000万次加上一个无竞争的WaitLock需要花费的时间
    using (var waitLock = new WaitLock())
    {
        sw.Restart();
        for (int i = 0; i < iteration; i++)
        {
            waitLock.Enter();
            x++;
            waitLock.Leave();
        }
        Console.WriteLine("x递增1000万次加上一个无竞争的WaitLock需要花费的时间: {0}", sw.ElapsedMilliseconds);
    }

    Console.ReadKey();
}
运行结果

image.png

可以看出SpinLock和WaitLock的行为完全相同,但是两个锁的性能完全不同。锁上面没有竞争的时候WaitLock比SpinLock慢得多,因为上面说到的WaitLock的Enter和Leave方法的每一次调用都强迫调用线程从托管代码转换成内核代码。但在存在竞争的时候,输掉的线程会被内核阻塞,不会造成自旋,这是好的地方。

通过例子可以看出内核构造速度慢得可怕,所以需要进行线程同步的时候尽量使用用户模式的构造。

Semaphore构造

信号量(Semaphore)是由内核维护的Int32变量,信号量为0时,在信号量上等待的线程会阻塞。信号量大于0时,就会解除阻塞。在一个信号量上等待的一个线程解除阻塞时,内核自动从信号量的计数中减1。当前信号量计数不能超过信号量关联的最大计数值。

Event构造与Semaphore构造对比
  • 自动重置事件:多个线程在一个自动重置事件上等待时,设置事件只导致一个线程被解除阻塞
  • 手动重置事件:多个线程在一个手动重置事件上等待时,设置事件会导致所有线程被解除阻塞
  • Semaphore构造:多个线程在一个信号量上等待时,释放信号量导致导致releaseCount(释放信号量个数)个线程被解除阻塞(releaseCount是传给Semaphore的Release方法的实参)

一个自动重置事件在行为上和最大计数为1的信号量非常相似,两者的区别就在,可以在一个自动重置事件上连续多次调用Set,同时仍然只有一个线程被解除阻塞。而在一个信号量上连续多次调用Release,会使它内部的计数一直递增,这可能造成解除大量线程的阻塞。而当计数超过最大计数时,Release会抛出SemaphoreFullException。

示例代码
class SemaphoreLock : IDisposable
{
    private Semaphore m_Resources;

    public SemaphoreLock(int coumaximumConcurThreads)
    {
        this.m_Resources = new Semaphore(coumaximumConcurThreads, coumaximumConcurThreads);
    }

    public void Enter()
    {
        //在内核中阻塞,等待资源可用然后返回
        this.m_Resources.WaitOne();
    }

    public void Leave()
    {
        //释放资源
        this.m_Resources.Release();
    }

    public void Dispose()
    {
        this.m_Resources.Close();
    }
}
Mutex(互斥锁)构造

互斥锁的逻辑
首先Mutex对象会查询调用线程的int ID,记录是哪一个线程获得了锁。一个线程调用ReleaseMutex时,Mutex确保调用线程就是获取Mutex的那个线程。如果不是,Mutex对象的状态就不会改变,同时ReleaseMutex也会抛出异常ApplicationException。

其次如果拥有Mutex的线程终止,那么Mutex上等待的一些线程会因为抛出一个AbandonedMutexException异常而被唤醒,通常该异常也会成为未处理异常。

Mutex对象还维护着一个递归计数,它指明拥有该Mutex的线程拥有了它多少次。如果一个线程当前拥有一个Mutex,然后该线程再次在Mutex上等待,递归计数将递增,且不会阻塞线程,允许这个线程继续执行。线程调用ReleaseMutex时,递归计数递减。只有在递归计数变成0时,另一个线程才能获取该Mutex。

Mutex的缺点

需要更多的内存容纳额外的线程ID和递归计数信息,Mutex代码还得维护这些信息,这些都会让锁变得更慢。

递归锁
class SomeType : IDisposable
{
    private readonly Mutex m_Lock = new Mutex();

    public void M1()
    {
        this.m_Lock.WaitOne();
        //do something...
        M2(); //递归获取锁
        this.m_Lock.ReleaseMutex();
    }

    public void M2()
    {
        this.m_Lock.WaitOne();
        //do something...
        this.m_Lock.ReleaseMutex();
    }

    public void Dispose()
    {
        this.m_Lock.Dispose();
    }
}

SomeType对象调用M1获取一个Mutex,然后调用M2,由于Mutex对象支持递归,所以线程会获取两次锁,然后释放两次,之后另一个线程才能拥有它。

内核构造可用时回调方法

让一个线程不确定地等待一个内核对象进入可用状态,这对线程的内存资源来说是一种浪费,因此线程池提供了一种方式,在一个内核对象变得可用时回调一个方法。

示例代码
class RegisterdWaitHandleClass
{
    public static void Main()
    {
        //构造自动重置事件
        AutoResetEvent autoResetEvent = new AutoResetEvent(false);

        //告诉线程池在AutoResetEvent上等待
        RegisteredWaitHandle rwh = ThreadPool.RegisterWaitForSingleObject(
            autoResetEvent, //在此事件上等待
            EventOperation, //回调EventOperation方法
            null, //向EventOperation传递null
            5000, //等5s事件变为True
            false //每次事件变为True时都调用EventOperation
        );

        var operation = (char)0;
        while(operation != 'Q')
        {
            operation = char.ToUpper(Console.ReadKey(true).KeyChar);
            if (operation == 'S')
                autoResetEvent.Set();
        }

        //取消注册
        rwh.Unregister(null);
    }

    //任何时候事件为True,或者自从上一次回调超过5s,就调用这个方法
    private static void EventOperation(object state, bool timedOut)
    {
        Console.WriteLine(timedOut ? "超时" : "事件为True");
    }
}
运行结果(每隔5s输出超时,键盘按下S输出事件为True)

image.png

你可能感兴趣的:(c#编程线程安全线程同步)