乐趣区

关于c#:线程安全

线程平安

多个线程试图同时拜访同一个数据时,数据不会受到毁坏

线程同步结构

结构模式别离有用户模式和内核模式两种,其中用户模式结构应用了非凡的 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();}
运行后果

能够看出 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)

退出移动版