进程管理

并行和并发的区别?

  • 并行:多个进程或线程同一时刻运行在多个CPU上
  • 并发:多个进程或线程同一时间段交替运行在单个CPU上

什么是进程?

运行中的程序就是进程,也是程序向操作系统申请资源的基本单位。

进程的状态有哪些?

进程有五种基本状态:创建、就绪、运行、阻塞和终止。还有其他两种特殊状态:激活和挂起.

  • 创建:进程正在被创建的过程

    1. 申请空白的PCB;
    2. 然后在PCB中填入进程标识符等信息;初始化虚拟内存等资源;
    3. 将PCB插入就绪队列,等待被调度运行
  • 就绪:进程已经具备运行条件,但缺乏CPU执行权

    将创建完成的PCB放入就绪队列,等待被CPU调度;

  • 运行:正在被CPU调度运行

    从就绪队列里调度进程运行,分配CPU时间片;

  • 阻塞:进程由于IO等事件阻塞操作导致CPU资源空闲,此时让出CPU资源,并进入阻塞态等待事件完成,唤醒阻塞

    阻塞:

    1. 找到需要阻塞的进程标识符对应的PCB;
    2. 如果当前状态为运行态,则保存当前待阻塞进程上下文,将其状态变更为阻塞态;
    3. 将PCB移入阻塞队列;

    唤醒:

    1. 在该事件的阻塞队列中找到相应进程的 PCB;
    2. 将其从阻塞队列中移出,并置其状态为就绪状态;
    3. 把该 PCB 插入到就绪队列中,等待调度程序调度;
  • 终止:进程执行完毕或异常,系统回收进程资源

    1. 先找到要终止进程的PCB
    2. 如果当前进程正在运行,则终止运行,让出CPU使用权
    3. 如果进程还有子进程,则将子进程交给1号进程托管
    4. 回收进程所有资源
    5. 删除PCB

什么是CPU上下文切换?

当前任务执行结束或被中断,需要保存当前任务的CPU现场,包括当前寄存器、程序计数器和程序状态字等信息,然后将当前程序计数器指向下一个任务并执行。

什么是进程上下文切换?

进程是由内核管理的,所以只能在内核切换。进程的上下文切换不仅包括虚拟内存等用户空间资源,还有内核堆栈、寄存器等资源。切换时,将这些资源暂存于PCB中,等到CPU调度执行时,再恢复到CPU中。

什么是线程上下文切换?

线程切换分为进程内线程切换和进程间线程切换。

  • 进程内线程切换

    进程内部共享虚拟地址空间,全局变量等资源,仅仅独享一部分堆栈和寄存器。切换的时候,只需要暂存进程内线程的私有数据即可。

  • 进程间线程切换

    同进程切换

发生进程上下文切换有哪些场景?

  • CPU时间片用完或者被CPU调度执行
  • 系统资源不足,运行进程被挂起,让出CPU执行权
  • 进程主动调用睡眠函数让自己挂起
  • 更高优先级进程打断当前进程执行
  • 中断执行,当前进程让出CPU执行权

什么是线程?

线程就是进程中的一条执行流程,是CPU执行的基本单位。

为什么会有线程?

  • 进程之间通过虚拟内存隔离,无法直接共享内部数据
  • 进程上下文切换、资源回收与创建等管理成本较高
  • 单个进程一个时间点只能做一件事,无法做到并发

线程的实现方式

  • 用户级线程:在用户空间实现的线程,不是由内核管理的,而是由用户态线程库函数管理的,操作系统无法感知TCB的存在。
  • 内核级线程:在内核空间实现的线程,是由内核管理的。 1. 2. 多核CPU,内核能同时调度同一进程的多个线程映射到多个CPU核上并行运行,做到并行
  • 轻量级进程(LWP):在内核中支持用户线程,与内核线程一一对应。所以,LWP的前提是拥有内核线程。

用户级线程是什么?有什么优缺点?

用户级线程是在用户空间由线程库函数管理的,内核不参与管理,也并不知道TCB。用户级线程就相当于多个用户线程对应一个LWP,一个LWP对应内核线程。

  • 缺点

    1. 而且同一进程中只能同时有一个用户级线程在运行,如果有一个用户级线程使用了系统调用而阻塞,那么整个进程都会被挂起,可以节约更多的系统资源。所以,用户级线程模型不具有并发性能
    2. 用户级线程执行后,OS由于不能感知TCB的存在,无法打断执行,而且进程内的其他线程也无法打断,只能等待用户级线程自己交出CPU执行权
    3. 用户级线程是用户空间进程内的多个线程之一,得到的CPU时间片较短,执行慢
  • 优点

    用户级线程管理无需内核参与,减少内核管理线程上下文切换的开销;

