C# 学习笔记:多线程

多线程是学习编程语言绕不开的东西,我们常常可以看到,代码是一条龙走下来的,电脑从上到下依次执行,这种编译器读取代码的一种行为,我们可以认为电脑给我们开了个“流水线”来专门处理我们的代码。这个“流水线”的专业名称就叫线程。

那么由此引申,电脑开了好几个“流水线”我们可以把它称为:多线程。

进程与线程

我们打开任务管理器就可以看到电脑当前正在运行的所有的进程。这些进程也就是我们平常使用的程序在运行时的状态。

进程:我们可以认为进程在Window中是一个基本单位,它包含了一个运行程序所需要的资源。一个正在运行的应用程序在操作系统中就是一个进程。不同的进程之间是相互独立的,一个两个进程之间的数据并不互通(分布式的情况除外)。

线程:线程是进程中的基本执行单元,是系统分配CPU时间的基本单位,一个进程内可包含多个线程(至少有一个)。在C#程序中,由CLR给我们的进程创建了一个线程。这个线程即为主线程。例如C#控制台程序中,程序的入口是Main函数,Main函数即始于主线程中。

在.NET中,一个线程主要由:CPU寄存器、调用栈、线程本地存储器TLS(Thread Local Storage)组成。

  • CPU寄存器主要记录当前所执行线程的状态。
  • 调用栈用于维护线程所调用到的内存与数据。
  • TLS用于存放线程的状态信息

C#中的多线程

多线程在C#中运用很多,我们可以通过System.Threading类库来调用多线程的功能。多线程在C#中基于委托的形式来执行逻辑。并且可以通过一些API来获得我们当前调用线程的信息,包括线程的名字、线程ID、线程状态、线程优先级等等。

我们通过下面的脚本来演示线程的一些用法,定时输出当前线程的信息(这个脚本来自于微软官方文档):

    class Programs
    {
        static void Main()
        {
            ParameterizedThreadStart start = Function;
            Thread th = new Thread(start);

            th.Start(4500);

            Thread.Sleep(1000);

            Console.WriteLine("主线程" + Thread.CurrentThread.ManagedThreadId + "退出。。。。");
        }
        private static void Function(object obj)
        {
            int interval;
            try
            {
                interval = (int)obj;
            }
            catch (InvalidCastException)
            {
                interval = 5000;
            }
            DateTime startCount = DateTime.Now;
            Stopwatch watcher = Stopwatch.StartNew();
            Console.WriteLine("该线程ID为" + Thread.CurrentThread.ManagedThreadId
                + "状态为" + Thread.CurrentThread.ThreadState
                + ",权限是" + Thread.CurrentThread.Priority);

            do
            {
                Console.WriteLine("线程" + Thread.CurrentThread.ManagedThreadId
                    + ":已经过去" + watcher.ElapsedMilliseconds / 1000f);
                Thread.Sleep(500);
            }
            while (watcher.ElapsedMilliseconds <= interval);
        }
    }

上文中我们主线程(Main)在执行完毕后就退出了 ,但是我们创建的线程“AAA”还没有退出,仍在执行任务。直到线程指定循环事件结束为止:

根据上面的例子我们可以看到:

1.对于线程的构造函数及其构造函数的重载来说,它只接受无返回值的函数。

2.线程委托的参数列表只能为空或者object类型,所以传入的参数往往需要类型转换。

3.可以通过Thread.CurrentThread来查看当前函数运行所在的线程的信息。即检索线程,在上面的例子中我们已经这样做了。

4.同样的,既然多线程基于委托,那么同样可以通过一个线程来调用多播委托:

    class Programs
    {
        static void Main()
        {
            ThreadStart thread = Function1;
            thread += Function2;

            Thread th = new Thread(thread);
            th.Name = "线程AAA";
            th.Start();
            
        }
        private static void Function1()
        {
            Console.WriteLine("函数1:此时的线程有" + Thread.CurrentThread.Name);
        }
        private static void Function2()
        {
            Console.WriteLine("函数2:此时的线程有" + Thread.CurrentThread.Name);
        }
    }

输出的结果为: 

