OS进程管理

512 阅读19分钟

进程

进程是操作系统资源分配的最小单元。代码只是一个存储在硬盘的静态文件,通过编译后就会生成二进制可执行文件,当运行这个可执行文件后,它会被装载到内存中,接着 CPU 会执行程序中的每一条指令,这个运行中的程序,就被称为「进程」(Process)

CPU 管理多个进程多个程序、交替执行。对于一个支持多进程的系统,CPU 会从一个进程快速切换至另一个进程,其间每个进程各运行几十或几百个毫秒,实现多进程并发。

CPU 可以从一个进程切换到另外一个进程,在切换前必须要记录当前进程中运行的状态信息,以备下次切换回来的时候可以恢复执行。所以,进程有着「运行 - 暂停 - 运行」的活动规律。

进程状态

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

  • 运行状态(Running):该时刻进程占用 CPU;
  • 就绪状态(Ready):可运行,由于其他进程处于运行状态而暂时停止运行;
  • 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;

进程还有另外两个基本状态:

  • 创建状态(new):进程正在被创建时的状态;
  • 结束状态(Exit):进程正在从系统中消失时的状态;

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

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

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

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

  • 通过 sleep 让进程间歇性挂起,其工作原理是设置一个定时器,到期后唤醒进程。
  • 用户希望挂起一个程序的执行,如在 Linux 中用 Ctrl+Z 挂起进程;

进程控制结构

在操作系统中用进程控制块(process control block,PCB)数据结构来描述进程。PCB 是进程存在的唯一标识,一个进程的存在必然会有一个 PCB。PCB 具体包含以下信息:

1) 进程描述信息

  • 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符;
  • 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务;

2) 进程控制和管理信息

  • 进程当前状态,如 new、ready、running、waiting 或 blocked 等;
  • 进程优先级:进程抢占 CPU 时的优先级;

3) 资源分配清单

  • 有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息。

4) CPU 相关信息

  • CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行。

PCB 通常通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列

  • 将所有处于就绪状态的进程链在一起,称为就绪队列
  • 把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列
  • 对于运行队列在单核 CPU 系统中只有一个运行指针,因为单核 CPU 在某个时间,只能运行一个程序。

进程控制

01 创建进程

操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源。创建进程的过程如下:

  • 申请一个空白的 PCB,并向 PCB 中填写一些控制和管理进程的信息,如进程的唯一标识等;
  • 为该进程分配运行时所必需的资源,如内存资源;
  • 将 PCB 插入到就绪队列,等待被调度运行;

02 终止进程

进程可以有 3 种终止方式:正常结束、异常结束以及外界干预(信号 kill 掉)。当子进程被终止时,其在父进程处继承的资源应当还给父进程。而当父进程被终止时,该父进程的子进程就变为孤儿进程,会被 1 号进程收养,并由 1 号进程对它们完成状态收集工作。终止进程的过程如下:

  • 查找需要终止的进程的 PCB;
  • 如果处于执行状态,则立即终止该进程的执行,然后将 CPU 资源分配给其他进程;
  • 如果其还有子进程,则应将该进程的子进程交给 1 号进程接管;
  • 将该进程所拥有的全部资源都归还给操作系统;
  • 将其从 PCB 所在队列中删除;

03 阻塞进程

当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由另一个进程唤醒。阻塞进程的过程如下:

  • 找到将要被阻塞进程标识号对应的 PCB;
  • 如果该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行;
  • 将该 PCB 插入到阻塞队列中去;

04 唤醒进程

进程由「运行」转变为「阻塞」状态是由于进程必须等待某一事件的完成,所以处于阻塞状态的进程是绝对不可能叫醒自己的。如果某进程正在等待 I/O 事件,需由别的进程发消息给它,则只有当该进程所期待的事件出现时,才由发现者进程用唤醒语句叫醒它。唤醒进程的过程如下:

  • 在该事件的阻塞队列中找到相应进程的 PCB;
  • 将其从阻塞队列中移出,并置其状态为就绪状态;
  • 把该 PCB 插入到就绪队列中,等待调度程序调度;

进程的阻塞和唤醒是一对功能相反的语句,如果某个进程调用了阻塞语句,则必有一个与之对应的唤醒语句。

进程上下文切换

各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,一个进程切换到另一个进程运行,称为进程的上下文切换

cpu上下文切换

CPU会在不同时间片内执行不同进程,操作系统需要事先帮 CPU 设置好 CPU 寄存器和程序计数器,CPU 寄存器和程序计数是 CPU 在运行任何任务前所必须依赖的环境,这些环境叫做 CPU 上下文

CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置运行新任务。系统内核会存储保持下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新加载这些上下文,这样就能保证任务原来的状态不受影响。

进程上下文切换

进程是由内核管理和调度的,所以进程的切换只能发生在内核态,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。

通常,OS会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行:

  • 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。当某个进程的时间片耗尽了,进程就从运行状态变为就绪状态,系统从就绪队列选择另外一个进程运行;
  • 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;
  • 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度;
  • 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;
  • 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序;