内核级线程是什么?有什么优缺点?

内核级现车给是在内核空间实现的。内核线程对应一个LWP,一个LWP对应一个用户线程。

  • 缺点
    1. 应用程序的运行在用户态,而线程的切换创建等管理工作交由内核完成,会有线程上下文切换的开销;
  • 优点
    1. 内核中的线程阻塞,还可以调度其他就绪线程执行,不会发生阻塞
    2. 分配给线程,多线程的进程获得更多的 CPU 运行时间

进程调度算法有哪些?

  • FIFO

    先来先服务。每次从就绪队列里调度最先进入就绪队列的进程执行,执行完毕再调度下一个次先进入就绪队列的进程执行。

    **缺点:**如果长进程先进入就绪队列,会造成短进程周转时间变长,引发饥饿。

    **优点:**对长进程有利,适合于CPU繁忙型的系统

  • SJF

    最短作业优先调度算法。每次从就绪队列调度执行时间最短的进程执行,执行完毕再调度下一个次短的进程执行

    **缺点:**会对长进程造成饥饿问题

    **优点:**利于短进程,提高系统吞吐量(单位时间完成的进程数)

  • HRRN

    高响应比优先调度算法。综合前两种算法优劣,每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行,「响应比优先级」的计算公式:优先权=(等待时间+要求服务时间)/要求服务时间

    **优点:**等待时间越长,优先级越高(利于长进程);要求服务时间越短,优先级越高(利于短进程)

    **缺点:**很难估计等待时间,算法实现难度大,几乎没有现成的实现

  • RR

    时间片轮转调度算法。最公平的调度算法,就绪队列里的每个进程没有优先级,依次获得相同CPU时间片使用权,时间片用完后或提前结束运行,就会调度下一个线程执行。

    **优点:**不会出现进程的饥饿问题

    **缺点:**没有优先级,无法即时响应紧急任务

  • HPF

    最高优先级调度算法。从就绪队列中选择最高优先级的进程调度并执行。

    进程的优先级可以分为,静态优先级和动态优先级:

    • 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化;
    • 动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是随着时间的推移增加等待进程的优先级

    该算法也有两种处理优先级高的方法,非抢占式和抢占式:

    • 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。
    • 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。

    **缺点:**可能会导致低优先级的进程饥饿,甚至无法被调度执行

    **优点:**能及时响应紧急任务

  • MFQ

    多级反馈队列调度算法。该调度算法是综合最高优先级和时间片轮转调度算法的优劣。

    **多级:**多个就绪队列,每个队列优先级从高到低,同时优先级越高时间片越短

    **反馈:**新的高优先级进入就绪队列,CPU停止执行当前进程,并将其移到当前就绪队列尾部,并调度新进程执行。

    过程:

    1. 多个就绪队列,从高到低优先级降低,并且优先级越高,时间片越短
    2. 新的进程会被放到第一级队列末尾,按照先来先服务原则执行,时间片用完后还没执行完毕就将其放到下一级就绪队列
    3. 当较高优先级就绪队列执行完毕,则下一级就绪队列开始执行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行

