Bootstrap

进程&线程(史上最全,通俗易懂)

操作系统学习

【进程、线程、纤程】
【进程】
进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。、
当进程要从硬盘读取数据时,CPU 不需要阻塞等待数据的返回,而是去执行另外的进程。当硬盘数据返回时,CPU 会收到个中断,于是 CPU 再继续运行这个进程。

【并发和并行区别】

【进程的状态】
一般说来,一个进程并不是自始至终连续不停地运行的,它与并发执行中的其他进程的执行是相互制约的。它有时处于运行状态,有时又由于某种原因而暂停运行处于等待状态,当使它暂停的原因消失后,它又进入准备运行状态。所以,在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。

  • 运行状态(Running):该时刻进程占用 CPU;

  • 就绪状态(Ready):可运行,由于其他进程处于运行状态而暂时停止运行;

  • 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;
    当然,进程还有另外两个基本状态:

  • 创建状态(new):进程正在被创建时的状态;

  • 结束状态(Exit):进程正在从系统中消失时的状态;
    于是,一个完整的进程状态的变迁如下图:

  • NULL -> 创建状态:一个新进程被创建时的第一个状态;

  • 创建状态 -> 就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的;

  • 就绪态 -> 运行状态:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程;

  • 运行状态 -> 结束状态:当进程已经运行完成或出错时,会被操作系统作结束状态处理;

  • 运行状态 -> 就绪状态:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行;

  • 运行状态 -> 阻塞状态:当进程请求某个事件且必须等待时,例如请求 I/O 事件;

  • 阻塞状态 -> 就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态;

如果有大量处于阻塞状态的进程,进程可能会占用着物理内存空间,显然不是我们所希望的,毕竟物理内存空间是有限的,被阻塞状态的进程占用着物理内存就一种浪费物理内存的行为。
所以,在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运行的时候,再从硬盘换入到物理内存。

那么,就需要一个新的状态,来描述进程没有占用实际的物理内存空间的情况,这个状态就是挂起状态。这跟阻塞状态是不一样,阻塞状态是等待某个事件的返回。
另外,挂起状态可以分为两种:

  • 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现;
  • 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行;

导致进程挂起的原因不只是因为进程所使用的内存空间不在物理内存,还包括如下情况:

  • 通过 sleep 让进程间歇性挂起,其工作原理是设置一个定时器,到期后唤醒进程。
  • 用户希望挂起一个程序的执行,比如在 Linux 中用 Ctrl+Z 挂起进程;
    【进程的控制结构】
    在操作系统中,是用进程控制块(process control block,PCB)数据结构来描述进程的。(PCB就是数据结构)
    PCB 是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。
    PCB包含的信息:
    进程描述信息:
    进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符;用户标识符:进程归属的用户,用户标识符主要为共享和保护服务;进程控制和管理信息:
    进程当前状态,如 new、ready、running、waiting 或 blocked 等;进程优先级:进程抢占 CPU 时的优先级;资源分配清单:
    有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息。CPU 相关信息:
    CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行。
    每个 PCB 是如何组织的
    通过链表,将具有相同状态的进程链接在一起,组成各种队列,比如:
    将所有处于就绪状态的进程链在一起,称为就绪队列;把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列;另外,对于运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个时间,只能运行一个程序。如下图:

除了链接的组织方式,还有索引方式,它的工作原理:将同一状态的进程组织在一个索引表中,索引表项指向相应的 PCB,不同状态对应不同的索引表。
一般会选择链表,因为可能面临进程创建,销毁等调度导致进程状态发生变化,所以链表能够更加灵活的插入和删除