线程

线程是操作系统执行的最小单元。线程是进程当中的一条执行流程。同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的。

线程上下文切换

线程是调度的基本单位,而进程则是资源拥有的基本单位

  • 当进程只有一个线程时,可以认为进程就等于线程;
  • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的;

当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据

线程实现

  • 用户线程(User Thread :在用户空间实现的线程,不是由内核管理的线程,由用户态的线程库来完成线程的管理,多个用户线程对应同一个内核线程
  • 内核线程(Kernel Thread :在内核中实现的线程,是由内核管理的线程,一个用户线程对应一个内核线程
  • 轻量级进程(LightWeight Process :在内核中来支持用户线程,多个用户线程对应到多个内核线程

进程调度

OS通过某种调度算法选择下一个要运行的进程,根据如何处理时钟中断 ,把调度算法分为两类:

  • 非抢占式调度算法:挑选一个进程,让该进程运行直到被阻塞,或者直到该进程退出,才会调用另外一个进程,也就是说不会理时钟中断这个事情。
  • 抢占式调度算法:挑选一个进程,让该进程只运行某段时间,如果在该时段结束时,该进程仍然在运行时,则会把它挂起,接着调度程序从就绪队列挑选另外一个进程。这种抢占式调度处理,需要在时间间隔的末端发生时钟中断,以便把 CPU 控制返回给调度程序进行调度,也就是常说的时间片机制

调度算法

1 先来先服务算法

先来先服务(First Come First Serve, FCFS)算法,最简单的调度算法

每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。FCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。

2 最短作业优先算法

最短作业优先(Shortest Job First, SJF)调度算法,优先选择运行时间最短的进程来运行,有助于提高系统的吞吐量。显然对长作业不利

3时间片轮转调度算法

时间片轮转(Round Robin, RR)调度算法,每个进程被分配一个时间段,称为时间片(Quantum),即允许该进程在该时间段中运行。

  • 如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配给另外一个进程;
  • 如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换;

4 最高优先级算法

「时间片轮转算法」做了个假设,即让所有的进程同等重要,所有程序的运行时间都一样。但是,多用户计算机系统希望调度是有优先级的,即希望调度程序能从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级(Highest Priority First,HPF)调度算法

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

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

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

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

但是依然有缺点,可能会导致低优先级的进程永远不会运行。

5 多级反馈队列算法

多级反馈队列(Multilevel Feedback Queue)调度算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。

  • 「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。
  • 「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列;
  • 设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短
  • 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成;
  • 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,把优先级改成动态的,如果客户办理业务时间增加,则降低其优先级,如果客户等待时间增加,则升高其优先级。

进程通信

每个进程的用户地址空间都是独立的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核。

管道

和Linux里的管道命令「|」类似,ps auxf | grep mysql,它的功能是将前一个命令(ps auxf)的输出,作为后一个命令(grep mysql)的输入,从功能描述可以看出管道传输数据是单向的,如果想相互通信,需要创建两个管道才行。

| 」表示的管道称为匿名管道,用完了就销毁。管道还有另外一个类型是命名管道,也被叫做 FIFO,因为数据是先进先出的传输方式。

  1. 在使用命名管道前,先需要通过 mkfifo 命令来创建,并且指定管道名字:
mkfifo myPipe
  1. myPipe 就是这个管道的名称,基于 Linux 一切皆文件的理念,管道也是以文件的方式存在,接下来,往 myPipe 这个管道写入数据:
echo "hello" > myPipe  // 将数据写进管道

执行上述命令后会被阻塞,这是因为管道里的内容没有被读取,只有当管道里的数据被读完后,命令才可以正常退出。

  1. 执行另外一个命令来读取这个管道里的数据:
cat < myPipe  // 读取管道里的数据
hello

管道这种通信方式效率低,不适合进程间频繁地交换数据。它的好处自然就是简单,同时也很容易得知管道里的数据已经被另一个进程读取了。

管道原理

匿名管道的创建,需要通过系统调用实现

int pipe(int fd[2]) 

pipe创建一个匿名管道,并返回了两个描述符,一个是管道的读取端描述符 fd[0],另一个是管道的写入端描述符 fd[1]。注意,这个匿名管道是特殊的文件,只存在于内存,不存于文件系统中。

所谓的管道,就是内核里面的一串缓存。从管道的一段写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。管道传输的数据是无格式的流且大小受限。

使用管道进程间通信需要fork 创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个「 fd[0]fd[1]」,两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信。

管道只能一端写入,另一端读出,所以上面这种模式容易造成混乱,因为父进程和子进程都可以同时写入,也都可以读出。为了避免这种情况,通常的做法是:

  • 父进程关闭读取的 fd[0],只保留写入的 fd[1];
  • 子进程关闭写入的 fd[1],只保留读取的 fd[0];

如果需要双向通信,则应该创建两个管道。

但是实际在 shell 里面执行 A | B命令的时候,A 进程和 B 进程都是 shell 创建出来的子进程,A 和 B 之间不存在父子关系,它俩的父进程都是 shell。

在 shell 里通过「|」匿名管道将多个命令连接在一起,实际上也就是创建了多个子进程,在编写 shell 脚本时能使用一个管道搞定的事情,就不要多用一个管道,这样可以减少创建子进程的系统开销。

对于匿名管道,它的通信范围是存在父子关系的进程。因为管道没有实体,也就是没有管道文件,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的。

对于命名管道,它可以在不相关的进程间也能相互通信。因为命令管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。

不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。

消息队列

管道的通信方式是效率低的,因此管道不适合进程间频繁地交换数据。对于这个问题,消息队列的通信模式就可以解决。如A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进程发送消息也是如此。

消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。

消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,而前面提到的匿名管道的生命周期是随进程的创建而建立,随进程的结束而销毁。

消息队列不适合较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。在 Linux 内核中,会有两个宏定义 MSGMAXMSGMNB,它们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。

消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。

共享内存

消息队列的读取和写入的过程,都会有发生用户态与内核态之间的消息拷贝过程。共享内存的方式就很好的解决了这一问题。

现代操作系统对于内存管理,采用的是虚拟内存技术,每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程 A 和 进程 B 的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响。

共享内存的机制就是拿出一块虚拟地址空间来映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,不需要拷贝,大大提高了进程间通信的速度。

信号量

共享内存通信方式带来了新的问题,如果多个进程同时修改同一个共享内存,很有可能会造成冲突。如两个进程都同时写一个地址,那先写的那个进程会发现内容被别人覆盖。

为了防止多进程竞争共享资源而造成的数据错乱,需要保护机制使得共享的资源在任意时刻只能被一个进程访问。信号量就实现了这一保护机制。

信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据

信号量表示资源的数量,控制信号量的方式有两种原子操作:

  • 一个是 P 操作,这个操作会把信号量减去 1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。
  • 另一个是 V 操作,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;

P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的,对应于加解锁,如果要使得两个进程互斥访问共享内存,可以初始化信号量为 1

具体过程如下:

  • 进程 A 在访问共享内存前,先执行了 P 操作,由于信号量的初始值为 1,故在进程 A 执行 P 操作后信号量变为 0,表示共享资源可用,于是进程 A 就可以访问共享内存。
  • 若此时,进程 B 也想访问共享内存,执行了 P 操作,结果信号量变为了 -1,这就意味着临界资源已被占用,因此进程 B 被阻塞。
  • 直到进程 A 访问完共享内存,才会执行 V 操作,使得信号量恢复为 0,接着就会唤醒阻塞中的线程 B,使得进程 B 可以访问共享内存,最后完成共享内存的访问后,执行 V 操作,使信号量恢复到初始值 1。

可以发现,信号初始化为 1,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。

另外,在多进程里,每个进程并不一定是顺序执行的,它们基本是以各自独立的、不可预知的速度向前推进,但有时候又希望多个进程能密切合作,以实现一个共同的任务。

例如,进程 A 是负责生产数据,而进程 B 是负责读取数据,这两个进程是相互合作、相互依赖的,进程 A 必须先生产了数据,进程 B 才能读取到数据,所以执行是有前后顺序的。

此时可以用信号量来实现多进程同步的方式,将信号量初始化为 0

具体过程:

  • 如果进程 B 比进程 A 先执行了,那么执行到 P 操作时,由于信号量初始值为 0,故信号量会变为 -1,表示进程 A 还没生产数据,于是进程 B 就阻塞等待;
  • 接着,当进程 A 生产完数据后,执行了 V 操作,就会使得信号量变为 0,于是就会唤醒阻塞在 P 操作的进程 B;
  • 最后,进程 B 被唤醒后,意味着进程 A 已经生产了数据,于是进程 B 就可以正常读取数据了。

可以发现,信号初始化为 0,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执行。

信号

对于异常情况下的工作模式,需要用「信号」的方式来通知进程。在 Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。可以通过 kill -l 命令查看所有的信号

运行在 shell 终端的进程,可以通过键盘输入某些组合键给进程发送信号。例如

  • Ctrl+C 产生 SIGINT 信号,表示终止该进程;
  • Ctrl+Z 产生 SIGTSTP 信号,表示停止该进程,但还未结束;

如果进程在后台运行,可以通过 kill 命令的方式给进程发送信号,但前提需要知道运行中的进程 PID 号,例如:

  • kill -9 1050 ,表示给 PID 为 1050 的进程发送 SIGKILL 信号,用来立即结束该进程;

信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)。信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,

用户进程对信号的处理方式:

  1. 执行默认操作。Linux 对每种信号都规定了默认操作,如 SIGTERM 信号就是终止进程的意思。
  2. 捕捉信号。可以为信号定义一个信号处理函数。当信号发生时就执行相应的信号处理函数。
  3. 忽略信号。当不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILLSEGSTOP,它们用于在任何时候中断或结束某一进程。

Reference

转载自小林coding:xiaolincoding.com/os/4_proces…