编程深水区之并发⑤:C#的Thread线程

Windows、Linux和MacOS三大操作系统的进程和线程机制,实现上有一些差异,但大体的原理是差不多的。本章节讨论的进程和线程,以Windows操作系统为准。

一、再次深入进程和线程

不晓得大家有没有用过Windows95(用过的在评论区扣个1)?那个时代,最无奈的按键应该就是主机上的Reset。Windows95,是从16位过渡到32位的第一代系统,同时兼容16位和32位,所以仍然存在很多16位时代的老毛病,爱死机。主要原因是,16位时代的Windows是单线程系统,其中任何一个任务陷入死循环,就会造成死机,当然也有可能是这个任务还在执行、但你看不到任何响应的假死机。所以,Win95之后的Windows系统都是多线程操作系统(抢占式)。

1.1 进程和内存

首先出现的是进程,在一个进程中运行应用程序的实例。进程是应用程序实例需要使用的资源的集合,包括开辟一个独立的虚拟地址空间、加载应用程序的EXE、DLL等文件到内存。进程之间、以及进程与操作系统之间的资源相互独立,不能相互访问,所以系统变得更加安全和健壮。

创建进程的开销是很大的,它需要将应用程序的文件加载到内存中,往往体现在打开应用的速度上。此外,如你所见,进程是与内存相关的概念。而线程要做的事情,是跑应用程序的代码,所以它和CPU相关。

1.2 线程和CPU

现在继续解决应用程序发生死循环导致死机的问题。如果CPU只能执行完一个线程后再执行下一个线程,它就永远无法解决爱死机的问题。所以,Windows将CPU虚拟化,具体的做法就是将CPU的执行时间划分为一个个细小的时间片,通常只有几十毫秒,我们称它为逻辑CPU,而操作系统的任务,就是统一调度逻辑CPU来执行线程,逻辑CPU的执行和切换速度很快,所以你感觉不到上一刻帮你处理事情的CPU,实际上已经跑到其它地方去了。Windows为每个进程都分配了线程,应用程序的代码进入死循环时,那个代码相关联的进程会冻结,CPU会让出时间片,并执行其它等待的线程。同时,线程还允许用户使用另外一个应用程序(比如任务管理器)强制终止可能已经冻结的应用程序,从此Reset键变成了Ctrl+Alt+Delete键。

