线程同步各样锁,线程同步

目录

多个线程同时访问共享数据时,线程同步能防止数据损坏。之所以要强调同时,是因为线程同步问题实际上就是计时问题。

 1)原子操作(Interlocked):所有方法都是执行一次原子读取或一次写入操作。

  • 1.1 简介
  • 1.2 执行基本原子操作
  • 1.3 使用Mutex类
  • 1.4 使用SemaphoreSlim类
  • 1.5 使用AutoResetEvent类
  • 1.6 使用ManualResetEventSlim类
  • 1.7 使用CountDownEvent类
  • 1.8 使用Barrier类
  • 1.9 使用ReaderWriterLockSlim类
  • 1.10 使用SpinWait类
  • 参考书籍
  • 笔者水平有限,如果错误欢迎各位批评指正!

不需要线程同步是最理想的情况,因为线程同步一般很繁琐,涉及到线程同步锁的获取和释放,容易遗漏,而且锁会损耗性能,获取和释放锁都需要时间,最后锁的玩法就在于一次只能让一个线程访问数据,那么就会阻塞线程,阻塞线程就会让额外的线程产生,阻塞越多,线程越多,线程过多的坏处就不谈了。

  2)lock()语句:避免锁定public类型,否则实例将超出代码控制的范围,定义private对象来锁定。


所以可以避免线程同步的话就应该去避免,尽量不要去使用静态字段这样的共享数据。

  3)Monitor实现线程同步


类库和线程安全

    通过Monitor.Enter() 和 Monitor.Exit()实现排它锁的获取和释放,获取之后独占资源,不允许其他线程访问。

1.1 简介

本章介绍在C#中实现线程同步的几种方法。因为多个线程同时访问共享数据时,可能会造成共享数据的损坏,从而导致与预期的结果不相符。为了解决这个问题,所以需要用到线程同步,也被俗称为“加锁”。但是加锁绝对不对提高性能,最多也就是不增不减,要实现性能不增不减还得靠高质量的同步源语(Synchronization Primitive)。但是因为正确永远比速度更重要,所以线程同步在某些场景下是必须的。

线程同步有两种源语(Primitive)构造:用户模式(user - mode)内核模式(kernel - mode),当资源可用时间短的情况下,用户模式要优于内核模式,但是如果长时间不能获得资源,或者说长时间处于“自旋”,那么内核模式是相对来说好的选择。

但是我们希望兼具用户模式和内核模式的优点,我们把它称为混合构造(hybrid construct),它兼具了两种模式的优点。

在C#中有多种线程同步的机制,通常可以按照以下顺序进行选择。

  1. 如果代码能通过优化可以不进行同步,那么就不要做同步。
  2. 使用原子性的Interlocked方法。
  3. 使用lock/Monitor类。
  4. 使用异步锁,如SemaphoreSlim.WaitAsync()
  5. 使用其它加锁机制,如ReaderWriterLockSlim、Mutex、Semaphore等。
  6. 如果系统提供了*Slim版本的异步对象,那么请选用它,因为*Slim版本全部都是混合锁,在进入内核模式前实现了某种形式的自旋。

在同步中,一定要注意避免死锁的发生,死锁的发生必须满足以下4个基本条件,所以只需要破坏任意一个条件,就可避免发生死锁。

  1. 排他或互斥(Mutual exclusion):一个线程(ThreadA)独占一个资源,没有其它线程(ThreadB)能获取相同的资源。
  2. 占有并等待(Hold and wait):互斥的一个线程(ThreadA)请求获取另一个线程(ThreadB)占有的资源.
  3. 不可抢先(No preemption):一个线程(ThreadA)占有资源不能被强制拿走(只能等待ThreadA主动释放它的资源)。
  4. 循环等待条件(Circular wait condition):两个或多个线程构成一个循环等待链,它们锁定两个或多个相同的资源,每个线程都在等待链中的下一个线程占有的资源。

.net类库保证了所有静态方法都是线程安全的,也就是说两个线程同时调用一个静态方法,不会发生数据被破坏的情况。

    还有一个TryEnter方法,请求不到资源时不会阻塞等待,可以设置超时时间,获取不到直接返回false。

1.2 执行基本原子操作

CLR保证了对这些数据类型的读写是原子性的:Boolean、Char、(S)Byte、(U)Int16、(U)Int32、(U)IntPtr和Single。但是如果读写Int64可能会发生读取撕裂(torn read)的问题,因为在32位操作系统中,它需要执行两次Mov操作,无法在一个时间内执行完成。

那么在本节中,就会着重的介绍System.Threading.Interlocked类提供的方法,Interlocked类中的每个方法都是执行一次的读取以及写入操作。更多与Interlocked类相关的资料请参考链接,戳一戳.aspx)本文不在赘述。

演示代码如下所示,分别使用了三种方式进行计数:错误计数方式、lock锁方式和Interlocked原子方式。

private static void Main(string[] args)
{
    Console.WriteLine("错误的计数");

    var c = new Counter();
    Execute(c);

    Console.WriteLine("--------------------------");


    Console.WriteLine("正确的计数 - 有锁");

    var c2 = new CounterWithLock();
    Execute(c2);

    Console.WriteLine("--------------------------");


    Console.WriteLine("正确的计数 - 无锁");

    var c3 = new CounterNoLock();
    Execute(c3);

    Console.ReadLine();
}

