并发与并行
- 并发:两个指令流在执行时间上发生重叠;
- 并行:两个指令流同时并发的运行在不同的处理器核或计算机上;
用户态和内核态
之所以要区分内核态和用户态,是因为处于安全的考虑,计算机中一些较危险的操作,比如说:管理外设、读写磁盘等操作,都不应该由用户任意操作,而是应该交由操作系统按照固定的套路来完成。
- 内核态:特权级别为0,在内核态态下,cpu可以访问内存的所有数据,包括外围设备,例如硬盘、网卡,处于内核态的 CPU也可以从一个程序切换到另一个程序;
- 用户态:特权级别为3,只能受限的访问内存,且不允许访问外围设备,用户态下的 CPU 不允许独占,也就是说 CPU能够被其他程序抢占;
进程与线程
- 进程是一个执行中程序的实例,一个进程 = 一份资源 + 多个指令执行序列,而一个线程就是一个指令执行序列;
- 进程是资源分配的基本单位,由一个进程的主线程创建出的多个线程共享进程所持有的资源;
- 线程是程序执行和调度的基本单位,同一进程中的线程切换不会引起进程切换,从一个进程中的线程切换到另一个进程中的线程时,才会引起进程切换;
- 从系统开销的角度来说:创建或销毁进程时,系统都要为之分配或回收资源,如虚拟地址空间、段表、页表等,所付出的开销远大于创建或销毁线程时的开销。同时,在进行进程切换时,除了要切换指令流,还要切换资源(如段表和页表),开销较大;而线程切换时只需切换指令执行序列,所以只需要切换少量的寄存器(如PC)和栈,开销很小。所以说,线程即保留了进程并发的优点,又减少了创建进程和进程切换时的系统开销;
- 从通信的角度来说:属于同一个进程的不同线程之间共享同一地址空间,所以一个线程的数据可以被其它线程感知,线程间可以直接读写该进程的数据来进行通信;但是对于不同的进程来说,它们的地址空间相互独立,要进行数据的传递只能通过进程间通信的方式进行;
进程的状态
当一个进程创建完成后会变为就绪态;当该进程被调度到CPU中执行时就是运行态;执行的过程中如果因为缺少某个条件而执行不下去时会变为阻塞态,然后当这个缺少的条件出现后会变为就绪态,等待再次被调度;当进程所有的指令序列都执行完毕或者发生异常或者被其它进程杀死后就会变成终止态,然后等待父进程的回收,被回收后操作系统内核就会释放掉该进程所占有的资源。
进程切换:
- 当前进程启动磁盘IO等会引发进程切换的操作;
- 将当前进程标记为阻塞态,然后将当前进程所对应的PCB放到阻塞队列当中;
- 执行进程调度算法,从就绪队列中获取一个PCB;
- 将当前程序的执行状态(如:寄存器、栈等)保存到PCB当中;
- 将调度得到的PCB中的资源(如段表、页表)以及程序执行状态加载到内存中,然后设置PC指针并开始执行程序;
内核线程切换:
- 触发中断,进入内核;
- 中断处理程序可能引起切换(如磁盘读写、时钟中断);
- 调度得到下一个要被执行的线程TCB;
- 切换内核栈以及用户栈;
- 进入用户态执行线程;
进程的调度算法
先来先服务FCFS
非抢占式的调度算法,按照请求的顺序进行调度。
该算法不利于短作业,因为短作业必须一直等待前面的长作业执行完毕才能执行,而长作业又需要执行很长时间,造成了短作业等待时间过长。
短作业优先SJF
非抢占式的调度算法,按估计运行时间最短的顺序进行调度。
优点就是可以降低平均周转时间;缺点就是长作业有可能出现饥饿,如果一直有短作业到来,则长作业永远得不到调度。
最短剩余时间优先SRTN
最短作业优先的抢占式版本,按剩余运行时间的顺序进行调度。 当一个新的作业到达时,其整个运行时间与当前进程的剩余时间作比较。如果新的进程需要的时间更少,则挂起当前进程,运行新的进程。否则新的进程等待。
高响应比优先HRRN
非抢占式的调度算法,在每次调度时先计算各个进程的响应比,选择响应比最高的作业/进程为其服务。
等待时间相同时,要求服务时间短的响应比大,会被优先调度,这是SJF 的优点;要求服务时间相同时,等待时间长的响应比大,会被优先调度,这是FCFS 的优点。对于长作业来说,随着等待时间越来越久,其响应比也会越来越大,从而避免了长作业饥饿的问题。所以,该算法是对先来先服务调度算法和短作业优先调度算法的一种综合,从而平衡了对短作业和长作业的调度问题。
时间片轮转
将所有就绪进程按到达时间的先后次序排成一个队列,每次调度时,把 CPU 时间分配给队首进程,该进程可以执行一个时间片。当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把 CPU 时间分配给队首的进程。
抢占式算法,可以降低平均响应时间,该算法的效率和时间片的大小有很大关系:如果时间片太小,会导致进程切换得太频繁,在进程上下文切换上就会花过多时间;如果时间片过长,那么实时性就不能得到保证,并且会降低吞吐量。
优先级调度
为每个进程分配一个优先级,按优先级进行调度。同时,为了防止低优先级的进程永远等不到调度,可以随着时间的推移改变进程的优先级,比如说如果进程的运行时间增加则降低其优先级,如果进程的等待时间增加则升高其优先级。
- 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。
- 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。
多级反馈队列
维护多个具有不同优先级的队列,所有队列按优先级从高到低的顺序排序,同时优先级越高该队列所对应的时间片越短。新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成。当较高优先级的队列为空,才调度较低优先级的队列中的进程。如果进程运行过程中,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,转而去运行优先级高的队列。
抢占式算法,该算法可以看成是时间片轮转调度算法和优先级调度算法的结合。对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是具有更好的实时性。
进程间的通信
管道
当前进程通过调用 int pipe(int fd[2]); 函数在内核中创建一个管道,fd[0] 用于读,fd[1] 用于写。然后当前进程调用fork()函数创建一个子进程,由于子进程拷贝父进程打开的文件描述符表,所以子进程也可以通过fd[0]、fd[1]来操作管道。如果父进程创建了多个子进程,则这多个兄弟进程之间都可以通过fd[0]、fd[1]来操作该管道,比如说一个进程通过fd[0]来向管道里写数据,另一个进程通过fd[1]来读取管道里的数据,从而完成进程间的通信。管道的生命周期,是随进程的创建而建立,随进程的结束而销毁。
缺点:①只支持半双工通信;②只能在父子进程或者兄弟进程中使用。
命名管道FIFO
使用 int mkfifo(const char *path, mode_t mode); 函数创建一个命名管道,它以一种特殊设备文件形式存在于文件系统中,并且有唯一的路径名与之相对应。在不同的进程里只需要通过读写这个设备文件,就可以实现进程间的相互通信。命名管道的方式可以在没有任何关系的进程间实现进程间通信。
消息队列
- A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。
- 消息队列是保存在内核中的消息链表,一个消息队列由一个标识符ID来标识。
- 消息队列中的消息具有特定的格式和固定的大小,这个格式是由消息的发送方和接收方提前约定好的。
- 并且消息队列生命周期跟随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,并不会随着进程的终止而消失。
- 读进程可以根据消息类型有选择地接收消息,而不像 FIFO 那样只能默认地接收。
缺点:
- 消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限;
- 消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销;
共享内存
共享内存的机制,就是从每个进程独立的虚拟地址空间中拿出一块地址空间来,将它们映射到相同的物理内存中。这样一个进程写入后,另外一个进程马上就能感知到。使用共享内存的方式,不存在数据拷贝的开销,所以效率高。
信号量
要想使用共享内存,就需要使用信号量来对共享存储的读写进行同步。信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据,若要在进程间传递数据需要结合共享内存。
信号量表示资源的数量,我们可以通过PV操作来修改资源的数量,这两种操作都是操作系统的原子操作。P是减少信号量,执行P操作以后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。V是增加信号量,增加后如果信号量 <= 0,则表明当前有阻塞中的进程,所以需要唤醒进程;相加后如果信号量 > 0,则表明当前没有阻塞中的进程。
(如果初始化信号量为1,则代表互斥信号量。生产者-消费者模型中的资源就是信号量。)
上面提到PV操作是原子操作,这就需要将修改计数器的操作置于临界区当中,一次只允许一个进程进入,实现临界区保护的方式如下:
套接字
如果想要实现不同主机上不同进程的通信,就需要借助套接字Socket通过网络来实现。
进程同步
临界区
对共享资源的访问的那段代码被称为临界区,只允许一个进程进入,我们可以通过上图的方式来达到互斥进入临界区的目的。
信号量
信号量是一个整型计数器,可以对其执行 P 和 V 操作,这两种操作都是由操作系统来保证的原子操作(通过临界区保护来实现的)。当计数器的值初始化为1时,就是一个互斥信号量。
管程
管程有一个重要特性:在一个时刻只能有一个进程持有管程。 它通过条件变量以及 wait() 和 signal() 函数来实现同步操作。对条件变量执行 wait() 操作会导致调用进程阻塞,把管程让出来给另一个进程持有。signal() 操作用于唤醒被阻塞的进程。
经典同步问题
生产者-消费者模型
问题描述:使用一个缓冲区来保存物品,只有缓冲区没有满,生产者才可以放入物品;只有缓冲区不为空,消费者才可以拿走物品。
缓冲区属于临界资源,需要使用一个互斥量 mutex 来控制对缓冲区的互斥访问。为了表示缓冲区中资源的数量,需要两个信号量:empty 记录空缓冲区的数量,full 记录满缓冲区的数量。
注意,不能先对缓冲区进行加锁,再测试信号量。也就是说,不能先执行 P(mutex) 再执行 P(empty)。如果这么做了,那么可能会出现这种情况:生产者对缓冲区加锁后,执行 P(empty) 操作,发现 empty = 0,此时生产者睡眠。因为生产者对缓冲区加锁了,导致消费者不能进入临界区,从而无法执行 V(empty) 操作,empty 永远都为 0,导致生产者永远等待下,不会释放锁,消费者因此也会永远等待下去。
#define N 100
typedef int semaphore;
semaphore mutex = 1;
semaphore empty = N;
semaphore full = 0;
void producer() {
while(TRUE) {
int item = produce_item();
P(&empty);
P(&mutex);
insert_item(item);
V(&mutex);
V(&full);
}
}
void consumer() {
while(TRUE) {
P(&full);
P(&mutex);
int item = remove_item();
consume_item(item);
V(&mutex);
V(&empty);
}
}
哲学家进餐问题
五个哲学家围着一张圆桌,每个哲学家面前放着食物。哲学家的生活有两种交替活动:吃饭以及思考。当一个哲学家吃饭时,需要先拿起自己左右两边的两根筷子,并且一次只能拿起一根筷子。如何保证哲学家们的动作有序执行,而不会出现有人永远拿不到两个叉子。
可以让偶数编号的哲学家先拿左边的叉子后拿右边的叉子,奇数编号的哲学家先拿右边的叉子后拿左边的叉子。
读者-写者问题
允许多个进程同时对数据进行读操作,但是不允许读和写以及写和写操作同时发生。
死锁
多个进程由于相互等待对方所持有的资源而造成谁也无法继续执行的环路等待情况。
必要条件
- 资源是互斥使用的;
- 资源是不可抢占的,只能由自己来释放;
- 进程占有了一些资源且不释放,然后再去申请其它资源;
- 形成了环路等待;
死锁处理方法
死锁预防
即破环死锁出现的必要条件,比如说:
- 在进程执行程序前,需要一次性申请到所需的全部资源;
- 对资源进行排序,资源的申请必须按需进行,这样就可以避免环路等待;
死锁避免
对每个资源申请请求,使用银行家算法来判断该请求是否会使得系统进入不安全状态,如果是则拒绝。
死锁检测+恢复
每隔一段时间检测有向图中是否产生了环,或者执行一次银行家算法,找到那些执行不下去的进程,然后从中选择一个让其回滚并让出所占有的资源。
鸵鸟策略
直接忽略掉死锁,比如说我们的PC机如果发生了死锁,可以不对它进行任何处理,直接重启即可。