快速让你理解进程与线程的区别

一、一切要从CPU说起

一切要从 CPU 说起,CPU 并不知道线程、进程之类的概念。CPU 只知道两件事:

  • 从内存中取指令
  • 执行指令,返回1
    在这里插入图片描述
    注意:CPU 不是直接从内存中取指令,而是先从内存中读取指令加载到寄存器中,CPU 从寄存器中取指令进行运算,主要是为了解决CPU和内存间操作速度上的差异

CPU这家伙执行一条指令大约需要耗时1ns,但是对于内存呢?它去硬盘读取数据至少8ms,在这段时间内,CPU可以执行大约800万条指令。

说到这里,我们顺便讲一下CPU的重要组成部分:

1、寄存器

  • 指令寄存器(IR):用于暂存当前正在执行的指令
  • 程序计数器(PC):指出下一条指令在内存中的地址
  • 数据寄存器(DR):内存和CPU间数据传送的中转站,弥补CPU和内存间操作速度上的差异
  • 地址寄存器(AR):保存CPU当前所访问的主存单元的地址
  • 程序状态寄存器(PSW):指出程序执行时处于用户态还是核心态

2、运算器
主要的作用就是用来做加减乘除这些运算的,不过嘞,这里你需要知道的一点就是,运算器是没法直接操作内存中的数据的,很容易想到,运算器操作的数据是寄存器中存放的数据。

CPU 从哪里取出指令呢?

答: CPU 内部有程序计数器(PC),它指出下一条指令在内存中的地址,CPU 从内存中将指令取出加载到寄存器中,然后 CPU 从寄存器中取指令进行运算。

那么是谁来设置 PC 寄存器中的指令地址呢?

答:由于大部分情况下 CPU 都是一条接一条顺序执行,所以 PC 寄存器中的地址默认是自动加 1,但当遇到 if、else 时,这种顺序就被打破了,CPU 在执行这类指令时会根据计算结果来动态改变 PC 寄存器中的值,这样 CPU 就可以正确地跳转到需要执行的指令了。

那么 PC 中的初始值是怎么被设置的呢?

答:函数被编译后会形成 CPU 执行的指令,当程序被执行时,它要被加载到内存中,那么很自然的,我们该如何让 CPU 执行一个函数呢?显然我们只需要找到函数被编译后形成的第一条指令就可以了,第一条指令就是函数入口。我们想要 CPU 执行一个函数,那么只需要把该函数对应的第一条机器指令的地址写入 PC 寄存器就可以了,这样我们写的函数就开始被 CPU 执行起来啦。

二、从 CPU 到操作系统

上一小节中我们明白了 CPU 的工作原理,我们想让 CPU 执行某个函数,那么只需要把函数对应的第一条机器执行装入 PC 寄存器就可以了,这样即使没有操作系统我们也可以让 CPU 执行程序,虽然可行但这是一个非常繁琐的过程,我们需要:

  • 在内存中找到一块大小合适的区域装入程序
  • 找到函数入口,设置好 PC 寄存器让 CPU 开始执行程序

这两个步骤绝不是那么容易的事情,如果每次执行程序时让程序员手动实现上述两个步骤,程序员会疯掉。因此聪明的程序员就会想干脆直接写个程序来自动完成上面两个步骤吧。
机器指令需要加载到内存中执行,因此需要记录下内存的起始地址和长度;同时要找到函数的入口地址并写到 PC 寄存器中,想一想这是不是需要一个数据结构来记录下这些信息:

struct *** {
   void* start_addr;  			//记录内存起始地址
   int len;           			//长度
   
   void* start_point; 			//记录函数入口地址
   ...
};

这个数据结构总要有个名字吧,这个结构体用来记录什么信息呢?记录的是程序在被加载到内存中的运行状态,程序从磁盘加载到内存跑起来叫什么好呢?干脆就叫进程(Process)好了。

进程无非就是内存中的一段区域,这段区域中保存了 CPU 执行的机器指令以及函数运行时的堆栈信息,要想让进程运行,就把 main 函数的第一条机器指令地址写入 PC 寄存器,这样进程就运行起来了。

CPU 执行的第一个函数也起个名字,第一个要被执行的函数听起来比较重要,干脆就叫 main 函数吧。

