【OS】进程和线程的区别

272 阅读18分钟

定义

进程是资源分配的最小单位,线程是CPU调度的最小单位

下面是知乎上的一个比喻:

进程=火车,线程=车厢

  • 线程在进程下行进(单纯的车厢无法运行)
  • 一个进程可以包含多个线程(一辆火车可以有多个车厢)
  • 不同进程间数据很难共享(一辆火车上的乘客很难换到另外一辆火车,比如站点换乘)
  • 同一进程下不同线程间数据很易共享(A车厢换到B车厢很容易)
  • 进程要比线程消耗更多的计算机资源(采用多列火车相比多个车厢更耗资源)
  • 进程间不会相互影响,一个线程挂掉将导致整个进程挂掉(一列火车不会影响到另外一列火车,但是如果一列火车上中间的一节车厢着火了,将影响到所有车厢)
  • 进程可以拓展到多机,进程最多适合多核(不同火车可以开在多个轨道上,同一火车的车厢不能在行进的不同的轨道上)
  • 进程使用的内存地址可以上锁,即一个线程使用某些共享内存时,其他线程必须等它结束,才能使用这一块内存。(比如火车上的洗手间)-"互斥锁"
  • 进程使用的内存地址可以限定使用量(比如火车上的餐厅,最多只允许多少人进入,如果满了需要在门口等,等有人出来了才能进去)-“信号量”

通过CPU时间进行解释

首先来一句概括的总论:进程和线程都是一个时间段的描述,是CPU工作时间段的描述。是运行中的程序指令的一种描述,这需要与程序中的代码区别开来。

另外注意这里我说的进程线程概念,和编程语言中的API接口对应的进程/线程是有差异的。

下面细说背景:

CPU+RAM+各种资源(比如显卡,光驱,键盘,GPS, 等等外设)构成我们的电脑,但是电脑的运行,实际就是CPU和相关寄存器以及RAM之间的事情。

一个最最基础的事实:CPU太快,太快,太快了,寄存器仅仅能够追的上他的脚步,RAM和别的挂在各总线上的设备则难以望其项背。那当多个任务要执行的时候怎么办呢?轮流着来?或者谁优先级高谁来?不管怎么样的策略,一句话就是在CPU看来就是轮流着来。而且因为速度差异,CPU实际的执行时间和等待执行的时间是数量级的差异。比如工作1秒钟,休息一个月。所以多个任务,轮流着来,让CPU不那么无聊,给流逝的时间增加再多一点点的意义。这些任务,在外在表现上就仿佛是同时在执行。

一个必须知道的事实:执行一段程序代码,实现一个功能的过程之前 ,当得到CPU的时候,相关的资源必须也已经就位,就是万事俱备只欠CPU这个东风。所有这些任务都处于就绪队列,然后由操作系统的调度算法,选出某个任务,让CPU来执行。然后就是PC指针指向该任务的代码开始,由CPU开始取指令,然后执行。

这里要引入一个概念:除了CPU以外所有的执行环境,主要是寄存器的一些内容,就构成了的进程的上下文环境。进程的上下文是进程执行的环境。当这个程序执行完了,或者分配给他的CPU时间片用完了,那它就要被切换出去,等待下一次CPU的临幸。在被切换出去做的主要工作就是保存程序上下文,因为这个是下次他被CPU临幸的运行环境,必须保存。

串联起来的事实:前面讲过在CPU看来所有的任务都是一个一个的轮流执行的,具体的轮流方法就是:先加载进程A的上下文,然后开始执行A,保存进程A的上下文,调入下一个要执行的进程B的进程上下文,然后开始执行B,保存进程B的上下文。。。。

========= 重要的东西出现了========

进程和线程就是这样的背景出来的,两个名词不过是对应的CPU时间段的描述,名词就是这样的功能。

  • 进程就是上下文切换之间的程序执行的部分。是运行中的程序的描述,也是对应于该段CPU执行时间的描述。
  • 在软件编码方面,我们说的进程,其实是稍不同的,编程语言中创建的进程是一个无限loop,对应的是tcb块。这个是操作系统进行调度的单位。所以和上面的cpu执行时间段还是不同的。
  • 进程,与之相关的东东有寻址空间,寄存器组,堆栈空间等。即不同的进程,这些东东都不同,从而能相互区别。

