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、防脱发、送外卖等高深问题,都可以私信提问哦!