• 线程基础必知必会(二)


    这篇文章将在上篇文章的基础上,进一步讲解线程的相关知识。这篇文章涉及到的知识点有 线程优先级前台与后台线程线程参数lockMonitor线程异常处理 。这篇要比上一篇难度有一点提高,但是大家不用担心,我依然会用大量的代码来展示每个知识点,并且对于其中的难点我会详细讲解。下面我们就开始学习基础知识的第二篇。

    一、线程优先级

    .NET 给我们定义了多种线程优先级,它们都位于 ThreadPriority 中,分别是: LowestBelowNormalNormalAboveNormalHighest 。它们的优先级和说明如下表所示:

    优先级 说明
    Lowest 最低的优先级
    BelowNormal 位于 Normal 优先级之后
    Normal 默认优先级,所有线程都具备的优先级
    AboveNormal 位于 Highest 优先级之后
    Highest 最高的优先级

    在普通的情况下,如果优先级高的线程在运行,就不会给优先级低的线程分配任何 CPU 时间,这样就可以保证重要/主要线程具有较高的优先级。在大多数的时间内,这个线程什么也不做,而其他线程则执行它们的任务。一旦有信息输入,这个线程就立即获得比其他线程更高的优先级,在短时间内处理输入的信息。根据我在项目中的经验来看,高优先级的线程一般用在 处理用户输入数据重要数据处理应用程序主线程 。下面我们通过一个例子来看一下线程的优先级。

    using System.Threading;
    using static System.Console;
    
    namespace Thread_Priority
    {
        class Program
        {
            static int printNumberRunCount = 0;
            static int printStringRunCount = 0;
            static void Main(string[] args)
            {
                Process.GetCurrentProcess().ProcessorAffinity = new System.IntPtr(1);
                Thread printNumberThread = new Thread(PrintNumber);
                Thread printStringThread = new Thread(PrintString);
                printNumberThread.Priority = ThreadPriority.Highest;
                printStringThread.Priority = ThreadPriority.Lowest;
                printNumberThread.Start();
                printStringThread.Start();
                Thread.Sleep(2000);
                printNumberThread.Abort();
                printStringThread.Abort();
                Write($"PrintNumber 循环了:{printNumberRunCount} 次,printStringThread 循环了:{printStringRunCount} 次");
                Read();
            }
    
            static void PrintNumber()
            {
                for (int i = 0; i < 10000; i++)
                {
                    printNumberRunCount++;
                    WriteLine($"输出数字:{i}");
                }
            }
    
            static void PrintString()
            {
                for(int i=0;i<10000;i++)
                {
                    printStringRunCount++;
                    WriteLine("are you ok?");
                }
            }
        }
    }
    
    

    在上面的代码中我们创建了两个线程 printNumberThreadprintStringThread 这两个线程分别调用 PrintNumberPrintString 方法。并且我们也定义了两个统计线程运行次数的变量 printNumberRunCountprintStringRunCount。之后我们将 printNumberThread 线程的优先级设置为最高 Highest ,将 printStringThread 线程的优先级设置为最低 Lowest ,接着我们在运行线程两秒后将线程停掉,这时我们从控制台输出的内容可以看出来高优先级的线程 printNumberThread 循环的次数大于低优先级的线程 printStringThread 循环的次数。 在代码中有这么一行 Process.GetCurrentProcess().ProcessorAffinity = new System.IntPtr(1);这段代码的意思是告诉操作系统将所有的线程都在 CPU 的第一个核心上进行运算。加这么一段代码只是为了将优先级更明显的表现出来而已,实际开发中除非特殊情况,一般不这么写。
    QELTUJ.png

    Tip:

    1. 优先级越高所占用的 CPU 时间就会越多。但是即使我们手动设置最高的优先级,也不会超过操作系统进程的优先级。

    二、前台与后台线程

    前台线程和后台线程大体上是一样的,唯一的不同是进程中所有的前台线程都完成工作完后,就会马上结束进程工作,即使还有后台线程在工作。简单的说就是后台线程不会确保进程一直运行,当进程中的所有前台线程都停止,系统会关闭所有后台线程。我们可以通过 ThreadIsBackground 属性来设置线程是前台线程还是后台线程,当复制为 True 时表示时后台线程,繁反之为前台线程。这里需要注意的是属于线程池的线程是后台线程,从非托管代码进入托管执行环境的线程都会变为后台线程,默认情况下通过新建并启动 Thread 对象生成的所有线程都是前台线程。

    using System.Threading;
    using static System.IO.File;
    using static System.Console;
    
    namespace ForegroundBackgroundThread
    {
        class Program
        {
            static void Main(string[] args)
            {
                Thread printNumberThread = new Thread(PrintNumber);
                Thread printStringThread = new Thread(PrintString);
                printStringThread.IsBackground = true;
                printNumberThread.Start();
                printStringThread.Start();
            }
            static void PrintNumber()
            {
                for (int i = 0; i < 20; i++)
                {
                    AppendAllText("PrintNumber.txt",i+"
    ");
                }
            }
            static void PrintString()
            {
                for(int i=0;i<50;i++)
                {
                    AppendAllText("PrintString.txt", i + "
    ");
                }
            }
        }
    }
    
    

    上述代码中我们定义了两个线程 printNumberThreadprintStringThread ,并分别调用 PrintNumberPrintString 方法。其中 PrintNumber 方法我们循环输出 20 个数字,PrintString 方法我们循环输出 50 个数字。然后我们将 printStringThread 线程通过属性 IsBackground 设置为后台线程,最后启动这两个线程。在代码运行完毕后我们来查看一下输出的两个文件 PrintNumber.txtPrintString.txt 中的内容。

    QVzMyn.png

    从两个文件的内容可以看出,PrintNumber 文件输出了所有的数字,而 PrintString 却没有输出所有数字,这是因为 printStringThread 为后台线程,当 printNumberThread 线程执行完毕后进程就推出了。
    那么到这里会有很多读者要问了,前后台线程有什么用呢?后台线程适用于后台任务,例如将被动侦听活动的线程设置为后台线程,将负责发送数据的线程设置为前台线程,这样在所有的数据发送完毕之后台前线程不会被终止。前台线程用于需要长时间等待的任务,例如监听客户端请求。后台线程用于处理时间较短的任务,例如处理客户端发送的请求。

    三、线程参数

    前面我们创建线程调用的方法都是不带参数的,但是在实际开发中线程调用的方法不带参数的情况很少,大部分情况都是带有参数的,那么遇到这种情况我们该怎么处理呢?我先来看一下代码。

    using System.Threading;
    using static System.Console;
    
    namespace ThreadPara
    {
        class Program
        {
            static void Main(string[] args)
            {
                Thread thread = new Thread(()=>PrintNumber(50));
                thread.Start();
                Read();
            }
            static void PrintNumber(int number)
            {
                for (int i = 0; i < number; i++)
                {
                    WriteLine($"输出数字:{i}");
                }
            }
        }
    }
    

    上述代码中我们利用匿名方法调用 PrintNumber 方法,并将参数传递进来。除了这种方法还有另一种方式传递参数,通过 Thread.Start(para) 传递参数,代码如下:

    using System.Threading;
    using static System.Console;
    
    
    namespace ThreadPara
    {
        class Program
        {
            static void Main(string[] args)
            {
                Thread thread = new Thread(PrintNumber);
                thread.Start(50);
                Read();
            }
            static void PrintNumber(object number)
            {
                for (int i = 0; i < (int)number; i++)
                {
                    WriteLine($"输出数字:{i}");
                }
            }
        }
    }
    
    

    这里需要注意的是在使用 Thread.Start(para) 方法传递参数时,被调用方法的参数类型必须是 object 类型。如果你觉得上述两种方法都不好,那么你还可以使用 ParameterizedThreadStart 委托,只需把前面行一段代码中的 Thread thread = new Thread(PrintNumber);修改为 Thread thread = new Thread(new ParameterizedThreadStart(PrintNumber));即可。同样利用 ParameterizedThreadStart 委托也需要把被调用方法参数的类型定为 object 类型。
    我个人建议大家在调用带参数的方法是使用匿名方法的方式调用,因为如果方法参数存在多个参数是这样调用更加便捷。当然了在遇到方法带有多个参数时你也可以使用自定义类的方式,但是这种方法并不被微软所推荐,而且这种方法代码量较大,为了调用多参数方法而去定义一个类,可以说是相当的鸡肋。

    四、lock

    当多个线程同时访问同一个对象时,会出现数据不正确的问题,下面我们先通过一个代码看一下这种情况。

    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Text;
    using System.Threading;
    using static System.Console;
    
    namespace ThreadLock
    {
        class Program
        {
            static void Main(string[] args)
            {
                CountOperating countOperating = new CountOperating();
                Thread thread1 = new Thread(() => update(countOperating));
                Thread thread2 = new Thread(() => update(countOperating));
                thread1.Start();
                thread2.Start();
                thread1.Join();
                thread2.Join();
                WriteLine(countOperating.count);
                Read();
            }
    
            static void update(CountOperating countOperating)
            {
                for (int i = 0; i < 10000; i++)
                {
                    countOperating.Add();
                    countOperating.Subtraction();
                }
            }
        }
        class CountOperating
        {
            public int count { get; set; }
            public void Add()
            {
                count++;
            }
            public void Subtraction()
            {
                count--;
            }
        }
    }
    
    

    上述代码我们希望最后输出的结果是 0 ,但是在代码运行后发现输出结果大部分情况并不是 0 ,这时因为我的创建的 CountOperating 类并非线程安全的了类,当多个线程同时调用同一个 CountOperating 实例时,有很大的可能出现如下情况。首先线程1将 count 值加1,这时第二个线程也获取到 count 的值,此时值已经变为1,再次加1后值变为了2,这时第一个线程再次获取到 count 值值为2,第一个线程在获取到值后准备进行减运算,但是第二个线程也获取到了 count 值值也是2,接着第一个线程执行了减操作,此时得到的值是1,然后第二个线程也同样进行了减操作,此时的值依然是1,也就是说我们只执行了一次减法操作,两次加法操作。为了防止这种情况的发生,我们就需要将我们创建的类修改为线程安全的类,也就是说当一个线程调用 CountOperating 实例时其他线程只能等待。因此我们在这里引入了 lock ,lock 关键字可确保当一个线程位于代码的临界区时,另一个线程不会进入该临界区。 如果其他线程尝试进入锁定的代码,则它将一直等待,直到该对象被释放。lock 关键字在块的开始处调用 Enter,而在块的结尾处调用 Exit。 ThreadInterruptedException 引发,如果 Interrupt 中断等待输入 lock 语句的线程。通常,应避免锁定 public 类型,否则实例将超出代码的控制范围。根据前面所说的我们将 CountOperating 代码修改如下即可:

    class CountOperating
    {
        readonly object obj = new object();
        public int count { get; set; }
        public void Add()
        {
            lock (obj)
            {
                count++;
            }
        }
        public void Subtraction()
        {
            lock (obj)
            {
                count--;
            }
        }
    }
    

    五、Monitor

    当我们使用 lock 关键字来锁定一个对象时,其他需要访问该对象的线程会处于阻塞状态,需要等到这个对象解锁后才能进行下一步操作,但是这会出现严重的性能问题和死锁的问题,性能问题相关的解决方式我会在后面的文章讲解,这一小节主要是讲解死锁的解决方案。所谓死锁举个例子来说就是线程A锁定了对象A,线程B锁定了对象B,线程A需要对象B释放后才能释放对象A,但是线程B要等到对象A释放后才能释放对象B。这样对象A和对象B永远不会被释放,线程A和线程B就永远在等待。为了解决这个问题微软为我们提供了一个解决方案,利用 Monitor 类来避免死锁,它通过获取和释放排它锁的方式实现多线程的同步问题。实际上在 .NET 中 lock 关键字时 Monitor 类用例的语法糖,lock 是对 Monitor 的 EnterExit 的一个封装,因此 Monitor 类的 Enter() 和 Exit() 方法的组合使用可以用 lock 关键字替代。Monitor 类除了具有 lock 功能还有以下功能:

    1. TryEnter() :解决长期死等的问题,如果一个并发经常发生,并且持续时间很长,使用TryEnter,可以有效防止死锁或者长时间 的等待。
    2. Wait() : 释放对象上的锁,以便允许其他线程锁定和访问该对象。在其他线程访问对象时,调用线程将等待。脉冲信号用于通知等待线程有关对象状态的更改。
    3. Pulse() / PulseAll() : 向一个或多个等待线程发送信号。该信号通知等待线程锁定对象的状态已更改,并且锁的所有者准备释放该锁。等待线程被放置在对象的就绪队列中以便它可以最后接收对象锁。一旦线程拥有了锁,它就可以检查对象的新状态以查看是否达到所需状态。

    上面这三个功能我将会在后续的文章中逐步讲解,下面我们先看一下 Monitor 类的基本用法:

    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Text;
    using System.Threading;
    
    namespace ThreadMonitor
    {
        class Program
        {
            static void Main(string[] args)
            {
                object lock1 = new object();
                object lock2 = new object();
                Thread thread = new Thread(() => Lock(lock1, lock2));
                Thread thread2 = new Thread(() => Lock(lock1, lock2));
                thread.Start();
                lock(lock2)
                {
                    Thread.Sleep(2000);
                    if(Monitor.TryEnter(lock1,5000))
                    {
                        Console.WriteLine("获取了被锁定的对象");
                    }
                    else
                    {
                        Console.WriteLine("超时了");
                    }
                }
                thread2.Start();
                lock (lock2)
                {
                    Thread.Sleep(1000);
                    lock (lock1)
                    {
                        Console.WriteLine("获取了被锁定的而对象");
                    }
                }
            }
    
            static void Lock(object lock1,object lock2)
            {
                lock(lock1)
                {
                    Thread.Sleep(1000);
                    lock (lock2);
                }
            }
        }
    }
    

    上述代码中的 Lock 方法先锁定了第一个 lock 对象然后一秒钟后有锁定了第二个 lock 对象。值后在 Main 方法中创建了两个线程都调用 Lock 方法,然后通过两种方式锁定第一个 lock 和第二个 lock ,第一种方法我们使用 Monitor.TryEnter 来锁定对象,并设置了超时时间,一旦超时将会输出 “超时了”,但是第中方式我们利用 lock 来锁定对象,这样就创建了一个死锁。

    六、线程异常处理

    线程也是代码,因此也会出现异常,大部分开发人员的习惯是直接向上抛出异常,这种做法在普通的代码中并不错,向上抛出异常让方法的调用方去处理这个异常,但是在线程中这种做法就是错误的,因为抛出的异常无法在线程之外被检测的,因此我们必须在线程中将异常处理掉,也就是说在异常中必须使用 try…catch 语句块来捕获和处理异常。

    七、源码下载

    1. https://github.com/Thomas-Zhu/Multithreading/tree/master/no2/NoTwo
      了”,但是第中方式我们利用 lock 来锁定对象,这样就创建了一个死锁。

    六、线程异常处理

    线程也是代码,因此也会出现异常,大部分开发人员的习惯是直接向上抛出异常,这种做法在普通的代码中并不错,向上抛出异常让方法的调用方去处理这个异常,但是在线程中这种做法就是错误的,因为抛出的异常无法在线程之外被检测的,因此我们必须在线程中将异常处理掉,也就是说在异常中必须使用 try…catch 语句块来捕获和处理异常。

    七、源码下载

    1. https://github.com/Thomas-Zhu/Multithreading/tree/master/no2/NoTwo
  • 相关阅读:
    微信小程序-movable-view
    微信小程序-swiper组件
    大文件上传的php.ini配置和apache或者nginx需要的配置
    Yii2.0 实现关联查询
    Yii2.0关闭自带的debug功能
    创建日志文件,并且追加内容
    微信小程序实现计算器功能
    Yii2.0实现后台接收json数据处理数据修改功能
    Yii2.0成功或者失败提示➕页面跳转
    Yii2.0配置pathinfo模式
  • 原文地址:https://www.cnblogs.com/gangzhucoll/p/12778124.html
Copyright © 2020-2023  润新知