5.由于线程基于委托的缘故,我们可以Lambda表达式的闭包特性来对线程的内部赋值:

        static void Main()
        {
            int count = 0;
            ThreadStart startA = () =>
              {
                  count++;
              };

            ThreadStart startB = () =>
              {
                  count += 2;
              };

            Thread threadA = new Thread(startA);
            Thread threadB = new Thread(startB);

            threadA.Start();
            threadB.Start();

            Thread.Sleep(1000);
            Console.WriteLine(count);
        }

 输出很明显为:3。

前台线程与后台线程

在C#中的线程,往往可以分为前台线程与后台线程两种类型:

  • 前台线程:系统必须运行完所有前台线程才能退出程序。
  • 后台线程:所有的后台线程将在系统运行完毕后自动结束。

一个线程被创建出来后,系统默认判定为前台线程或是后台线程也遵循一定的规则:

  • 在主应用程序创建的线程,或是通过Thread类构造函数的线程默认为前台执行的线程。
  • 通过ThreadPool线程池创建的线程默认为后台执行。

一个线程是否是前台和后台并不是割裂的,我们可以通过Thread中的isBackground属性来切换前台或者后台,例如在上面的第一个例子中,我们添加一句修改为后台线程的代码:

那么后台线程也会因为前台线程(在例子中是主线程Main)的关闭而关闭:

线程的关闭

线程在运行时是允许被关闭的,而且只需要调用函数Abort(Abort在英文中也是放弃计划、流产的意思)即可:

    class Program
    {
        static void Main()
        {
            Thread threadA = new Thread(ThreadMethod);
            threadA.Name = "A";
            Thread threadB = new Thread(ThreadMethod);
            threadB.Name = "B";

            threadA.Start(3);
            threadB.Start(6);
        }
        static void ThreadMethod(Object parameter)
        {
            int count;
            try
            {
                count = (int)parameter;
            }
            catch(InvalidCastException e)
            {
                count = 5;
            }
            for (int i = 0; i < 10; i++)
            {
                if (i > count)
                {
                    Console.WriteLine("线程" + Thread.CurrentThread.Name + "已经强制停止");
                    Thread.CurrentThread.Abort();
                }
                Console.WriteLine("该线程为" + Thread.CurrentThread.Name + ",它循环了" + i + "次");
                Thread.Sleep(300);
            }
        }
    }

此时我们可以看到,当A线程的count大于3时,A线程停止。当B线程的count大于6时,B线程停止:

线程的阻塞

线程中同样可以通过调用一个线程的Join方法来阻塞其他线程。当某个线程A调用了Join方法时,其他线程都会停止运行直至线程A执行完毕。

我们把上面的例子修改一下作为线程阻塞的例子:

    class Program
    {
        static Thread threadA, threadB;
        static void Main()
        {
            threadA = new Thread(ThreadMethod);
            threadA.Name = "A";
            threadB = new Thread(ThreadMethod);
            threadB.Name = "B";

            threadA.Start(10);
            threadB.Start(2);

            for (int i = 0; i < 10; i++)
            {
                Console.WriteLine("主线程此时有" + i);
                Thread.Sleep(500);
            }
        }
        static void ThreadMethod(Object parameter)
        {
            int count;
            try
            {
                count = (int)parameter;
            }
            catch(InvalidCastException e)
            {
                count = 5;
            }
            for (int i = 0; i < 10; i++)
            {
                if (i == count && Thread.CurrentThread.Name == "B") 
                {
                    Console.WriteLine("线程A已经阻塞");
                    threadA.Join();
                }
                Console.WriteLine("线程" + Thread.CurrentThread.Name+"的运行状态为"+Thread.CurrentThread.ThreadState);
                Thread.Sleep(500);
            }
        }
    }

上面输出很明显,由于当线程B大于2时调用线程A的阻塞,所以我们可以看到结果直到线程A的逻辑结束后线程B才会继续调用:

我们在调用线程阻塞的方法时需要注意:

  • 线程在阻塞时不会阻塞主线程,在上文中的输出中有相关的体现。
  • 线程的阻塞不能“骑驴找驴”。即一个线程不能调用自己的阻塞方法。那样会造成当前应用程序的停止,此时线程会开始无限期的等待

线程的优先级

对于不同线程来说,C#明确的定义了一个给线程划定优先等级的枚举:

