李治军操作系统实验4——信号量的实现和应用
操作系统实验4——信号量的实现和应用
代码仓库
GitLab实验内容
- 在Ubuntu下编写程序,用信号量解决生产者——消费者问题。
- 在0.11中实现信号量,用信号量解决生产者—消费者问题。
实验步骤
1. 在Ubuntu下编写程序,用信号量解决生产者——消费者问题。
在Ubuntu上编写应用程序“pc.c”,解决经典的生产者—消费者问题,完成下面的功能:
- 建立一个生产者进程,N个消费者进程(N>1);
- 用文件建立一个共享缓冲区;
- 生产者进程依次向缓冲区写入整数0,1,2,...,M, M>=500;
- 消费者进程从缓冲区读数,每次读一个,并将读出的数字从缓冲区删除,然后将本进程ID和数字输出到标准输出;
- 缓冲区同时最多只能保存10个数。
使用信号量解决生产者消费者问题有全球统一模板,就不再赘述。使用"buffer"文件当作缓冲区,第0-9个int存放产品,第10个int处存放费者要读取和生产者要放在哪个字节的标记pos。整个缓冲区(第0-9个int)可以看作是一个长度为10的循环缓冲区。典型buffer文件,可以看到这里int为4个字节:
/* Open buffer file */
fid = open("buffer", O_RDWR | O_CREAT | O_TRUNC, 0666)
/* Produter *****************************************/
/* Write data */
lseek(*id, (*pos) * sizeof(int), SEEK_SET);
write(*id, data, sizeof(int));
*pos = (*pos + 1) % BUFSIZE;
/* Consumer *****************************************/
/* Get pos */
lseek(*id, 10 * sizeof(int), SEEK_SET);
read(*id, &pos, sizeof(int));
/* Get data */
lseek(*id, pos * sizeof(int), SEEK_SET);
read(*id, &data, sizeof(int));
/* Update pos */
lseek(*id, 10 * sizeof(int), SEEK_SET);
pos = (pos + 1) % BUFSIZE;
write(*id, &pos, sizeof(int));
使用信号量,消费者读取必然是有序的:
consumer | pid | product
...
1 60376 36
1 60376 37
0 60375 38
2 60377 39
2 60377 40
2 60377 41
...
2. 实现信号量
可以在刚才写的程序“pc.c”中看到我们使用的是POSIX信号量,主要有 sem_open() , sem_wait(), sem_post(), sem_unlink(),他们分别是:打开信号量,信号量“减1”操作,信号量“增1”操作,销毁信号量。我们这里仅实现这4个接口,实现一个最小的可用的信号量。
>>>基本思路<<<
我们先来看一个信号量的结构:
// linux-0.11/include/linux/sem.h:4
typedef struct semaphore_t
{
int value;
char name[20];
struct semaphore_t *next;
struct task_struct *wait_queue[NR_TASKS];
unsigned int wait_queue_front;
unsigned int wait_queue_rear;
} sem_t;
成员 | 描述 |
---|---|
value | 最重要的,也就是信号量的值 |
name | 信号量名称 |
next | 链表next指针,这是为了把所有的信号量,用sem.c中的头节点以链表方式组织起来。 |
wait_queue[NR_TASKS] | 每个信号量都有一个存放所有因对这个信号量P操作而导致阻塞的进程地址(task_struct *)的队列,以便于有进程对这个信号量V操作后唤醒被阻塞进程。 |
wait_queue_front | 上述队列数组的头部秩,以便于队列取和删除头部节点操作。 |
wait_queue_rear | 上述队列数组的尾部秩,以便于队列插入操作。 |
看了信号量的结构,对信号量的实现也有了一点思路。
-
打开信号量,首先应该在链表中取找名字匹配的节点,若找不到我们则考虑新建一个信号量,并把它插到链表中。
-
删除信号量,一样是先在链表中找,找到后使用free删除掉信号量,并从链表中删去即可。
-
P操作,首先对信号量值减1操作,随后判断是否小于0(从0减到-1)。若小于0,则阻塞自己,把自己进程地址(task_struct *)插入到该信号量的wait_queue队列的尾部,并调用进程调度程序。需要注意的是对信号量的值减1操作一定要保证原子性,我们这里用了非常简单粗暴的方法:使用sti()和cli()来开关中断保证原子性(仅适用于单处理器)。
-
V操作,相对应的需要对信号量值加1操作。判断是否有因对这个信号量P操作而导致阻塞的进程(也就是wait_queue里有没有进程地址),若有则唤醒 1个 ,并把这个进程从信号量里的wait_queue中删去。一样的,这个操作也需要保证原子性。
>>>sem_open()<<<
以名字打开一个信号量或者创建一个新的信号量,同时需要对信号量值完成初始化,最后返回信号量。
// sem_open()原型
sem_t *sem_open(const char *name, unsigned int value);
利用get_sem_by_name()函数,可以通过名字查找信号量。
// sem_open()实现
// linux-0.11//kernel/sem.c:33
sem_t *sys_sem_open(const char *name, unsigned int value)
{
sem_t *sem_tmp;
sem_t *sem_new;
sem_tmp = get_sem_by_name(name);
之前说过,所有的信号量是通过sem.c中的头节点按链表组织起来的。而get_sem_by_name()函数就是通过头节点去遍历链表找到名字匹配的信号量的 前一个节点 (方便删除操作)。若在链表中没有成功找到,函数将会返回最后一个节点(方便创建插入操作)。
// linux-0.11/kernel/sem.c:7
sem_t sem_chain_head = {0, "", {NULL}, NULL, 0, 0};
// get_sem_by_name()实现
// linux-0.11/kernel/sem.c:12
sem_t *get_sem_by_name(const char *name)
{
...
根据get_sem_by_name()函数返回特性,我们在sem_open()中需要对返回回来的信号量在做一次名字匹配。若匹配成功则直接返回该信号量,否则我们就需要创建一个信号量,并将其插入链表当中,最后返回信号量。
// sem_open()实现
// linux-0.11//kernel/sem.c:37
sem_tmp = get_sem_by_name(name);
if (strcmp(sem_tmp->next->name, name) == 0)
return sem_tmp->next;
/* Not found \|/ */
sem_new = (sem_t *)malloc(sizeof(sem_t));
strcpy(sem_new->name, name);
sem_new->value = value;
sem_new->next = NULL;
sem_new->wait_queue_front = 0;
sem_new->wait_queue_rear = 0;
sem_tmp->next = sem_new;
return sem_new;
}
// sem_open()实现结束
>>>sem_unlink()<<<
以名字删除一个信号量。
同样用到了get_sem_by_name()函数,同样根据它的返回特性,我们需要对返回回来的信号量做一次名字匹配。匹配成功则将该信号量从链表中删去,并用free()将其从内存中删去,返回成功标记0。若名字匹配失败,就意味着没有找到信号量,返回失败标记-1。
// sem_unlink()实现
// linux-0.11//kernel/sem.c:51
int sys_sem_unlink(const char *name)
{
sem_t *sem_del;
sem_t *sem_tmp = get_sem_by_name(name);
if (strcmp(sem_tmp->next->name, name) == 0)
{
sem_del = sem_tmp->next;
sem_tmp->next = sem_tmp->next->next;
free(sem_del);
return 0;
}
return -1;
}
>>>sem_wait()<<<
P操作。
该操作需要保证原子性,这里简单粗暴的使用开关中断(cli()和sti())来保证原子性。首先最重要的就是把信号量值减1。然后判断信号量值是否严格小于0,若是,根据信号量定义,就需要阻塞自己。由于执行V操作时,就需要唤醒一个因为对同一个信号量P操作而导致阻塞的进程,所以我们就必须把每个因P操作而阻塞的进程 保存 入信号量。前面的信号量结构体说过,结构体里的等待队列wait_queue[] 就是用来放被阻塞的进程的。所以这里除了改变 current->state和调用调度函数以完成阻塞进程外,还需要将进程地址current(task_struct *)存入信号量里。
// sem_wait()实现
// linux-0.11//kernel/sem.c:65
int sys_sem_wait(sem_t *sem)
{
cli();
sem->value--;
if (sem->value < 0)
{
sem->wait_queue[sem->wait_queue_rear++] = current;
sem->wait_queue_rear %= NR_TASKS;
current->state = TASK_UNINTERRUPTIBLE;
schedule();
}
sti();
return 0;
}
>>>sem_post()<<<
V操作。
与P操作相同,该操作依然使用cli()和sti()来保证原子性。最重要的也是对信号量值加1操作。之后就是我们在P操作里提到的,需要唤醒一个因对同一个信号里P操作而导致阻塞的进程,他们被存放在信号量里的等待队列 wait_queue[] ,我们只需利用队列的尾部头部秩 wait_queue_front 就可方便将它取出进行唤醒。当然如果队列里一个阻塞进程都没有,我们自然也不需要进行这一系列操作。判断等待 wait_queue[] 队列内有没有进程,可以利用队列的头部秩和尾部秩,也可以利用信号量值进行判断。
// sem_post()实现
// linux-0.11//kernel/sem.c:80
int sys_sem_post(sem_t *sem)
{
cli();
struct task_struct *p;
sem->value++;
if (sem->value <= 0) /* Some process in wait queue */
{
p = sem->wait_queue[sem->wait_queue_front++];
sem->wait_queue_front %= NR_TASKS;
p->state = TASK_RUNNING;
}
sti();
return 0;
}
3. 修改pc.c,使用我们实现的信号量
修改pc.c
在系统调用实验那里,学会了如何调用我们自己实现的系统调用:
// pc-linux-0.11.c:1
#define __LIBRARY__
...
_syscall2(sem_t*,sem_open,const char *,name,unsigned int,value);
_syscall1(int,sem_wait,sem_t*,sem);
_syscall1(int,sem_post,sem_t*,sem);
_syscall1(int,sem_unlink,const char *,name);
同时别忘了我们信号量结构体定义在linux/sem.h:
// pc-linux-0.11.c:7
#include <linux/sem.h>
做这些修改后(记得gcc3.4,注释只能用/* 😭 */),就能愉快的在linux0.11使用我们实现的信号量运行了。
添加系统调用声明
// linux-0.11/include/linux/sys.h:73
extern int sys_setregid();
extern int sys_sem_open();
extern int sys_sem_wait();
extern int sys_sem_post();
extern int sys_sem_unlink();
// linux-0.11/include/linux/sys.h:90
...
sys_setreuid, sys_setregid, sys_sem_open, sys_sem_wait, sys_sem_post,
sys_sem_unlink };
// unistd.h:132
#define __NR_sem_open 72
#define __NR_sem_wait 73
#define __NR_sem_post 74
#define __NR_sem_unlink 75
修改系统调用数
# /kernel/system_call.s:61
nr_system_calls = 76
编译linux0.11
把我们写好的sem.h放入linux0.11/include/linux下,把sem.c放入linux0.11/kernel下。修改linux-0.11/kernel/Makefile,编译。
# linux-0.11/kernel/Makefile:27
OBJS = sched.o system_call.o traps.o asm.o fork.o \
panic.o printk.o vsprintf.o sys.o exit.o \
signal.o mktime.o sem.o
# linux-0.11/kernel/Makefile:84
sem.s sem.o: sem.c ../include/unistd.h ../include/linux/sched.h \
../include/linux/kernel.h ../include/asm/segment.h ../include/asm/system.h \
../include/linux/sem.h
编译后还得记得手动把修改后的unistd.h放入linux0.11系统的/usr/include下。
运行pc-linux0.11.c
这样,就可以在linux0.11编译运行pc-linux0.11.c了。
我们把pc-linux0.11.c的运行结果重定向到一个文件里,sync后关机到我们的电脑上再对其进行查看,可以看到消费者进程交替取出产品,产品有序被取出:
2 18 291
2 18 292
1 17 293
1 17 294
1 17 295
1 17 296
1 17 297
1 17 298
1 17 299
1 17 300
0 16 301
0 16 302
0 16 303
0 16 304
0 16 305
0 16 306
0 16 307
0 16 308
2 18 309
2 18 310
2 18 311
至此实验完成。