首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >线程基础必知必会(二)

线程基础必知必会(二)

作者头像
喵叔
发布2020-09-08 16:43:51
2710
发布2020-09-08 16:43:51
举报
文章被收录于专栏:喵叔's 专栏喵叔's 专栏

这篇文章将在上篇文章的基础上,进一步讲解线程的相关知识。这篇文章涉及到的知识点有 线程优先级前台与后台线程线程参数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
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+"\r\n");
            }
        }
        static void PrintString()
        {
            for(int i=0;i<50;i++)
            {
                AppendAllText("PrintString.txt", i + "\r\n");
            }
        }
    }
}

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

QVzMyn.png
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
本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2019-12-11 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、线程优先级
  • 二、前台与后台线程
  • 三、线程参数
  • 四、lock
  • 五、Monitor
  • 六、线程异常处理
  • 七、源码下载
  • 六、线程异常处理
  • 七、源码下载
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档