Lab4 - 进程同步
实验要求
-
实现信号量
-
封装系统调用
-
实现共享变量
-
解决同步问题
-
基于
XCHG
指令的自旋锁实现(选做)
实现格式化输入函数
在lab2中,我们已经实现了格式化输出函数(如printf()
),但并未实现功能完备的格式化输入函数(如scanf()
)。这是为什么呢?
格式化输入函数的难点
前序实验未涉及进程同步机制,因此
scanf()
的实现更具挑战性。本次实验我们先实现一个简化版的
scanf()
函数,并通过测试代码验证其功能。这不仅是格式化输入机制的实践,也为后续进程同步的学习奠定基础。
#include "lib.h"
#include "types.h"
int uEntry(void) {
int dec = 0;
int hex = 0;
char str[6];
char cha = 0;
int ret = 0;
while(1){
printf("Input:\" Test %%c Test %%6s %%d %%x\"\n");
ret = scanf(" Test %c Test %6s %d %x", &cha, str, &dec, &hex);
printf("Ret: %d; %c, %s, %d, %x.\n", ret, cha, str, dec, hex);
if (ret == 4)
break;
}
return 0;
}
输入Test a Test oslab 2025 0xadc
后,屏幕上输出为Ret: 4; a, oslab, 2025, adc.
要求
非格式化字符原样输入
其它字符(输入和要求格式不符、是否回显等)可自行斟酌决定如何处理
实现信号量相关的系统调用
实现SEM_INIT
、SEM_POST
、SEM_WAIT
、SEM_DESTROY
系统调用,使用以下用户程序测试,并在实验报告中说明实验结果
#include "lib.h"
#include "types.h"
int uEntry(void) {
int i = 4;
int ret = 0;
int value = 2;
sem_t sem;
printf("Parent Process: Semaphore Initializing.\n");
ret = sem_init(&sem, value);
if (ret == -1) {
printf("Parent Process: Semaphore Initializing Failed.\n");
exit();
}
ret = fork();
if (ret == 0) {
while( i != 0) {
i --;
printf("Child Process: Semaphore Waiting.\n");
sem_wait(&sem);
printf("Child Process: In Critical Area.\n");
}
printf("Child Process: Semaphore Destroying.\n");
sem_destroy(&sem);
exit();
}
else if (ret != -1) {
while( i != 0) {
i --;
printf("Parent Process: Sleeping.\n");
sleep(128);
printf("Parent Process: Semaphore Posting.\n");
sem_post(&sem);
}
printf("Parent Process: Semaphore Destroying.\n");
sem_destroy(&sem);
exit();
}
return 0;
}
实现共享变量
增加进程间共享变量相关的系统调用和对应的库函数
printf("==============TEST SHAREDVARIABLE=============\n");
int number = 114514;
sharedvar_t svar;
ret = createSharedVariable(&svar, number);
printf("Parent Process: create Shared Variable: %d with value: %d\n", svar, number);
if (ret == -1) exit();
ret = fork();
if (ret == 0)
{
number = readSharedVariable(&svar);
printf("Child Process: readShared Variable: %d get value: %d\n", svar, number);
sleep(128);
number = readSharedVariable(&svar);
printf("Child Process: readShared Variable: %d get value: %d\n", svar, number);
number = 2333;
writeSharedVariable(&svar, number);
printf("Child Process: writeShared Variable: %d with value: %d\n", svar, number);
exit();
}
基于信号量解决进程同步问题
我们需要基于信号量机制解决进程同步问题。以下是具体要求和实现步骤:
-
实现验证
-
在
uEntry()
中编写代码,验证信号量在进程同步中的正确性。 -
可以自行定义所需的功能性函数,并直接实现在
lab4/app/main.c
文件中。
-
-
代码提交要求
最终提交的 lab4/app/main.c
文件应包含完整的实现代码。
注意
在完成
lab4.4
实验后,请删除lab4.3
中父进程的exit()
操作,以确保代码符合实验要求。
#include "lib.h"
#include "types.h"
int uEntry(void) {
// For lab4.1
// Test 'scanf'
...
// For lab4.2
// Test 'Semaphore'
...
// For lab4.3
// Test 'Shared Variable'
...
// For lab4.4
// TODO: You need to design and test the problem.
// Note that you can create your own functions.
// Requirements are demonstrated in the guide.
...
return 0;
}
基于XCHG
指令的自旋锁实现
实现一个基于XCHG
指令的自旋锁,用于保护信号量的P()
和V()
操作中对信号量值和阻塞队列的修改,确保这些操作在临界区内的安全性。
单核环境的特殊性
由于不是多核CPU环境,也不容易测试临界区保护的作用
相关资料
信号量
相信课上大家已经对信号量有了一定的了解,这里以另一个角度介绍信号量
信号是一种抽象数据类型,由一个整型(sem)变量和两个原子操作组成;
P()
(Prolaag,荷兰语尝试减少)
sem
减1- 如
sem < 0
,进入等待,否则继续
V()
(Verhoog,荷兰语增加)
sem
加1- 如
sem <= 0
,唤醒一个等待进程
信号量的实现(伪代码):
struct Semaphore {
int state;
int value;
struct ListHead pcb; // link to all pcb ListHead blocked on this semaphore
};
int sem_wait(sem_t *sem) {
sem->value -= 1;
if(sem->value <= 0) {
// Add this process to Queue
// block(current)
}
}
int sem_post(sem_t *sem) {
sem->value += 1;
if(sem->value < 0) {
// Remove this process from Queue
// wakeup(current)
}
}
信号量的简单应用
用信号量实现临界区的互斥访问
每类资源设置一个信号量,其初值为1
sem_init(&mutex, 1);
mutex->P();
// Critical Section;
mutex->V();
这里要注意必须成对使用P()
操作和V()
操作
P()
操作保证互斥访问临界资源V()
操作在使用后释放临界资源PV
操作不能次序错误、重复或遗漏
用信号量实现条件同步
条件同步设置一个信号量,其初值为0
sem_init(&sem, 0);
process A process B
... M ...
... N ... ... X ...
... Y ...
A
有M
和N
模块,B
有X
和Y
模块,这里为了保证B
执行到X
后,A
才能执行N
,可以使用信号量实现条件同步
process A process B
... M ...
sem->P(); -----------+
... N ... | ... X ...
+---------> sem->V();
... Y ...
经典进程同步问题
友情提示
下面的三个问题是进程同步中非常经典的问题,相信课上老师已经着重讲述
虽然我们的实验中只涉及到部分问题,但是其他问题也贴出来,希望大家弄懂
哲学家就餐问题
场景描述:
-
5 位哲学家围坐在一张圆桌旁。
-
桌上有 5 支叉子,每两位哲学家之间放一支。
哲学家行为:
-
思考:哲学家不需要叉子,处于思考状态。
-
进餐:
-
需要同时拿到左右两支叉子才能进餐。
-
进餐结束后,将两支叉子放回原处,继续思考。
-
核心问题
如何设计一种机制,确保哲学家们的动作有序进行,避免以下问题:
死锁:所有哲学家同时拿起左边的叉子,导致无法拿到右边的叉子。
饥饿:某些哲学家始终无法获得叉子,无法进餐。
方案1:
#define N 5 // 哲学家个数
semaphore fork[N]; // 信号量初值为1
void philosopher(int i) { // 哲学家编号:0-4
while(TRUE) {
think(); // 哲学家在思考
P(fork[i]); // 去拿左边的叉子
P(fork[(i+1) % N]); // 去拿右边的叉子
eat(); // 吃面条
V(fork[i]); // 放下左边的叉子
V(fork[(i+1) % N]); // 放下右边的叉子
}
}
极端情况下不正确,可能导致死锁
方案2:
#define N 5 // 哲学家个数
semaphore fork[N]; // 信号量初值为1
semaphore mutex; // 互斥信号量,初值1
void philosopher(int i) { // 哲学家编号:0-4
while(TRUE) {
think(); // 哲学家在思考
P(mutex); // 进入临界区
P(fork[i]); // 去拿左边的叉子
P(fork[(i+1)%N]); // 去拿右边的叉子
eat(); // 吃面条
V(fork[i]); // 放下左边的叉子
V(fork[(i+1)%N]); // 放下右边的叉子
V(mutex); // 退出临界区
}
}
互斥访问正确,但是每次只允许一个人就餐
方案3:
#define N 5 // 哲学家个数
semaphore fork[5]; // 信号量初值为1
void philosopher(int i){ // 哲学家编号:0-4
while(TRUE){
think(); // 哲学家在思考
if(i%2==0){
P(fork[i]); // 去拿左边的叉子
P(fork[(i+1)%N]); // 去拿右边的叉子
} else {
P(fork[(i+1)%N]); // 去拿右边的叉子
P(fork[i]); // 去拿左边的叉子
}
eat(); // 吃面条
V(fork[i]); // 放下左边的叉子
V(fork[(i+1)%N]); // 放下右边的叉子
}
}
没有死锁,可以实现多人同时就餐
Questions?
有没有更好的方式处理这个就餐问题?
生产者-消费者问题
生产者—->缓冲区—->消费者
有界缓冲区的生产者-消费者问题描述:
- 一个或多个生产者在生产数据后放在一个缓冲区里
- 单个消费者从缓冲区取出数据处理
- 任何时刻只能有一个生产者或消费者可访问缓冲区
问题分析:
- 任何时刻只能有一个进程(线程)操作缓冲区(互斥访问)
- 缓冲区空时,消费者必须等待生产者(条件同步)
- 缓冲区满时,生产者必须等待消费者(条件同步)
用信号量描述每个约束:
- 二进制信号量
mutex
- 资源信号量
fullBuffers
- 资源信号量
emptyBuffers
伪代码描述一下:
class BoundedBuffer {
mutex = new Semaphore(1);
fullBuffers = new Semaphore(0);
emptyBuffers = new Semaphore(n);
}
BoundedBuffer::Deposit(c){ BoundedBuffer::Remove(c){
emptyBuffers->P(); fullBuffers->P();
mutex->P(); mutex->P();
Add c to the buffer; Remove c from buffer;
mutex->V(); mutex->V();
fullBuffers->V(); emptyBuffers->V();
} }
读者-写者问题
读者-写者问题主要出现在数据库等共享资源的访问当中
问题描述:
- 共享数据的两类使用者
- 读者:只读取数据,不修改
- 写者:读取和修改数据
- 对共享数据的读写
- “读-读”允许,同一时刻,允许有多个读者同时读
- “读-写”互斥,没有写者时读者才能读,没有读者时写者才能写
- “写-写”互斥,没有其他写者,写者才能写
用信号量描述每个约束:
- 信号量
WriteMutex
,控制读写操作的互斥,初始化为1 - 读者计数
Rcount
,正在进行读操作的读者数目,初始化为0 - 信号量
CountMutex
,控制对读者计数的互斥修改,初始化为1,只允许一个进程(线程)修改Rcount
计数
写者进程
P(WriteMutex);
write;
V(WriteMutex);
读者进程
P(CountMutex);
if (Rcount == 0)
P(WriteMutex);
++Rcount;
V(CountMutex);
read;
P(CountMutex);
--Rcount;
if (Rcount == 0)
V(WriteMutex);
V(CountMutex);
相关系统调用
sem_init
sem_init
系统调用用于初始化信号量,其中参数value
用于指定信号量的初始值.
初始化成功则返回0
,指针sem
指向初始化成功的信号量;否则返回-1
int sem_init(sem_t *sem, uint32_t value);
sem_post
sem_post
系统调用对应信号量的V
操作,其使得sem
指向的信号量的value
增一,若value
取值不大于0
,则释放一个阻塞在该信号量上进程(即将该进程设置为就绪态),若操作成功则返回0
,否则返回-1
int sem_post(sem_t *sem);
sem_wait
sem_wait
系统调用对应信号量的P
操作,其使得sem
指向的信号量的value
减一,若value
取值小于0
,则阻塞自身,否则进程继续执行,若操作成功则返回0
,否则返回-1
int sem_wait(sem_t *sem);
sem_destroy
sem_destroy
系统调用用于销毁sem
指向的信号量,销毁成功则返回0
,否则返回-1
,若尚有进程阻塞在该信号量上,可带来未知错误
int sem_destroy(sem_t *sem);
自旋锁
- 自旋锁是一种轻量级的锁机制,线程在尝试获取锁时会不断循环检查锁的状态(自旋),直到成功获取锁。
- 自旋锁适用于临界区较短的场景,因为自旋会占用 CPU 资源。
XCHG
指令:
XCHG
是一种原子操作指令,可以在不被中断的情况下交换两个值。- 通过
XCHG
实现的自旋锁可以避免中断或线程切换导致的并发问题。
单核环境的特殊性
- 在单核 CPU 环境中,虽然不会有多个线程同时执行,但中断可能导致线程切换,从而破坏临界区的完整性。
- 因此,临界区保护仍然是必要的,尽管在单核环境中不容易测试其作用。
实验攻略
在攻略之前,先带大家看一看实验4新增的或修改的数据结构等:
新增数据结构
struct Semaphore {
int state;
int value;
struct ListHead pcb; // link to all pcb ListHead blocked on this semaphore
};
typedef struct Semaphore Semaphore;
struct Device {
int state;
int value;
struct ListHead pcb; // link to all pcb ListHead blocked on this device
};
typedef struct Device Device;
struct SharedVariable {
int state;
int value;
};
typedef struct SharedVariable SharedVariable;
Semaphore sem[MAX_SEM_NUM];
Device dev[MAX_DEV_NUM];
SharedVariable shared_var[MAX_SHARED_VAR_NUM];
对于信号量Semaphore
,相信大家在看完相关资料后能理解。因为我们将信号量定义成数组,所以添加了一个state
成员,表示当前信号量是否正在使用;1表示正在使用,0表示未使用
那么
Device
是干啥用的,为什么和信号量的定义这么相似?说这个之前我们需要想一下
stdin
标准输入。在操作系统中,我们不可能一直监听键盘中断来进行输入,因为过于占用系统资源,所以我们需要一个键盘输入缓冲区与类似信号量的机制,以此实现条件同步。在键盘中断将输入存入缓冲区后再让用户程序读取,所以代码中定义了
Device
,他其实就是信号量,只不过不能由用户通过系统调用控制,而是直接和硬件绑定
在键盘中断的处理中,输入内容会先被存入缓冲区,随后由用户程序读取。为了实现这一机制,代码中定义了 Device 结构体。
#define STD_OUT 0
#define STD_IN 1
实际上,stdout
是非阻塞式的,stdin
上才会有进程阻塞.
struct ListHead {
struct ListHead *next;
struct ListHead *prev;
};
ListHead
是一个双向链表
如下两个函数用于初始化sem
和dev
void initSem() {
int i;
for (i = 0; i < MAX_SEM_NUM; i++) {
sem[i].state = 0; // 0: not in use; 1: in use;
sem[i].value = 0; // >=0: no process blocked; -1: 1 process blocked; -2: 2 process blocked;...
sem[i].pcb.next = &(sem[i].pcb);
sem[i].pcb.prev = &(sem[i].pcb);
}
}
void initDev() {
int i;
for (i = 0; i < MAX_DEV_NUM; i++) {
dev[i].state = 1; // 0: not in use; 1: in use;
dev[i].value = 0; // >=0: no blocked; -1: 1 process blocked; -2: 2 process blocked;...
dev[i].pcb.next = &(dev[i].pcb);
dev[i].pcb.prev = &(dev[i].pcb);
}
}
修改的数据结构:PCB中添加对应的双向链表结构
struct ProcessTable {
uint32_t stack[MAX_STACK_SIZE];
struct TrapFrame regs;
uint32_t stackTop;
uint32_t prevStackTop;
int state;
int timeCount;
int sleepTime;
uint32_t pid;
char name[32];
+ struct ListHead blocked; // sempahore, device, file blocked on
};
typedef struct ProcessTable ProcessTable;
这样将current
进程加到信号量i的阻塞列表可以通过以下代码实现
pcb[current].blocked.next = sem[i].pcb.next;
pcb[current].blocked.prev = &(sem[i].pcb);
sem[i].pcb.next = &(pcb[current].blocked);
(pcb[current].blocked.next)->prev = &(pcb[current].blocked);
以下代码可以从信号量i上阻塞的进程列表取出一个进程:
pt = (ProcessTable*)((uint32_t)(sem[i].pcb.prev) -
(uint32_t)&(((ProcessTable*)0)->blocked));
sem[i].pcb.prev = (sem[i].pcb.prev)->prev;
(sem[i].pcb.prev)->next = &(sem[i].pcb);
irqHandle.c
中的sysWrite
也有一些变化:
void sysWrite(struct TrapFrame *sf) {
switch(sf->ecx) { // file descriptor
- case 0:
- sysPrint(sf);
+ case STD_OUT:
+ if (dev[STD_OUT].state == 1) {
+ sysWriteStdOut(sf);
+ }
break; // for STD_OUT
default:break;
}
}
另外对sysWrite
进行了重命名
-void sysPrint(struct TrapFrame *sf) {
+void sysWriteStdOut(struct TrapFrame *sf) {
实现格式化输入函数
为了降低实验难度,syscall.c
中的scanf()
已经完成,同学们只需要完成对应的中断处理例程
irqHandle.c
中添加了sysRead()
内核函数处理各种读数据:
void sysRead(struct StackFrame *sf) {
switch(sf->ecx) {
case STD_IN:
if (dev[STD_IN].state == 1)
sysReadStdIn(sf);
break; // for STD_IN
default:
break;
}
}
在这一节主要关注的就是sysReadStdIn()
,同学们需要去完成它。那么如何完成呢,它是和键盘中断有条件同步的,所以的这一步还要结合keyboardHandle()
一起完成
在实验2中,有很多同学不知道下面的代码是干啥的
extern uint32_t keyBuffer[MAX_KEYBUFFER_SIZE];
extern int bufferHead;
extern int bufferTail;
其实这就是键盘输入的缓冲区,把所有零碎的知识拼凑在一起,keyboardHandle()
要做的事情就两件:
- 将读取到的
keyCode
放入到keyBuffer
中 - 唤醒阻塞在
dev[STD_IN]
上的一个进程
接下来安排sysReadStdIn()
,它要做的事情也就两件:
- 如果
dev[STD_IN].value == 0
,将当前进程阻塞在dev[STD_IN]
上 - 进程被唤醒,读
keyBuffer
中的所有数据
注意
最多只能有一个进程被阻塞在
dev[STD_IN]
上,多个进程想读,那么后来的进程会返回-1
,其他情况scanf()
的返回值应该是实际读取的字节数
和实验2中printf()
的处理例程类似,以下代码可以将读取的字符character
传到用户进程
int sel = sf->ds;
char *str = (char *)sf->edx;
int i = 0;
asm volatile("movw %0, %%es"::"m"(sel));
asm volatile("movb %0, %%es:(%1)"::"r"(character),"r"(str + i));
完成这一步后请测试scanf()
,并在实验报告展示结果
实现信号量
这一部分也只需要完善处理例程,其它部分已经实现,所有的信号量相关调用有一个总的处理:
void sysSem(struct StackFrame *sf) {
switch(sf->ecx) {
case SEM_INIT:
sysSemInit(sf);
break;
case SEM_WAIT:
sysSemWait(sf);
break;
case SEM_POST:
sysSemPost(sf);
break;
case SEM_DESTROY:
sysSemDestroy(sf);
break;
default:break;
}
}
需要完成的是4个子例程:sysSemInit
、sysSemWait
、sysSemPost
和sysSemDestroy
在实现时,因为信号量以数组形式存在,所以只要一个下标就可以定位信号量
完成后请进行测试,并在实验报告中展示结果
实现共享变量
我们需要在kernel和lib部分,实现进程间的共享变量,并增加进程间共享变量的相关系统调用和对应的故函数
库函数声明参考如下:
int createSharedVariable(sharedvar_t *svar, int value);
//成功创建时返回对应的共享变量描述符int destroySharedVariable(sharedvar_t *svar);
//销毁共享变量描述符对应的共享变量int readSharedVariable(sharedvar_t *svar);
//返回共享变量的值int writeSharedVariable(sharedvar_t *svar, int value);
//修改共享变量的值
我们需要自行实现共享变量的数据结构以及相应的操作
解决进程同步问题(lab4.4)
以下问题,请你自行完成相应的实现需求,并在lab4.4
中撰写相应的测试代码,以此展示功能
生产者-消费者问题
同学们需要在lab4/app/main.c
中实现生产者-消费者问题
生产者-消费者问题:
- 4个生产者,1个消费者同时运行
- 生产者生产,
printf("Producer %d: produce\n", id);
- 消费者消费,
printf("Consumer : consume\n");
- 任意P、V及生产、消费动作之间添加
sleep(128);
读者-写者问题
问题描述:
-
多个读者进程可以同时读取共享变量,但写者进程必须独占访问。
-
需要实现两种同步策略:
-
读者优先(Reader-Priority):只要还有读者在读,写者就必须等待。
-
读写公平(Fair):避免读者或写者无限等待,确保公平竞争。
-
Tips
你需要实现
SharedVariable
以及相应的操作函数,然后完成本问题
哲学家就餐问题
哲学家就餐问题:
- 5个哲学家同时运行
- 哲学家思考,
printf("Philosopher %d: think\n", id);
- 哲学家就餐,
printf("Philosopher %d: eat\n", id);
- 任意P、V及思考、就餐动作之间添加
sleep(128);
选择
生产者-消费者问题与哲学家就餐问题,二选一完成
基于XCHG
指令的自旋锁实现(可选)
下面是一段nasm
实现的自旋锁代码
section .text
global _lock
global _unlock
_lock:
mov rax, 1
xchg rax, [rdi]
cmp rax, 0
jne _lock
ret
_unlock:
mov long[rdi], 0
ret
这段代码是用 NASM 汇编语言 实现的一个简单的 自旋锁,用于在多线程或多进程环境中保护临界区,确保只有一个线程或进程能够进入临界区。
下面是自旋锁的使用
#include<stdio.h>
#include<pthread.h>
#define NUM 500000
extern void _lock(long* l);
extern void _unlock(long* l);
long l = 0;
int count = 0;
void * worker_func(void *arg)
{
for (int i = 0; i < NUM; i++) {
_lock(&l);
count++;
_unlock(&l);
}
return NULL;
}
int main(void)
{
pthread_t worker1, worker2;
void *worker1_status;
void *worker2_status;
pthread_create(&worker1, NULL, worker_func, NULL);
pthread_create(&worker2, NULL, worker_func, NULL);
pthread_join(worker1, &worker1_status);
pthread_join(worker2, &worker2_status);
printf("Count: %d\n", count);
return 0;
}
虽然我们的实验没有引入线程的概念,但是也可以使用自旋锁,保护PV操作中关于信号量修改和队列修改时的临界区保护
请你根据课堂上对自旋锁的理解,在内核区域完成自旋锁的实现(开中断的条件下)
代码框架与结果演示
提供的框架结构
lab4-STUID
├── lab
│ ├── app
│ │ ├── main.c
│ │ └── Makefile
│ ├── bootloader
│ │ ├── boot.c
│ │ ├── boot.h
│ │ ├── Makefile
│ │ └── start.S
│ ├── kernel
│ │ ├── include
│ │ │ ├── common
│ │ │ │ ├── assert.h
│ │ │ │ ├── const.h
│ │ │ │ └── types.h
│ │ │ ├── common.h
│ │ │ ├── device
│ │ │ │ ├── disk.h
│ │ │ │ ├── keyboard.h
│ │ │ │ ├── serial.h
│ │ │ │ ├── timer.h
│ │ │ │ └── vga.h
│ │ │ ├── device.h
│ │ │ ├── x86
│ │ │ │ ├── cpu.h
│ │ │ │ ├── io.h
│ │ │ │ ├── irq.h
│ │ │ │ └── memory.h
│ │ │ └── x86.h
│ │ ├── kernel
│ │ │ ├── disk.c
│ │ │ ├── doIrq.S
│ │ │ ├── i8259.c
│ │ │ ├── idt.c
│ │ │ ├── irqHandle.c
│ │ │ ├── keyboard.c
│ │ │ ├── kvm.c
│ │ │ ├── serial.c
│ │ │ ├── timer.c
│ │ │ └── vga.c
│ │ ├── lib
│ │ │ └── abort.c
│ │ ├── main.c
│ │ └── Makefile
│ ├── lib
│ │ ├── lib.h
│ │ ├── syscall.c
│ │ └── types.h
│ ├── Makefile
│ └── utils
│ ├── genBoot.pl
│ └── genKernel.pl
└── report
└── 231220000.pdf
结果演示
scanf()
测试结果
Semaphore测试结果
SharedVariable测试结果
作业提交
- 本次作业需提交可通过编译的实验相关源码与报告,提交前请确认
make clean
过. - 请大家在提交的实验报告中注明你的邮箱, 方便我们及时给你一些反馈信息.
- 其他问题参看
Introduction
中的作业规范与提交一章 - 本实验最终解释权归助教所有