完成上述两个步骤的程序也要起个名字,干脆就叫操作系统好了。就这样操作系统诞生了,程序员要想运行程序再也不用自己手动加载一遍了。
现在进程和操作系统都有了,一切看上去都很完美。

三、从单核到多核,如何充分利用多核

人类的一大特点就是生命不息折腾不止,从单核折腾到了多核。
在这里插入图片描述
这时,假设我们想写一个程序并且要充分利用多核该怎么办呢?

有的同学可能会说不是有进程吗,创建多个进程不就可以了?听上去似乎很有道理,但是主要存在这样几个问题:

  • 进程无非就是内存中的一段区域,需要占用内存空间,如果多开几个进程,显然会造成内存浪费
  • 计算机处理的任务会涉及到进程间通信,由于各个进程处于不同的内存地址空间,进程间通信天然需要借助操作系统,这就在增大编程难度的同时也增加了系统开销

该怎么办呢?

四、从进程到线程

让我们再来仔细地想一想这个问题,所谓进程无非就是内存中的一段区域,这段区域中保存了 CPU 执行的机器指令以及函数运行时的堆栈信息,要想让进程运行,就把 main 函数的第一条机器指令地址写入 PC 寄存器,这样进程就运行起来了。
在这里插入图片描述
进程的缺点在于只有一个入口函数,也就是 main 函数,因此进程中的机器指令只能被一个 CPU 执行,那么有没有办法让多个 CPU 来执行同一个进程中的机器指令呢?

解决思路:main 函数的特殊之处无非就在于是 CPU 执行的第一个函数,除此之外再无特别之处,我们可以把 PC 寄存器指向 main 函数,就可以把 PC 寄存器指向任何一个函数。

当我们把 PC 寄存器指向非 main 函数时,线程就诞生了。
在这里插入图片描述
至此我们解放了思想,一个进程内可以有多个入口函数,也就是说属于同一个进程中的机器指令可以被多个 CPU 同时执行。

注意,这是一个和进程不同的概念,创建进程时我们需要在内存中找到一块合适的区域以装入进程,然后把 CPU 的 PC 寄存器指向 main 函数,也就是说进程中只有一个执行流。
在这里插入图片描述
但是现在不一样了,多个 CPU 可以在同一个屋檐下(进程占用的内存区域)同时执行属于该进程的多个入口函数,也就是说现在一个进程内可以有多个执行流了。
在这里插入图片描述
我们把这个执行流起个不容易懂的名字,就叫线程(Thread)吧,这就是线程的由来。

操作系统为每个进程维护了一堆信息,用来记录进程所处的内存空间等,这堆信息记为数据集 A。同样的,操作系统也需要为线程维护一堆信息,用来记录线程的入口函数或者栈信息等,这堆数据记为数据集 B。

显然数据集 B 要比数据集 A 的量要少,同时不像进程,创建一个线程时无需去内存中找一段内存空间,因为线程是运行在所处进程的地址空间的,这块地址空间在程序启动时已经创建完毕,同时线程是程序在运行期间创建的(进程启动后),因此当线程开始运行的时候这块地址空间就已经存在了,线程可以直接使用。这就是为什么各种教材上提的创建线程要比创建进程快的原因(当然还有其它原因)。

值得注意的是,有了线程这个概念后,我们只需要进程开启后创建多个线程就可以让所有 CPU 都忙起来,这就是所谓高性能、高并发的根本所在。

另外值得注意的一点是,由于各个线程共享进程的内存地址空间,因此线程之间的通信无需借助操作系统,这给程序员带来极大方便的同时也带来了无尽的麻烦,多线程遇到的多数问题都出自于线程间通信,简直太方便了以至于非常容易出错。出错的根源在于 CPU 执行指令时根本没有线程的概念,多线程编程面临的互斥与同步问题需要程序员自己解决。

需要提醒的是,虽然前面关于线程讲解使用的图中用了多个 CPU,但不是说一定要有多核才能使用多线程,在单核的情况下一样可以创建出多个线程,原因在于线程是操作系统层面的实现,和有多少个核心是没有关系的,CPU 在执行机器指令时也意识不到执行的机器指令属于哪个线程。即使在只有一个 CPU 的情况下,操作系统也可以通过线程调度让各个线程"同时"向前推进,方法就是将 CPU 的时间片在各个线程之间来回分配,这样多个线程看起来就是"同时"运行了,但实际上任意时刻还是只有一个线程在运行。