static void Execute(CounterBase c)
{
    // 统计耗时
    var sw = new Stopwatch();
    sw.Start();

    var t1 = new Thread(() => TestCounter(c));
    var t2 = new Thread(() => TestCounter(c));
    var t3 = new Thread(() => TestCounter(c));
    t1.Start();
    t2.Start();
    t3.Start();
    t1.Join();
    t2.Join();
    t3.Join();

    sw.Stop();
    Console.WriteLine($"Total count: {c.Count} Time:{sw.ElapsedMilliseconds} ms");
}

static void TestCounter(CounterBase c)
{
    for (int i = 0; i < 100000; i  )
    {
        c.Increment();
        c.Decrement();
    }
}

class Counter : CounterBase
{
    public override void Increment()
    {
        _count  ;
    }

    public override void Decrement()
    {
        _count--;
    }
}

class CounterNoLock : CounterBase
{
    public override void Increment()
    {
        // 使用Interlocked执行原子操作
        Interlocked.Increment(ref _count);
    }

    public override void Decrement()
    {
        Interlocked.Decrement(ref _count);
    }
}

class CounterWithLock : CounterBase
{
    private readonly object _syncRoot = new Object();

    public override void Increment()
    {
        // 使用Lock关键字 锁定私有变量
        lock (_syncRoot)
        {
            // 同步块
            Count  ;
        }
    }

    public override void Decrement()
    {
        lock (_syncRoot)
        {
            Count--;
        }
    }
}


abstract class CounterBase
{
    protected int _count;

    public int Count
    {
        get
        {
            return _count;
        }
        set
        {
            _count = value;
        }
    }

    public abstract void Increment();

    public abstract void Decrement();
}

运行结果如下所示,与预期结果基本相符。

图片 1

并不能保证所有实例方法线程安全。因为一般情况下实例创建后只有创建的线程能访问到,除非后来将实例的引用传给了一个静态变量,或者将引用传给了线程池的队列或者任务,那么此时可能就要考虑用线程同步了。

  4)ReaderWriterLock

1.3 使用Mutex类

System.Threading.Mutex在概念上和System.Threading.Monitor几乎一样,但是Mutex同步对文件或者其他跨进程的资源进行访问,也就是说Mutex是可跨进程的。因为其特性,它的一个用途是限制应用程序不能同时运行多个实例。

Mutex对象支持递归,也就是说同一个线程可多次获取同一个锁,这在后面演示代码中可观察到。由于Mutex的基类System.Theading.WaitHandle实现了IDisposable接口,所以当不需要在使用它时要注意进行资源的释放。更多资料:戳一戳

演示代码如下所示,简单的演示了如何创建单实例的应用程序和Mutex递归获取锁的实现。

const string MutexName = "CSharpThreadingCookbook";

static void Main(string[] args)
{
    // 使用using 及时释放资源
    using (var m = new Mutex(false, MutexName))
    {
        if (!m.WaitOne(TimeSpan.FromSeconds(5), false))
        {
            Console.WriteLine("已经有实例正在运行!");
        }
        else
        {

            Console.WriteLine("运行中...");

            // 演示递归获取锁
            Recursion();

            Console.ReadLine();
            m.ReleaseMutex();
        }
    }

    Console.ReadLine();
}

static void Recursion()
{
    using (var m = new Mutex(false, MutexName))
    {
        if (!m.WaitOne(TimeSpan.FromSeconds(2), false))
        {
            // 因为Mutex支持递归获取锁 所以永远不会执行到这里
            Console.WriteLine("递归获取锁失败!");
        }
        else
        {
            Console.WriteLine("递归获取锁成功!");
        }
    }
}

运行结果如下图所示,打开了两个应用程序,因为使用Mutex实现了单实例,所以第二个应用程序无法获取锁,就会显示已有实例正在运行

图片 2

Console类包含一个静态字段,类的许多方法都要获取和释放这个对象上的锁,确保只有一个线程访问控制台。

    当对资源操作读多写少的时候,为了提高资源的利用率,让读操作锁为共享锁,多个线程可以并发读取资源,而写操作为独占锁,只允许一个线程操作。

1.4 使用SemaphoreSlim类

SemaphoreSlim类与之前提到的同步类有锁不同,之前提到的同步类都是互斥的,也就是说只允许一个线程进行访问资源,而SemaphoreSlim是可以允许多个访问。

在之前的部分有提到,以*Slim结尾的线程同步类,都是工作在混合模式下的,也就是说开始它们都是在用户模式下"自旋",等发生第一次竞争时,才切换到内核模式。但是SemaphoreSlim不同于Semaphore类,它不支持系统信号量,所以它不能用于进程之间的同步

该类使用比较简单,演示代码演示了6个线程竞争访问只允许4个线程同时访问的数据库,如下所示。

static void Main(string[] args)
{
    // 创建6个线程 竞争访问AccessDatabase
    for (int i = 1; i <= 6; i  )
    {
        string threadName = "线程 "   i;
        // 越后面的线程,访问时间越久 方便查看效果
        int secondsToWait = 2   2 * i;
        var t = new Thread(() => AccessDatabase(threadName, secondsToWait));
        t.Start();
    }

    Console.ReadLine();
}

// 同时允许4个线程访问
static SemaphoreSlim _semaphore = new SemaphoreSlim(4);

static void AccessDatabase(string name, int seconds)
{
    Console.WriteLine($"{name} 等待访问数据库.... {DateTime.Now.ToString("HH:mm:ss.ffff")}");

    // 等待获取锁 进入临界区
    _semaphore.Wait();

    Console.WriteLine($"{name} 已获取对数据库的访问权限 {DateTime.Now.ToString("HH:mm:ss.ffff")}");
    // Do something
    Thread.Sleep(TimeSpan.FromSeconds(seconds));

    Console.WriteLine($"{name} 访问完成... {DateTime.Now.ToString("HH:mm:ss.ffff")}");
    // 释放锁
    _semaphore.Release();
}

