C# 温故而知新: 线程篇(三)上

时间:2022-05-07
本文章向大家介绍C# 温故而知新: 线程篇(三)上,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

线程同步篇 (上)

  • 线程同步中的一些重要概念
  • 临界区(共享区)的概念
  • 基元用户模式
  • 基元内核模式
  • 原子性操作
  • 非阻止同步
  • 阻止同步
  • 详解Thread类 中的VolatileRead和VolatileWrite方法和Volatile关键字的作用
  • Volatile关键字的作用
  • 介绍下Interlocked
  • 介绍下Lock关键字
  • 详解ReaderWriterLock 类
  • 本章总结
  • 参考文献

1.线程同步中的一些重要概念

1.1临界区(共享区)的概念

在多线程的环境中,可能需要共同使用一些公共资源,这些资源可能是变量,方法逻辑段等等,这些被多个线程

共用的区域统称为临界区(共享区),聪明的你肯定会想到,临界区的资源不是很安全,因为线程的状态是不定的,所以

可能带来的结果是临界区的资源遭到其他线程的破坏,我们必须采取策略或者措施让共享区数据在多线程的环境下保持

完成性不让其受到多线程访问的破坏

1.2基元用户模式

可能大家觉得这个很难理解,的确如果光看概念解释的话,会让人抓狂的,因为这个模式牵涉到了深奥的底层cup

内核和windows的一些底层机制,所以我用最简单的理解相信大家一定能理解,因为这对于理解同步也很重要

回到正题,基元用户模式是指使用cpu的特殊指令来调度线程,所以这种协调调度线程是在硬件中进行的所以得出

了它第一些优点:

速度特别快

线程阻塞时间特别短

但是由于该模式中的线程可能被系统抢占,导致该模式中的线程为了获取某个资源,而浪费许多cpu时间,同时如果一直处

于等待的话会导致”活锁”,也就是既浪费了内存,又浪费了cpu时间,这比下文中的死锁更可怕,那么如何利用强大的

cpu时间做更多的事呢?那就引出了下面的一个模式

1.3基元内核模式

该模式和用户模式不同,它是windows系统自身提供的,使用了操作系统中内核函数,所以它能够阻塞线程提高了cpu的利

用率,同时也带来了一个很可怕的bug,死锁,可能线程会一直阻塞导致程序的奔溃,常用的内核模式的技术例如Monitor,Mutex,

等等会在下一章节介绍。本章将详细讨论锁的概念,使用方法和注意事项

*1.4原子性操作

如果一个语句执行一个单独不可分割的指令,那么它是原子的。严格的原子操作排除了任何抢占的可能性(这也是实现同步的一

个重要条件,也就是说没有一个线程可以把这个美女占为己有,更方便的理解是这个值永远是最新的),在c#中原子操作如下图所示:

其实要符合原子操作必须满足以下条件

  1. c#中如果是32位cpu的话,为一个少于等于32位字段赋值是原子操作,其他(自增,读,写操作)的则不是
  2. 对于64位cpu而言,操作32或64位的字段赋值都属于原子操作
  3. 其他读写操作都不能属于原子操作

相信大家能够理解原子的特点,下文中的Volatil和interlocked会详细模拟原子操作来实现线程同步,所以在使用原子操

作时也需要注意当前操作系统是32位或是64位cpu或者两者皆要考虑

1.5非阻止同步

非阻止同步说到底,就是利用原子性操作实现线程间的同步,不刻意阻塞线程,减少相应线程的开销,下文中的VolatileRead,V

olatileWrite,Volatile关键字,interlocked类便是c#中非阻止同步的理念所产生的线程同步技术

1.6阻止同步

阻止同步正好相反,其实阻止同步也是基元内核模式的特点之一,例如c# 中的锁机制,及其下几章介绍的mutex,monitor等都属

于阻止同步,他们的根本目的是,以互斥的效果让同一时间只有一个线程能够访问共享区,其他线程必须阻止等待,直到该线程离开共享

区后,在让其他一个线程访问共享区,阻止同步缺点也是容易产生死锁,但是阻止同步提高了cpu时间的利用率

2.详解Thread类中的VolatileRead和VolatileWrite方法和Volatile关键字

前文中,我们已经对原子操作和非阻止同步的概念已经有了大概的认识,接着让我们从新回到Thread类来看下其中比较经典的VolatileRead

和VolatileWrite方法

VolatileWrite: 该方法作用是,当线程在共享区(临界区)传递信息时,通过此方法来原子性的写入最后一个值VolatileRead: 该方法作用是,当线程在共享区(临界区)传递信息时,通过此方法来原子性的读取第一个值。

可能这样的解释会让大家困惑,老规矩,直接上例子让大家能够理解:

/// <summary>
    /// 本例利用VolatileWrite和VolatileRead来实现同步,来实现一个计算
    /// 的例子,每个线程负责运算1000万个数据,共开启10个线程计算至1亿,
    /// 而且每个线程都无法干扰其他线程工作
    /// </summary>
    class Program
    {
        static Int32 count;//计数值,用于线程同步 (注意原子性,所以本例中使用int32)
        static Int32 value;//实际运算值,用于显示计算结果
        static void Main(string[] args)
        {
            //开辟一个线程专门负责读value的值,这样就能看见一个计算的过程
            Thread thread2 = new Thread(new ThreadStart(Read));
            thread2.Start();
            //开辟10个线程来负责计算,每个线程负责1000万条数据
            for (int i = 0; i < 10; i++)
            {
                Thread.Sleep(20);
                Thread thread = new Thread(new ThreadStart(Write));
                thread.Start();
            }
            Console.ReadKey();
        }


        /// <summary>
        /// 实际运算写操作
        /// </summary>
        private static void Write()
        {
            Int32 temp = 0;
            for (int i = 0; i < 10000000; i++)
            {
                temp += 1;
            }
            value += temp;
            //注意VolatileWrite 在每个线程计算完毕时会写入同步计数值为1,告诉程序该线程已经执行完毕
            //所以VolatileWrite方法类似与一个按铃,往往在原子性的最后写入告诉程序我完成了
            Thread.VolatileWrite(ref count, 1);
        }


        /// <summary>
        ///  显示计算后的数据,使用该方法的线程会死循环等待写
        ///  操作的线程发出完毕信号后显示当前计算结果
        /// </summary>
        private static void Read()
        {
            while (true)
            {
                //一旦监听到一个写操作线执行完毕后立刻显示操作结果
                //和VolatileWrite相反,VolatileRead类似一个门禁,只有原子性的最先读取他,才能达到同步效果
                //同时count值保持最新
                if (Thread.VolatileRead(ref count) > 0)
                {
                    Console.WriteLine("累计计数:{1}", Thread.CurrentThread.ManagedThreadId, value);
                    //将count设置成0,等待另一个线程执行完毕
                    count = 0;
                }
            }
        }
    }

显示结果:

例子中我们可以看出当个线程调用Read方法时,代码会先判断Thread. VolatileRead先读取计数值是否返回正确的计数值,如果正确则显示

结果,不正确的话继续循环等待,而这个返回值是通过其他线程操作Write方法时最后写入的,也就是说对于Thread. VolatileWrite

方法的作用便一目了然了,在实现Thread. VolatileWrite写入其他的数据或进行相应的逻辑处理,在我们示例代码中我们会先去加运算到

10000000时,通过thread. VolatileWrite原子性的操作写入计数值告诉那个操作Read方法的线程有一个计算任务已经完成,于是死循环中

的Thread. VolatileRead方法接受到了信号,你可以显示计算结果了,于是结果便会被显示,同时计数值归零,这样便起到了一个非阻塞功能

的同步效果,同样对于临界区(此例中的Write方法体和Read方法体)起到了保护的作用。当然由于使用上述两个方法在复杂的项目中很容易

出错,往往这种错误是很难被发现,所以微软为了让我们更好使用,便开发出了一个新的关键字Volatile

Volatile关键字的作用

Volatile关键字的本质含义是告诉编译器,声明为Volatile关键字的变量或字段都是提供给多个线程使用的,当然不是每个类型都

可以声明为Volatile类型字段,msdn中详细说明了那些类型可以声明为Volatile 所以不再陈述,但是有一点必须注意,Volatile

无法声明为局部变量。作为原子性的操作,Volatile关键字具有原子特性,所以线程间无法对其占有,它的值永远是最新的。那我

们就对上文的那个例子简化如下:

/// <summary>
    /// 本例利用volatile关键字来实现同步,来实现一个计算
    /// 的例子,每个线程负责运算1000万个数据,共开启10个线程计算至1亿,
    /// 而且每个线程都无法干扰其他线程工作
    /// </summary>
    class Program
    {
        static volatile Int32 count;//计数值,用于线程同步 (注意原子性,所以本例中使用int32)
        static Int32 value;//实际运算值,用于显示计算结果
        static void Main(string[] args)
        {
            //开辟一个线程专门负责读value的值,这样就能看见一个计算的过程
            Thread thread2 = new Thread(new ThreadStart(Read));
            thread2.Start();
            //开辟10个线程来负责计算,每个线程负责1000万条数据
            for (int i = 0; i < 10; i++)
            {
                Thread.Sleep(20);
                Thread thread = new Thread(new ThreadStart(Write));
                thread.Start();
            }
            Console.ReadKey();
        }


        /// <summary>
        /// 实际运算写操作
        /// </summary>
        private static void Write()
        {
            Int32 temp = 0;
            for (int i = 0; i < 10000000; i++)
            {
                temp += 1;
            }
            value += temp;
            //注意VolatileWrite 在每个线程计算完毕时会写入同步计数值为1,告诉程序该线程已经执行完毕
            //将count值设置成1,效果等同于Thread.VolatileWrite
            count = 1;
        }


        /// <summary>
        ///  显示计算后的数据,使用该方法的线程会死循环等待写
        ///  操作的线程发出完毕信号后显示当前计算结果
        /// </summary>
        private static void Read()
        {
            while (true)
            {
                //一旦监听到一个写操作线执行完毕后立刻显示操作结果,效果等同于Thread.VolatileRead
                if (count==1)
                {
                    Console.WriteLine("累计计数:{1}", Thread.CurrentThread.ManagedThreadId, value);
                    //将count设置成0,等待另一个线程执行完毕
                    count = 0;
                }
            }
        }
    }