枚举值枚举号意义
AboveNormal3将Thread安排在Highest之后且Normal之前
BelowNormal1将Thread安排在Normal之后且Lowest之前
Highest4将Thread安排在任何其他优先线程之前
Lowest0讲Thread安排在其他任何线程之后
Normal2(线程优先级的默认值)处于AboveNormal和BelowNormal之间

每个线程都分配有优先级,线程的优先级是相对的。操作系统根据优先级来讲线程排入计划中。

操作系统会为所有线程分配CPU时间片。在一些操作系统中,对于可执行的线程有:

  • 优先级最高的线程始终都会被计划为第一个运行。
  • 对于多个优先级相同的线程,线程计划程序会遍历此优先级下的所有线程,并为每个线程分配固定的时间片。
  • 如果给定优先级内没有可运行的线程,线程计划程序会转到对优先级比较低的线程进行计划。
  • 如果较高优先级的线程变得可运行,就会强占优先级较低的线程并获准重新执行。

不同的操作系统的线程计划算法有所区别,如果应用的用户界面在前台和后台之间切换,操作系统还可以动态地调整线程优先级。

 我们可以通过一个例子来测试一下线程的优先级之间的区别:

    class Program
    {
        static void Main()
        {
            Thread.CurrentThread.Name = "C";
            Thread.CurrentThread.Priority = ThreadPriority.Normal;
            Thread threadA = new Thread(ThreadMethod);
            threadA.Name = "A";

            Thread threadB = new Thread(ThreadMethod);
            threadB.Name = "B";

            threadA.Priority = ThreadPriority.Highest;
            threadB.Priority = ThreadPriority.Lowest;


            threadB.Start();
            
            threadA.Start();

            ThreadMethod();
        }
        static void ThreadMethod()
        {
            for (int i = 0; i < 500; i++)
            {
                Console.Write(Thread.CurrentThread.Name);
            }
        }
    }

我们将当前的主线程命名为:“C”,将线程A的优先级置为最高,线程B的优先级置为最低,但是让线程B首先开始,受到优先级的影响,我们可以看到以下的情况:

即使是线程B开始调用,但是由于线程A分配的CPU时间片更多,所以能比线程A更早的执行完毕。但这并不是绝对的,并没有说线程优先级高就决定一切,优先级只能决定系统给该线程分配了多少时间片。事实上,上面的这个例子每次的结果都有很大的区别。

注意:更多的CPU时间片意味着线程有更多的执行时间。

线程安全

我们可以看到,不同的线程在执行时都是并行的,但是我们在使用多线程时不能不注意安全性,对于一个系统中的资源,我们不可能同时让两个线程都操控它。所以,当一个线程访问某个数据时,我们需要进行保护。直到该线程处理完资源后才允许其他线程使用该资源。我们把这种行为成为保护线程安全。

为了保证线程之间不会争抢某一个资源,我们需要给多线程串行化(许多地方把这种需求称为线程同步,但是这样表达往往会产生歧义)。通俗说就是让它们有序地按照某种顺序一个个来访问资源,这很像生活中的排队,但是又有一些细微的区别。

排他锁Lock

一般在C#中比较常见就是排他锁Lock,只需要Lock关键字框选需要锁住的对象即可,这样即使多个线程同时使用是也可以保证它们不会起冲突。我们在之前设计模式中的单例模式中就看到了应用,只需要对单例的对象进行锁住就可以保证在实例没有生成时不会因为多线程给实例产生冲突.

在我们实际的使用中,也需要锁住一些特定的对象防止在多线程的情况下产生冲突,我们写一个最简单的例子:

    class Program
    {
        static void Main()
        {
            ThreadTestClass testClass = new ThreadTestClass();、

            Thread threadA = new Thread(testClass.ThreadMethod);
            threadA.Name = "A";

            Thread threadB = new Thread(testClass.ThreadMethod);
            threadB.Name = "B";

            threadA.Start();
            
            threadB.Start();
        }
    }
    class ThreadTestClass
    {
        private static ThreadTestClass testInstance = new ThreadTestClass();

        public void ThreadMethod(Object parameter)
        {
            lock(testInstance)
            {
                for (int i = 0; i < 5; i++)
                {
                    Console.WriteLine("此时线程" + Thread.CurrentThread.Name + "循环了" + i + "次");
                    Thread.Sleep(300);
                }
            }
        }
    }