运行结果如下所示,可见前4个线程马上就获取到了锁,进入了临界区,而另外两个线程在等待;等有锁被释放时,才能进入临界区。图片 3

基元用户模式和内核模式构造(这一部分看不明白可以先看看后面的用户模式和内核模式的讲解,就会清楚了)

  5)事件(Event)类实现同步

1.5 使用AutoResetEvent类

AutoResetEvent叫自动重置事件,虽然名称中有事件一词,但是重置事件和C#中的委托没有任何关系,这里的事件只是由内核维护的Boolean变量,当事件为false,那么在事件上等待的线程就阻塞;事件变为true,那么阻塞解除。

在.Net中有两种此类事件,即AutoResetEvent(自动重置事件)ManualResetEvent(手动重置事件)。这两者均是采用内核模式,它的区别在于当重置事件为true时,自动重置事件它只唤醒一个阻塞的线程,会自动将事件重置回false,造成其它线程继续阻塞。而手动重置事件不会自动重置,必须通过代码手动重置回false

因为以上的原因,所以在很多文章和书籍中不推荐使用AutoResetEvent(自动重置事件),因为它很容易在编写生产者线程时发生失误,造成它的迭代次数多余消费者线程。

演示代码如下所示,该代码演示了通过AutoResetEvent实现两个线程的互相同步。

static void Main(string[] args)
{
    var t = new Thread(() => Process(10));
    t.Start();

    Console.WriteLine("等待另一个线程完成工作!");
    // 等待工作线程通知 主线程阻塞
    _workerEvent.WaitOne();
    Console.WriteLine("第一个操作已经完成!");
    Console.WriteLine("在主线程上执行操作");
    Thread.Sleep(TimeSpan.FromSeconds(5));

    // 发送通知 工作线程继续运行
    _mainEvent.Set();
    Console.WriteLine("现在在第二个线程上运行第二个操作");

    // 等待工作线程通知 主线程阻塞
    _workerEvent.WaitOne();
    Console.WriteLine("第二次操作完成!");

    Console.ReadLine();
}

// 工作线程Event
private static AutoResetEvent _workerEvent = new AutoResetEvent(false);
// 主线程Event
private static AutoResetEvent _mainEvent = new AutoResetEvent(false);

static void Process(int seconds)
{
    Console.WriteLine("开始长时间的工作...");
    Thread.Sleep(TimeSpan.FromSeconds(seconds));
    Console.WriteLine("工作完成!");

    // 发送通知 主线程继续运行
    _workerEvent.Set();
    Console.WriteLine("等待主线程完成其它工作");

    // 等待主线程通知 工作线程阻塞
    _mainEvent.WaitOne();
    Console.WriteLine("启动第二次操作...");
    Thread.Sleep(TimeSpan.FromSeconds(seconds));
    Console.WriteLine("工作完成!");

    // 发送通知 主线程继续运行
    _workerEvent.Set();
}

运行结果如下图所示,与预期结果符合。

图片 4

基元是指可以在代码中使用的最简单的构造。

    事件类有两种状态,终止状态和非终止状态,终止状态时调用WaitOne可以请求成功,通过Set将时间状态设置为终止状态。

1.6 使用ManualResetEventSlim类

ManualResetEventSlim使用和ManualResetEvent类基本一致,只是ManualResetEventSlim工作在混合模式下,而它与AutoResetEventSlim不同的地方就是需要手动重置事件,也就是调用Reset()才能将事件重置为false

演示代码如下,形象的将ManualResetEventSlim比喻成大门,当事件为true时大门打开,线程解除阻塞;而事件为false时大门关闭,线程阻塞。

static void Main(string[] args)
        {
            var t1 = new Thread(() => TravelThroughGates("Thread 1", 5));
            var t2 = new Thread(() => TravelThroughGates("Thread 2", 6));
            var t3 = new Thread(() => TravelThroughGates("Thread 3", 12));
            t1.Start();
            t2.Start();
            t3.Start();

            // 休眠6秒钟  只有Thread 1小于 6秒钟,所以事件重置时 Thread 1 肯定能进入大门  而 Thread 2 可能可以进入大门
            Thread.Sleep(TimeSpan.FromSeconds(6));
            Console.WriteLine($"大门现在打开了!  时间:{DateTime.Now.ToString("mm:ss.ffff")}");
            _mainEvent.Set();

            // 休眠2秒钟 此时 Thread 2 肯定可以进入大门
            Thread.Sleep(TimeSpan.FromSeconds(2));
            _mainEvent.Reset();
            Console.WriteLine($"大门现在关闭了! 时间:{DateTime.Now.ToString("mm: ss.ffff")}");

            // 休眠10秒钟 Thread 3 可以进入大门
            Thread.Sleep(TimeSpan.FromSeconds(10));
            Console.WriteLine($"大门现在第二次打开! 时间:{DateTime.Now.ToString("mm: ss.ffff")}");
            _mainEvent.Set();
            Thread.Sleep(TimeSpan.FromSeconds(2));

            Console.WriteLine($"大门现在关闭了! 时间:{DateTime.Now.ToString("mm: ss.ffff")}");
            _mainEvent.Reset();

            Console.ReadLine();
        }

        static void TravelThroughGates(string threadName, int seconds)
        {
            Console.WriteLine($"{threadName} 进入睡眠 时间:{DateTime.Now.ToString("mm:ss.ffff")}");
            Thread.Sleep(TimeSpan.FromSeconds(seconds));

            Console.WriteLine($"{threadName} 等待大门打开! 时间:{DateTime.Now.ToString("mm:ss.ffff")}");
            _mainEvent.Wait();

            Console.WriteLine($"{threadName} 进入大门! 时间:{DateTime.Now.ToString("mm:ss.ffff")}");
        }

        static ManualResetEventSlim _mainEvent = new ManualResetEventSlim(false);