从例子中大家可以看出Volatile关键字的出现替代了原先VolatileRead 和VolatileWrite方法的繁琐,同时原子性的操作更加直观透明

3.详解Interlocked

相信大家理解了Volatile后对于非阻止同步和原子操作有了更深的认识,接下来的Interlocked虽然也属于非阻止同步但是而后Volatile相比也

有着很大的不同,interlocked 利用了一个计数值的概念来实现同步,当然这个计数值也是属于原子性的操作,每个线程都有机会通过Interlocked

去递增或递减这个计数值来达到同步的效果,同时Interlocked比Volatile更加适应复杂的逻辑和并发的情况

首先让我们了解下Interlocked类的一些重要方法

static long Read()以原子操作形式读取计数值,该方法能够读取当前计数值,但是如果是64位cpu的可以不需要使用该方法读取.*但是如果是32位的cpu则必须使用interlocked类的方法对64位的变量进行操作来保持原子操作,否则就不是原子操作static int or long Increment(Int32 Or Int64)该方法已原子操作的形式递增指定变量的值并存储结果,也可以理解成以原子的操作对计数器加1Increment有2个返回类型的版本,分别是int 和 longstatic int or long Decrement(Int32 Or Int64)和Increment方法相反,该方法已原子操作的形式递减指定变量的值并存储结果,也可以理解成以原子的操作对计数器减1同样,Decrement也有2个返回类型的版本,分别是int 和 longstatic int Add(ref int location1,int value)该方法是将Value的值和loation1中的值相加替换location1中原有值并且存储在locaion1中,注意,该方法不会抛出溢出异常,如果location中的值和Value之和大于int32.Max则,location1中的值会变成int32.Min和Value之和Exchange(double location1,double value)Exchange方法有多个重载,但是使用方法是一致的,以原子操作的形式将Value的值赋值给location1

看完了概念性的介绍后,让我们马上进入很简单的一个示例,来深刻理解下Interlocked的使用方法



/// <summary>
    /// 本示例通过Interlocked实现同步示例,通过Interlocked.Increment和
    /// Interlocked.Decrement来实现同步,此例有2个共享区,一个必须满足计数值为0,另
    /// 一个满足计数值为1时才能进入
    /// </summary>
    class Program
    {
        //声明计数变量
        //(注意这里用的是long是64位的,所以在32位机子上一定要通过Interlocked来实现原子操作)
        static long _count = 0;


        static void Main(string[] args)
        {
            //开启6个线程,3个执行Excution1,三个执行Excution2
            for (int i = 0; i < 3; i++)
            {
                Thread thread = new Thread(new ThreadStart(Excution1));
                Thread thread2 = new Thread(new ThreadStart(Excution2));
                thread.Start();
                Thread.Sleep(10);
                thread2.Start();
                Thread.Sleep(10);
            }
            //这里和同步无关,只是简单的对Interlocked方法进行示例
            Interlocked.Add(ref _count, 2);
            Console.WriteLine("为当前计数值加上一个数量级:{0}后,当前计数值为:{1}", 2, _count);
            Interlocked.Exchange(ref _count, 1);
            Console.WriteLine("将当前计数值改变后,当前计数值为:{0}", _count);
            Console.Read();
        }


        static void Excution1()
        {
            //进入共享区1的条件
            if (Interlocked.Read(ref _count) == 0)
            {
                Console.WriteLine("Thread ID:{0} 进入了共享区1", Thread.CurrentThread.ManagedThreadId);
                //原子性增加计数值,让其他线程进入共享区2
                Interlocked.Increment(ref _count);
                Console.WriteLine("此时计数值Count为:{0}", Interlocked.Read(ref _count));
            }
        }


        static void Excution2()
        {
            //进入共享区2的条件
            if (Interlocked.Read(ref _count) == 1)
            {
                Console.WriteLine("Thread ID:{0} 进入了共享区2", Thread.CurrentThread.ManagedThreadId);
                //原子性减少计数值,让其他线程进入共享区1
                Interlocked.Decrement(ref _count);
                Console.WriteLine("此时计数值Count为:{0}", Interlocked.Read(ref _count));
            }
        }
    }

