不知从几何起,可能是大三那年的操作系统考试,也可能是刚经历完的秋招,这些概念总是迷迷糊糊,可能自己回答的和其他人的答复也差不多,并没有什么亮点,通常都会以:「我们换个题」的方式结束,有时候也挺尴尬的。我们不妨看看这样几个题应该怎么去回答
进程和线程是什么
进程和线程有什么区别
为什么有了进程又出现线程
内核态和用户态有啥不同
协程有什么特点
太多太多一系列的问题伴随到学习,工作的各个阶段,这些问题确实不怎么好回答,除非你真的理解到它的底层原理,否则很容易就把自己套进去,那么今天我们一起来看看这些问题都是怎么产生的,为什么总是会问这些题,开始吧
前言
进程线程协程
进程和线程
进程,平时我们打开一个播放器,开一个记事本,这些都是应用程序,一个软件的执行副本,这就是
进程
。从操作系统层面而言,进程是
分配资源
的基本单位,线程在很长时间被称为
轻量级的进程
,是
程序执行
的基本单位。
这样看来一个分配资源的基本单位,一个是程序执行的基本单元。以前面试的时候,我经常也就这样背给面试官了,当自己成为了面试官才发现这些孩子答案为啥都是这个,原来网上大部分的资料也就说了这些呢,直接这样死记硬背当然不行,让我们回到最初的计算机时代。
最初的计算机时代是什么样子呢
那个时代呀,程序员会将写好的程序放入闪存中,然后插入到机器里,通过电能推动芯片计算,那么芯片从闪存中取出指令,然后执行下一条执行,一旦闪存中的执行执行完了,计算机就要关机了
闪存时代
这在早期叫做
单任务模型
,也叫做
作业
(Job)。随着人们的需求越来越多,生活的多元化,慢慢出现了办公,聊天,游戏等,这个时候不得不在同一台计算机中来回的切换,人们就想要不通过线程和进程来处理这个问题
那是怎么处理的方式?
比如说一个游戏,启动后为一个进程,但是一个游戏场面的呈现需要图形的渲染,联网,这些操作不能相互的阻塞,如果阻塞了,卡起就很难受,总觉得这游戏怎么这么 low,我们希望它们能同时的运行,所以将其各个部分设计为
线程
,这就出现了一个进程有多个线程
既
然一个进程有多个线程,这个资源分配如何处理?
启动一个游戏,首先需要存储这些游戏参数,所以需要内存资源,当进行攻击等动作时候,发出的各种动作指令需要计算,所以需要计算资源 CPU,还需要需要存储一些文件,所以还需要文件资源。由于早期的 OS 没有线程的概念,所以让各个进程采用
分时
的技术交替执行,通过
管道
等技术让各个进程进行通信。
这样看上去比较完美了,启动一个游戏后出来这么多进程,那么能不能启动游戏后,在这个进程下面安排一种技术,让其仅仅分配 CPU 资源呢,这就出现了
线程
这个线程如何分配的?
线程概念被提出来以后,因为只分配了CPU 计算资源,所以也叫做
轻量级的进程
。通过操作系统来调度线程,也就是说操作系统创建进程后,“
牵个线
”,进程的入口程序被放在主线程中,看起来就感觉是操作系统在调度进程,实际上调度的是进程中线程,这种被操作系统直接调度的线程叫做
内核级线程
。
既然有内核级别线程,当然有用户级线程,相当于操作系统调度线程,主线程通过程序的方式实现子线程,这就是
用户级线程
,典型的即 Linux 中的 Phread API。既然说到内核态和用户态,我们来看看两者有什么作用
用户态线程
它完全是在用户空间创建,对于操作系统而言是不知情的,用户级线程的优势如下:
用户态线程有什么缺点
与内核沟通成本大
:因为这种线程大部分时间在用户空间,如果进行 IO 操作,很难利用内核的优势,且需要频繁的用户态和内核态的切换
线程之间的协作麻烦
:想象两个线程 A 和 B需要通信,通信通常会涉及到 IO 操作,IO 操作涉及到系统调用,系统调用又要发生用户态和内核套的切换成本,难
操作系统无法针对线程的调度进行优化
:如果一个进程的用户态线程阻塞了操作系统无法及时的发现和处理阻塞问题,它不会切换其他线程从而造成浪费
内核态线程
内核态线程执行在内核态,一般通过系统调用创造一个内核级线程,那么有哪些优点?
内核级线程有什么缺点?
用户态线程和内核态线程的映射关系是怎样的呢
上面谈到用户态线程和内核态线程都有缺点,用户态线程创建成本低,不可以利用多核,而内核态线程创建成本高,虽可以利用多核,但是切换速度慢。所以,通常都会在内核中
预留
一些线程并反复使用这些线程,至此出现了以下几种映射关系
用户态和内核态映射之一--多对一
内核线程的创建成本既然高,那么我们就是多个用户态进程的多线程复用一个内核态线程,可是这样线程不能并发,所以此模型用户很少
用户态线程与内核态线程多对一
用户态和内核态映射之二--一对一
让每个用户态线程分配一个单独的内核态线程,每个用户态线程通过系统调用创建一个绑定的内核线程,这种模型能够并发执行,充分利用多核的优势,出名的 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 通过执行中断处理程序,将控制权给操作系统,操作系统随后将阻塞的进程状态修改为就绪状态,安排重新排队,再加上因为进程进入阻塞状态无事可做,但是又不能干瘪瘪的让他去排队(因为需要等待中断),所以进入到阻塞状态。
下面对以上所说的三种状态进行一个小结
其实,进程还有两种基本状态
所以一共就包含了五个状态,为了更加直观,其变迁图如下
五种形态
Null---->创建状态:最初创建的第一个状态
创建状态----->就绪状态:进行一些列的初始化称为就绪状态
就绪状态----->运行状态:当操作系统调度就绪状态的进程并分配给 CPU 变为运行状态
运行状态------>结束状态:当进程完成相应任务或出错则被操作系统结束的状态
运行状态------>阻塞状态:运行状态的进程由于时间片用完,操作系统将进程更改为就绪状态
阻塞状态------->就绪状态:阻塞状态的进程等待某事件结束进入就绪状态
其实不是卖光子,实际上还有两种状态,分别是就绪挂起和阻塞挂起,那我们看看那这两者有啥不一样
挂起是一种行为,而阻塞是进程的状态
导致进程挂起的原因通常是因为内存不足或者用户的请求,进程的修改等,而进程的阻塞是进程正在等待某个事件发生,可能是等待资源或响应
挂起对应的是行为的激活,将外存中的进程掉入内存中,而处于阻塞状态的进程需要等待其他进程或系统唤醒
挂起属于被动行为,进程被迫从内存转移到外存,而进入阻塞为主动的行为
综上,现在咋们的进程图就变为了七种状态,如下
进程的七种状态
进程与线程的底层原理
上面我们了解了进程,线程的由来以及状态变迁,但是显然不能让我自如的了解进程和线程,至于其如何在内存表示等问题还是比较空虚的,所以我们继续往下看
进程和线程在内存中如何表示
在整个设计过程中,涉及了两张表,分别是
进程表
和
线程表
。其中进程表会记录进程在内存的位置,PID是多少,以及当前什么状态,内存给它分配了多大使用空间以及属于哪个用户,假设没有这张表,操作系统就不知道有哪些进程,也就更不清楚怎么去调度,就仿佛失去XXX,不知道了方向
进程表
尤其需要注意进程表这样几个部分
资源信息会记录这个进程有哪些资源,比如进程和虚拟内存怎么映射,拥有哪些文件等
内存的知识点太多,如果在这里写文章将会非常的长,所以打算单独使用一篇文章写。
在 Linux 中,操作系统采用虚拟内存管理技术,使得进程都拥有独立的虚拟内存空间,理由也比较直接,物理内存不够用且不安全(用户不能直接访问物理内存),使用虚拟内存不但更安全且可以使用比物理内存更大的地址空间。
另外,在 32 位的操作系统中,4GB 的进程地址空间分为两个部分,用户空间和内核空间,用户空间为 0~3G,内核地址空间占据 3~4G,用户不能直接操作内核空间虚拟地址,只有通过系统调用的方式访问内核空间。
操作系统会告诉进程如何使用内存,大概分为哪些区域以及每个区域做什么。简单描述下下图各个段的作用。
栈:系统自动分配释放,平时经常使用的函数参数值,局部变量,返回地址等就在此
堆:存放动态分配的数据,通常由开发人员自行管理,如果开发人员使用后不释放,那么程序结束后可能会被操作系统收回
数据段:存放的是全局变量和静态变量。其中初始化数据段(.data)存放显示初始化的全局变量和静态变量,未初始化数据段,此段通常也被称为BSS段(.bss),存放未进行显示初始化的全局变量和静态变量。
进程内存布局
描述信息包含进程的唯一识别号,进程的名称以及用户等
除了给进程安排一张表以外,给线程也安排了一张表,这就是
线程表
。线程表也包含了一个 ID,这 ID 叫做 ThreadID,同时也会记录自己在不同阶段的状态,比如阻塞,运行,就绪。由于多个线程会共用 CPU 且需要不停的切换,所以需要记录
程序计数器
和
寄存器
的值。
说到了用户级的线程和内核级的线程,两者又是怎么个亲密关系
两者映射的关系如何去表示
可以想像在内核中有一个线程池,给予用户空间使用,每次用户级线程把程序计数器等传递过去,执行结束后,内核线程不销毁,等待下一个任务,从这里可以看出创建进程开销大、成本高;创建线程开销小,成本低。
这么多进程难道共用内存?