【进程的控制】
01创建进程:
操作系统允许一个进程创建另外一个进程,而且允许子进程继承父进程所拥有的资源。
创建进程的过程:

  1. 申请一个空白的PCB,并向PCB中填写一些控制和管理进程的信息,比如进程的唯一标识符等。
  2. 为该进程分配运行时所必须的资源,比如内存资源;
  3. 将PCB插入到就绪队列,等待被调度执行;
    02终止进程:
    三种终止方式:正常结束、异常结束、外界干预(信号kill掉);
    当子进程被终止时,其在父进程继承的资源应当还给父进程。而当父进程被终止时,该父进程的子进程将会变为孤儿进程,会被1号进程收养,并由1号进程对他们完成状态进行收集工作。
    终止进程的过程如下:
  • 查找需要终止进程的PCB;

  • 如果出处于执行状态,则立即终止该进程的执行,然后将CPU资源分配给其他进程;

  • 如果还有子进程,则应将该进程的子进程交给1号进程托管;

  • 将该进程所拥有的全部资源都归还给操作系统;

  • 将其从PCB队列里删除;
    03阻塞进程:
    当进程等待某一事件发生时,便会调用阻塞语句将自己阻塞,一旦阻塞,只能通过其他进程唤醒。
    阻塞过程:

  • 找到将要被阻塞进程标识符的PCB;

  • 如果该进程为运行态,则保护现场,将其状态转换为阻塞态,停止运行;

  • 将该PCB插入到阻塞队列
    04唤醒进程:
    进程由运行转换为阻塞状态,是由于必须等待某一事件的完成,所以阻塞的进程是不可能自己唤醒自己的。
    如果某进程正在等待I/0事件,需由别的进程发消息给他,则只有该进程期待的事件发生时,才会由发现者进程用唤醒语句唤醒它。
    唤醒过程:

  • 在该事件的阻塞队列中找到该阻塞进程的PCB;

  • 将其从阻塞队列中移除,并置其状态为就绪态;

  • 把该PCB插入到就绪队列中,等待调度程序调度;
    进程的阻塞和唤醒是一对功能相反的语句,如果某个进程调用了阻塞语句,则必定有一个对应的唤醒语句。
    【进程的上下文切换】
    各个进程是共享CPU资源的,在不同的时候进程之间需要进行上下文切换,让不同的进程在CPU上运行,所以,从一个进程切换到另一个进程运行,称为进程的上下文切换。
    CPU上下文切换:
    大多数操作系统都是多任务,通常支持大于 CPU 数量的任务同时运行。实际上,这些任务并不是同时运行的,只是因为系统在很短的时间内,让各个任务分别在 CPU 运行,于是就造成同时运行的错觉。
    在每个任务运行前,CPU 需要知道任务从哪里加载,又从哪里开始运行。所以,操作系统需要事先帮 CPU 设置好 CPU 寄存器和程序计数器。
    CPU 寄存器是 CPU 内部一个容量小,但是速度极快的内存(缓存)。我举个例子,寄存器像是你的口袋,内存像你的书包,硬盘则是你家里的柜子,如果你的东西存放到口袋,那肯定是比你从书包或家里柜子取出来要快的多。
    程序计数器则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。
    所以说,CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文(上下文是指某一时间点 CPU 寄存器和程序计数器的内容)
    上下文切换可以认为是内核(操作系统的核心)在 CPU 上对于进程(包括线程)进行以下的活动:
    挂起一个进程,将这个进程在 CPU 中的状态(上下文)存储于内存中的某处;在内存中检索下一个进程的上下文并将其在 CPU 的寄存器中恢复;跳转到程序计数器所指向的位置(即跳转到进程被中断时的代码行),以恢复该进程。根据任务的不同,把 CPU 上下文切换分成:进程上下文切换、线程上下文切换和中断上下文切换。

    进程上下文切换:
    进程是由内核进行管理和调度的,所以进程切换只能发生在内核态。
    进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
    通常,会把交换信息保存在进程的PCB中,当要运行另外一个进程的时候,需要从这个进程的PCB中去除上下文,并加载到CPU中,使该进程可以被继续执行。
    进程上下文切换是有开销的,所以我们希望进程可以把更多的时间花费在执行程序上,而不是花费在上下文切换。

    进程上下文切换有哪些常见场景:
    为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,进程就从运行状态变为就绪状态,系统从就绪队列选择另外一个进程运行;进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度;当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序;
    【线程】
    一个进程指的使正在执行的应用程序。线程的功能是执行应用程序中某个具体的任务,比如一段程序,一个函数。
    即:操作系统中执行调度的基本单位。
    【为什么要使用线程】
    假设你要编写一个视频播放器软件,那么该软件功能的核心模块有三个:

  • 从视频文件当中读取数据;

  • 对读取的数据进行解压缩;

  • 把解压缩后的视频数据播放出来;
    对于单进程的实现方式,会是以下这个方式:

单进程存在的问题:

  • 播放出来的画面和声音会不连贯,因为当 CPU 能力不够强的时候,Read 的时候可能进程就等在这了,这样就会导致等半天才进行数据解压和播放;
  • 各个函数之间不是并发执行,影响资源的使用效率;
    改成多进程的方式:

多进程存在的问题:

  • 进程之间如何通信,共享数据?
  • 维护进程的系统开销较大,如创建进程时,分配资源、建立 PCB;终止进程时,回收资源、撤销 PCB;进程切换时,保存当前进程的状态信息;
    那到底如何解决呢?需要有一种新的实体,满足以下特性:
  • 实体之间可以并发运行;
  • 实体之间共享相同的地址空间;
    这个新的实体,就是线程( Thread ),线程之间可以并发运行且共享相同的地址空间。
    同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的。
    进程与线程之间的关系如下:

【线程的优缺点】
线程的优点:

  1. 提高程序的并发性和响应性:线程允许程序在同一时间内执行多个任务,提高程序的并发性和响应性。
  2. 分离出独立的任务:线程可以将不同的任务分离出来,使得程序更易于设计、维护和扩展,同时也可以提高代码的复用性。
  3. 节省资源:线程的创建和销毁的开销相对较小,可以节省系统资源的使用。
  4. 提高CPU利用率:多线程可以使CPU在时间片内快速地切换执行不同的任务,充分利用CPU的性能,提高CPU利用率。
  5. 支持共享内存:线程可以方便地共享同一个进程的内存空间,简化进程间通信的需求,提高通信的效率。
    线程的缺点:
  6. 线程安全问题:由于多个线程同时访问同一个资源,可能导致并发访问冲突。例如在数据库系统中,多个线程同时读写同一个表格的记录,容易导致数据丢失、数据错误等问题。
  7. 上下文切换开销问题:线程切换需要保存当前线程的状态和加载新线程的状态,涉及到大量的上下文切换操作,会带来一定的开销。例如,一个程序需要频繁地在多个线程之间切换执行,就会产生很多的上下文切换开销。
  8. 竞态条件问题:多个线程同时访问共享资源时,容易出现资源竞争的问题,导致数据的不一致或错误。例如,两个线程同时想要修改同一个变量的值,就会产生竞争的情况。
  9. 死锁问题:多个线程之间的互相等待可能导致死锁,即所有的线程都无法继续执行下去。例如一个线程正在等待另一个线程释放某个资源,而另一个线程也在等待某个被其它线程占用的资源,两个线程之间就可能出现死锁。
  10. 调试难度大:多线程程序的调试难度大,因为线程间的关系复杂,容易出现难以重现的问题。
  • 当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃(这里是针对 C/C++ 语言,Java语言中的线程奔溃不会造成进程崩溃,具体分析原因可以看这篇:线程崩溃了,进程也会崩溃吗? (opens new window))。
    举个例子,对于游戏的用户设计,则不应该使用多线程的方式,否则一个用户挂了,会影响其他同个进程的线程。
    【线程与进程的比较】
    线程与进程的比较如下:
  • 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位;
  • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
  • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
  • 线程能减少并发执行的时间和空间开销;
    对于线程相比进程能减少开销,体现在:
  1. 线程创建消耗的资源较少:线程的创建只需要额外的一些内存空间,而进程则需要完整的系统资源分配和初始化,包括内存、文件和网络连接等。
  2. 线程切换的开销较小:在同一进程中的线程之间的切换只需保存少量的寄存器和栈指针等信息,而进程之间的切换则需要保存更多的状态信息和上下文环境。
  3. 线程的终止时间比进程快,因为线程释放的资源相比进程少很多;
  4. 线程通信的成本较低:在同一进程中的线程之间的通信可以直接共享内存和其他资源,而进程之间的通信则需要通过进程间通信机制,如管道、共享内存和消息队列等。
    所以,不管是时间效率,还是空间效率线程比进程都要高。
    【线程的上下文切换】
    因为线程与进程最大的区别是进程是操作系统资源分配的基本单位,线程是执行调度的基本单位。所以,所谓操作系统任务调度,实际上调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。
    对于进程和线程,我们可以理解为:
  • 当进程只有一个线程时,可以认为进程就等于线程;
  • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的;
    另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。
    【线程上下文切换的是什么】
    这取决于两个要切换的线程是不是属于同一个进程:
  • 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;
  • 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据;
    所以,线程的上下文切换相比进程,开销要小很多。
    【线程的实现】
    主要有三种实现方式:
  • 用户线程(User Thread):在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理;
  • 内核线程(Kernel Thread):在内核中实现的线程,是由内核管理的线程;
  • 轻量级进程(LightWeight Process):在内核中来支持用户线程;
    用户线程是基于用户态的线程管理库来实现的,那么线程控制块(Thread Control Block, TCB),也是在库里面实现的,对于操作系统而言是看不到这个TCB的,他只能看见整个进程的PCB。
    所以,用户线程的整个线程调度,是由用户级线程库函数来管理,包括线程的创建、终止、同步和调度等。
    用户级线程与内核线程可以是n:1、n:m、1:n、1:1的关系,具体取决于操作系统的实现方式和应用程序的设计。如下图:
    1:1