线程是什么呢?

进程的颗粒度太大,每次的执行都要进行进程上下文的切换。如果我们把进程比喻为一个运行在电脑上的软件,那么一个软件的执行不可能是一条逻辑执行的,必定有多个分支和多个程序段,就好比要实现程序A,实际分成 a,b,c等多个块组合而成。那么这里具体的执行就可能变成:

程序A得到CPU =》CPU加载上下文,开始执行程序A的a小段,然后执行A的b小段,然后再执行A的c小段,最后CPU保存A的上下文。

这里a,b,c的执行是共享了A进程的上下文,CPU在执行的时候仅仅切换线程的上下文,而没有进行进程上下文切换的。进程的上下文切换的时间开销是远远大于线程上下文时间的开销。这样就让CPU的有效使用率得到提高。这里的a,b,c就是线程,也就是说线程是共享了进程的上下文环境,的更为细小的CPU时间段。线程主要共享的是进程的地址空间。

再一个总结:

进程和线程都是一个时间段的描述,是CPU工作时间段的描述,不过是颗粒大小不同。

注意这里描述的进程线程概念和实际代码中所说的进程线程是有区别的。编程语言中的定义方式仅仅是语言的实现方式,是对进程线程概念的物化。

深入理解

进程(线程+内存+文件/网络句柄)

我们通过上面的图片进行进一步理解:

“内存”: 我们通常所理解的内存是我们所见到的(2G/4G/8G/16G)物理内存,它为什么会在进程之中呢?

实际上,这里的内存是逻辑内存。指的是内存的寻址空间。每个进程的内存是相互独立的。否则的话会出现一个问题:我们把指针的值改一改就指向其他进程的内存了,通过这样我们岂不是就可以看到其他进程中"微信"或者是"网上银行"的信息,这样的话,那我们的微信聊天记录或者是银行账户的信息就都被别人找到了,这是一个很危险的信号!显然这样是不可能的。

“文件/网络句柄”: 它们是所有的进程所共有的,例如打开同一个文件,去抢同一个网络的端口这样的操作是被允许的。

“线程”: 接下来,我们就要介绍一下我们的“线程”有关知识

线程(栈+PC+TLS)

我们通常都是说调用堆栈,其实这里的堆是没有含义的,调用堆栈就是调用栈的意思。

那么我们的栈里面有什么呢?

我们从主线程的入口main函数,会不断的进行函数调用, 每次调用的时候,会把所有的参数和返回地址压入到栈中。

PC

Program Counter 程序计数器,操作系统真正运行的是一个个的线程,而我们的进程只是它的一个容器。PC就是指向当前的指令,而这个指令是放在内存中。

每个线程都有一串自己的指针,去指向自己当前所在内存的指针。 计算机绝大部分是存储程序性的,说的就是我们的数据和程序是存储在同一片内存里的这个内存中既有我们的数据变量又有我们的程序。所以我们的PC指针就是指向我们的内存的。

缓冲区溢出

例如我们经常听到一个漏洞:缓冲区溢出

这是什么意思呢?

例如:我们有个地方要输入用户名,本来是用来存数据的地方。 然后黑客把数据输入的特别长。这个长度超出了我们给数据存储的内存区,这时候跑到了我们给程序分配的一部分内存中。黑客就可以通过这种办法将他所要运行的代码写入到用户名框中,来植入进来。我们的解决方法就是,用用户名的长度来限制不要超过用户名的缓冲区的大小来解决。

TLS

全称:thread local storage

之前我们看到每个进程都有自己独立的内存,这时候我们想,我们的线程有没有一块独立的内存呢?答案是有的,就是TLS。

可以用来存储我们线程所独有的数据。

可以看到:线程才是我们操作系统所真正去运行的,而进程呢,则是像容器一样他把需要的一些东西放在了一起,而把不需要的东西做了一层隔离,进行隔离开来。

