打开线程 | 进程 | 协程的大门

C语言与CPP编程

共 8686字,需浏览 18分钟

 ·

2021-10-13 21:46

不知从几何起,可能是大三那年的操作系统考试,也可能是刚经历完的秋招,这些概念总是迷迷糊糊,可能自己回答的和其他人的答复也差不多,并没有什么亮点,通常都会以:「我们换个题」的方式结束,有时候也挺尴尬的。我们不妨看看这样几个题应该怎么去回答

  • 进程和线程是什么

  • 进程和线程有什么区别

  • 为什么有了进程又出现线程

  • 内核态和用户态有啥不同

  • 协程有什么特点

太多太多一系列的问题伴随到学习,工作的各个阶段,这些问题确实不怎么好回答,除非你真的理解到它的底层原理,否则很容易就把自己套进去,那么今天我们一起来看看这些问题都是怎么产生的,为什么总是会问这些题,开始吧

前言

进程线程协程

进程和线程

进程,平时我们打开一个播放器,开一个记事本,这些都是应用程序,一个软件的执行副本,这就是进程。从操作系统层面而言,进程是分配资源的基本单位,线程在很长时间被称为轻量级的进程,是程序执行的基本单位。

这样看来一个分配资源的基本单位,一个是程序执行的基本单元。以前面试的时候,我经常也就这样背给面试官了,当自己成为了面试官才发现这些孩子答案为啥都是这个,原来网上大部分的资料也就说了这些呢,直接这样死记硬背当然不行,让我们回到最初的计算机时代。

最初的计算机时代是什么样子呢

那个时代呀,程序员会将写好的程序放入闪存中,然后插入到机器里,通过电能推动芯片计算,那么芯片从闪存中取出指令,然后执行下一条执行,一旦闪存中的执行执行完了,计算机就要关机了

闪存时代

这在早期叫做单任务模型,也叫做作业(Job)。随着人们的需求越来越多,生活的多元化,慢慢出现了办公,聊天,游戏等,这个时候不得不在同一台计算机中来回的切换,人们就想要不通过线程和进程来处理这个问题

那是怎么处理的方式?

比如说一个游戏,启动后为一个进程,但是一个游戏场面的呈现需要图形的渲染,联网,这些操作不能相互的阻塞,如果阻塞了,卡起就很难受,总觉得这游戏怎么这么 low,我们希望它们能同时的运行,所以将其各个部分设计为线程,这就出现了一个进程有多个线程

然一个进程有多个线程,这个资源分配如何处理?

启动一个游戏,首先需要存储这些游戏参数,所以需要内存资源,当进行攻击等动作时候,发出的各种动作指令需要计算,所以需要计算资源 CPU,还需要需要存储一些文件,所以还需要文件资源。由于早期的 OS 没有线程的概念,所以让各个进程采用分时的技术交替执行,通过管道等技术让各个进程进行通信。

这样看上去比较完美了,启动一个游戏后出来这么多进程,那么能不能启动游戏后,在这个进程下面安排一种技术,让其仅仅分配 CPU 资源呢,这就出现了线程

这个线程如何分配的?

线程概念被提出来以后,因为只分配了CPU 计算资源,所以也叫做轻量级的进程。通过操作系统来调度线程,也就是说操作系统创建进程后,“牵个线”,进程的入口程序被放在主线程中,看起来就感觉是操作系统在调度进程,实际上调度的是进程中线程,这种被操作系统直接调度的线程叫做内核级线程

既然有内核级别线程,当然有用户级线程,相当于操作系统调度线程,主线程通过程序的方式实现子线程,这就是用户级线程,典型的即 Linux 中的 Phread API。既然说到内核态和用户态,我们来看看两者有什么作用

用户态线程

它完全是在用户空间创建,对于操作系统而言是不知情的,用户级线程的优势如下:

  • 切换成本低:用户空间自己维护,不用走操作系统的调度

  • 管理开销小:创建和销毁不用系统调用,系统调用所造成的上下文切换下文会讲解

