C#十2线程编制程序体系(3)- 线程同步

目录



1.1 简介

本章介绍在C#中完成线程同步的两种方法。因为多少个线程同时访问共享数据时,大概会招致共享数据的毁坏,从而变成与预期的结果不切合。为了消除那个标题,所以必要用到线程同步,也被俗称为“加锁”。不过加锁绝对不对升高品质,最多也等于不增不减,要促成性能不增不减还得靠高素质的同步源语(Synchronization
Primitive)。但是因为没有错永世比速度更主要,所以线程同步在少数场景下是必须的。

线程同步有三种源语(Primitive)构造:用户方式(user –
mode)
基础格局(kernel –
mode)
,当能源可用时间短的情况下,用户形式要优于根本形式,可是倘使长日子不可能获取财富,大概说长日子处于“自旋”,那么内核形式是绝对来讲好的取舍。

而是我们期望全体用户格局和基础情势的独到之处,大家把它叫做掺杂构造(hybrid
construct)
,它兼具了三种情势的长处。

在C#中有各个线程同步的编写制定,平日可以遵守以下顺序举行分选。

  1. 1旦代码能透过优化能够不进行联合,那么就不要做联合。
  2. 应用原子性的Interlocked方法。
  3. 使用lock/Monitor类。
  4. 选择异步锁,如SemaphoreSlim.WaitAsync()
  5. 运用其余加锁机制,如ReaderWriterLockSlim、Mutex、Semaphore等。
  6. 假设系统提供了*Slim本子的异步对象,那么请选择它,因为*Slim本子全体都以混合锁,在进入基础形式前完成了某种方式的自旋。

在联合中,一定要小心防止死锁的发生,死锁的发生必须满意以下5个着力尺度,所以只须要破坏任性3个尺度,就可幸免爆发死锁。

  1. 排他或互斥(Mutual
    exclusion):3个线程(ThreadA)独占八个能源,未有任何线程(ThreadB)能获得一样的财富。
  2. 占用并伺机(Hold and
    wait):互斥的三个线程(ThreadA)请求获取另1个线程(ThreadB)据有的资源.
  3. 不得超越(No
    preemption):3个线程(ThreadA)占领资源无法被勒迫拿走(只好等待ThreadA主动释放它的能源)。
  4. 循环等待条件(Circular wait
    condition):多少个或几个线程构成3个循环等待链,它们锁定七个或四个同样的能源,各类线程都在等待链中的下2个线程据有的财富。

一.贰 实行基本原子操作

CL冠道保险了对这个数据类型的读写是原子性的:Boolean、Char、(S)Byte、(U)Int16、(U)Int32、(U)IntPtr和Single。可是要是读写Int64或许会生出读取撕裂(torn
read)的主题素材,因为在三1贰个人操作系统中,它要求奉行两回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

1.3 使用Mutex类

System.Threading.Mutex在概念上和System.Threading.Monitor差了一些千篇一律,不过Mutex2头对文件可能其余跨进度的财富开展走访,也正是说Mutex是可跨进程的。因为其特征,它的3个用途是限量应用程序不能够同时运维两个实例。

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福寿绵绵了单实例,所以第3个应用程序不恐怕获得锁,就能够体现已有实例正在运维

图片 2

1.4 使用SemaphoreSlim类

SemaphoreSlim类与事先涉嫌的一路类有锁区别,从前提到的同步类都以排斥的,相当于说只允许一个线程实行访问财富,而SemaphoreSlim是足以允许四个访问。

在头里的一些有关系,以*Slim终极的线程同步类,都以干活在混合方式下的,也正是说到头它们都以在用户方式下”自旋”,等发出第3回竞争时,才切换成根本方式。不过SemaphoreSlim不同于Semaphore类,它不辅助系统功率信号量,所以它无法用于进程之间的联合签字

该类应用相比较轻易,演示代码演示了几个线程竞争访问只同意几个线程同时做客的数据库,如下所示。

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();
}

运维结果如下所示,可知前几个线程马上就获得到了锁,进入了临界区,而除此以外两个线程在等待;等有锁被放走时,技艺进来临界区。图片 3

1.5 使用AutoResetEvent类