n:1

n:m

具体可参考:用户线程、内核线程对应关系的三种模型_用户线程和内核线程的映射关系_weixin_42873905的博客-CSDN博客
用户级线程模型:

用户级线程的优点:

  • 用户线程的切换也是由线程库函数来完成的,无需用户态与内核态的切换,所以速度特别快;
  • 线程占用资源少。因为用户级线程的实现由应用程序自己完成,不需要操作系统的支持,所以线程占用的系统资源相对较少。
    用户级线程缺点:
  1. 阻塞:当一个用户级线程阻塞时,整个进程都会被阻塞,因为操作系统无法发现线程的阻塞状态。
  2. 调度:用户级线程的调度由用户控制,因此可能导致不合理的线程切换,例如长时间运行的线程可能会影响其他线程的执行。
  3. 系统调用:当用户级线程进行系统调用时,由于操作系统对线程的不可见性,会导致整个进程阻塞。
  4. 信号处理:在使用用户级线程时,无法对单个线程进行信号处理。
  5. 并发性:由于用户级线程的调度由用户控制,因此无法实现真正的并发执行,而只能在一个CPU上交替执行线程。
    内核级线程:
    内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责。
    【进程、线程、纤程概念】
    进程是OS分配资源的基本单位;线程是执行调度的基本单位;分配资源最重要的是独立的内存空间,线程调度执行(线程共享进程的内存空间,没有自己的独立内存空间)
    纤程是用户态的进程,线程中的线程,单位比线程更小一级,切换和调度不需要经过OS;
    纤程的优势:占用资源很少;切换简单;go语言更高一级的特点就是拥有独有的—纤程库;

【僵尸进程和孤儿进程】

僵尸进程状态为:fefunct
杀掉僵尸进程必-须要把他的父进程干掉,因为只有他的父进程才能消掉僵尸进程的PCB,僵尸进程占用的资源也只有他的PCB,因为执行任务结束,所以其他的资源全都释放掉了。
进程类型:

【进程/线程之间是如何通信的】

【进程间通信】
管道 (使用最简单)、信号 (开销最小)、共享映射区 (无血缘关系)、本地套接字 (最稳定)
每个进程的用户地址空间都是独立的,一般而言是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核。