用户态线程有什么缺点

  • 与内核沟通成本大:因为这种线程大部分时间在用户空间,如果进行 IO 操作,很难利用内核的优势,且需要频繁的用户态和内核态的切换

  • 线程之间的协作麻烦:想象两个线程 A 和 B需要通信,通信通常会涉及到 IO 操作,IO 操作涉及到系统调用,系统调用又要发生用户态和内核套的切换成本,难

  • 操作系统无法针对线程的调度进行优化:如果一个进程的用户态线程阻塞了操作系统无法及时的发现和处理阻塞问题,它不会切换其他线程从而造成浪费

内核态线程

内核态线程执行在内核态,一般通过系统调用创造一个内核级线程,那么有哪些优点?

  • 操作系统级优化:内核中的线程即使执行 IO 操作也不需要进行系统调用,一个内核阻塞可以让其他立即执行

  • 充分利用多核优势:内核权限足够高,可以在多个 CPU 核心执行内核线程

内核级线程有什么缺点?

  • 创建成本比较高:创建的时候需要使用系统调用即切换到内核态

  • 切换成本高:切换的时候需要进行内核操作

  • 扩展性差:因为一个内核管理,坑位有限,不可能数量太多

用户态线程和内核态线程的映射关系是怎样的呢

上面谈到用户态线程和内核态线程都有缺点,用户态线程创建成本低,不可以利用多核,而内核态线程创建成本高,虽可以利用多核,但是切换速度慢。所以,通常都会在内核中预留一些线程并反复使用这些线程,至此出现了以下几种映射关系

用户态和内核态映射之一--多对一

内核线程的创建成本既然高,那么我们就是多个用户态进程的多线程复用一个内核态线程,可是这样线程不能并发,所以此模型用户很少

用户态线程与内核态线程多对一

用户态和内核态映射之二--一对一

让每个用户态线程分配一个单独的内核态线程,每个用户态线程通过系统调用创建一个绑定的内核线程,这种模型能够并发执行,充分利用多核的优势,出名的 Windows NT即采用这种模型,但是如果线程比较多,对内核的压力就太大

用户态线程与内核态线程一对一

用户态和内核态映射之三--多对多

即 n 个用户态线程对应 m 个内核态线程。m通常小于等于n,m通常设置为核数,这种多对多的关系减少了内核线程且完成了并发,Linux即采用的这种模型

用户态线程与内核态线程多对一用户态线程与内核态线程多对多

一台计算机会启动很多进程,其数量当然是大于 CPU 数量,只好让 CPU 轮流的分配给它们,让我们产生了多任务同时执行的错觉,那有没有想过这些任务执行之前,CPU都会干啥?

CPU 既然要执行它,势必会去了解从哪里加载它,又从哪里开始运行,也就是说,需要系统提前将它们设置好 CPU 寄存器和程序计数器

眼中的寄存器和程序计数器是什么?

它虽小不过威力却很大,速度很快的内存。而程序计数器用来记录正在执行指令的位置,这些CPU需要依赖的环境即 CPU 的上下文。上下文知道了,那么 CPU 的切换是不是就很好理解

将前一个任务的 CPU 上下文保存下来,加载新任务的上下文到寄存器和程序计数器中,然后跳转到程序计数器所指向的位置。根据任务的不同又分为进程的上下文和线程的上下文

进程的上下文

进程在用户空间运行的时候叫做用户态,陷入到内核空间叫做进程的内核态,如果用户态的进程想转变到内核态,则可以通过系统调用的方式完成。进程由内核调度,进程的切换发生在内核态

进程的上下文包含哪些数据?

既然进程的切换发生在内核态,那么进程的上下文不仅仅包括虚拟内存,栈,全局变量等用户空间资源,还包括了内核堆栈,寄存器等内核空间的状态

