进程三种基本状态:
概念:
QQ 和浏览器是两个进程,浏览器进程里面有很多线程,线程的并发执行使得在浏览器可以打开一个窗口后继续响应其他事件,QQ可以同时发信息和上传文件。
实现方式:
一个线程只能属于一个进程,但一个进程中可以有多个线程,它们共享进程资源。
概念:
操作系统管理了系统的有限资源,当有多个进程或线程同时竞争CPU时,因为资源的有限性,必须按照一定的原则选择进程(请求)来占用资源,只要有两个或更多的进程处于就绪状态,如果只有一个CPU可用,那么就必须选择下一个要运行的进程,这就是调度,其中使用的算法就是调度算法。
不同环境的调度算法目标不同,因此需要针对不同环境来讨论调度算法。
1. 批处理系统
批处理系统没有太多的用户操作,在该系统中,调度算法目标是保证吞吐量和周转时间(从提交到终止的时间),有以下三种:
先来先服务 first-come first-serverd(FCFS)
即按照请求的顺序进行调度,非抢占式。
分析:有利于长作业,但不利于短作业,因为短作业必须一直等待前面的长作业执行完毕才能执行,而长作业又需要执行很长时间,造成了短作业等待时间过长。
短作业优先 shortest job first(SJF)
按估计运行时间最短的顺序进行调度,非抢占式。
分析:长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度。
最短剩余时间优先 shortest remaining time next(SRTN)
按估计剩余时间最短的顺序进行调度,抢占式。
2. 交互式系统
交互式系统有大量的用户交互操作,在该系统中调度算法的目标是快速地进行响应。
将所有就绪进程按 FCFS 的原则排成一个队列,每次调度时,把 CPU 时间分配给队首进程,该进程可以执行一个时间片。当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把 CPU 时间分配给队首的进程,抢占式。
时间片轮转算法的效率和时间片的大小有很大关系:
因为进程切换都要保存进程的信息并且载入新进程的信息,如果时间片太小,会导致进程切换得太频繁,在进程切换上就会花过多时间。
而如果时间片过长,那么实时性就不能得到保证。
为每个进程分配一个优先级,按优先级进行调度,抢占式和非抢占式都有。
为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级。
如果一个进程需要执行 100 个时间片,采用时间片轮转调度算法,那么需要交换 100 次。
多级队列是为这种需要连续执行多个时间片的进程考虑,它设置了多个队列,优先级从高到低,时间片从小到大,每个队列时间片大小都不同,例如 1,2,4,8,…。进程在第一个队列没执行完,就会被移到下一个队列。这种方式下,之前的进程只需要交换 7 次,抢占式。
每个队列优先权也不同,最上面的优先权最高。因此只有上一个队列没有进程在排队,才能调度当前队列上的进程。
可以将这种调度算法看成是时间片轮转调度算法和优先级调度算法的结合。
3. 实时系统
实时系统要求一个请求在一个确定时间内得到响应。
分为硬实时和软实时,前者必须满足绝对的截止时间,后者可以容忍一定的超时。
进程同步:指相互合作去完成相同的任务的进程间,由同步机构对执行次序进行协调。(在多道程序环境下,进程是并发执行的,不同进程之间存在着不同的相互制约关系。);
进程互斥:指多个进程在对临界资源进行访问的时候,应采用互斥方式;
简单来说,同步:多个进程按一定顺序执行;互斥:多个进程在同一时刻只有一个进程能进入临界区。
信号量(Semaphore)是一个整型变量,可以对其执行 down 和 up 操作,也就是常见的 P 和 V 操作。
如果信号量的取值只能为 0 或者 1,那么就成为了 互斥量(Mutex) ,0 表示临界区已经加锁,1 表示临界区解锁。
问题描述:
生产者、消费者共享一个初始为空、大小为n的缓冲区;
只有缓冲区没满时,生产者才可以放入物品,否则必须等待;
只有缓冲区不为空,消费者才可以拿走物品,否则必须等待;
缓冲区属于临界资源,各进程必须互斥地访问;
分析:用信号量机制(P、V操作)实现生产者、消费者互斥、同步:
semaphore mutex = 1; //互斥信号量 mutex 来控制对缓冲区的互斥访问
semaphore empty= 0; //同步信号量,表示空闲缓冲区的数量,当 empty 不为 0 时,生产者才可以放入物品
semaphore full = 0; //同步信号量,表示产品的数量,当 full 信号量不为 0 时,消费者才可以取走物品
#define N 100 /*缓冲区的槽数目 */
typedef int semaphore;
semaphore mutex = 1; /*控制对临界区的访问*/
semaphore empty = N; /*计数缓冲区的空槽数目 */
semaphore full = 0; /*计数缓冲区的满槽数目,即产品数量 */
void producer() {
while(TRUE) {
int item = produce_item();
down(&empty); /*将空槽数目减1 */
down(&mutex); /*进入临界区 */
insert_item(item); /*将新数据项放到缓冲区中*/
up(&mutex); /*离开临界区*/
up(&full); /*将满槽数目加1*/
}
}
void consumer() {
while(TRUE) {
down(&full); /*将满槽数目减1 */
down(&mutex); /*进入临界区 */
int item = remove_item(); /*从缓冲区中取出数据项 */
consume_item(item); /*处理数据项*/
up(&mutex); /*离开临界区*/
up(&empty); /*将空槽数目加1*/
}
}
**注意:**不能先对缓冲区进行加锁,再测试信号量。也就是说,不能先执行 down(mutex) 再执行 down(empty)。如果这么做了,那么可能会出现这种情况:生产者对缓冲区加锁后,执行 down(empty) 操作,发现 empty = 0,此时生产者睡眠。消费者不能进入临界区,因为生产者对缓冲区加锁了,消费者就无法执行 up(empty) 操作,empty 永远都为 0,导致生产者永远等待下,不会释放锁,消费者因此也会永远等待下去,出现“死锁”现象。
因此:实现互斥的down(P)操作一定要在实现同步的down(P)操作之后,up(V)操作不会导致进程阻塞,因此两个V操作顺序可以交换。
生产者消费者问题分析:
类似的经典同步问题还有:
1. 读者-写者问题:
允许多个进程同时对数据进行读操作,但是不允许读和写以及写和写操作同时发生。
互斥关系:写进程——写进程、写进程——读进程、读进程与读进程不存在互斥问题。
一个整型变量 count 记录在对数据进行读操作的进程数量
一个互斥量 count_mutex 用于对 count 加锁(防止两个读进程并发执行,这样两个进程先后执行down(&data_mutex),从而使第二个线程阻塞)
一个互斥量 data_mutex 用于对读写的数据加锁,保证对文件的互斥访问。
typedef int semaphore;
semaphore count_mutex = 1;
semaphore data_mutex = 1;
int count = 0;
void reader() {
while(TRUE) {
down(&count_mutex);
count++;
if(count == 1) down(&data_mutex); // 第一个读者需要对数据进行加锁,防止写进程访问
up(&count_mutex);
read();
down(&count_mutex);
count--; // 访问文件的读进程数-1
if(count == 0) up(&data_mutex); // 最后一个进程负责解锁
up(&count_mutex);
}
}
void writer() {
while(TRUE) {
down(&data_mutex);
write();
up(&data_mutex);
}
}
以上这种算法潜在的问题:只要读进程还在读,写进程就要一直阻塞等待,可能“饿死”。因此,这种算法中,读进程是优先的。
防止写进程饿死的方法:
即在第一个读者到达,且一个写者在等待时,读者在写者之后被挂起,而不是立即允许进入。用这种方式,在一个写者到达时如果有正在工作的读者,那么该写者只要等待这个读者完成,而不用等候其后面到来的读者。但是该方案并发度和效率较低。
2. 哲学家进餐问题:
五个哲学家围着一张圆桌,每个哲学家面前放着食物。哲学家的生活有两种交替活动:吃饭以及思考。当一个哲学家吃饭时,需要先拿起自己左右两边的两根筷子(一边一根),并且一次只能拿起一根筷子。
下面是一种错误的解法,考虑到如果所有哲学家同时拿起左手边的筷子,那么就无法拿起右手边的筷子,造成死锁。
#define N 5 // 哲学家个数
void philosopher(int i) // 哲学家编号:0 - 4
{
while(TRUE)
{
think(); // 哲学家在思考
take_fork(i); // 去拿左边的叉子
take_fork((i + 1) % N); // 去拿右边的叉子
eat(); // 吃饭
put_fork(i); // 放下左边的叉子
put_fork((i + 1) % N); // 放下右边的叉子
}
}
为了防止死锁的发生,可以设置两个条件(临界资源):
#define N 5
#define LEFT (i + N - 1) % N // 左邻居
#define RIGHT (i + 1) % N // 右邻居
#define THINKING 0
#define HUNGRY 1
#define EATING 2
typedef int semaphore;
int state[N]; // 跟踪每个哲学家的状态
semaphore mutex = 1; // 临界区的互斥
semaphore s[N]; // 每个哲学家一个信号量
void philosopher(int i) {
while(TRUE) {
think(); // 思考
take_two(i); // 拿起两个筷子
eat();
put_two(i);
}
}
void take_two(int i) {
down(&mutex); // 进入临界区
state[i] = HUNGRY; // 我饿了
test(i); // 试图拿两只筷子
up(&mutex); // 退出临界区
down(&s[i]); // 没有筷子便阻塞
}
void put_two(i) {
down(&mutex);
state[i] = THINKING;
test(LEFT); // 左边的人尝试
test(RIGHT); //右边的人尝试
up(&mutex);
}
void test(i) { // 尝试拿起两把筷子
if(state[i] == HUNGRY && state[LEFT] != EATING && state[RIGHT] !=EATING) {
state[i] = EATING;
up(&s[i]); // 通知第i个人可以吃饭了
}
}
哲学家问题关键在于解决进程死锁;
这些进程之间只存在互斥关系,但是和之前的互斥关系不同的是: 每个进程都需要同时持有两个临界资源,因此有死锁的可能;
管程是一个由过程、变量及数据结构等组成的一个集合,他们组成一个特殊的模块或软件包。
使用用信号量机制实现的生产者消费者问题需要客户端代码做很多控制而且编程麻烦,而管程把控制的代码独立出来,不仅不容易出错,也使得客户端代码调用更容易。
c 语言不支持管程,下面的示例代码使用了类 Pascal 语言来描述管程。示例代码的管程提供了 insert() 和 remove() 方法,客户端代码通过调用这两个方法来解决生产者-消费者问题。
monitor ProducerConsumer
integer i;
condition c;
procedure insert();
begin
// ...
end;
procedure remove();
begin
// ...
end;
end monitor;
管程有基本特性:
管程引入了 条件变量 以及相关的操作:wait() 和 signal() 来实现同步操作。
使用管程实现生产者-消费者问题 :
一次只能有一个管程过程活跃
// 管程
monitor ProducerConsumer
condition full, empty;
integer count := 0;
condition c;
procedure insert(item: integer);
begin
if count = N then wait(full);
insert_item(item);
count := count + 1;
if count = 1 then signal(empty);
end;
function remove: integer;
begin
if count = 0 then wait(empty);
remove = remove_item;
count := count - 1;
if count = N -1 then signal(full);
end;
end monitor;
// 生产者客户端
procedure producer
begin
while true do
begin
item = produce_item;
ProducerConsumer.insert(item);
end
end;
// 消费者客户端
procedure consumer
begin
while true do
begin
item = ProducerConsumer.remove;
consume_item(item);
end
end;
可以参考
进程间通信(IPC,InterProcess Communication)是指在不同进程之间传播或交换信息。
IPC的方式通常有管道(包括无名管道和命名管道)、消息队列、信号量、共享存储、Socket、Streams等。其中 Socket和Streams支持不同主机上的两个进程IPC。
管道,通常指无名管道,是 UNIX 系统IPC最古老的形式。
特点:
它是半双工的(即数据只能在一个方向上流动),具有固定的读端和写端。
它只能用于父子进程之间的通信。
当一个管道建立时,它会创建两个文件描述符:fd[0]为读而打开,fd[1]为写而打开,要关闭管道只需将这两个文件描述符关闭即可。
1 #include <unistd.h>
2 int pipe(int fd[2]); // 返回值:若成功返回0,失败返回-1
FIFO,也称为命名管道,它是一种文件类型,去除了管道只能在父子进程中使用的限制,FIFO可以在无关的进程之间交换数据。
FIFO 常用于客户-服务器应用程序中,FIFO 用作汇聚点,在客户进程和服务器进程之间传递数据。
消息队列,是消息的链接表,存放在内核中。一个消息队列由一个标识符(即队列ID)来标识。
1、特点
消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级。
消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除。
消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。
信号量(semaphore)与已经介绍过的 IPC 结构不同,它是一个计数器。信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。
特点:
信号量用于进程间同步,若要在进程间传递数据需要结合共享内存。
信号量基于操作系统的 PV 操作,程序对信号量的操作都是原子操作。
每次对信号量的 PV 操作不仅限于对信号量值加 1 或减 1,而且可以加减任意正整数。
支持信号量组。
特点:
共享内存是最快的一种 IPC,因为进程是直接对内存进行存取。
因为多个进程可以同时操作,所以需要进行同步。
信号量+共享内存通常结合在一起使用,信号量用来同步对共享内存的访问。
管道:速度慢,容量有限,只有父子进程能通讯
FIFO:任何进程间都能通讯,但速度慢
消息队列:容量受到系统限制,且要注意第一次读的时候,要考虑上一次没有读完数据的问题
信号量:不能传递复杂消息,只能用来同步
共享内存:能够很容易控制容量,速度快,但要保持同步,比如一个进程在写的时候,另一个进程要注意读写的问题,相当于线程中的线程安全,当然,共享内存区同样可以用作线程间通讯,不过没这个必要,线程间本来就已经共享了同一进程内的一块内存
可参考书本或者cyc
死锁: 如果一个进程集合里面的每个进程都在等待只能由这个进程集合中的其他一个进程(包括他自身)才能引发的事件,那么该进程集合就是死锁的。
死锁发生时,以上四个条件一定是同时满足的,缺一不可。
忽略该问题,把头埋在沙子里,假装根本没发生问题。
因为解决死锁问题的代价很高,因此鸵鸟策略这种不采取任务措施的方案会获得更高的性能。
当发生死锁时不会对用户造成多大影响,或发生死锁的概率很低,可以采用鸵鸟策略。
系统并不试图阻止死锁的产生,而是允许死锁发生,当检测到死锁发生时,采取措施进行恢复。
死锁恢复:
安全状态:
如果没有死锁发生,并且即使所有进程突然请求对资源的最大需求,也仍然存在某种调度次序能够使得每一个进程运行完毕,则称该状态是安全的。
单个资源的银行家算法:
该模型基于一个小城镇的银行家,他向一群客户分别承诺了一定的贷款额度,算法要做的是判断对请求的满足是否会进入不安全状态,如果是,就拒绝请求;否则予以分配。
多个资源的银行家算法:
破坏死锁产生必要条件,使死锁不会产生。
1. 破坏互斥条件:
将临界资源改造为可共享使用的资源(如SPOOLing技术)
缺点:可行性不高,很多时候无法破坏互斥条件
2. 破坏占有和等待条件:
一种实现方式是规定所有进程在开始执行前请求所需要的全部资源,之后一直保持。
缺点:资源利用率低;可能导致饥饿
3. 破坏不可抢占条件:
一种实现方式是申请的资源得不到满足时,立即释放拥有的所有资源
缺点:实现复杂,反复申请和释放导致系统开销大
4. 破坏环路等待条件:
给资源编号,进程必须按照编号的顺序申请资源
缺点:不方便增加新设备;会导致资源浪费;用户编程麻烦
参考书籍:《现代操作系统》