运行结果如下,与预期结果相符。

图片 5

有两种基元构造:用户模式和内核模式。应尽量使用基元用户模式构造,它们的速度显著高于内核模式的构造。

    1)AutoResetEvent(自动重置事件)

1.7 使用CountDownEvent类

CountDownEvent类内部构造使用了一个ManualResetEventSlim对象。这个构造阻塞一个线程,直到它内部计数器(CurrentCount)变为0时,才解除阻塞。也就是说它并不是阻止对已经枯竭的资源池的访问,而是只有当计数为0时才允许访问。

这里需要注意的是,当CurrentCount变为0时,那么它就不能被更改了。为0以后,Wait()方法的阻塞被解除。

演示代码如下所示,只有当Signal()方法被调用2次以后,Wait()方法的阻塞才被解除。

static void Main(string[] args)
{
    Console.WriteLine($"开始两个操作  {DateTime.Now.ToString("mm:ss.ffff")}");
    var t1 = new Thread(() => PerformOperation("操作 1 完成!", 4));
    var t2 = new Thread(() => PerformOperation("操作 2 完成!", 8));
    t1.Start();
    t2.Start();

    // 等待操作完成
    _countdown.Wait();
    Console.WriteLine($"所有操作都完成  {DateTime.Now.ToString("mm: ss.ffff")}");
    _countdown.Dispose();

    Console.ReadLine();
}

// 构造函数的参数为2 表示只有调用了两次 Signal方法 CurrentCount 为 0时  Wait的阻塞才解除
static CountdownEvent _countdown = new CountdownEvent(2);

static void PerformOperation(string message, int seconds)
{
    Thread.Sleep(TimeSpan.FromSeconds(seconds));
    Console.WriteLine($"{message}  {DateTime.Now.ToString("mm:ss.ffff")}");

    // CurrentCount 递减 1
    _countdown.Signal();
}

运行结果如下图所示,可见只有当操作1和操作2都完成以后,才执行输出所有操作都完成。

图片 6

这是因为它们使用特殊的CPU指令来协调线程,意味着协调是在硬件上发生的,也意味着操作系统永远检测不到一个线程在基元用户模式的构造上阻塞了。

    2)ManualResetEvent(手动重置事件)

1.8 使用Barrier类

Barrier类用于解决一个非常稀有的问题,平时一般用不上。Barrier类控制一系列线程进行阶段性的并行工作。

假设现在并行工作分为2个阶段,每个线程在完成它自己那部分阶段1的工作后,必须停下来等待其它线程完成阶段1的工作;等所有线程均完成阶段1工作后,每个线程又开始运行,完成阶段2工作,等待其它线程全部完成阶段2工作后,整个流程才结束。

演示代码如下所示,该代码演示了两个线程分阶段的完成工作。

static void Main(string[] args)
{
    var t1 = new Thread(() => PlayMusic("钢琴家", "演奏一首令人惊叹的独奏曲", 5));
    var t2 = new Thread(() => PlayMusic("歌手", "唱着他的歌", 2));

    t1.Start();
    t2.Start();

    Console.ReadLine();
}

static Barrier _barrier = new Barrier(2,
 Console.WriteLine($"第 {b.CurrentPhaseNumber   1} 阶段结束"));

static void PlayMusic(string name, string message, int seconds)
{
    for (int i = 1; i < 3; i  )
    {
        Console.WriteLine("----------------------------------------------");
        Thread.Sleep(TimeSpan.FromSeconds(seconds));
        Console.WriteLine($"{name} 开始 {message}");
        Thread.Sleep(TimeSpan.FromSeconds(seconds));
        Console.WriteLine($"{name} 结束 {message}");
        _barrier.SignalAndWait();
    }
}

运行结果如下所示,当“歌手”线程完成后,并没有马上结束,而是等待“钢琴家”线程结束,当"钢琴家"线程结束后,才开始第2阶段的工作。

图片 7

只有操作系统内核才能停止一个线程的运行。

  6)信号量(Semaphore)

1.9 使用ReaderWriterLockSlim类

ReaderWriterLockSlim类主要是解决在某些场景下,读操作多于写操作而使用某些互斥锁当多个线程同时访问资源时,只有一个线程能访问,导致性能急剧下降。

如果所有线程都希望以只读的方式访问数据,就根本没有必要阻塞它们;如果一个线程希望修改数据,那么这个线程才需要独占访问,这就是ReaderWriterLockSlim的典型应用场景。这个类就像下面这样来控制线程。

  • 一个线程向数据写入是,请求访问的其他所有线程都被阻塞。
  • 一个线程读取数据时,请求读取的线程允许读取,而请求写入的线程被阻塞。
  • 写入线程结束后,要么解除一个写入线程的阻塞,使写入线程能向数据接入,要么解除所有读取线程的阻塞,使它们能并发读取数据。如果线程没有被阻塞,锁就可以进入自由使用的状态,可供下一个读线程或写线程获取。
  • 从数据读取的所有线程结束后,一个写线程被解除阻塞,使它能向数据写入。如果线程没有被阻塞,锁就可以进入自由使用的状态,可供下一个读线程或写线程获取。

