操作系统中许多进程的并发执行不一定完全独立,会相互依赖,从这个问题就可以引出进程同步。
信号量(Semaphore)
信号量是一个整型变量,可以对其执行P、V操作
- P:如果信号量大于0,执行-1操作;如果信号量等于 0,进程睡眠,等待信号量大于 0
- V:对信号量执行+1操作,唤醒睡眠的进程让其完成 down 操作
P、V操作必须是原子操作,通常执行时屏蔽中断,当信号量取值只能为0或1时,这个信号量就是互斥量(Mutex),0表示临界区加锁,1表示临界区解锁
typedef int semaphore;
semaphore mutex = 1;
void P1() {
p(&mutex);
// 临界区
v(&mutex);
}
void P2() {
p(&mutex);
// 临界区
v(&mutex);
}
临界区
对临界资源进行访问和修改的那段代码称为临界区,为了互斥访问临界资源,每个进程在进入临界区之前,需要先进行检查。
多个进程在同一时刻只有一个能进入临界区,也被称为互斥
//进入区
//临界区
//退出区
消费者和生产者问题
问题描述:使用一个缓冲区来保存物品,只有缓冲区没有满,生产者才可以放入物品;只有缓冲区不为空,消费者才可以拿走物品。
因为缓冲区属于临界资源,因此需要使用一个互斥量 mutex 来控制对缓冲区的互斥访问。
为了同步生产者和消费者的行为,需要记录缓冲区中物品的数量。数量可以使用信号量来进行统计,这里需要使用两个信号量:empty 记录空缓冲区的数量,full 记录满缓冲区的数量。其中,empty 信号量是在生产者进程中使用,当 empty 不为 0 时,生产者才可以放入物品;full 信号量是在消费者进程中使用,当 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();
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);
}
}
管程
定义:将分散的临界段集中到抽象数据类型模版中
组成:局部于该管程的共享数据、局部于该管程的一组操作过程(函数)、局部于该管程的数据的初始化
特点:管程拥有很好的封装性、管程互斥使用、管程中有进程等待队列和相应等待和唤醒的操作
哲学家就餐问题
五个哲学家围着一张圆桌,每个哲学家面前放着食物。哲学家的生活有两种交替活动:吃饭以及思考。当一个哲学家吃饭时,需要先拿起自己左右两边的两根筷子,并且一次只能拿起一根筷子。
下面是一种解法
#define N 5
void philosopher(int i) {
while(TRUE) {
think();
take(i); // 拿起左边的筷子
take((i+1)%N); // 拿起右边的筷子
eat();
put(i);
put((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; // 临界区的互斥,临界区是 state 数组,对其修改需要互斥
semaphore s[N]; // 每个哲学家一个信号量
void philosopher(int i) {
while(TRUE) {
think(i);
take_two(i);
eat(i);
put_two(i);
}
}
void take_two(int i) {
down(&mutex);
state[i] = HUNGRY;
check(i);
up(&mutex);
down(&s[i]); // 只有收到通知之后才可以开始吃,否则会一直等下去
}
void put_two(i) {
down(&mutex);
state[i] = THINKING;
check(LEFT); // 尝试通知左右邻居,自己吃完了,你们可以开始吃了
check(RIGHT);
up(&mutex);
}
void eat(int i) {
down(&mutex);
state[i] = EATING;
up(&mutex);
}
// 检查两个邻居是否都没有用餐,如果是的话,就 up(&s[i]),使得 down(&s[i]) 能够得到通知并继续执行
void check(i) {
if(state[i] == HUNGRY && state[LEFT] != EATING && state[RIGHT] !=EATING) {
state[i] = EATING;
up(&s[i]);
}
}
读者写者问题
允许多个进程同时对数据进行读操作,但是不允许读和写以及写和写操作同时发生。
一个整型变量 count 记录在对数据进行读操作的进程数量,一个互斥量 count_mutex 用于对 count 加锁,一个互斥量 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--;
if(count == 0) up(&data_mutex);
up(&count_mutex);
}
}
void writer() {
while(TRUE) {
down(&data_mutex);
write();
up(&data_mutex);
}
}
进程通信
- 共享存储:在通信的进程之间存在一片可直接访问的共享空间,通过对这片空间的读写操作实现它们之间的信息交换
消息传递
管道
管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系或兄弟进程
命名管道(FIFO)
有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信
消息队列
消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。
消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限
避免了 FIFO 的同步阻塞问题,不需要进程自己提供同步方法
消息队列可以独立于读写进程存在,从而避免了 FIFO 中同步管道的打开和关闭时可能产生的困难
读进程可以根据消息类型有选择地接收消息,而不像 FIFO 那样只能默认地接收
信号量
前文已有,主要作为进程间以及同一进程内不同线程之间的同步手段
套接字
与其它通信机制不同的是,它可用于不同机器间的进程通信