进程有哪些通信方式?

  • 管道|

    管道分匿名管道和命名管道,本质上就是内核的一段缓存,只能单向写入或读出如果需要进程间相互通信,需要创建两个管道A和B,管道A写数据到管道B,管道B从管道A读数据。而且写入管道数据后,会阻塞到读取数据完毕

    优点:操作简单

    缺点:因为管道本质上是内核的一段缓存,所以需要进程的上下文切换成本和内核态与用户态数据拷贝的开销,再加上管道读写是同步的,导致通信效率低,不适合进程间频繁的数据交换。

  • 消息队列

    消息队列指内核里的消息链表。消息链表的节点单元是消息体,不能无限制存储数据,大小限制。如果进程读取了消息体,内核会把这个消息体删除。消息队列与管道通信不同,进程间的通信是异步的:进程A将数据放到消息体,进程B找个时间来读取,读取之后从消息链表里删除。

    缺点:通信不及时;不能传输太大的数据;和管道一样,都是内核数据,会存在进程上下文切换和内核态与用户态数据拷贝。

  • 共享内存

    共享内存指的是共享物理内存。将进程中的虚拟地址映射为同一块物理地址,那么就可以消除管道和消息队列通信的内核态与用户态的数据拷贝过程。

    优点:消除内核态与用户态之间的数据拷贝

    缺点:多进程并发读写共享内存会导致冲突

  • 信号量

    信号量其实是一个整形计数器,大小就是资源数目。

    信号量分两种:

    • 互斥信号量:信号量值为1,实现对资源的互斥访问。
    • 同步信号量:信号量值为0,控制进程之间的执行顺序。

    信号量还有两种原子操作:

    • P操作:将当前信号量-1,如果当前信号量<0,说明资源已被占用,则会阻塞当前进程;如果当前信号量>=0,说明资源可用,不会阻塞
    • V操作:将当前信号量+1,如果当前信号量<=0,则说明有进程阻塞,那就去唤醒阻塞的进程;如果当前信号量>0,说明没有进程阻塞,继续执行
  • 信号

    内核里的管道和消息队列、共享内存以及信号量都是系统正常情况下的进程之间常用的通信方式,那么异常情况下,则使用信号来通知进程。例如linux中,kill -9 pid指的就是杀死进程id为pid的进程,-9指的是信号SIGKILL

    用户进程可以对信号有几种处理方式:

    • 执行默认操作。进程收到信号过后都会有默认的操作
    • 捕捉信号。可以为信号设置信号处理函数,等到信号发生时就触发处理函数
    • 忽略信号。收到信号什么都不做,但是有些信号不能忽略,例如SIGKILLSIGSTOP
  • socket

    套接字,与前面的所有通信方式不同,socket不仅可以实现主机内部进程的通信,也可以实现不同主机进程之间的通信。

    socket有几种实现

    • 字节流(TCP):TCP/IP协议栈,面向连接的,需要bind\listen\connect\accept
    • 数据报(UDP):UDP协议,面向无连接的,只需要bind端口。
    • 原始套接字:相比于字节流和数据报更加底层,原始套接字可以直接收发MAC帧数据,没有IP头和TCP或UDP头。

线程有哪些通信方式?

同进程内的线程是共享资源的。

  • 全局变量

  • 加锁操作可以解决并发线程/进程的互斥问题。拿到锁才能访问临界区资源,否则只能阻塞。

  • 信号量

    信号量可以实现进程/线程的同步与互斥。

    • 同步:先执行的线程/进程使用V操作,后执行的线程/进程使用P操作

    • 互斥:线程/进程进入临界区前先执行P操作,锁住临界资源,离开临界区前再执行V操作,释放临界资源

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      
      // @author cold bin
      // @date 2023/8/8
      
      package main
      
      import (
      	"context"
      	"fmt"
      	"golang.org/x/sync/semaphore"
      	"sync"
      )
      
      var (
      	resource = 0
      	sig      = semaphore.NewWeighted(1) // 初始化互斥信号量
      	wg       sync.WaitGroup
      )
      
      func main() {
      	for i := 0; i < 1000; i++ {
      		go inrResource(1)
      		wg.Add(1)
      	}
      	wg.Wait()
      	fmt.Println(resource)
      }
      
      func inrResource(offset int) {
      	err := sig.Acquire(context.Background(), 1) // p操作
      	if err != nil {
      		return
      	}
      	resource += offset
      	sig.Release(1) // v操作
      	wg.Done()
      }
      

多线程冲突了怎么办?

多线程访问共享资源发生冲突,我们可以采取:

  • 进程内的锁
  • 信号量
  • 原子操作

什么是死锁?

在两个及两个以上的线程在执行过程中,由于竞争资源或彼此通信而造成的阻塞现象,若无外力作用,它们无法继续执行下去。此时称死锁。

死锁如何预防或解除?

死锁的产生有四个必要不充分条件,也就是说,满足下面四个条件才可能发生死锁,不满足一定不会死锁:

  • 互斥条件:多个线程不能同时使用一个资源
  • 持有并等待条件:某个线程已经持有了资源,再去请求另一个资源时阻塞,不会释放自己持有的资源
  • 不可剥夺条件:当线程持有了资源,在自己使用完毕前不能被其他线程获取
  • 环路等待条件:多个线程之间资源等待图构成环路