五、线程与内存

在前面的讨论中我们知道了线程和 CPU 的关系,也就是把 CPU 的 PC 寄存器指向线程的入口函数,这样线程就可以运行起来了,这就是为什么我们创建线程时必须指定一个入口函数的原因。无论使用哪种编程语言,创建一个线程大体相同:

// 设置线程入口函数DoSomething
thread = CreateThread(DoSomething);

// 让线程运行起来
thread.Run();

那么线程和内存又有什么关联呢?

我们知道函数在被执行的时候产生的数据包括函数参数、局部变量、返回地址等信息,这些信息是保存在栈中的,线程这个概念还没有出现时进程中只有一个执行流,因此只有一个栈,这个栈的栈底就是进程的入口函数,也就是 main 函数,假设 main 函数调用了 funA,funA 又调用了 funB,如图所示:
在这里插入图片描述
那么有了线程以后呢?

有了线程以后,一个进程中就存在多个执行入口,即同时存在多个执行流,那么只有一个执行流的进程需要一个栈来保存运行时信息,那么很显然有多个执行流时就需要有多个栈来保存各个执行流的信息,也就是说操作系统要为每个线程在进程的地址空间中分配一个栈,即每个线程都有独属于自己的栈,能意识到这一点是极其关键的。
在这里插入图片描述
同时我们也可以看到,创建线程是要消耗进程内存空间的,这一点也值得注意。

六、从多线程到线程池

从前几节我们能看到,线程是操作系统中的概念,但对于执行大量短任务,频繁创建、销毁线程的缺点:

  • 创建线程天然需要借助操作系统来完成,操作系统创建和销毁线程是需要消耗时间的
  • 每个线程需要有自己独立的栈,因此当创建大量线程时会消耗过多的内存等系统资源

这就引出线程池的概念。

线程池的概念是非常简单的,无非就是创建一批线程,之后就不再释放了,有任务就提交给这些线程处理,因此无需频繁地创建、销毁线程,同时由于线程池中的线程个数通常是固定的,也不会消耗过多的内存,因此这里的思想就是复用、可控。

可能有的同学会问,该怎么给线程池提交任务呢?这些任务又是怎么给到线程池中线程呢?

很显然,数据结构中的队列天然适合这种场景,提交任务的就是生产者,消费任务的线程就是消费者,实际上这就是经典的生产者-消费者问题。
在这里插入图片描述
线程池中的线程会阻塞在队列上,当生产者向队列中写入数据后,线程池中的某个线程会被唤醒,该线程从队列中取出上述结构体(或者对象),以结构体(或者对象)中的数据为参数并调用处理函数。

七、总结

创建进程时我们需要在内存中找到一块合适的区域以装入进程,然后把 CPU 的 PC 寄存器指向 main 函数。

任意创建多个进程的缺点:

  • 进程无非就是内存中的一段区域,需要占用内存空间,如果多开几个进程,显然会造成内存浪费
  • 计算机处理的任务会涉及到进程间通信,由于各个进程处于不同的内存地址空间,进程间通信天然需要借助操作系统,这就在增大编程难度的同时也增加了系统开销

这就引出了线程的概念

main 函数的特殊之处无非就在于是 CPU 执行的第一个函数,除此之外再无特别之处,我们可以把 PC 寄存器指向 main 函数,就可以把 PC 寄存器指向任何一个函数。当我们把 PC 寄存器指向非 main 函数时,线程就诞生了。

创建一个线程时无需去内存中找一段内存空间,因为线程是运行在所处进程的地址空间的,这块地址空间在程序启动时已经创建完毕。但创建线程必须指定一个入口函数,产生的数据包括函数参数、局部变量、返回地址等信息,这些信息是保存在栈中,因此每个线程都有独属于自己的栈,所以创建线程要消耗内存空间。

创建多个线程的缺点:

  • 创建线程天然需要借助操作系统来完成,操作系统创建和销毁线程是需要消耗时间的
  • 每个线程需要有自己独立的栈,因此当创建大量线程时会消耗过多的内存等系统资源

这就引出了线程池的概念

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值