这里的保存上下文和恢复上下文也不是说免费的,需要内核在 CPU 上运行才能完成

上下文保存

线程上下文切换

看到这里,你肯定可以脱口而出两者的区别在于线程是调度的基本单位,而进程是资源拥有的基本单位。讲白了,内核的任务调度实际上调度的是线程,进程只是为线程提供虚拟内存,全局变量等资源,所以这样理解可能更好:

  • 进程如果只有一个线程,那么认为进程就是线程

  • 如果进程有多个线程,那么多个线程会共享相同的虚拟内存和全局变量等资源,上下文的切换不会影响这些资源

  • 线程拥有自己的私有数据比如栈和寄存器,上下文切换的时候需要提前保存

综上,线程的上下文切换将分为两个部分

  • 两个线程不属于同一个进程,那么资源不共享,所以切换过程就会涉及到进程的上下文切换

  • 第二种情况即两个线程属于同一个进程。因为共享虚拟内存,所以切换的时候这些资源保持不动,只需要切换线程的私有数据等不共享的数据

这也从侧面表明了,进程内的线程切换比多进程间的切换会节省不少资源,这也是多线程逐渐替代多进程的一个优势

那么系统调用又是怎么执行的?

真的是一环接一环,是不是像极了面试,是的,我们对面试官的每一次回答都应该尽全力的让面试官上钩,问自己所能回答的问题不是。

如果用户态的程序要执行系统调用,则需要切换到内核态执行,这个过程如下图所示,一图胜千言

系统调用过程

既然分为了用户态和内核态,两者权限级别不尽相同,用户态的程序发起系统调用,因为涉及到权限问题,不得不牵扯到特权指令,所以就会通过中断的方式执行,即上图的 Trap。

发生中断以后,内核程序就开始执行,处理完成又要触发 Trap,切换到用户态的工作,这里又涉及到了中断,我们这篇就先简单了解下中断

中断做了什么?

我们以平时经常接触的键盘为例,当我们敲下键盘,主板收到按键后通知 CPU ,CPU 此时可能在忙处理其他程序,需要先中断当前执行的程序,然后将 PC 指针跳转到固定的位置,这就是一次中断的简单描述

可是我们不同的组合按键对应不同的事件,所以需要根据中断类型判断 PC 指针到底跳转到哪儿,中断类型的不同,PC 指针所执行的位置也就不同,因此进行了分类,这个类型呢我们称为中断识别码。CPU 通过 PC 指针知道需要跳转到哪个地址进行处理,这个地址叫做 中断向量表

举个例子,使用编号 8 表示按键中断类型A的识别码,编号 9 表示中断类型 B 的识别码。当中断发生的时候,对于CPU而言,是需要知道到底让 PC 指针指向哪个地址,这个地址就是中断向量

假设我们设置了 255 个中断,编号为 0 - 255,在 32 位机器中差不多需要 1k 的内存地址存储中断向量,这里的 1k 空间就是中断向量表。

因此,当 CPU 接收到中断,根据中断类型操作 PC 指针,找到中断向量,修改中断向量,插入指令实现跳转功能

进程和线程都出现了,那么怎么调度?

计算机资源有限,太多的进程消耗机器自然受不住,我们人也一样,胃也有限嘛,一顿不吃饿得慌,可是吃多了也会走路脚颤抖不是,所以聪明的计算机也会想办法来处理这个问题。两手一挥,既然我们的 CPU 的核数有限,要不咋们给每个进程分配一个时间片,排队一个个执行,超出给定的时间就直接让另一个进程执行如何

那时间片怎么分配?

假设此时有三个进程,进程1只需要 2 个时间片,进程2需要1个时间片,进程3需要3个时间片。进程1执行到一半的时候,累了,不想执行了,休息会(挂起),进程2执行,进程2一梭子就执行完了,进程3等不及了马上执行,执行三分之一后,进程1开始执行,这样循环根据时间片的执行方式即分时技术