在本例中,我们使用和上文一样的思路,通过不同线程来原子性的操作计数值来达到同步效果,大家可以仔细观察到,通过

Interlocked对计数值进行操作就能够让我们非常方便的使用非阻止的同步效果了,但是在复杂的项目或逻辑中,可能也会出

错导致活锁的可能,大家务必当心

4.介绍下Lock关键字

Lock关键字是用来对于多线程中的共享区进行阻止同步的一种方案,当某一个线程进入临界区时,lock关键字会锁住共享区,

同样可以理解为互斥段,互斥段在某一时刻内只允许一个线程进入,同时编译器会把这个关键字编译成Monitor.Entery和

Monitor.Exit 方法,关于Monitor类会在下章详细阐述。既然有Lock关键字,那么它是如何工作的?到底锁住了什么,怎么

高效和正确的使用lock关键字呢?

其实锁的概念还是来自于现实生活,共享区就是多个人能够共同拥有房间,当其中一个人进入房间后,他把锁反锁,直到他解锁

出门后将钥匙交给下个人,可能房间的门可能有问题,或者进入房间的人因为某种原因出不来了,导致全部的人都无法进去,这些

问题也是我们应该考虑到的,好,首先让我们讨论下我们应该Lock住什么,什么材料适合当锁呢?

虽然说lock关键字可以锁住任何object类型及其派生类,但是尽量不要用public 类型的,因为public类型难以控制

有可能大伙对上面的有点疑问,为什么不能用public类型的呢,为什么会难以控制呢?

好,以下3个例子是比较经典的例证

1.Lock(this):大伙肯定会知道this指的是当前类对象,Lock(this) 、意味着将当前类对象给锁住了,假设我需要同时使用这个类的别的方法,那么某一线程一旦进入临界区后,那完蛋了,该类所有的成员(方法)都无法访问,这可能在某些时刻是致命的错误2.同理Lock(typeof(XXX)) 更厉害,一方面对锁的性能有很大影响,因为一个类型太大了,其次,当某一线程进入临界区后,包括所有该类型的type都可能会被锁住而产生死锁3.最严重的某过于锁住字符串对象,lock(“Test”),c#中的字符串对象很特殊,string test=”Test”和 string test2=”Test” 其实是一个对象,假如你使用了lock(“Test”)那么,所有字符串值为"Test"的字符串都有可能被锁住,甚至造成死锁,所以有些奇怪的bug都是因为一些简单的细节导致

接着这个例子便是lock(this)的一个示例,既能让大伙了解如何使用Lock关键字,更是让大伙了解,lock(this)的危害性

/// <summary>
    /// 本例展示下如何使用lock关键字和lock(this)时产生死锁的情况
    /// </summary>
    class Program
    {


        static void Main(string[] args)
        {
            //创建b对象,演示lock
            B b = new B();
            Console.ReadKey();
        }
    }


    /// <summary>
    /// A类构造中初始化一个线程并且启动,
    /// 线程调用的方法内放入死循环,并且在死循环中放入lock(this),
    /// </summary>
    public class A
    {
        public A()
        {
            Thread th = new Thread(new ThreadStart
                (
                   () =>
                   {
                       while (true)
                       {
                           lock (this)
                           {
                               Console.WriteLine("进入a类共享区");
                               Thread.Sleep(3000);
                           }
                       }
                   }
                ));
            th.Start();
        }
    }


    /// <summary>
    ///  B类在构造中创建A的对象,并且还是锁住a对象,这样就创建的死锁的条件
    ///  因为初始化A类对象时,A类的构造函数会锁住自身对象,这样在A类死循环间隔期,一旦出了 A类中的锁时
    ///  进入B的锁住的区域内,A 对象永远无法进入a类共享区,从而产生了死锁
    /// </summary>
    public class B
    {
        public B()
        {
            A a = new A();
            lock (a)
            {
                Console.WriteLine(@"将a类对象锁住的话,a类中的lock将进入死锁,
                直到3秒后B类中的将a类对象释放锁,如果我不释放,那么 a类中将永远无法进入a类共享区");
                //计时器
                Timer timer = new Timer(new TimerCallback
                    (
                      (obj) => { Console.WriteLine(DateTime.Now); }
                    ), this, 0, 1000);
                //如果这里运行很长时间, a类中将永远无法进入a类共享区
                Thread.Sleep(3000000);
            }
        }
    }