我们在类中设定一个私有的对象,然后在函数运行时设定排他锁(这里看起来和单例很像),然后进行遍历。此时由于线程A进入后获得了排他锁,所以只有线程A会执行逻辑,线程A对委托执行完毕后排他锁释放,线程B占得排他锁开始执行线程B的逻辑:

C#排他锁的背后:Monitor类

正如同终结器那样,lock关键字在编译成IL代码时也会进行转换,它的背后即为System.Threading下的Monitor类,它们之间有如下关系:

                lock(obj)
                {
                    //代码。。。。
                }

等同于:

                Monitor.Enter(obj);
                //代码。。。。
                Monitor.Exit(obj);

 所以我们的上面的例子完全可以用Monitor的方法来表示。但是,为了防止在获得排他锁之后产生异常造成Monitor.Exit方法没有执行,进而导致死锁,所以需要使用try-finally来规避这种情况。即上面的例子可以改写为:

            Monitor.Enter(obj);
            try
            {
                //执行代码。。。。
            }
            finally
            {
                Monitor.Exit(obj);
            }

上面的Lock例子的结果很类似于线程阻塞join()方法,但是相对于线程阻塞那个没有参数列表的方法来说,排他锁的要求要更多,对于排他锁“锁住”的对象往往有以下要求:

  1. 排他锁Lock必须为引用类型对象不能传入值类型对象
  2. 排他锁Lock的对象最好为静态私有只读如果排他锁的对象为公有,会存在其他地方锁住该对象造死锁的可能。
  3. 排他锁Lock不能这样写:Lock(typeof(TestClass)),这样将会锁住这种类型的所有的实例,所以最好不要这么做。
  4. 由于string类型适配享元模式,相同值的实例唯一,所以排他锁Lock不能锁住string类型对象。
  5. 使用Lock(this)情况只能适配不同的线程执行同一个对象的函数,如果多对象的多线程则不适用这种写法

我们接下来的例子来自于微软的官方文档,里面写了对于排他锁的对象如果不加约束会产生何种结果。

我们采用Task(与Thread相似的异步委托)的List表来调用一个Lambda表达式,但是Lock中的对象使用int型:

    class Program
    {
        static void Main()
        {
            int number = 0;
            List<Task> tasks = new List<Task>();

            try
            {
                for (int i = 0; i < 10; i++)
                {
                    //对列表中添加并执行一个Lambda表达式
                    tasks.Add(Task.Run(() =>
                    {
                        
                        Thread.Sleep(250);

                        //标记1,此处对值类型对象nTasks进行排他锁
                        Monitor.Enter(number);

                        try
                        {
                            //标记2
                            number += 1;
                        }
                        finally
                        {
                            //标记3
                            Monitor.Exit(number);
                        }
                    }));
                }
                //将等待所有任务执行完毕
                Task.WaitAll(tasks.ToArray());

                Console.WriteLine("此时的任务计数器有" + number);
            }
            catch (AggregateException e)
            {
                string msg = string.Empty;
                foreach (var ie in e.InnerExceptions)
                {
                    //将生成的错误输出
                    Console.WriteLine(ie.GetType().Name);
                    if (!msg.Contains(ie.Message))
                    {
                        
                        msg += ie.Message + Environment.NewLine;
                    }
                }
                Console.WriteLine("错误消息是:");
                Console.WriteLine(msg);
            }
        }
    }

 我们代码在书写的时候并不会报错,但是会输出错误信息:

这是为什么呢,首先我们可以看到,和线程的委托一样,Monitor类的静态方法里面都是Object类型:

以下提到的标记位置位于上面的代码中:

标记1位置,当一个值类型的参数传入时,不可避免的产生了装箱,由于不能传入值类型,所以此处产生了SynchronizationLockException异常。由于有十个Task的异步委托,SynchronizationLockException输出了十次。

并且,虽然Lambda表达式使用闭包获得的外部变量时的number唯一,但是装箱之后的number在堆中产生了副本。每个Task中nTask变量之间互相独立。