分时技术

刚才有说到进程的状态,那么有哪些状态?

一个进程的周期一般会分为下面三种状态

  • 就绪状态:进程创建好了会开始排队,这个时候叫做“就绪状态”

  • 运行状态:当一切准备就绪,天时地利人和后开始执行,此时为“运行状态”

  • 如果将时间片用完了会再次变为就绪状态

运行就绪

如果进程因为等待某个进程的完成,此时会进入阻塞状态

进程阻塞

为什么需要阻塞状态

我们想想,有的时候计算机会因为各种原因不能响应我们的请求,可能是因为等待磁盘,可能因为等待打印机,毕竟不会总是的及时的满足我们的需求,所以它这个时候通过中断告诉 CPU ,CPU 通过执行中断处理程序,将控制权给操作系统,操作系统随后将阻塞的进程状态修改为就绪状态,安排重新排队,再加上因为进程进入阻塞状态无事可做,但是又不能干瘪瘪的让他去排队(因为需要等待中断),所以进入到阻塞状态。

下面对以上所说的三种状态进行一个小结

  • 就绪状态( Ready ):可运行,只不过其他进程在运行暂时停止

  • 运行( Running):此时进程占用 CPU

  • 阻塞状态( blo ck ): 此时可能因为等待相关事件(请求 IO/等待 IO 完成等) 而停止运行,此时即使把 CPU 控制权给它,仍然无法运行

其实,进程还有两种基本状态

  • 创建状态 ( New ):进程刚被创建还没有提交时的状态,主要功能为分配和建立进程控制块等初始化工作。创建进程有两个阶段,第一个阶段为为新的进程创建必要的管理信息。第二个阶段为让进程进入就绪状态

  • 终止状态 ( Exit ):进程退出的状态,即回收除了进程控制块以外的资源。也分为两个阶段,第一个阶段为等待操作系统进行善后处理,第二个阶段为释放主存

所以一共就包含了五个状态,为了更加直观,其变迁图如下

五种形态
  • Null---->创建状态:最初创建的第一个状态

  • 创建状态----->就绪状态:进行一些列的初始化称为就绪状态

  • 就绪状态----->运行状态:当操作系统调度就绪状态的进程并分配给 CPU 变为运行状态

  • 运行状态------>结束状态:当进程完成相应任务或出错则被操作系统结束的状态

  • 运行状态------>阻塞状态:运行状态的进程由于时间片用完,操作系统将进程更改为就绪状态

  • 阻塞状态------->就绪状态:阻塞状态的进程等待某事件结束进入就绪状态

其实不是卖光子,实际上还有两种状态,分别是就绪挂起和阻塞挂起,那我们看看那这两者有啥不一样

  • 挂起是一种行为,而阻塞是进程的状态

  • 导致进程挂起的原因通常是因为内存不足或者用户的请求,进程的修改等,而进程的阻塞是进程正在等待某个事件发生,可能是等待资源或响应

  • 挂起对应的是行为的激活,将外存中的进程掉入内存中,而处于阻塞状态的进程需要等待其他进程或系统唤醒

  • 挂起属于被动行为,进程被迫从内存转移到外存,而进入阻塞为主动的行为

综上,现在咋们的进程图就变为了七种状态,如下

进程的七种状态

进程与线程的底层原理

上面我们了解了进程,线程的由来以及状态变迁,但是显然不能让我自如的了解进程和线程,至于其如何在内存表示等问题还是比较空虚的,所以我们继续往下看

进程和线程在内存中如何表示

在整个设计过程中,涉及了两张表,分别是进程表线程表。其中进程表会记录进程在内存的位置,PID是多少,以及当前什么状态,内存给它分配了多大使用空间以及属于哪个用户,假设没有这张表,操作系统就不知道有哪些进程,也就更不清楚怎么去调度,就仿佛失去XXX,不知道了方向