管道
linux中 " | " 就是一个管道,功能是将前一个命令的输出,作为后一个命令的输入
管道特性:

  • 管道的本质是一块内核缓冲区
    • 由两个文件描述符引用,一个表示读端,一个表示写端。
    • 规定数据从管道的写端流入管道,从读端流出。
    • 当两个进程都终结的时候,管道也自动消失。管道的读端和写端默认都是阻塞的。
      【线程通信几种方式】
      1.使用 volatile 关键字,多个线程同时监听一个变量,当这个变量发生变化的时候 ,线程能够感知并执行相应的业务。这也是最简单的一种实现方式。
      2、Object 类提供了线程间通信的方法:wait()、notify()、notifyAll(),它们是多线程通信的基础,而这种实现方式的思想自然是线程间通信。wait 方法释放锁,notify 方法不释放锁。
      3、使用JUC工具类 CountDownLatch。CountDownLatch 基于 AQS 框架,相当于也是维护了一个线程间共享变量 state。
      4、使用 ReentrantLock 结合 Condition。线程 B 在被 A 唤醒之后由于没有获取锁还是不能立即执行,也就是说,A 在唤醒操作之后,并不释放锁。这种方法跟 Object 的 wait()/notify() 一样。
      5、基本 LockSupport 实现线程间的阻塞和唤醒。LockSupport 是一种非常灵活的实现线程间阻塞和唤醒的工具,使用它不用关注是等待线程先进行还是唤醒线程先运行,但是得知道线程的名字。

【进程调度】
Linux默认调度策略:
实时进程:优先级分高低 - FIFO(first in first out) 优先级一样 - RR(Round Robin
普通进程: CFS

【内存管理】
解决内存撑爆问题:分页:分块装入页框(内存页4K标准页)
分页:内存分成固定大小的页框(标准4K),把程序(硬盘上)分成4k大小的块需要哪块就装入哪块,当内存满时,最不常用的块将进入交换分区(swap),把最新的一块加载进来。这就是著名的LRU算法(最久未使用算法)。
内存管理底层结构:哈希表(保证查找操作O(1)) + 链表(保证排序和新增操作O(1))
虚拟内存:(解决相互打扰问题)
为保证相互不影响,让进程工作在虚拟空间,程序中用到的空间地址不再是直接的物理地址,而是虚拟地址,这样,A进程永远访问不到B进程的空间。
虚拟空间大小:寻址空间 - 64位操作系统(2^64),比物理空间大得多,单位是bit;
站在虚拟的角度,进程是独享整个系统+CPU
物理地址和虚拟地址的对应关系就叫地址映射

内存地址映射:偏移量+段的基地址=线性地址

【产生死锁的必要条件】
(1)互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。
(2)请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
(3)不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
(4)环路等待条件:在发生死锁时,必然存在一个进程–资源的环形链。
【解决死锁的基本方法】
(1)资源一次性分配:一次性分配所有资源,这样就不会再有请求了:(破坏请求条件)
(2)只要有一个资源得不到分配,也不给这个进程分配其他的资源:(破坏请保持条件)
(3)可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件)
(4)资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)
【进程的五大状态】
1)运行态(Running)。进程正在处理机上运行。在单处理机环境下,每个时刻最多只有一个进程处于运行态。
2)就绪态(Ready)。进程获得了除处理机外的一切所需资源,一旦得到处理机,便可立即运行。系统中处于就绪状态的进程可能有多个,通常将他们排成一个队列,称为就绪队列。
3)阻塞态(Waiting),又称等待态。进程正在等待某一事件而暂停运行,如等待某资源为可用(不包括处理机)或等待输入/输出完成。即使处理机空闲,该进程也不能运行。
4)创建态(New)。进程正在被创建,尚未转到就绪态。创建进程通常需要多个步骤:首先申请一个空白的PCB,并向PCB中填写一些控制和管理进程的信息;然后由系统为该进程分配运行时所必需的资源;最后把该进程转入就绪态。
5)结束态(Terminated)。进程正在从系统中消失,可能是进程正常结束或其他原因中断退出运行。进程需要结束运行时,系统首先必须置该进程为结束态,然后再进一步处理资源释放和回收等工作。

;