标记2位置,由于此时进行值类型int的运算,此时每个number又重新拆箱进行了计算。

标记3位置,此时由于Exit函数参数是Object类型的缘故,上面拆箱的number又重新装箱,由于装箱后的副本与第一次装箱时的对象并不是一个,所以此时触发了异常,因为代码在尝试在之前未锁定的新创建的变量上面释放锁定。也就是控制台输出的最后一句话。

以上就是为什么不能将值类型被排他锁锁定的对象的原因,这样造成的装箱是C#不能接受的。当然并不是说不能投机取巧,可以在传入之前就将值类型对象装箱,但这样做也没什么意义,因为对装箱后的变量的更改并不会影响到它未装箱时的实例

就绪队列与等待队列

最开始粗略的学习多线程的时候,我往往很简单的认为多线程的锁就像是生活中的排队,一个线程处理完该对象了另一个就会立马接手,但实际上C#中,除了当前在占有排他锁的线程,其他的线程分为就绪队列和等待队列两种状态。

  • 我们可以想象就绪队列中的线程正在排队,一旦线程的排他锁释放,会立即占有排他锁。
  • 而等待队列的线程正坐在旁边等着,并不在排队,他们不知道对象的排他锁何时释放,除非他们被通知进入就绪队列中。

实际上,Monitor类为每个排他锁的对象维护了以下信息:

  • 对当前持有锁的线程的引用。
  • 对就绪队列的引用,其中包含已经准备好获取排他锁的线程。
  • 对等待队列的引用,其中包含等待锁定对象状态的更改通知的线程。

并且,Monitor类也提供了线程调换队列或者尝试获取排他锁的方法:

Monitor.TyrEnter:在一定时间内尝试获取排他锁:

TryEnter可以在某一段时间内让方法尝试获取排他锁,获取的结果将返回bool值。

    class Program
    {
        static void Main()
        {
            ThreadTestClass testClass = new ThreadTestClass();

            Thread threadA = new Thread(testClass.ThreadMethod);
            threadA.Name = "A";

            Thread threadB = new Thread(testClass.ThreadMethod);
            threadB.Name = "B";

            threadA.Start();
            
            threadB.Start();
        }
    }
    class ThreadTestClass
    {
        private static object myObj = new object();
        
        public void ThreadMethod(Object parameter)
        {
            bool flag = Monitor.TryEnter(myObj, 100);
            try
            {
                if (flag)
                {
                    for (int i = 0; i < 30; i++)
                    {
                        Thread.Sleep(10);
                        Console.Write(Thread.CurrentThread.Name);
                    }
                }
                else
                {
                    Console.WriteLine("线程" + Thread.CurrentThread.Name + "未能获得排它锁中的内容");
                }
            }
            finally
            {
                if(flag)
                {
                    Monitor.Exit(myObj);
                }
            }
        }
    }

对于线程A来说,已经获取了排他锁,所以可以执行接下来的代码,而且此时的尝试获取的时间限制也对它没有了意义,但是对于线程B来说,在尝试获取的时间内没有获得排他锁,所以只能输出指定的逻辑: 

我们把TryEnter中的时间限制变得宽松,可以看到在A线程运行完毕后B线程尝试获得排他锁成功:

Monitor.Wait线程等待方法与Monitor.Pulse线程通知方法

这里的两个方法都是交替使用的,二者其实有点绕:

Monitor.Wait:当线程调用Wait方法时,它会自动将线程进入等待队列,直到被通知进入就绪队列之前, 它会一直暂停,当重新获得排他锁后,会从调用等待方法的位置继续执行(类似于迭代器中的yield return)。

Monitor.Wait参数列表中第二个值是超时时限。如果超出了等待时限,它会自动将线程进入就绪队列中。它的默认值是-1,即不会主动进入就绪队列中。如果我们将超时时限设置为0,那么它会立马释放排他锁并进入就绪队列中(相当于一个人从队头重新排到队尾)。

Monitor.Pulse:当线程调用Pulse方法时,它会通知等待队列中的线程排他锁对象的状态发生改变(类似于大喊一声:还在等着的线程来排队了!),然后其他收到这个通知的等待队列线程将会进入就绪队列。