进程表

尤其需要注意进程表这样几个部分

  • 资源信息

资源信息会记录这个进程有哪些资源,比如进程和虚拟内存怎么映射,拥有哪些文件等

  • 内存布局

内存的知识点太多,如果在这里写文章将会非常的长,所以打算单独使用一篇文章写。

在 Linux 中,操作系统采用虚拟内存管理技术,使得进程都拥有独立的虚拟内存空间,理由也比较直接,物理内存不够用且不安全(用户不能直接访问物理内存),使用虚拟内存不但更安全且可以使用比物理内存更大的地址空间。

另外,在 32 位的操作系统中,4GB 的进程地址空间分为两个部分,用户空间和内核空间,用户空间为 0~3G,内核地址空间占据 3~4G,用户不能直接操作内核空间虚拟地址,只有通过系统调用的方式访问内核空间。

操作系统会告诉进程如何使用内存,大概分为哪些区域以及每个区域做什么。简单描述下下图各个段的作用。

  • 栈:系统自动分配释放,平时经常使用的函数参数值,局部变量,返回地址等就在此

  • 堆:存放动态分配的数据,通常由开发人员自行管理,如果开发人员使用后不释放,那么程序结束后可能会被操作系统收回

  • 数据段:存放的是全局变量和静态变量。其中初始化数据段(.data)存放显示初始化的全局变量和静态变量,未初始化数据段,此段通常也被称为BSS段(.bss),存放未进行显示初始化的全局变量和静态变量。

进程内存布局
  • 描述信息

描述信息包含进程的唯一识别号,进程的名称以及用户等

除了给进程安排一张表以外,给线程也安排了一张表,这就是线程表。线程表也包含了一个 ID,这 ID 叫做 ThreadID,同时也会记录自己在不同阶段的状态,比如阻塞,运行,就绪。由于多个线程会共用 CPU 且需要不停的切换,所以需要记录程序计数器寄存器的值。

说到了用户级的线程和内核级的线程,两者又是怎么个亲密关系

两者映射的关系如何去表示

可以想像在内核中有一个线程池,给予用户空间使用,每次用户级线程把程序计数器等传递过去,执行结束后,内核线程不销毁,等待下一个任务,从这里可以看出创建进程开销大、成本高;创建线程开销小,成本低。

这么多进程难道共用内存?

操作系统太多的进程,为了让他们各司其职,互不干扰,考虑为他们分配完全隔离的内存区域,即使程序内部读取相同的内存地址,但实际上他们的物理地址也不一样。就仿佛我在 X 座的 501 和你在 Y 座的501一样却不是一个房子,这就是地址空间

所以在正常的情况下 A 进程不能访问 B 进程的内存,除非你植入一个木马,恶意操作 B 进程的内存或者通过我们后面说的进程间通信的方式进行访问

那进程线程怎么切换的呢?

操作系统的大量进程需要来回的切换,保持有借有还再借不难的传统美德,每次切换之前需要先记录下当前寄存器值的内存地址,方便下次回到原位置继续执行。恢复执行的时候就从内存中读取,然后恢复状态执行即可

进程切换

为了详细的让大家理解这个过程,我将其拆分为下面几个步骤

  • 操作系统感知到有个进程需要切换,先发出一个中断信号给 CPU ,让其停止当前进程

  • CPU 收到中断信号后,正在执行的进程会停止,好心的操作系统会想办法先保存当前的状态

  • 操作系统接管中断后,执行一段汇编程序帮助寄存器之前进程的状态

  • 当操作系统保存好状态后就会执行调度程序,让其决定下一个将要执行的进程

  • 最后操作系统会执行下一个进程

进程与中断

中断以后如何恢复之前进程运行呢

上面说到操作系统会执行一段代码帮助进程恢复状态,其实现方式中,有一种方式即通过的先进后出的数据结构,所以对吧,大学中的基础课程真的好重要。