线程运行在进程环境中,共享进程的资源,创建线程的代价比进程低很多,但也不是完全没有开销。线程的开销主要包括以下几个方面:

  • **创建和销毁线程的开销:**创建内核对象(包括线程上下文)、环境块、用户模式栈、内核模式栈、DLL连接与分离(有些程序可能有几百个DLL),这里面包含了空间和时间的开销。
  • **线程切换的开销:**线程分配给CPU后,运行一个时间片,时间片到期,立即切换到下一个线程。切换线程时,①当前运行线程中CPU寄存器保存的结果要转移到内核对象的线程上下文中,②然后操作系统选择一个线程供调度,如果这个线程属于另外一个进程,还需要切换到另一个虚拟地址空间;③将新线程上下文结构中的值加载到CPU寄存器中。上下文切换的开销比想象中大,线程上下文保存在内存中,CPU执行时需要加载到寄存器中,而30ms之后,一次新的切换又发生了。
  • **垃圾回收机制GC和调试:**GC执行时,CLR(包含C#的运行时)必须挂起所有线程,干完活后,再恢复所有线程,挂起和恢复都需要额外开销,所以GC的性能和线程数量息息相关。调试程序时,也有类似行为。

补充一点,线程机制刚出来时,电脑都还是单核CPU,但现在是多核时代,还有超线程技术加持,CPU得以实现真正的并行,极大提升了操作系统的性能和响应能力。但是,单个CPU的运行机制仍然没有改变,且分配到某个CPU上执行的线程,会一直待在这个CPU上。

1.3 线程优先级

本节纯碎了解一下,对理解线程影响不大,一般也不需要去设置线程优先级。

前面有提到,现在Windows操作系统是抢占式的多线程操作系统,线程在任何时间都有可能停止并切换到另外一个线程,那Windows是如何决定什么时候执行哪个线程呢?

首先,检查前面提到的内核对象(上下文在里面),挑选出适合调度的线程;然后,在这些备选的线程中,执行优先级高的线程。早期版本中线程的优先级,划分为0-31级,31级的优先级最高。有一个默认的0级线程,由操作系统在启动时创建,它是内存的清道夫,但没有其它任何线程可执行时,系统就会执行这个0级线程,将所有空闲的内存清零。

这个0-31级的优先级,我们是无法控制的,但Windows公开了优先级的一个抽象层。进程被划分为:Idle、BelowNormal、Normal、AboveNormal、High和Realtime6个层级,线程被划分为Idle、Lowest、BelowNormal、Normal、AboveNormal、Highest和Time-Critical7个层级,它们两两相交,确定了一个线程的层级,比如一个Normal进程中的Normal线程,它映射的优先级为8。我们开发应用程序,一般都有宿主环境,比如基于AspNetCore的应用,所有应用进程的优先级都是受宿主环境约束的。

二、Thread线程

2.1 创建线程的方式

在C#中,我们可以非常方便的创建和使用线程,实现并发(异步)编程。如前所述,C#的多线程实际上是由操作系统进行统一调度和管理的,CLR只是公开了相应的操作API。目前使用多线程的方式,主要有三种,一是使用Thread创建前台线程;二是使用CLR管理的线程池;三是在线程池基础上的TPL。本章节先说Thread,但需要提醒的是,任何时候,都应该优先考虑使用线程池。

2.2 创建Thread线程

javascript 复制代码
//Thread的构造方法有多个重载,参数是委托类型
//1、传入方法===================================================
//以下代码输出 1 7,其中1为主线程的ID,7为新线程的ID
//如果将Thread.Sleep(500)注释打开,输出7 1
//Thread.Sleep()方法阻塞当前线程,让当前线程等待
public class Program
{
    static void Main(string[] args)
    {
        
        var mythread = new Thread(MyThreadMethod);//创建新线程
        mythread.Start();//启动线程
        //Thread.Sleep(500); //阻塞当前线程(主线程)
        Thread thread = Thread.CurrentThread;//获取当前主线程对象
        Console.WriteLine(thread.ManagedThreadId);//输出主线程ID
        
    }
    //在新线程中执行的方法
    static void MyThreadMethod()
    {
        
        Thread thread = Thread.CurrentThread;//获取新线程对象
        Console.WriteLine(thread.ManagedThreadId);//输出新线程ID
    }
}


//2、传入Lambda=================================================
public class Program
{
    static void Main(string[] args)
    {

        var mythread = new Thread(() =>
        {
            Thread thread = Thread.CurrentThread;
            Console.WriteLine(thread.ManagedThreadId);
        });

        mythread.Start();
        Thread thread = Thread.CurrentThread;
        Console.WriteLine(thread.ManagedThreadId);
    }
}


//3、创建新线程时传参============================================
//注意,委托的参数只有一个,且必须是object类型,在新线程内要进行转换
public class Program
{
    static void Main(string[] args)
    {
        var mythread = new Thread((object? obj) => {
            if (obj is not null) 
            {
                int num;
                var sucess = int.TryParse(obj.ToString(), out num);
                if (sucess)
                {
                    Console.WriteLine($"输入参数为:{num}");
                }
                
            }
        });
        mythread.Start(5);//传入参数
    }
}

2.3 当前线程状态Thread.CurrentThread

javascript 复制代码
//通过Thread的静态属性CurrentThread,获取当前线程对象
public class Program
{
    static void Main(string[] args)
    {
        var currentThread = Thread.CurrentThread;//获取当前线程对象
        currentThread.Name = "主线程";//设置当前线程名称

        Console.WriteLine(currentThread.Name);//线程名称
        Console.WriteLine(currentThread.ManagedThreadId);//线程ID
        Console.WriteLine(currentThread.CurrentCulture);//线程区域
        Console.WriteLine(currentThread.CurrentUICulture);//线程语言
        Console.WriteLine(currentThread.IsAlive);//是否存活
        Console.WriteLine(currentThread.IsBackground);//是否是后台线程
        Console.WriteLine(currentThread.IsThreadPoolThread);//是否是线程池线程
    }
}
/*输出:
主线程
1
zh-CN
zh-CN
True
False
False
*/

2.4 线程阻塞,Thread.Sleep()和线程对象的Join()方法

javascript 复制代码
//1、Thread.Sleep()静态方法======================================
//Thread.Sleep(),会阻塞当前线程,让当前线程等待规定时间
//Task.Delay()可以实现类似功能,但它不会阻塞,两者区别在下个章节展开
public class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
        Console.WriteLine(DateTime.Now);
        Thread.Sleep(1000);
        Console.WriteLine(DateTime.Now);
        Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
    }
}
/*输出
1
2024/8/1 15:03:06
2024/8/1 15:03:07
1
*/