这两个函数很有互补的意思,当线程开始进入等待队列后,另一个线程执行完再通知它执行,二者可以交替进行,我们写一个例子来演示一下二者:

    internal class ThreadWatcher
    {
        public void WatchThread(ThreadUser user)
        {
            Console.ForegroundColor = ConsoleColor.Green;
            Console.WriteLine("当前占用此对象的实例名称是" + user.Name);
        }
    }

    internal class ThreadUser 
    {
        public string Name { get; set; }

        private ThreadWatcher watcher;
        public ThreadUser(ThreadWatcher watcher)
        {
            this.watcher = watcher;
        }
        public void getInstanceA()
        {
            Console.WriteLine("A函数开始运行!");
            Monitor.Enter(watcher);

            Console.WriteLine("进入A函数的排他锁了!");
            Console.WriteLine("A函数的排他锁开始等待!");

            Monitor.Wait(watcher);
            

            Console.WriteLine("等待后重新进入A函数!");

            watcher.WatchThread(this);

            Console.ForegroundColor = ConsoleColor.White;
            Thread.Sleep(300);

            Console.WriteLine("A函数向等待的线程发送讯号!");
            Monitor.Pulse(watcher);

            Console.WriteLine("A函数退出排他锁");
            Monitor.Exit(watcher);

        }

        public void getInstanceB()
        {
            Console.WriteLine("B函数开始运行");
            Monitor.Enter(watcher);
            Console.WriteLine("进入B函数的排他锁了!");
            Console.WriteLine("B函数的排他锁开始等待1秒!并通知等待线程对象状态已改变!");

            Monitor.Pulse(watcher);
            if (Monitor.Wait(watcher, 1000))
            {
                Console.WriteLine("等待后重新进入B函数!");
                watcher.WatchThread(this);
                Console.ForegroundColor = ConsoleColor.White;
                Thread.Sleep(300);
            }
            else
            {
                Console.WriteLine("此时B函数仍然没有获得对象权限!");
            }

            Thread.Sleep(300);
            Console.WriteLine("B函数退出排他锁");
            Monitor.Exit(watcher);
        }

    }
    class Program
    {
        static void Main()
        {
            Console.ForegroundColor = ConsoleColor.White;
            ThreadWatcher watcher = new ThreadWatcher();

            ThreadUser userA = new ThreadUser(watcher)
            {
                Name = "A",
            };

            ThreadUser userB = new ThreadUser(watcher)
            {
                Name = "B",
            };

            Thread threadA = new Thread(userA.getInstanceA);
            Thread threadB = new Thread(userB.getInstanceB);

            threadA.Start();
            threadB.Start();
        }
    }

我们在两个线程的状态发生改变的时候放入很多输出的地方,然后二者交替进行,正常的输出为:

此时两边都访问到了ThreadWatcher对象实例的内部。并且我们可以清楚的看到

  1. A线程首先获得排他锁,然后执行wait进入等待队列。
  2. 此时就绪队列中的B线程得到了排他锁,然后也开始进行等待并通知等待队列中的线程这个对象的排他锁改变了,设置超时时限为1秒。
  3. 在等待队列中的A线程受到通知,进入就绪队列,然后接着执行代码,进入对象实例中访问里面的函数。
  4. A线程执行接近尾声,此时通知等待队列中的线程排他锁的状态改变了,接着释放排他锁。
  5. 在等待队列的B线程收到通知,进入就绪队列,获得排他锁,然后执行B线程委托中的逻辑。
  6. B线程释放排他锁。

这就是两个方法的应用,当然两个方法还有许多重载,但大同小异,我们可以尝试把上文中两个Pulse通知方法注释掉,就可以看到,B线程进入等待后A线程没有收到进入就绪队列的通知,此时排他锁并没有被正确的释放,所以即使一秒后B线程尝试获得排他锁也会失败,A线程则会无限期地等待:

由于线程谁先占用到排他锁并不是绝对的,并且需要规避到死锁情况,这两个方法需要谨慎使用,需要及时地对等待队列中的线程进行通知,不然最后很可能就是死锁,一群线程互相干瞪眼。

同步基元Mutex类

mutex同步基元可以做线程的同步也可以做进程的同步(进程我完全不会),如果之前的Monitor类叫排他锁的话,我们可以把Mutex称为互斥体。它的父类是WaitHandle类

