多数操作系统有两种运行模式:用户态和内核态,区分内核态和用户态主要是为了限制不同程序的访问权限,保护系统程序,避免用户程序直接访问系统资源。
内核态:
用户态:
用户态切换到内核态的唯一途径——>中断/异常/陷入
内核态切换到用户态的途径——>设置程序状态字
CPU 执行完一条指令之后,控制单元会检查执行上一条指令的过程中是否出现过中断。如果有,就会执行如下处理中断的流程:
在进程模型中,计算机上所有可运行的软件,通常也包括操作系统,被组织成若干顺序进程,简称进程,一个进程就是一个正在执行程序的实例,包括程序计数器、寄存器和变量的当前值。是资源分配的基本单位。
四种主要事件会导致进程的创建:
① 系统初始化
启动系统时,通常会创建若干个进程,一些是前台进程,和用户交互并且完成工作,一些是后台进程,与用户没有直接关系。负责例如接受电子邮件等
② 正在运行的程序执行了创建进程的系统调用
新的进程也可以日后创建,一个正在运行的进程经常发出系统调用,可以创建一个或多个新进程协助工作,例如UNIX的fork函数
③ 用户请求创建一个新进程
交互式系统中,键入一个命令或者双击一个图标就可以启动一个程序,也就是创建一个新的进程
④ 一个批处理作业的初始化
这种情况发生在大型机的批处理系统中应用,在操作系统认为有资源可以运行下一个任务时,它创建一个进程运行下一个作业
进程的终止也通常因为以下四个原因引起
① 正常退出(自愿的)
由于一个进程完成了它的工作而终止,例如点击右上角告诉操作系统,它的工作已经完成,然后UNIX系统中调用exit退出进程
② 出错退出(自愿的)
进程发现了错误,但是没有立即退出,给出错误参数之后,面向交互式系统通常并不退出,而是有一个弹窗指示下一步操作,用户可以点击退出
③ 严重错误(非自愿)
这通常是程序错误,如空指针,除数是零等
④ 被其他进程杀死(非自愿)
一个进程执行一个系统调用杀死某个其他线程,例如UNIX系统中使用kill命令
某些系统中,当进程创建了另一个进程之后,父进程和子进程就以某种形式继续保持关联
在UNIX系统中,进程和他的所有的子进程以及后裔共同组成一个进程组
Windows中没有进程层次的概念,所有进程都是地位相等的,但父进程创建子进程后会得到一个令牌(称为句柄),该句柄可以用来控制子进程,但父进程有权把这个令牌传递给其他进程,这样,就不存在层次概念了。
进程的三种状态:
为了实现进程模型,操作系统维护着一张表格,(一个结构数组),即进程表,每个进程占用一个进程表项,即进程控制块(process control block,PCB)该表项包含了进程状态的重要信息,如下图
在了解进程表之后,就可以对在单个(或每一个)CPU上如何维持多个顺序进程的错觉做阐述
每个进程运行过程中会被中断,之后CPU又重新分配到该进程,该进程又会返回到中断发生之前完全相同的状态
进程是由内核管理和调度的,所以进程的切换只能发⽣在内核态。
进程的上下⽂切换不仅包含了虚拟内存、栈、全局变量等⽤户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。而线程共享进程的虚拟内存以及全局变量等资源,所以只需要对寄存器和一些私有数据进行切换。
通常,会把交换的信息保存在进程的 PCB,当要运⾏另外⼀个进程的时候,我们需要从这个
进程的 PCB 取出上下⽂,然后恢复到 CPU 中,这使得这个进程可以继续执⾏。
僵尸进程
概念:一个进程使用 fork 创建子进程,如果子进程退出而父进程并没有调用 wait() (父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息)或者 waitpid() 获取子进程信息,那么子进程的描述符仍然保存在系统中。这种进程就被称为僵尸进程 -------- 即 Z 进程
危害:占用资源不放,正常进程可能无法进行创建
解决方法:我们要解决的话就只能找到那个产生大量僵死进程的父进程,只有杀死掉那个父进程 (通过 kill 发送 SIGTERM 或 SIGKILL) 杀死掉那个父进程之后,那些僵死进程就成了孤儿进程,孤儿进程会被 init 进程接管,init 会 wait 掉这些孤儿进程并且释放它们在系统中占用的资源这些僵死的孤儿进程就会死去。
孤儿进程
概念: 如果父进程退出而它的一个或多个子进程还在运行,那么这些子进程就被称为孤儿进程孤儿进程最终将被 init 进程 (进程号为 1 的 init进程) 所收养并由 init 进程完成对它们的状态收集工作。
孤儿进程是没有危害的,孤儿进程是没有父进程的子进程,当孤儿进程没有父进程时,内核就会init设置为孤儿进程的父进程,init进程就会调用wait去释放那些已经退出的子进程,当孤儿进程完成其声明周期之后,init会释放掉其状态信息。
后台进程
后台进程的文件描述符也是继承于父进程,例如shell,所以并没有脱离控制台,终端关闭之后后台进程也会关闭。
守护进程
守护进程变成自己的进程组长,脱离终端独立运行。
线程就是运行在进程上下文中的逻辑流,一个进程至少有一个线程,也可以由多个线程,它们共享进程的资源,是操作系统独立调度的基本单位。
每个线程都包含有表示执行环境所必须的信息,包括进程标识线程的线程 ID,一组寄存器的值,栈,调度优先级和策略,信号屏蔽字,errno 变量等。
需要多线程的原因:
为实现可移植的线程程序,IEEE指定了线程的标准。它定义的线程包叫做Pthread,大部分UNIX系统都支持该标准。Posix线程就是在C程序中处理线程的一个标准接口
所有Pthread线程都有某些特性。每一个都含有一个标识符、一组寄存器(包括程序计数器)和一组存储在结构中的属性。这些属性包括堆栈大小、调度参数以及使用线程需要的其他项目。
一些pthread函数调用如下
用户空间中实现线程
优点:
缺点:
在内核中实现线程
优点:
缺点:
混合实现
使用内核线程,然后将用户级线程与某些或者全部内核线程多路复用起来。
内核只识别内核线程,并对其进行调度。一些内核线程会被多个用户级线程多路复用。在这种模型中,每个内核级线程有一个可以轮流使用的用户级线程集合
进程间通信(Inter Process Communication,IPC)主要有以下三个问题
同样这三个问题也适用于线程
在一些操作系统中,协作的进程可能共享一些彼此都能读写的公用存储区。
两个或多个进程读写某些共享数据,而最后的结果取决于进程运行的精确时序,称为竞争条件(race condition)。
以某种手段确保当一个进程在使用一个共享变量或文件时,其他进程不能做同样的操作,称为互斥(mutual exclusion)。,是避免竞争条件的重要方式
我们把对共享内存进行访问的程序片段称作临界区(critical section)。
对于一个并发好的解决方案,需要满足以下四个条件
这节讨论几种互斥的实现方案
① 屏蔽中断
每个进程在刚刚进入临界区后立即屏蔽所用中断,并在就要离开之前再打开中断。
适用于单核操作系统,对操作系统本身而言很有用,单对于用户进程则不是一种合适的互斥机制。
② 锁变量
共享锁变量,初始为0。当进程想进入临界区时,首先测试这把锁。
如果锁为0,则进程将所设置为1并进入临界区。并且当退出的时候将值设置为0
如果锁为1,则进程将等待其值变为0。
这种方式同样存在竞争条件。因为对于值的查看和设置过程不是原子操作,这就相当于不是原子性的CAS
③ 严格轮换法
有一个变量记录轮到哪一个进程进入临界区,然后不同的进程在等待循环中不听的测试该变量。知道测试变量变成某个值为止,进入临界区,这成为忙等待
这种方式浪费CPU时间,通常应该避免。只有在有理由认为等待时间是非常短的情形下,才使用忙等待。用于忙等待的锁,称为自旋锁(spin lock)。
示例:
/**
* @Description 测试忙等待
* @Date 2021/7/14 16:25
* @author: A.iguodala
*/
public class SpinLock {
static volatile int flag = 0;
public static void main(String[] args) {
new Thread(() -> {
while (true) {
while (flag != 0){}
/* 临界区 begin */
System.out.println("① 线程 1 进入临界区");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("① 线程 1 退出临界区");
flag = 1;
/* 临界区 end */
}
}).start();
new Thread(() -> {
while (true) {
while (flag != 1){}
/* 临界区 begin */
System.out.println("② 线程 2 进入临界区");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("② 线程 2 退出临界区");
flag = 0;
/* 临界区 end */
}
}).start();
}
}
④ Peterson 解法
该解法也通过自旋的方式获取锁,但是解决了上面的一个问题,就是上面如果一个进程很慢,一直不执行,那么第一个进程执行完操作之后会一直自旋等待第二个线程改变锁变量,而自己无法运行
/**
* @Description 测试Peterson解法
* @Date 2021/7/14 16:38
* @author: A.iguodala
*/
public class Peterson {
/**
* 标志位
*/
static volatile int flag = -1;
/**
* 表示想进入临界区,假设就两个线程
*/
static boolean[] interested = new boolean[2];
public static void main(String[] args) {
for (int i = 0; i < 2; i++) {
int j = i;
new Thread(() -> {
while (true) {
enter(j);
System.out.println(Thread.currentThread().getName() + ":我来啦");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":溜溜球");
exit(j);
}
},"线程:" + i).start();
}
}
static void enter (int processId) {
int other = 1 - processId;// 另一个进程的进程Id
interested[processId] = true; // 表示该进程想进入临界区
flag = processId;
while (flag == processId && interested[other]){}
}
static void exit (int processId) {
interested[processId] = false;
}
}
⑤ TSL指令
需要硬件支持的一种方案。
某些计算机中,特别是那些设计为多处理器的计算机。都有指令TSL RX, LOCK。称为测试并加锁(Test and Set Lock),他将一个内存字lock读到寄存器RX中,然后再该内存地址上存一个非零值。读字和写字操作保证是不可分割的。即该指令结束之前其他处理器均不允许访问该内存字。执行TSL指令的CPU将内存总线锁住,禁止其他CPU在本指令结束之前访问内存
一个可替代TSL的指令时XCHG,它原子性的交换两个位置的内容。
Peterson解法和TSL或XCHG解法都有忙等待的缺点。
这种方法不仅浪费了CPU时间,而且还可能引起预想不到的结果。
所以出现几条进程间通信原语,它们在无法进入临界区的时候阻塞,而不是忙等待(自旋消耗CPU)
最简单的就是sleep和wakeup
信号量(semaphore)是Dijkstra在1965年提出的一种方法,使用一个整型变量来累计唤醒次数。
存在两种操作 P 和 V (也叫down和up),这两个都是原子操作
该方法可以用来实现同步和互斥。
如果不需要信号量的计数能力,有时可以使用信号量的一个简化版本,称为互斥量(mutex)。
互斥量是一个可以处于两态之一的变量:解锁和加锁。通过可用的TSL或XCHG指令,很容易在用户控件实现。
快速用户区互斥量futex
如果等待时间长,自旋锁很消耗CPU,所以引入futex,当锁没有被占用则直接获取锁,占用的话加入等待队列,等释放锁之后内核唤醒等待队列中的一个或多个线程
一个管程(monitor)是一个由过程、变量及数据结构等组成的一个集合,它们组成一个特殊的模块或软件包。
一个进程调用管程过程时,如果管程有其他活跃进程,则被挂起,直到另一个进程离开管程把它唤醒,或者没有其他活跃进程直接进入,通常用一个互斥量或者二元信号量实现
为了解决进程在无法继续运行的时候阻塞,引入条件变量和 wait signal 两个操作
消息传递使用两条原语send 和 receive ,它们像信号量而不像管程,是系统调用而不是语言成分。
消息队列解决了命名管道读取和写入的效率问题,例如,A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进程发送消息也是如此。
消息队列是保存在内核中的消息链表,在发送数据时,会分成⼀个⼀个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。
缺点:
屏障是用于进程组而不是用于双进程的生产者-消费者情形的。
就是将某些应用中划分了若干阶段,到所有进程都执行到该屏障才继续执行,否则任何进程都不能进入下一个阶段。
可以通过在每个阶段的结尾安置屏障(barrier)来实现。
如Java中的CyclicBarrier
管道是UNIX系统IPC 的最古老形式,所有UNIX系统都提供此种通信机制。
ps auxf | grep java
对于该命令来说,| 就代表一个管道,将前一段命令(ps auxf)的输出,作为后一段命令(grep java)的输入,同时,这种管道是没有名字的,称为匿名管道。
int pipe(int fd[2])
所谓的管道,就是内核里面的⼀串缓存。从管道的⼀段写入的数据,实际上是缓存在内核中的,另⼀端读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的流且大小受限。
单进程的管道几乎没有任何作用,通常,进程会先调用pipe,接着调用 fork 从而创建从父进程到子进程的IPC通道。创建的子进程会复制父进程的文件描述符。从而就拥有两组对同一个管道的读端和写端。
由于管道是半全双工只能一端读取,另一端写入,上图这种形式很容易造成混乱,所以通常,父进程关闭fd[0] 读端,子进程关闭 fd[1] 写端,如果要进行双向通信,则需要建立两个管道。
对于开头的 Shell 程序来说,ps auxf | grep java ,ps 进程和 grep 进程都是 shell 的子进程,通过fork子进程再关闭一些读写端之后,再进行通信。
对于匿名管道,他只能在两个具有相同祖先关系的进程间通信。因为匿名管道没有实体,没有管道文件,只能通过 fork 来复制父进程 fd 文件描述符,以此来进行通信。
FIFO有时被称为命名管道。通过命名管道,不相关的进程也能进行通信。
该管道通信方式效率低,不适合进程间频繁地交换数据,但是,通过创建一个管道类型的文件,进程都可以使用这个文件,从而对于不相关的进程之间也能相互通信。
共享存储允许两个或多个进程共享一个给定的存储区。可以通过多个进程将同一个文件映射到他们的地址空间中来实现。因为数据不需要在客户端和服务端之间相互复制,所以这是最快的一种进程间通信方式。
内核会为每个共享存储段维护一个结构,如下:
shmget 和 mmap 对于共享内存的区别
mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间。
二者本质上是类似的,mmap可以看到文件的实体,而 shmget 对应的文件在交换分区上的 shm 文件系统内,无法直接 cat 查看。
这篇博客有总结区别
对于异常情况下的工作模式,就需要⽤「信号」的方式来通知进程。信号是软件中断。很多比较重要的应用程序都需处理信号。信号提供了一种处理异步事件的方法。每个信号都有一个名字。这些名字都以3个字符SIG开头。
信号是进程间通信机制中唯⼀的异步通信机制,因为可以在任何时候发送信号给某⼀进程,⼀旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。
Socket 只要是针对于不同主机间的通信。
多道程序设计系统中,多个线程或进程同时竞争CPU,当只有一个CPU可用时,那么就必须选择下一个要运行的进程。
完成选择工作的这一部分称为调度程序(scheduler),该程序使用的算法称为调度算法(scheduling algoritgm)。
① 进程行为
几乎所有进程的(磁盘)I/O请求或计算都是交替突发的。CPU不停顿地运行一段时间,然后发出一个系统调用以便读写文件。在完成系统调用之后,CPU又开始计算,直到它需要读取更多的数据或写更多的数据为止
② 何时调度
根据如何处理时钟中断,可以把调度算法分为两类
③ 调度算法分类
不同的环境需要不同的调度算法
分为三种环境
④ 调度算法的目标
轮转调度(round robin):
最⾼优先级调度(highest priority first):
多级反馈队列(multilevel feedback queue):
最短进程优先:根据进程过去的行为进行推测,估计运行时间最短的那一个
保证调度:像用户做出明确保证然后实现,例如有n个用户登录,那每个人都获得1/n的CPU时间
彩票调度:进程抽取彩票调度
公平分享调度:每个用户分一部分CPU时间,不关注进程
实时系统的调度算法可以是静态的或动态的。前者在系统开始运行之前做出调度决策;后者在运行过程中进程调度决策。
补充一个本书没有提到的:
⾼响应⽐优先调度算法(Highest Response Ratio Next, HRRN)
⾼响应⽐优先调度算法主要是权衡了短作业和⻓作业。每次进⾏进程调度时,先计算「响应⽐优先级」,然后把「响应⽐优先级」最⾼的进程投⼊运⾏,通过如下公式计算优先级