预防死锁

预防指的是,所有线程或进程运行之前。

我们只需要破坏上面任意一个条件,即可预防死锁的产生:

  • 破坏互斥条件:将资源互斥访问改成资源共享访问

    缺点:不是所有资源都可以共享。某些资源无法通过这种方式来预防死锁

  • 破坏持有并等待条件:①一次性把所有资源申请到位;②每使用完一个资源就及时释放它

    缺点:第一种方式会造成资源浪费,其他线程会饥饿;第二种方式可以很好解决饥饿的问题

  • 破坏不可剥夺条件:线程持有资源后,在没释放之前可以被其他线程剥夺使用。

  • 破坏循环等待条件:避免资源等待图出现环路。

避免死锁

避免指的是,所有线程或进程已经运行但还没有发生死锁。

  • 银行家算法

解除死锁

解除是指死锁已经发生。我们可以补救:

  • 进程终止法:出现死锁,kill掉当前进程
  • 资源剥夺法:出现死锁也就意味着循环资源等待图存在环路,我们找出构成环路的成本最小的路径,释放掉对应的请求资源即可
  • 进程回退法:保存进程历史快照,回滚到足以避免死锁的快照版本

有哪些常用的锁?

  • 互斥锁:线程抢锁失败时会释放CPU,然后由内核将其调度到阻塞队列里,直到锁释放,才会在一定的时机去唤醒锁。这个过程会涉及到线程的上下文切换。而且,只要抢锁失败的线程就会多经历两次线程上下文切换。所以,互斥锁针对高并发场景会有频繁的上下文切换。
  • 自旋锁:线程抢锁失败时不会释放CPU,会一直在CPU上空转等待锁释放并拿到锁。自旋锁相较于互斥锁而言,没有线程上下文切换的开销,但是会占用CPU资源,适用于自旋等待时间(临界区代码执行时间)要少于线程上下文切换的时间时,可以考虑使用自旋锁来代替互斥锁,否则,使用互斥锁更佳。但是瞬间太多线程自旋也会造成CPU压力暴增。
  • 读锁:当写锁没有被线程持有时,多个线程能够并发地抢到读锁,但不能抢写锁
  • 写锁:当写锁被某个线程持有时,多个线程不能并发抢读锁和写锁
  • 悲观锁:总是认为数据修改经常发生。线程访问临界区之前,会事先加锁,防止冲突发生。自旋锁、互斥锁和读写锁都是悲观锁
  • 乐观锁:总是认为数据修改不经常发生。线程修改数据之前,会携带版本号,如果与临界区资源版本号一致则可以修改并更新版本号;不一致则更新失败,放弃操作等待重试。

互斥锁和自旋锁是底层实现。读锁、写锁都是基于这两个实现的。

锁的开销在哪里?

  • 互斥锁:每一个线程抢锁失败就会多两次线程上下文切换。

    并发线程数为1000的场景,使用互斥锁,最好的情况下也有2000次线程上下文切换。每次上下文切换几us,所以总计差不多2ms的成本。

    并发线程数为10000的场景,使用互斥锁,最好的情况下也有20000次线程上下文切换。每次上下文切换几us,所以总计差不多20ms的成本。

    并发线程数为100000的场景,使用互斥锁,最好的情况下也有200000次线程上下文切换。每次上下文切换几us,所以总计差不多200ms的成本。

    并发线程数为1000000的场景,使用互斥锁,最好的情况下也有2000000次线程上下文切换。每次上下文切换几us,所以总计差不多2000ms的成本。

  • 自旋锁:线程抢锁失败会一直在CPU上空转,增加CPU负担。

  • 读写锁:适合读多写少的场景

  • 乐观锁:不适合写多读少的场景,因为会造成大量线程执行失败重试的情况。

如何做到进程的优雅关机?

捕获外部信号kill,不能捕获kill -9

线程崩溃,进程也会崩溃吗?

线程崩溃,可能是因为非法内存访问等错误。由于进程内的线程共享资源和地址空间,所以就可能会导致其他线程持有的资源被非法访问。所以,进程会收到SIGSEGV信号表示非法内存,然后这个信号内核有默认的handler就是kill掉出现非法访问内存的bug。所以,看具体实现。