信号量的实现和应用
一、实验环境
本次实验的操作环境还是一样的实验环境。环境文件如下:
如果不清楚的话请参考往期博客。
二、实验目标与内容
1、目标:
-
加深对进程同步与互斥概念的认识;
-
掌握信号量的使用,并应用它解决生产者——消费者问题;
-
掌握信号量的实现原理。
2、内容:
本次实验的基本内容是:
- 在
Ubuntu
下编写程序,用信号量解决生产者——消费者问题; - 在
linux-0.11
中实现信号量,用生产者—消费者程序检验之。
(1)用信号量解决生产者——消费者问题
在Ubuntu
上编写应用程序pc.c
,解决经典的生产者—消费者问题,完成下面的功能:
- 建立一个生产者进程,N个消费者进程(N>1);
- 用文件建立一个共享缓冲区;
- 生产者进程依次向缓冲区写入整数0,1,2,…,M,M>=500;
- 消费者进程从缓冲区读数,每次读一个,并将读出的数字从缓冲区删除,然后将本进程ID和数字输出到标准输出;
- 缓冲区同时最多只能保存10个数。
一种可能的输出效果是:
10: 0
10: 1
10: 2
10: 3
10: 4
11: 5
11: 6
12: 7
10: 8
12: 9
12: 10
12: 11
12: 12
……
11: 498
11: 499
其中ID的顺序会有较大变化,但冒号后的数字一定是从0开始递增加一的。
(2)实现信号量
Linux
在0.11
版还没有实现信号量,Linus把这件富有挑战的工作留给了你。如果能实现一套山寨版的完全符合POSIX
规范的信号量, 无疑是很有成就感的。但时间暂时不允许我们这么做,所以先弄一套缩水版的类POSIX
信号量,它的函数原型和标准并不完全相同, 而且只包含如下系统调用:
sem_t *sem_open(const char *name, unsigned int value);
int sem_wait(sem_t *sem);
int sem_post(sem_t *sem);
int sem_unlink(const char *name);
在 kernel
目录下新建 sem.c
文件实现如上功能。然后将 pc.c
从 Ubuntu
移植到0.11
下,测试自己实现的信号量。
三、实验原理
1、进程同步与互斥
(1)什么是进程同步
在多道批处理系统中,多个进程是可以并发执行的,但由于系统的资源有限,进程的执行不是一贯到底的, 而是走走停停,以不可预知的速度向前推进,这就是进程的**「异步性」**。
那么,「进程的异步性会带来什么问题呢」?举个例子,如果有 A、B 两个进程分别负责读和写数据的操作,这两个线程是相互合作、相互依赖的。那么写数据应该发生在读数据之前。而实际上,由于异步性的存在,可能会发生先读后写的情况,而此时由于缓冲区还没有被写入数据,读进程 A 没有数据可读,因此读进程 A 被阻塞。
进程同步(synchronization)就是用来解决这个问题的。从上面的例子我们能看出,一个进程的执行可能影响到另一个进程的执行,「所谓进程同步就是指协调这些完成某个共同任务的并发线程,在某些位置上指定线程的先后执行次序、传递信号或消息」。
举个生活中的进程同步的例子,你想要喝热水,于是你打了一壶水开始烧,在这壶水烧开之前,你只能一直等着,水烧开之后水壶自然会发生响声提醒你来喝水,于是你就可以喝水了。就是说**「水烧开这个事情必须发生在你喝水之前」**。
注意不要把进程同步和进程调度搞混了:
- 进程调度是为了最大程度的利用 CPU 资源,选用合适的算法调度就绪队列中的进程。
- 进程同步是为了协调一些进程以完成某个任务,比如读和写,你肯定先写后读,不能先读后写吧,这就是进程同步做的事情了,指定这些进程的先后执行次序使得某个任务能够顺利完成。
(2)什么是进程互斥
同样的,也是因为进程的并发性,并发执行的线程不可避免地需要共享一些系统资源,比如内存、打印机、摄像头等。举个例子:我们去学校打印店打印论文,你按下了 WPS 的 “打印” 选项,于是打印机开始工作。你的论文打印到一半时,另一位同学按下了 Word 的 “打印” 按钮,开始打印他自己的论文。想象一下如果两个进程可以随意的、并发的共享打印机资源,会发生什么情况?
显然,两个进程并发运行,导致打印机设备交替的收到 WPS 和 Word 两个进程发来的打印请求,结果两篇论文的内容混杂在一起了。
进程互斥(mutual exclusion)就是用来解决这个问题的。当某个进程 A 在访问打印机时,如果另一个进程 B 也想要访问打印机,它就必须等待,直到 A 进程访问结束并释放打印机资源后,B 进程才能去访问。
实际上,像上述的打印机这种**「在一个时间段内只允许一个进程使用的资源」(这也就是互斥的意思),我们将其称为「临界资源」,对临界资源进行访问的那段代码称为「临界区」**。
通俗的对比一下进程互斥和进程同步:
- 进程同步:进程 A 应在进程 B 之前执行
- 进程互斥:进程 A 和进程 B 不能在同一时刻执行
从上不难看出,「进程互斥是一种特殊的进程同步」,即逐次使用临界资源,也是对进程使用资源的先后执行次序的一种协调。
2、信号量
信号量
,英文为semaphore,最早由荷兰科学家、图灵奖获得者E. W. Dijkstra设计,任何操作系统教科书的“进程同步”部分都会有详细叙述。
Linux
的信号量秉承POSIX
规范,用man sem_overview
可以查看相关信息。本次实验涉及到的信号量系统调用包括:sem_open()
、sem_wait()
、sem_post()
和sem_unlink()
。
sem_t *sem_open(const char *name, unsigned int value);//创建或打开信号量
int sem_wait(sem_t *sem);//P操作
int sem_post(sem_t *sem);//V操作
int sem_unlink(const char *name);//删除信号量
-
sem_open()
的功能是创建一个信号量,或打开一个已经存在的信号量。 -
sem_t
是信号量类型,根据实现的需要自定义。name
是信号量的名字。不同的进程可以通过提供同样的 name 而共享同一个信号量。如果该信号量不存在,就创建新的名为 name 的信号量;如果存在,就打开已经存在的名为 name 的信号量。value
是信号量的初值,仅当新建信号量时,此参数才有效,其余情况下它被忽略。当成功时,返回值是该信号量的唯一标识(比如,在内核的地址、ID 等),由另两个系统调用使用。如失败,返回值是NULL
。
-
sem_wait()
就是信号量的 P 原子操作。如果继续运行的条件不满足,则令调用进程等待在信号量 sem 上。返回 0 表示成功,返回 -1 表示失败。 -
sem_post()
就是信号量的 V 原子操作。如果有等待 sem 的进程,它会唤醒其中的一个。返回 0 表示成功,返回 -1 表示失败。 -
sem_unlink()
的功能是删除名为 name 的信号量。返回 0 表示成功,返回 -1 表示失败。
3、生产者—消费者问题
生产者消费者问题
(英语:Producer-consumer problem),也称有限缓冲问题(Bounded-buffer problem),是一个多进程同步问题的经典案例。该问题描述了共享固定大小缓冲区的两个进程——即所谓的“生产者”和“消费者”——在实际运行时会发生的问题。生产者的主要作用是生成一定量的数据放到缓冲区中,然后重复此过程。与此同时,消费者也在缓冲区消耗这些数据。该问题的关键就是要保证生产者不会在缓冲区满时加入数据,消费者也不会在缓冲区中空时消耗数据。
生产者—消费者问题的解法几乎在所有操作系统教科书上都有,其基本结构为:
Producer()
{
// 生产一个产品 item;
// 空闲缓存资源
P(Empty);
// 互斥信号量
P(Mutex);
// 将item放到空闲缓存中;
V(Mutex);
// 产品资源
V(Full);
}
Consumer()
{
P(Full);
P(Mutex);
//从缓存区取出一个赋值给item;
V(Mutex);
// 消费产品item;
V(Empty);
}
显然在演示这一过程时需要创建两类进程,一类执行函数 Producer()
,另一类执行函数 Consumer()
。
4、总结
本实验的总体思路就是,分别实现sem_open()、sem_wait()、sem_post()和sem_unlink()系统调用,在pc.c程序中调用4个函数完成PV操作,解决生产者——消费者问题。
四、实验步骤
1、信号量的实现
因为在Linux0.11
中没有信号量的定义,所有我们要自己写一个信号量的头文件(也就是库)sem.h
,运用数据结构的知识,我们可以写出以下代码:
#ifndef _SEM_H
#define _SEM_H
#include <linux/sched.h>
#define SEMTABLE_LEN 20
#define SEM_NAME_LEN 20
typedef struct semaphore{
char name[SEM_NAME_LEN];
int value;
struct task_struct *queue;
} sem_t;
extern sem_t semtable[SEMTABLE_LEN];
#endif
有了信号量的头文件之后,就可以实现信号量的代码。sem.c
#include <linux/sem.h>
#include <linux/sched.h>
#include <unistd.h>
#include <asm/segment.h>
#include <linux/tty.h>
#include <linux/kernel.h>
#include <linux/fdreg.h>
#include <asm/system.h>
#include <asm/io.h>
//#include <string.h>
sem_t semtable[SEMTABLE_LEN];
//信号量的个数
int cnt = 0;
//创建一个信号量,或打开一个已经存在的信号量
sem_t *sys_sem_open(const char *name,unsigned int value)
{
char kernelname[100];
int isExist = 0;
int i=0;
//信号量名字字符数
int name_cnt=0;
//获取信号量名字的字符数
while( get_fs_byte(name+name_cnt) != '\0')
name_cnt++;
//如果字符数大于设定的字符数,返回空
if(name_cnt>SEM_NAME_LEN)
return NULL;
//获得传入的信号量名
for(i=0;i<name_cnt;i++)
kernelname[i]=get_fs_byte(name+i);
int name_len = strlen(kernelname);
int sem_name_len =0;
sem_t *p=NULL;
//如果信号量已存在,找到并打开
for(i=0;i<cnt;i++)
{
sem_name_len = strlen(semtable[i].name);
if(sem_name_len == name_len)
{
if( !strcmp(kernelname,semtable[i].name) )
{
isExist = 1;
break;
}
}
}
if(isExist == 1)
{
p=(sem_t*)(&semtable[i]);
//printk("find previous name!\n");
}
//如果信号量不存在,创建,信号量个数+1
else
{
i=0;
for(i=0;i<name_len;i++)
{
semtable[cnt].name[i]=kernelname[i];
}
semtable[cnt].value = value;
p=(sem_t*)(&semtable[cnt]);
//printk("creat name!\n");
cnt++;
}
return p;
}
/*
cli()函数和sti()函数分别是关中断和开中断操作,保证P操作的原子性
*/
//P操作
int sys_sem_wait(sem_t *sem)
{
cli();
//如果信号量的值小于0,则休眠等待
while( sem->value <= 0 )
sleep_on(&(sem->queue));
sem->value--;
sti();
return 0;
}
//V操作
int sys_sem_post(sem_t *sem)
{
cli();
sem->value++;
if( (sem->value) <= 1)
wake_up(&(sem->queue));
sti();
return 0;
}
//删除信号量
//sys_sem_open代码同理
int sys_sem_unlink(const char *name)
{
char kernelname[100];
int isExist = 0;
int i=0;
int name_cnt=0;
while( get_fs_byte(name+name_cnt) != '\0')
name_cnt++;
if(name_cnt>SEM_NAME_LEN)
return NULL;
for(i=0;i<name_cnt;i++)
kernelname[i]=get_fs_byte(name+i);
int name_len = strlen(name);
int sem_name_len =0;
for(i=0;i<cnt;i++)
{
sem_name_len = strlen(semtable[i].name);
if(sem_name_len == name_len)
{
if( !strcmp(kernelname,semtable[i].name))
{
isExist = 1;
break;
}
}
}
if(isExist == 1)
{
int tmp=0;
for(tmp=i;tmp<=cnt;tmp++)
{
semtable[tmp]=semtable[tmp+1];
}
cnt = cnt-1;
return 0;
}
else
return -1;
}
将写好的sem.c
放在linux-0.11/kernel
目录下:
将写好的sem.h
放在linux-0.11/include/linux
目录下:
2、 实现信号量的系统调用
结合上一个实验的操作,很容易实现信号量的系统调用
(1)添加系统调用号
修改linux-0.11/include
目录下的unistd.h
文件,添加对应的系统调用号。
由于是接着上一个实验,所以系统调用号从74开始。
#define __NR_sem_open 74
#define __NR_sem_wait 75
#define __NR_sem_post 76
#define __NR_sem_unlink 77
(2)修改系统调用总数
修改linux-0.11/kernel
目录下的system_call.s
文件
修改第61行的代码,改为78。(系统调用号从0开始)
(3)在系统调用函数表中添加新的系统调用函数
修改linux-0.11/include/linux
目录下的sys.h
文件
在代码末尾处按照相同的格式添加系统调用函数,在系统调用函数表中,也按照相同的顺序添加系统调用函数。
extern int sys_sem_open();
extern int sys_sem_wait();
extern int sys_sem_post();
extern int sys_sem_unlink();
sys_sem_open,sys_sem_wait,sys_sem_post,sys_sem_unlink
(4)修改Makefile
修改linux-0.11/kernel
目录下的Makefile
文件
修改2处
第1处:在OBJS
的末尾处添加sem.o
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 who.o sem.o
第2处:在Dependencies
中添加相关依赖
sem.s sem.o: sem.c ../include/linux/sem.h ../include/unistd.h
(5)编译内核
进入到linux-0.11
目录下,输入make
命令,进行编译。
最后一行出现sync
,说明编译成功。
(6)挂载文件
在oslab
目录下使用./mount_hdc
命令,运行mount_hdc
文件。
将unistd.h
文件放入到hdc/usr/include
目录下
cp ./linux-0.11/include/unistd.h ./hdc/usr/include/
sys.h
文件和sem.h
文件放入到hdc/usr/include/kernel
目录下
cp ./linux-0.11/include/linux/sys.h ./hdc/usr/include/linux/
cp ./linux-0.11/include/linux/sem.h ./hdc/usr/include/linux/
3、解决生产者——消费者问题
(1)编写pc.c
在hdc/uer/root
目录下新建pc.c
文件。代码如下:
#define __LIBRARY__
#include <unistd.h>
#include <linux/sem.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <linux/sched.h>
_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)
const char *FILENAME = "/usr/root/buffer_file"; /* 消费生产的产品存放的缓冲文件的路径 */
const int NR_CONSUMERS = 5; /* 消费者的数量 */
const int NR_ITEMS = 50; /* 产品的最大量 */
const int BUFFER_SIZE = 10; /* 缓冲区大小,表示可同时存在的产品数量 */
sem_t *metux, *full, *empty; /* 3个信号量 */
unsigned int item_pro, item_used; /* 刚生产的产品号;刚消费的产品号 */
int fi, fo; /* 供生产者写入或消费者读取的缓冲文件的句柄 */
int main(int argc, char *argv[])
{
char *filename;
int pid;
int i;
filename = argc > 1 ? argv[1] : FILENAME;
/* O_TRUNC 表示:当文件以只读或只写打开时,若文件存在,则将其长度截为0(即清空文件)
* 0222 和 0444 分别表示文件只写和只读(前面的0是八进制标识)
*/
fi = open(filename, O_CREAT| O_TRUNC| O_WRONLY, 0222); /* 以只写方式打开文件给生产者写入产品编号 */
fo = open(filename, O_TRUNC| O_RDONLY, 0444); /* 以只读方式打开文件给消费者读出产品编号 */
metux = sem_open("METUX", 1); /* 互斥信号量,防止生产消费同时进行 */
full = sem_open("FULL", 0); /* 产品剩余信号量,大于0则可消费 */
empty = sem_open("EMPTY", BUFFER_SIZE); /* 空信号量,它与产品剩余信号量此消彼长,大于0时生产者才能继续生产 */
item_pro = 0;
if ((pid = fork())) /* 父进程用来执行消费者动作 */
{
printf("pid %d:\tproducer created....\n", pid);
/* printf()输出的信息会先保存到输出缓冲区,并没有马上输出到标准输出(通常为终端控制台)。
* 为避免偶然因素的影响,我们每次printf()都调用一下stdio.h中的fflush(stdout)
* 来确保将输出立刻输出到标准输出。
*/
fflush(stdout);
while (item_pro <= NR_ITEMS) /* 生产完所需产品 */
{
sem_wait(empty);
sem_wait(metux);
/* 生产完一轮产品(文件缓冲区只能容纳BUFFER_SIZE个产品编号)后
* 将缓冲文件的位置指针重新定位到文件首部。
*/
if(!(item_pro % BUFFER_SIZE))
lseek(fi, 0, 0);
write(fi, (char *) &item_pro, sizeof(item_pro)); /* 写入产品编号 */
printf("pid %d:\tproduces item %d\n", pid, item_pro);
fflush(stdout);
item_pro++;
sem_post(full); /* 唤醒消费者进程 */
sem_post(metux);
}
}
else /* 子进程来创建消费者 */
{
i = NR_CONSUMERS;
while(i--)
{
if(!(pid=fork())) /* 创建i个消费者进程 */
{
pid = getpid();
printf("pid %d:\tconsumer %d created....\n", pid, NR_CONSUMERS-i);
fflush(stdout);
while(1)
{
sem_wait(full);
sem_wait(metux);
/* read()读到文件末尾时返回0,将文件的位置指针重新定位到文件首部 */
if(!read(fo, (char *)&item_used, sizeof(item_used)))
{
lseek(fo, 0, 0);
read(fo, (char *)&item_used, sizeof(item_used));
}
printf("pid %d:\tconsumer %d consumes item %d\n", pid, NR_CONSUMERS-i+1, item_used);
fflush(stdout);
sem_post(empty); /* 唤醒生产者进程 */
sem_post(metux);
if(item_used == NR_ITEMS) /* 如果已经消费完最后一个商品,则结束 */
goto OK;
}
}
}
}
OK:
close(fi);
close(fo);
return 0;
}
(2)编译pc.c
使用./run
命令进入到pc.c
虚拟机
使用gcc -o pc pc.c
命令编译pc.c
编译之后,会出现2个警告,不会对编译产生影响。
(3)查看结果
不知是Linux 0.11还是bochs的bug,如果向终端输出的信息较多,bochs的虚拟屏幕会产生混乱。此时按ctrl+L可以重新初始化一下屏幕,但输出信息一多,还是会混乱。建议把输出信息重定向到一个文件,然后用vi、more等工具按屏查看这个文件,可以基本解决此问题。
使用./pc > out
命令,把结果输入到out
文件当中,并使用vi命令查看。
结果
可以看出是从0开始,按照依次顺序正常生产消费。
实验结束。