ReaderWriterLockSlim还支持从读线程升级为写线程的操作,详情请戳一戳.aspx)。文本不作介绍。ReaderWriterLock类已经过时,而且存在许多问题,没有必要去使用。

示例代码如下所示,创建了3个读线程,2个写线程,读线程和写线程竞争获取锁。

static void Main(string[] args)
{
    // 创建3个 读线程
    new Thread(() => Read("Reader 1")) { IsBackground = true }.Start();
    new Thread(() => Read("Reader 2")) { IsBackground = true }.Start();
    new Thread(() => Read("Reader 3")) { IsBackground = true }.Start();

    // 创建两个写线程
    new Thread(() => Write("Writer 1")) { IsBackground = true }.Start();
    new Thread(() => Write("Writer 2")) { IsBackground = true }.Start();

    // 使程序运行30S
    Thread.Sleep(TimeSpan.FromSeconds(30));

    Console.ReadLine();
}

static ReaderWriterLockSlim _rw = new ReaderWriterLockSlim();
static Dictionary<int, int> _items = new Dictionary<int, int>();

static void Read(string threadName)
{
    while (true)
    {
        try
        {
            // 获取读锁定
            _rw.EnterReadLock();
            Console.WriteLine($"{threadName} 从字典中读取内容  {DateTime.Now.ToString("mm:ss.ffff")}");
            foreach (var key in _items.Keys)
            {
                Thread.Sleep(TimeSpan.FromSeconds(0.1));
            }
        }
        finally
        {
            // 释放读锁定
            _rw.ExitReadLock();
        }
    }
}

static void Write(string threadName)
{
    while (true)
    {
        try
        {
            int newKey = new Random().Next(250);
            // 尝试进入可升级锁模式状态
            _rw.EnterUpgradeableReadLock();
            if (!_items.ContainsKey(newKey))
            {
                try
                {
                    // 获取写锁定
                    _rw.EnterWriteLock();
                    _items[newKey] = 1;
                    Console.WriteLine($"{threadName} 将新的键 {newKey} 添加进入字典中  {DateTime.Now.ToString("mm:ss.ffff")}");
                }
                finally
                {
                    // 释放写锁定
                    _rw.ExitWriteLock();
                }
            }
            Thread.Sleep(TimeSpan.FromSeconds(0.1));
        }
        finally
        {
            // 减少可升级模式递归计数,并在计数为0时  推出可升级模式
            _rw.ExitUpgradeableReadLock();
        }
    }
}

运行结果如下所示,与预期结果相符。

图片 8

所以在用户模式下运行的线程可能被系统抢占。

      信号量是由内核对象维护的int变量,为0时,线程阻塞,大于0时解除阻塞,当一个信号量上的等待线程解除阻塞后,信号量计数 1。

1.10 使用SpinWait类

SpinWait是一个常用的混合模式的类,它被设计成使用用户模式等待一段时间,人后切换至内核模式以节省CPU时间。

它的使用非常简单,演示代码如下所示。

static void Main(string[] args)
{
    var t1 = new Thread(UserModeWait);
    var t2 = new Thread(HybridSpinWait);

    Console.WriteLine("运行在用户模式下");
    t1.Start();
    Thread.Sleep(20);
    _isCompleted = true;
    Thread.Sleep(TimeSpan.FromSeconds(1));
    _isCompleted = false;

    Console.WriteLine("运行在混合模式下");
    t2.Start();
    Thread.Sleep(5);
    _isCompleted = true;

    Console.ReadLine();
}

static volatile bool _isCompleted = false;

static void UserModeWait()
{
    while (!_isCompleted)
    {
        Console.Write(".");
    }
    Console.WriteLine();
    Console.WriteLine("等待结束");
}

static void HybridSpinWait()
{
    var w = new SpinWait();
    while (!_isCompleted)
    {
        w.SpinOnce();
        Console.WriteLine(w.NextSpinWillYield);
    }
    Console.WriteLine("等待结束");
}

运行结果如下两图所示,首先程序运行在模拟的用户模式下,使CPU有一个短暂的峰值。然后使用SpinWait工作在混合模式下,首先标志变量为False处于用户模式自旋中,等待以后进入内核模式。

图片 9

图片 10

所以也可以用内核模式构造,因为线程通过内核模式的构造获取其它线程拥有的资源时,Windows会阻塞线程以避免它浪费CPU时间。当资源变得可用时,Windows会恢复线程,允许它访问资源。

      线程通过WaitOne将信号量减1,通过Release将信号量加1,使用很简单。

参考书籍

本文主要参考了以下几本书,在此对这些作者表示由衷的感谢你们提供了这么好的资料。

  1. 《CLR via C#》
  2. 《C# in Depth Third Edition》
  3. 《Essential C# 6.0》
  4. 《Multithreading with C# Cookbook Second Edition》

源码下载点击链接 示例源码下载

然而线程从用户模式切换到内核模式(或相反)会招致巨大的性能损失。

  7)互斥体(Mutex)

笔者水平有限,如果错误欢迎各位批评指正!

对于在一个构造上等待的线程,如果占有构造的这个线程不释放它,前者就可能一直阻塞。构造是用户模式的构造情况下,线程会一直在一个CPU上运行,称为“活锁”。如果是内核模式的构造,线程会一直阻塞,称为“死锁”。

      独占资源,用法与Semaphore相似。