//1、Join(),Thread的实例方法====================================
//当在一个线程中调用另一个线程对象的 Join() 方法时
//当前线程会被阻塞,直到被调用 Join() 方法的线程执行完毕。
public class Program
{
    static void Main()
    {
        var newthread = new Thread(NewThreadMethod);
        newthread.Start();

        //主线程调用newthread的Join方法,等待newthread执行完毕
        newthread.Join();

        Console.WriteLine("newthread执行完毕,主线程继续执行");
    }

    static void NewThreadMethod()
    {
        Console.WriteLine("子线程开始执行");
        Thread.Sleep(3000);//模拟耗时操作
        Console.WriteLine("子线程执行结束");
    }
}

2.5 前台线程和后台线程

使用Thread创建的线程,默认和主线程一样,IsBackground值为false,即前台线程,而使用CLR线程池创建立的线程,默认为后台线程。前台线程和后台线程的区别为:当一个进程的所有前台线程都停止时,CLR强制终止仍在运行的所有后台进程,并退出进程。随意使用Thread创建一个长时运行的前台线程是很危险的,比如你在GUI的Button事件上创建了一个长时运行的Thread前台线程,当你关闭UI窗口后,会发现任务管理器中,仍然在运行着这个应用。Thread创建线程时,是可以设置为后台线程的,如下:

javascript 复制代码
public class Program
{
    static void Main()
    {
        var newthread = new Thread(NewThreadMethod);
        newthread.IsBackground = true;
        newthread.Start();
        Console.ReadKey();
    }

    static void NewThreadMethod()
    {
        var currentThread = Thread.CurrentThread;
        Console.WriteLine(currentThread.IsBackground);
        Console.WriteLine(currentThread.ManagedThreadId);
    }
}
/*输出
true
7
*/

2.6 线程终止

最优情况下,Thread线程应该可以自然终止,这样线程退出时能够安全的清理资源。除此之外,也可以通过代码强行终止线程。.NET Framework,可以使用thread.Abort()强行终止,但它不安全,可能导致资源泄漏或不一致状态。所以,到了.NETCore和现在的.NET时代,移除了这个API。如果要强行退出线程,推荐使用共享变量或CancellationToken,两种用法相似。CancellationToken常用于TPL任务并行库(Task Parallel Library),放到下个章节。以下为使用共享变量:

javascript 复制代码
//调用Stop()方法,线程退出
//CancellationToken原理和这个类似
private bool _shouldStop = false;
public void DoWork()
{
    while (!_shouldStop)
    {
        // 执行线程的工作
    }
    // 清理资源
}

public void Stop()
{
    _shouldStop = true;
}

*这是一个系列文章,将全面介绍多线程、用户态协程和单线程事件循环机制,建议收藏、点赞哦!

*你在并发编程过程中碰到了哪些难题?欢迎评论区交流~~~


我是functionMC > function MyClass(){...}

C#/TS/鸿蒙/AI等技术问题,以及如何写Bug、防脱发、送外卖等高深问题,都可以私信提问哦!

相关推荐
向宇it24 分钟前
【从零开始入门unity游戏开发之——C#篇24】C#面向对象继承——万物之父(object)、装箱和拆箱、sealed 密封类
java·开发语言·unity·c#·游戏引擎
坐井观老天5 小时前
在C#中使用资源保存图像和文本和其他数据并在运行时加载
开发语言·c#
pchmi7 小时前
C# OpenCV机器视觉:模板匹配
opencv·c#·机器视觉
bufanjun0018 小时前
JUC并发工具---ThreadLocal
java·jvm·面试·并发·并发基础
黄油饼卷咖喱鸡就味增汤拌孜然羊肉炒饭9 小时前
C#都可以找哪些工作?
开发语言·c#
boligongzhu11 小时前
Dalsa线阵CCD相机使用开发手册
c#
向宇it1 天前
【从零开始入门unity游戏开发之——C#篇23】C#面向对象继承——`as`类型转化和`is`类型检查、向上转型和向下转型、里氏替换原则(LSP)
java·开发语言·unity·c#·游戏引擎·里氏替换原则
sukalot1 天前
windows C#-命名实参和可选实参(下)
windows·c#
小码编匠1 天前
.NET 下 RabbitMQ 队列、死信队列、延时队列及小应用
后端·c#·.net