进程(线程)中断后,操作系统负责压栈关键数据(比如寄存器)。恢复执行时,操作系统负责出栈和恢复寄存器的值。

协程

第一次接触协程是一次自动驾驶项目中,一起干活的同事说这个库底层使用了协程,我一脸懵逼,啊?携程?准备收拾行李回家了?半天想过来了,其有个底层库使用了协程,当时还一脸懵逼,进程,线程就已经够折腾人了,怎么又来个协程,当时想着到时候面试官是不是又多了问问题的思路

  • 什么是协程

  • 协程和进程,线程的区别是什么

  • 协程有什么优缺点

你们说头不秃怎么破?行嘛,为了生活,不,喜爱计算机,止不住学习的步伐,下面我们看看这个东西是什么

为什么需要协程?

我们在执行多任务的时候通常采用多线程的方式并发执行。我们以最近非常火热的电商促销茅台为例,不管茅台是在缓存中还是后端的数据,最开始的用户也就是10个,每当收到10条付款信息就开启10个线程去查询数据库,此时用户量少,马上就可返回,第二天增加到100人,使用100个线程去查询,感觉确实效果不错,加大促销力度,当同时出现1000个人的时候感觉到有点吃力了

增长的线程

1000-10000,看了前面的内容应该清楚创建销毁线程还是挺费资源的,假设每个线程占用 4M内存空间,那么10000个线程大概需要消耗 39G 内存,可是服务器也就 8G 内存。

此时的方案要么增加服务器要么提升代码效率。多个线程在进行作业的时候,难免会遇到某个线程等待 IO 的情况,此时会阻塞当前线程切换到其他线程,使得其他线程照常执行,线程少的时候没什么问题,当线程数量变多就会出现问题,线程数量的增加不仅占用非常多的内存空间且过多的线程的切换也会占用大量的系统时间

线程开销

此时就可以通过协程的方式解决这个问题

协程运行在线程之上,协程执行完成后,可以选择主动让出,让另一个协程运行在当前线程之上。即协程并没有增加线程的数量,而是在线程的基础上通过分时复用的方式运行多个协程,还有关键一点是它的切换发生在用户态,所有也不存在用户态到内核态的切换,代价更低

协程开销

类比上面,我们只需要启动 100 个线程,然后每个线程跑100个协程就可以完成上述同时处理10000个任务

那么协程在使用的过程中需要主要哪些内容呢

刚说协程运行于线程之上,如果线程等待 IO 的时候阻塞了,这时候会出现什么情况?其实操作系统主要关心线程,协程调用阻塞IO的时候,操作系统会让进程处于阻塞状态,此时当前的协程和绑定在线程之上的协程都会陷入阻塞而得不到调度,这样就很难受了

因此协程中,不能调用导致线程阻塞的操作,即协程最好了异步 IO 结合起来才能发挥最大的威力

怎么处理在协程中调用阻塞IO的操作呢

  • 比较简答的思路是当调用阻塞 IO 的时候,重新启动一个线程去执行这个操作,等执行完成后,协程再去读取结果,这是不是和多线程很像

  • 将系统 IO 进行封装,改为异步调用的方式,此时需要大量的工作,所以需要寄生于编程语言的原生支持

所以对于计算密集型的任务不太建议使用协程,计算机密集型的任务需要大量的线程切换,线程切换涉及太多的资源交换

总结

线程进程涉及的知识点好复杂,本文包含了线程,进程是什么,两者的区别,内核级线程与用户态线程,线程进程的上下文切换,系统调用的过程等一系列知识点,并没有对进程的调度等做详细的介绍,自己还需要多多补充知识。

不知不觉中这篇文章从素材的确认,关键字的过滤,上下文的衔接,画图,算下来差不多两周,不过在这个过程确实学习了不少新的知识点。有点收获不妨点赞,在看,谢谢!

浏览 23
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报