死锁优于活锁,因为活锁既浪费CPU时间,又浪费内存,而死锁只浪费内存。

   8)跨进程间的同步

而混合构造兼具两者之长,在没有竞争的情况下,这个构造很快且不会阻塞(就像用户模式的构造),在存在对构造的竞争的情况下,它会被操作系统内核阻塞。(下一章讲)

      通过设置同步对象的名称就可以实现系统级的同步,不同应用程序通过同步对象的名称识别不同同步对象。

用户模式构造

CLR保证对以下数据类型的变量的读写是原子性的:Boolean,Char,S(Byte),U(Int16),U(Int32),U(IntPtr),Single以及引用类型。

这意味着变量中的所有字节都是一次性读取或写入。(举个反例,对于一个Int64静态变量初始化为0,一个线程写它的时候只写了一半,另一个线程读取的时候读取到的是中间状态。不过话说回来,貌似64位机器一次性读取64位,是不是在这个时候Int64也会编程原子性呢,未验证,不过不影响我们理解。)

本章讲解的基元用户模式构造就在于规划好这些原子性数据的读取/写入时间。

实际上这些构造也可以强制为Int32和Double这些类型数据进行原子性的规划好时间的访问。

有两种基元用户模式线程同步构造

  • 易变构造
  • 互锁构造

所有易变和互锁构造都要求传递对包含简单数据类型的一个变量的引用(内存地址)。

易变构造

在讲易变构造之前,得先讲一个问题,就是代码优化的问题。

之前我们讲过C#编译器,JIT编译器,CPU都可能会优化代码,典型的例子就是Timer的应用,一个Timer对象在后续没有使用的情况下,可能直接被优化掉了,根本不会定时执行回调函数。

而这些优化效果是很难在调试的时候看出来,因为调试的时候并没有对代码进行优化。

而多线程也会导致这样的问题,比如一个线程回调函数用到某个静态变量后,且并不改变这个变量,那么可能就会进行优化,认为这个变量的值不变,让其直接优化成固定的值。而你本来的目的实在另一个线程中改变这个静态变量的值,现在你的改变也起不了效果看了。