当两个以上线程访问资源时,系统需要一个互斥机制来确保每次只有一个线程使用资源。互斥体Mutex仅向一个线程授予对共享资源的独占访问权限。如果某个线程获取到了互斥体,那么需要获取该互斥体的第二个线程将挂起,直到第一个线程释放该互斥体

如果将排他锁比作一个皮球,所有的获得皮球的人(线程)主动将它(排他锁)抱在怀里(获得锁)。

那么互斥体就可以比喻成食堂大妈,不管有多少人(线程)过来打饭(申请获得资源),食堂大妈(互斥体)都只会对人(线程)一个个地打饭(授予资源独占访问权限),并阻止其他人(线程)插队打饭(请求获得互斥体所有权)。

在互斥锁中,有两个比较关键的函数:

WaitOne:请求获得互斥体访问权限,返回一个bool值,并对第一个调用它的线程授予互斥体的独占访问权限。该函数的重载版本中可以定义一个请求超时时限,当某个线程请求超过时限后,将不会进一步尝试获取互斥体的所有权,并且因此也不会调用释放互斥体的ReleaseMutex方法。

ReleaseMutex:释放互斥体的访问所有权,并取消阻止其他尝试获得互斥体所有权的线程。

我们写一个例子来演示一下互斥体Mutex,这个例子来源于微软的官方文档,我自己做了一些修改:

    class PublicClass
    {
        public int PublicNumber;
        public PublicClass(int i)
        {
            PublicNumber = i;
        }
        public void Plus()
        {
            Console.ForegroundColor = ConsoleColor.Green;

            PublicNumber++;
            Console.WriteLine("线程" + Thread.CurrentThread.Name + "调用了公有对象中的方法!此时的公有值:" + PublicNumber);

            Console.ForegroundColor = ConsoleColor.White;
        }
    }
    class Program
    {
        private static PublicClass publicInstance = new PublicClass(0);
        private static Mutex mutex = new Mutex();
        static void Main()
        {
            Console.ForegroundColor = ConsoleColor.White;
            for (int i = 0; i < 3; i++)
            {
                Thread newThread = new Thread(UseResouce);

                newThread.Name = i.ToString();

                newThread.Start();
            }

            Thread.Sleep(3000);
            Console.WriteLine("主线程:此时的公有值为" + publicInstance.PublicNumber);
        }
        static void UseResouce()
        {
            try
            {
                Console.WriteLine("线程" + Thread.CurrentThread.Name + "正在请求");
                mutex.WaitOne();
                
                Console.WriteLine("线程" + Thread.CurrentThread.Name + "正在进入保护区域");

                publicInstance.Plus();
                Thread.Sleep(500);

                Console.WriteLine("线程" + Thread.CurrentThread.Name + "正在离开保护区域");
            }
            finally
            {
                mutex.ReleaseMutex();

                Console.WriteLine("线程" + Thread.CurrentThread.Name + "已经释放同步基元");
            }
        }
    }

当进入线程后将调用一个公共资源publicInstance中的方法,并且阻止其他线程调用方法,,它的输出为:

最开始多线程同时开始请求时,线程0第一个获得了互斥体,并且调用了公共资源中的方法。此时线程2请求成功,并且输出。然后是线程1请求然后输出,实现了线程串行化的需求。互斥体在使用中与排他锁类似,这篇博客对它只是一个非常基础的应用,如果想要深入学习,还是应该参看互斥体Mutex的父类:WaitHandle类

信号量Semaphore类

与上面的排他锁和互斥体不同的是,信号量可以限制可以同时访问某一资源或资源池的线程数。

Semaphore使用类控制对资源的访问。它内部维护一个计数量,为了实现线程数的控制,它有以下规则:

  • 我们执行Semahore的构造函数时,即可赋予计数量初始值。
  • 每次线程进入信号量(WaitOne)时,计数量都将减一。
  • 当计数量为0时,后续的资源请求就会阻塞。
  • 释放信号量(Release)时,计数量加一。
  • 当所有线程都已释放信号量后,计数量将达到创建信号量的初始值。