AutoResetEvent叫自动重新恢复设置事件,固然名称中有事件1词,然而重新设置事件和C#中的委托未有别的涉及,这里的事件只是由基本维护的Boolean变量,当事件为false,那么在事件上等待的线程就卡住;事件形成true,那么阻塞解除。

在.Net中有二种此类事件,即AutoResetEvent(自动重置事件)ManualResetEvent(手动重置事件)。那两边均是利用水源格局,它的分裂在于当重新设置事件为true时,自行复位事件它只唤醒2个不通的线程,会活动将事件复位回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

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.7 使用CountDownEvent类

CountDownEvent类内部结构选用了三个ManualResetEventSlim对象。这么些结构阻塞多少个线程,直到它里面计数器(CurrentCount)变为0时,才免除阻塞。相当于说它并不是阻碍对曾经缺乏的财富池的走访,而是只有当计数为0时才允许访问。

此间须求小心的是,当CurrentCount变为0时,那么它就不能够被退换了。为0以后,Wait()主意的封堵被排除。

身体力行代码如下所示,惟有当Signal()方法被调用1遍随后,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();
}

运作结果如下图所示,可知唯有当操作一和操作二都达成以往,才实施输出全数操作都成功。

图片 6

1.8 使用Barrier类

Barrier类用于消除三个不胜天下无双的标题,平日相像用不上。Barrier类调控壹多种线程进行阶段性的互动专门的职业。

设若现在相互专门的学业分为一个级次,每种线程在成功它和煦那部分阶段一的行事后,必须停下来等待别的线程实现阶段一的劳作;等有着线程均完成阶段一干活后,各种线程又开端运营,完毕阶段二职业,等待其余线程全体完毕阶段二做事后,整个工艺流程才甘休。

身体力行代码如下所示,该代码演示了八个线程分品级的到位专门的学问。

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();
    }
}

运维结果如下所示,当“歌唱家”线程完成后,并不曾即刻结束,而是等待“钢琴家”线程停止,当”钢琴家”线程停止后,才开头第3品级的做事。

图片 7

1.9 使用ReaderWriterLockSlim类

ReaderWriterLockSlim类重要是涸泽而渔在一些场景下,读操作多于写操作而采纳一些互斥锁当三个线程同时做客财富时,唯有3个线程能访问,导致质量大幅下跌。

尽管持有线程都盼望以只读的措施访问数据,就平素没有要求阻塞它们;如若3个线程希望修改数据,那么这几个线程才须求独占访问,那正是ReaderWriterLockSlim的卓著应用场景。那个类就好像上面那样来调节线程。

  • 1个线程向数据写入是,请求访问的任何具有线程都被封堵。
  • 三个线程读取数据时,请求读取的线程允许读取,而请求写入的线程被封堵。
  • 写入线程甘休后,要么解除贰个写入线程的围堵,使写入线程能向数据联网,要么解除所有读取线程的隔离,使它们能并发读取多少。假若线程没有被卡住,锁就足以进来自由使用的情景,可供下叁个读线程或写线程获取。
  • 从数量读取的拥有线程停止后,一个写线程被破除阻塞,使它能向数据写入。假使线程未有被卡住,锁就足以进入自由使用的情况,可供下2个读线程或写线程获取。

ReaderWriterLockSlim还帮衬从读线程晋级为写线程的操作,详情请戳一戳.aspx)。文本不作介绍。ReaderWriterLock类已经不合时宜,而且存在许多标题,无需去接纳。

躬体力行代码如下所示,制造了二个读线程,1个写线程,读线程和写线程竞争得到锁。

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

1.10 使用SpinWait类

SpinWait是2个常用的混合形式的类,它被设计成采纳用户形式等待一段时间,人后切换至基本形式以节省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有2个短距离赛跑的峰值。然后利用SpinWait行事在混合形式下,首先标识变量为False地处用户形式自旋中,等待将来进入基础格局。

图片 9

图片 10

参照书籍

本文首要参考了以下几本书,在此对这个小编表示衷心的谢谢你们提供了那般好的素材。

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

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

作者水平有限,假若不当招待各位争持指正!

相关文章