并且以下这样的代码而言可能因为代码的执行顺序不同而出现超出预料的结果。

        static int you = 0;
        static int me = 0;
        private static void Thread1() {
            me = 2;
            you = 2;
        }
        private static void Thread2()
        {
            if (you == 2) {
                Console.WriteLine(me);

        }    

像上面的代码,Thread1和Thread2方法分别在两个线程中循环运行。

按照我们预计的结果是,当Thread1运行完了,那么Thread2就会检测到你2了,然后就打印我是2.

然而因为编译器优化的原因,you=2和me=2的顺序完全是可以反过来的,那么当先写了you=2后,me=2这句代码还没执行,此时Thread2已经开始检测到you==2了,那么此时打印的话,会显示我不是2,是0.

或者Thread1中的顺序没有变,而Thread2中的顺序变了,即you读取到数据和me读取到数据的代码也是可以被优化的,编译器在Thread1未运行时,先读了me的值为0,而此时Thread1运行了,虽然给了me为2,但是线程2的寄存器中已经存为0了,所以未读取,那么此时结果依然是你是2,而我不是2;

要解决这个问题就引入了我们的易变构造,这需要了解到一个静态类System.Threading.Volatile,它提供了两个静态方法Write和Read。

这两个方法比较特殊,它们会禁止C#编译器,JIT编译器和CPU平常执行的一些优化。

具体的实现在于,Write方法会保证函数中,所有在Write方法之前执行的数据读写操作都在Write方法写入之前就执行了。

而Read方法会保证函数中,所有在Read方法执行之后的数据读写操作,一定实在Read方法执行后才进行。

修改代码后

        static int you = 0;
        static int me = 0;
        private static void Thread1() {
            me= 2;
            Volatile.Write(ref you,2);
        }
        private static void Thread2()
        {
            if (Volatile.Read(ref you) == 2) {
                Console.WriteLine(me);

        }

此时因为Volatile.Write使编译器会保证函数中,所有在Write方法之前执行的数据读写操作都在Write方法写入之前就执行了。

也就是说编译器不会在执行的时候将you=2放在me=2后面了。解决了之前说的第一种情况。

而Volatile.Read保证函数中,所有在Read方法执行之后的数据读写操作,一定实在Read方法执行后才进行。

也就是说me读取肯定在有读取数据的后面,也就解决了之前说的第二种情况。

然而正如你所看到的,这很难理解,关键是自己用到项目中都会觉得真蛋疼,还得百度一下看看是不是Read和Write的保证记混了。

所以为了简化编程,C#编译器提供了volatile关键字,它可以应用于之前提到的那些原子性的简单类型。

volatile声明后,JIT编译器会确保易变字段都是以易变读取和写入的方式进行,不必显示调用Read和Write。(也就是说只要用了volatile,那么me=2的效果就是Volatile.Write(ref me,2),同理读也是一样)

并且volatile会告诉C#编译器和JIT编译器不将字段缓存到CPU寄存器,确保字段的所有读写操作都在内存中进行。

现在再改写之前的代码:

        static volatile int you = 0;
        static int me = 0;
        private static void Thread1() {
            me= 2;
            you=2;
        }
        private static void Thread2()
        {
            if (you == 2) {
                Console.WriteLine(me);

        }

然而作者却表示并不喜欢volatile关键字,因为出现上述所说的情况的概率很低,并且volatile禁止优化后对性能会有影响。且C#不支持以传引用的方式传递volatile变量给某个函数。

互锁构造

说道互锁构造,就要说System.Threading.Interlocked类提供的方法。

这个类中的每个方法都执行一次原子性的读或者写操作。

这个类中的所有方法都建立了完整的内存栅栏,也就是说调用某个Interlocked方法之前的任何变量写入都在这个Interlocked方法调用之前执行,而这个调用之后的任何变量读取都在这个调用之后读取。

它的作用就等于之前的Volilate的Read和Write的作用加在一起。

作者推荐使用Interlocked的方法,它们不仅快,而且也能做不少事情,比简单的加(Add),自增(Increment),自减(Decrement),互换(Exchange)。

Interlocked的方法虽然好用,但主要用于操作Int类型。

如果想要原子性地操作类对象中的一组字段,那么可以用以下方法实现:

/// <summary>
    /// 简单的自旋锁
    /// </summary>
    struct SimpleSpinLock {
        private Int32 m_ResourceInUse;//0表示false,1表示true

        public void Enter() {
            while (true) {
                //将资源设为正在使用,Exchange方法的意思是,将m_ResourceInUse赋值为1,并返回原来的m_ResourceInUse的值
                if (Interlocked.Exchange(ref m_ResourceInUse, 1) == 0) return;

            }
        }

        public void Leave() {
            Volatile.Write(ref m_ResourceInUse, 0);
        }
    }
    public class SomeResource {
        private SimpleSpinLock m_sl = new SimpleSpinLock();
        public void AccessResource() {
            m_sl.Enter();
            /*每次只有一个线程能访问到这里的代码*/
            m_sl.Leave();
        }
    }

上面的代码原理就是,当一个线程调用Enter后,那么就会return,并置m_ResourceInUse为1,此时表示资源被占用了。

如果另外一个线程再调用Enter,那么得到的m_ResourceInUse为1,所以不会返回,就不断执行循环,直到第一个线程调用Leave函数,将m_ResourceInUse置为0。

原理很简单,但相信看这个模式的人也应该很清楚了,也就是说只要第一个线程不退出,其它所有的线程都要不断进行循环操作(术语为自旋)。

所以自旋锁应该是用于保护那些会执行得非常快的代码区域。(且不要用在单CPU机器上,因为占有锁的线程不能快速释放锁)

如果占有锁的线程优先级地狱想要获取锁的线程,那么这就造成占有锁的线程可能根本没机会运行,更别提释放锁了。(这就是活锁,前面也提到了)

实际上FCL就提供了一个类似的自旋锁,也就是System.Threading.SpinLock结构,并且还是用了SpinWait结构来增强性能。

由于SpinLock和之前我们自己写的SimpleSpinLock都是结构体,也就是说他们都是值类型,都是轻量级且内存友好的。

然而不要传递它们的实例,因为值类型会复制,而你将失去所有的同步。

事实上Interlocked.CompareExchange本来就可以不仅仅用于操作整数,还可以用来操作其它原子性的基元类型,他还有一个泛型方法。

它的作用是,对比第1个参数和第3个参数,如果两者相等,那么将第2个参数的值赋给第1个参数,并返回第一个参数之前的值。

内核模式构造

内核模式比用户模式慢,这个是可以预见的,因为线程要从托管代码转为本机用户模式代码,再转为内核模式代码,然后原路返回,也就了解为什么慢了。

但是之前也介绍过了,内核模式也具备用户模式所不具备的优点:

  • 内核模式的构造检测到一个资源上的竞争,windows会阻塞输掉的线程,使他不会像之前介绍的用户模式那样“自旋”(也就是那个不断循环的鬼),这样也就不会一直占着一个CPU了,浪费资源。
  • 内核模式的构造可实现本机和托管线程相互之间的同步
  • 内核模式的构造可同步在同一台机器的不同进程中运行的线程。
  • 内核模式的构造可应用安全性设置,防止未经授权的帐户访问它们。
  • 线程可一直阻塞,直到集合中所有内核模式构造可用,或直到集合中的任何内核模式构造可用
  • 在内核模式的构造上阻塞的线程可指定超时值;指定时间内访问不到希望的资源,线程就可以解除阻塞并执行任务。

事件和信号量是两种基元内核模式线程同步构造,至于互斥体什么的则是在这两者基础上建立而来的。

System.Threading命名空间提供了一个抽象基类WaitHandle。这个简单的类唯一的作用就是包装一个Windows内核对象句柄。(它有一些派生类EventWaitHandle,AutoResetEvent,ManualResetEvent,Semaphore,Mutex)

WaitHandle基类内部有一个SafeWaitHandle字段,它容纳一个Win32内核对象句柄。

这个字段在构造一个具体的WaitHandle派生类时初始化。

在一个内核模式的构造上调用的每个方法都代表一个完整的内存栅栏。(之前也说过了,表示调用这个方法之前的任何变量的写入都必须在此方法前完成,调用这个方法之后的任何变量的读取都必须在此方法后完成)。

这个类中的方法就不具体介绍了,基本上这些方法的主要功能呢个就是调用线程等待一个或多个底层内核对象收到信号。

只是要注意在等待多个的方法(即WaitAll和WiatAny这种)中,传递的内核数组参数,数组最大元素数不能超过64,否则会异常。

主要讲一下三个内核构造,也是之前WaitHandle的三个直接继承派生类:

  • EventHandle(Event构造)

    • 事件实际上就是由内核维护的Boolean变量。为false就阻塞,为true就解除阻塞。
    • 有两种事件,即自动重置事件(AutoResetEvent)和手动重置事件(ManualResetEvent)。区别就在于是否在接触一个线程的阻塞后,将事件自动重置为false。
    • 用自动重置事件写个锁示例如下:

        /// <summary>
          /// 简单的阻塞锁
          /// </summary>
          class SimpleWaitLock {
              private readonly AutoResetEvent m_ResourceInUse;
      
              public SimpleWaitLock() {
                  m_ResourceInUse = new AutoResetEvent(true);//初始化事件,表示事件构造可用
              }
      
              public void Enter() {
                  //阻塞内核,直到资源可用
                  m_ResourceInUse.WaitOne();
              }
      
              public void Leave() {
                  //解除当前线程阻塞,让另一个线程访问资源
                  m_ResourceInUse.Set();
              }
              public void Dispose() {
                  m_ResourceInUse.Dispose();
              }
          }
      

      此示例可以和前面的那个自旋锁相对比,调用方法一模一样。

  • Semaphore(Semaphore构造)

    • Semaphore的英文就是信号量,其实是由内核维护的Int32变量。信号量为0时,在信号量上等待的线程阻塞,信号量大于0时接触阻塞。信号量上等待的线解除阻塞时,信号量自动减1.
    • 同样一个例子来表示,与上面代码对比之后更清晰:(信号量最大值设置为1的话,且释放的时候也只释放一个的话,那么实际上和事件效果一样)

       /// <summary>
          /// 简单的阻塞锁
          /// </summary>
          class SimpleWaitLock {
              private readonly Semaphore m_ResourceInUse;
      
              public SimpleWaitLock(Int32 maxCount) {
                  m_ResourceInUse = new Semaphore(maxCount, maxCount);
              }
      
              public void Enter() {
                  //阻塞内核,直到资源可用
                  m_ResourceInUse.WaitOne();
              }
      
              public void Leave() {
                  //解除当前线程阻塞,让另外2个线程访问资源
                  m_ResourceInUse.Release(2);
              }
              public void Dispose() {
                  m_ResourceInUse.Close();
              }
          }
      
  • Mutex(Mutex构造)

    • Mutex的中文就是互斥体。代表了一个互斥的锁。
    • 互斥体有一个额外的逻辑,Mutex会记录下线程的ID值,如果释放的时候不是这个线程释放的,那么就不会释放掉,并且还会抛异常。
    • 互斥体实际上在维护一个递归计数,一个线程当前拥有一个Mutex,而后该线程再次在Mutex等待,那么此计数就会递增,而线程调用ReleaseMutex会导致递减,只有计数递减为0,那么这个线程才会解除阻塞。另一个线程才会称为该Mutex的所有者
    • Mutex对象需要额外的内存来容纳那些记录下来的ID值和计数信息,并且锁也会变得更慢了。所以很多人避免用Mutex对象。
    • 通常一个方法在用到一个锁时调用了另一个方法,这个方法也要用到锁,那么就可以考虑用互斥体。因为用事件这种内核构造方法的话,在调用的另一个方法中用到锁就会导致阻塞,从而死锁。例子:

       public class SomeResource {
              private readonly Mutex m_lock = new Mutex();
              public void Method1() {
                  m_lock.WaitOne();
                  Method2();//递归获取锁
                  m_lock.ReleaseMutex();
              }
              public void Method2()
              {
                  m_lock.WaitOne();
                  /*做点什么*/
                  m_lock.ReleaseMutex();
              }
          }
      

      像以上的这种结构如果简单得用事件来写就会有问题,然而并不是不能用事件去递归实现,而且如果用以下的方法递归实现效果反而会更好:

    • 用事件方式实现递归锁:

      /// <summary>
          /// 事件构造实现的递归锁,效率比Mutex高很多
          /// </summary>
          class ComplexWaitLock:IDisposable {
              private  AutoResetEvent m_lock=new AutoResetEvent(true);
              private Int32 m_owningThreadId = 0;
              private Int32 m_lockCount = 0;
      
              public void Enter() {
                  //获取当前线程ID
                  Int32 currentThreadId = Thread.CurrentThread.ManagedThreadId;
                  //当前线程再次进入就会递增计数
                  if (m_owningThreadId == currentThreadId) {
                      m_lockCount  ;
                      return;
                  }
                  m_lock.WaitOne();
                  m_owningThreadId = currentThreadId;
                  m_lockCount = 1;
      
              }
      
              public void Leave() {
                  //获取当前线程ID
                  Int32 currentThreadId = Thread.CurrentThread.ManagedThreadId;
                  if (m_owningThreadId != currentThreadId)
                      throw new InvalidOperationException();
      
                  if (--m_lockCount == 0) {
                      m_owningThreadId = 0;
                      m_lock.Set();
                  } 
              }
              public void Dispose() {
                  m_lock.Dispose();
              }
          }
      

      上面的代码其实很好搞懂,就是用事件把Mutex的玩法自己实现了。然而上面的代码之所以比Mutex快,是因为这些代码都是用托管代码在实现,而不是像Mutex一样用内核代码,仅仅只有调用事件构造的方法时才会用到内核代码。

本文由星彩网app下载发布于计算机编程,转载请注明出处:线程同步各样锁,线程同步

TAG标签: 星彩网app下载
Ctrl+D 将本页面保存为书签,全面了解最新资讯,方便快捷。