小结:

  • 进程要分配一大部分的内存,而线程只需要分配一部分栈就可以了.
  • 一个程序至少有一个进程,一个进程至少有一个线程.
  • 进程是资源分配的最小单位,线程是程序执行的最小单位。
  • 一个线程可以创建和撤销另一个线程,同一个进程中的多个线程之间可以并发执行.

另外一种解释

什么是进程?为什么要有进程?

进程有一个相当精简的解释:进程是对操作系统上正在运行程序的一个抽象。

这个概念确实挺抽象,仔细想想却也挺精准。

我们平常使用计算机,都会在同一时间做许多事,比如边看电影,边微信聊天,顺便打开浏览器百度搜索一下,我们所做的这么多事情背后都是一个个正在运行中的软件程序;这些软件想要运行起来,首先在磁盘上需要有各自的程序代码,然后将代码加载到内存中,CPU会去执行这些代码,运行中会产生很多数据需要存放,也可能需要和网卡、显卡、键盘等外部设备交互,这背后其实就涉及到程序对计算机资源的使用,存在这么多程序,我们当然需要想办法管理程序资源的使用。并且CPU如果只有一个,那么还需要操作系统调度CPU分配给各个程序使用,让用户感觉这些程序在同时运行,不影响用户体验。

理所当然,操作系统会把每个运行中的程序封装成独立的实体,分配各自所需要的资源,再根据调度算法切换执行。这个抽象程序实体就是进程。

所以很多对进程的官方解释中都会提到:进程是操作系统进行资源分配和调度的一个基本单位。

什么是线程?为什么要有线程?

在早期的操作系统中并没有线程的概念,进程是拥有资源和独立运行的最小单位,也是程序执行的最小单位。任务调度采用的是时间片轮转的抢占式调度方式,而进程是任务调度的最小单位,每个进程有各自独立的内存空间,使得各个进程之间内存地址相互隔离。

后来,随着计算机行业的发展,程序的功能设计越来越复杂,我们的应用中同时发生着多种活动,其中某些活动随着时间的推移会被阻塞,比如网络请求、读写文件(也就是IO操作),我们自然而然地想着能不能把这些应用程序分解成更细粒度、能 准并行运行 多个顺序执行实体,并且这些细粒度的执行实体可以共享进程的地址空间,也就是可以共享程序代码、数据、内存空间等,这样程序设计模型会变得更加简单。

其实很多计算机世界里的技术演变,都是模拟现实世界。比如我们把一个进程当成一个项目,当项目任务变得复杂时,自然想着能不能将项目按照业务、产品、工作方向等分成一个个任务模块,分派给不同人员各自并行完成,再按照某种方式组织起各自的任务成果,最终完成项目。

需要多线程还有一个重要的理由就是:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器,线程之间切换的开销小。所以线程的创建、销毁、调度性能远远优于进程。

在引入多线程模型后,进程和线程在程序执行过程中的分工就相当明确了,进程负责分配和管理系统资源,线程负责CPU调度运算,也是CPU切换时间片的最小单位。对于任何一个进程来讲,即便我们没有主动去创建线程,进程也是默认有一个主线程的。

它们在Linux内核中实现方式有何不同?

在Linux 里面,无论是进程,还是线程,到了内核里面,我们统一都叫任务(Task),由一个统一的结构 task_struct 进行管理,这个task_struct 数据结构非常复杂,囊括了进程管理生命周期中的各种信息。 在Linux操作系统内核初始化时会创建第一个进程,即0号创始进程。随后会初始化1号进程(用户进程祖宗:/usr/lib/systemd/systemd),2号进程(内核进程祖宗:[kthreadd]),其后所有的进程线程都是在他们的基础上fork出来的。

我们一般都是通过fork系统调用来创建新的进程,fork 系统调用包含两个重要的事件,一个是将 task_struct 结构复制一份并且初始化,另一个是试图唤醒新创建的子进程。

我们说无论是进程还是线程,在内核里面都是task,管起来不是都一样吗?到底如何区分呢?其实,线程不是一个完全由内核实现的机制,它是由内核态和用户态合作完成的。