信号量Semaphore类与互斥锁Mutex类都继承自WaitHandle类,所以都存在WaitOneRelease方法。由于信号量可以控制访问的线程数,所以一个线程可以多次进入信号量中。

信号量中的计数量在开发过程中需要我们自己维护,这要求我们保证线程中不会重复的执行WaitOneRelease方法。例如,一个信号量支持两个线程进入,但是某个线程多执行了一次Release方法,此时计数量已经加满,那么另一个线程执行Release方法时将抛出SemaphoreFullException问题。

在执行信号量前,需要调用Semaphore类的Release(Int32)重载方法,指定释放信号量的线程数量,这相当于手动地将计数量拉满。这在下文的例子中有所体现。

我们修改一下互斥体的例子,来简单的演示一下信号量是如何限制线程访问数量的:

    class PublicClass
    {
        private int PublicNumber;
        public PublicClass(int i)
        {
            PublicNumber = i;
        }
        public void Plus()
        {
            Console.ForegroundColor = ConsoleColor.Green;

            PublicNumber++;
            Console.WriteLine("线程" + Thread.CurrentThread.Name + "调用了公有对象中的方法!此时的公有值:" + PublicNumber);

            Console.ForegroundColor = ConsoleColor.White;
        }
    }
    public class Example
    {
        private static PublicClass publicInstance = new PublicClass(0);
        private static Semaphore semaphorePool;
        //private static int _padding;
        static void Main()
        {
            Console.ForegroundColor = ConsoleColor.White;

            semaphorePool = new Semaphore(0, 3);

            for (int i = 1; i <= 5; i++)
            {
                Thread Newthread = new Thread(Worker);
                Newthread.Name = i.ToString();
                Newthread.Start();
            }

            Thread.Sleep(1000);
            Console.WriteLine("主线程释放信号量锁" + semaphorePool.Release(3));

            Console.WriteLine("主线程退出");
        }

        private static void Worker()
        {
            Console.WriteLine("线程" + Thread.CurrentThread.Name + "开始并等待信号量");
            semaphorePool.WaitOne();

            publicInstance.Plus();

            Thread.Sleep(1000);

            Console.WriteLine("线程" + Thread.CurrentThread.Name + "释放信号量");
            Console.WriteLine("线程" + Thread.CurrentThread.Name + "的上一个信号量计数" + semaphorePool.Release());
        }
    }

这里我们通过Semaphore的构造函数设定了初始线程入口数最大并发线程入口数,并且在主线程中指定释放信号量,这样其他的请求的线程才能进入并执行方法。

我们看到线程5、1、4首先进入并执行逻辑,然后3和2进入。

这是信号量的简单应用,实际上信号量Semaphore类的方法与功能浩如烟海,这里只是只取一瓢饮了。 

线程池ThreadPool

我们这里稍微提一下线程池,为以后的专门的线程池的博客开个头。

我们在使用线程时,它的数量往往不会像我们写的例子那样,实际开发中的多线程大量而且复杂。并且,创建一个新线程在代码上看清晰简单,但实际上非常昂贵,对短暂的异步操作创建线程会产生显著的开销。所以我们需要一个线程池。

池这个东西很类似于之前设计模式中享元模式,当我们需要一个新的实例,不是主动创建,而是去享元工厂中索取。而.Net线程池是享元模式在线程中的实现。通过System.Threading.ThreadPool来操控它。

线程池受到了CLR的管理,同样,这也意味着每个CLR都有一个线程池的实例。ThreadPool中存在一个QueueUserWorkItem静态方法,与线程一样,同样接受一个委托。在线程池中存在一个存放委托的内部队列。

当线程池中没有进入新的委托时,会删除一些过期的线程并释放资源。

线程适用于短时间的异步操作,它可以减少并行的耗费与操作系统的资源。我们这里就不过多的阐述了,之后会专门写一篇线程池的博客,这篇文章已经太长了且花了我太多时间了(虽然没有序列化那篇花的时间多)。

总结:

最后作为总结,简单说说线程池的优缺点:

优点:

  • 提高了CPU的利用率。
  • 实现了并行操作的需求。

缺点:

  • 线程越多,内存的耗费越大。
  • 增加了对代码的协调管理成本。
  • 线程的资源调配是个坑,很可能出现各种各样的问题。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值