创建进程的话,调用的系统调用是 fork,会将五大结构 files_struct、fs_struct、sighand_struct、signal_struct、mm_struct 都复制一遍,从此父进程和子进程各用各的数据结构。而创建线程的话,调用的是系统调用 clone,五大结构仅仅是引用计数加一,也即线程共享进程的数据结构。

所以它们到底有哪些区别?

功能: 进程是操作系统资源分配的基本单位,而线程是任务调度和执行的基本单位

开销: 每个进程都有独立的内存空间,存放代码和数据段等,程序之间的切换会有较大的开销;线程可以看做轻量级的进程,共享内存空间,每个线程都有自己独立的运行栈和程序计数器,线程之间切换的开销小。

运行环境: 在操作系统中能同时运行多个进程;而在同一个进程(程序)中有多个线程同时执行(通过CPU调度,在每个时间片中只有一个线程执行)

创建过程: 在创建新进程的时候,会将父进程的所有五大数据结构复制新的,形成自己新的内存空间数据,而在创建新线程的时候,则是引用进程的五大数据结构数据,但是线程会有自己的私有数据、栈空间。

进程和线程其实在cpu看来都是task_struct结构的一个封装,执行不同task即可,而且在cpu看来就是在执行这些task时候遵循对应的调度策略以及上下文资源切换定义,包括寄存器地址切换,内核栈切换。所以对于cpu而言,进程和线程是没有区别的。

我们通常所说的上下文切换具体指什么?

操作系统抽象出一个进程的概念,让应用程序专心于实现自己的业务逻辑既可,对应用程序屏蔽了CPU调度、内存管理等硬件细节,而且在有限的CPU上可以“同时”进行许多个任务。但是它为用户带来方便的同时,也引入了一些额外的开销。

在操作系统中,由于CPU的时间片调度策略,从一个进程切换到另一个进程需要保存当前进程的状态并恢复另一个进程的状态:当前运行任务转为就绪(或者挂起、删除)状态,另一个被选定的就绪任务成为当前任务。上下文切换包括保存当前任务的运行环境,恢复将要运行任务的运行环境。

在上下文切换过程中,CPU会停止处理当前运行的程序,并保存当前程序运行的具体位置以便之后继续运行。从这个角度来看,上下文切换有点像我们同时阅读几本书,在来回切换书本的同时我们需要记住每本书当前读到的页码。

在三种情况下可能会发生上下文切换:中断处理,多任务处理,内核/用户态切换。

在中断处理中,其他程序”打断”了当前正在运行的程序。当CPU接收到中断请求时,会在正在运行的程序和发起中断请求的程序之间进行一次上下文切换。

在多任务处理中,CPU会在不同程序之间来回切换,每个程序都有相应的处理时间片,CPU在两个时间片的间隔中进行上下文切换。

在Linux中进行内核/用户态切换也会进行上下文切换,进行系统调用时,CPU寄存器里原来用户态的指令位置需要先保存起来。接着,为了执行内核态代码,CPU寄存器需要更新为内核态指令的新位置。最后才是跳转到内核态运行内核任务。而系统调用结束后,CPU寄存器需要恢复原来保存的用户态,然后再切换到用户空间,继续运行进程,所以一次系统调用的过程,其实是发生了两次CPU上下文切换。

CPU上下文切换,是保证Linux系统正常工作的核心功能之一,一般情况下不需要我们特别关注。

但过多的上下文切换,会把CPU时间消耗在寄存器、内核栈以及虚拟内存等数据的保存和恢复上,从而缩短进程真正运行的时间,导致系统的整体性能大幅下降。

面试题

进程与线程之间有什么区别?

进程、线程都各有什么特点?

进程之间的是怎么进行交互的呢?

什么是缓冲区溢出?

进程之间如何进行交互?

进程之间的通信需要以通信的方式(IPC, Inter-Process Communication)进行

无名管道、有名管道、信号、共享内存、消息队列、信号量

线程之间如何进行交互?

同一进程下的线程共享全局变量、静态变量等数据

互斥量、读写锁、自旋锁、线程信号、条件变量

参考文章