当前位置: 首页 > 工具软件 > cli-lab > 使用案例 >

哈工大操作系统实验---lab5:信号量的实现与应用

毋城
2023-12-01

实验目的:

  • 加深对进程同步与互斥概念的认识
  • 掌握信号量的实现原理(两种不同的实现方式)
  • 掌握信号量的使用,并应用它解决生产者-消费者问题

实验内容:

  • Linux0.11中实现信号量(原本是没有信号量机制的)
  • Ubuntu下编写程序,用已经实现的信号量解决生产者-消费者问题

实现信号量:

Linux0.11是没有实现信号量的,在这个实验中只是弄一套缩水版的类POSIX信号量机制(如果是搞纯POSIX规范,时间不太允许),它的函数原型和标准的并不相同,并且只包含如下的系统调用:
注:
1.信号量位于内核中 + 信号量的基本操作属于系统调用
2.将信号量的实现放在kerner目录下新建的sem.c文件中

sem_t是信号量类型,根据实现的需要自定义
(也就是一个数据结构,包含:value+阻塞队列|对信号量的P.V操作)

//打开一个名称为name的信号量,如果没有就新建一个名称为name的信号量
//返回操作结果(成功->指向信号量的指针,也就是信号量在内核中的地址   失败->返回null)
sem_t *sem_open(const char * name,unsigned int value);

//p操作(value--看是不是有进程阻塞)
//返回操作结果(成功->0   失败->-1)
int sem_wait(sem_t *sem);

//v操作(value++看是不是有进程被唤醒)
//返回操作结果(成功->0   失败->-1)
int sem_post(sem_t *sem);

//删除信号量name
//返回操作结果(成功->0   失败->-1)
int sem_unlink(const char * name);

P.V操作模板

P操作:信号量–,判断是不是要阻塞
V操作:信号量++,判断是不是要唤醒

由于在执行这两个操作的时候,涉及到信号量的改变,所以防止放置进程的切换,使得信号量的值被搞乱了,就需要设置临界区来保护信号量:
这里不采用软件保护法(比如:轮换法\标记法\ peterson算法\ Lamport面包店算法),
采用硬件保护法
由于是linux0.11运行在cpu(Bochs虚拟机提供单cpu环境),所以可以采用简单的开关中断的方法,
如果是cpu环境,就使用硬件原子指令保护法(用硬件原子指令操控一个mutex信号量来保护临界区)

模板1:

如果要将阻塞的进程唤醒,直接取出阻塞队列的首个进程,用if语句实现:

//信号量--操作,判断是不是要阻塞
P()
{
    cli();//关中断
    value--;
    if(value < 0)
    {
        //阻塞进程
        //1.将进程放入对应(producer|consumer)的阻塞队列中
        //2.将进程的状态设置为阻塞态

        //调度另外一个进程
        schedual();
    }
    sti();//开中断
}
//信号量++操作,判断是不是要唤醒
V()
{
    cli();//关中断
    value++;
    if(value <= 0)
    {
        //唤醒进程
        //1.将进程从对应的阻塞队列中取出来
        //2.将进程的状态设置为就绪态,并将进程加入就绪队列
    }
    sti();//开中断
}

模板2:

如果要将阻塞的进程唤醒,因为要保证优先级高的先唤醒,所以需要先将所有阻塞的进程全部唤醒(V操作),然后用schedule()来进行调度分配信号量给唤醒的某个进程,用while()语句(P操作)来找到已经分配了信号量的进程:

//信号量--操作,判断是不是要阻塞
P()
{
    cli();//关中断
    //遍历阻塞队列所有被唤醒的进程中分配了信号量的,也就是!=0的
    while(sem->value == 0)
    {
        //则将当前进程加入到阻塞队列
        schedule();
    }
    sem->value--;
    sti();//开中断
}
//信号量++操作,判断是不是要唤醒
V()
{
    cli();//关中断
    sem->value++;
    //让阻塞队列中所有进程全部被唤醒,也就是变成就绪态,进入就绪队列
    //if 阻塞队列中没有进程 就不需要做啥
    sti();//开中断
}

用信号量解决生产者-消费者问题:

Ubuntu上编写应用程序pc.c,用来模拟经典的生产者-消费者问题(producer-consumer),完成下面的功能:

  • 建立一个生产者进程,N个(N>1)消费者进程 (使用fork()来新建进程)
  • 建立一个文件作为生产者进程和消费者进程的共享缓冲区
  • 生产者进程依次向缓冲区(文件)写入整数0,1,2,…,499
  • 消费者进程从缓冲区(文件)读出整数,每次读一个,读一个删一个,然后标准输出本进程的PID和读出的数字
  • 缓冲区同时最多只能保存10个数(也就是缓冲区的大小为10)
可能的输出效果:
1:  0
1:  1
1:  2
2:  3
2:  4
2:  5
1:  6
3:  7
3:  8
4:  9
5:  10
...
1:  498
5:  499
其中PID的顺序变化是不确定的(消费者进程的并发执行是不确定的,哪个时刻谁在执行,执行多久都是不确定的,但是后面的数一定是有序的)

多进程共享文件的操作方式:

在Linux下使用c语言,可以通过三种方式进程文件的读写:
1.使用标准C函数fopen()\fread()\fwrite()\fseek()\fclose()\ ...
2.使用系统调用函数open()\read()\write()\lseek()\close()\ ...
3.通过内存镜像文件,使用系统调用mmap() (Linux0.11不支持)

生产者-消费者程序模板:

//生产者进程:
producer()
{
    p(empty);//empty--,看是不是要阻塞(生产者进程)
    p(mutex);//mutex--,看是不是可以进入临界区(此处对应共享缓冲区-也就是文件)
    //向缓冲区(共享文件)中写入数据
    v(mutex);//mutex++,出了临界区,需要mutex++,以便下一次可以进入
    v(full);//full++,看是不是要唤醒(消费者进程)
}
//消费者进程:
consumer()
{
    p(full);//full--,看是不是要阻塞(消费者进程)
    p(mutex);//mutex--,看是不是可以进入临界区(此处对应共享缓冲区-也就是文件)
    //向缓冲区(共享文件)中写入数据
    v(mutex);//mutex++,出了临界区,需要mutex++,以便下一次可以进入
    v(empty);//empty++,看是不是要唤醒(生产者进程)
}

实验过程:

1.初始工作: 添加信号量的几个操作的系统调用(回顾lab2-实现系统调用):

在unistd.h中添加系统调用号:
...
#define __NR_setregid 71
//添加的系统调用号
#define __NR_sem_open 72
#define __NR_sem_wait 73
#define __NR_sem_post 74
#define __NR_sem_unlink 75
在system_call.s中改写系统调用数:
nr_system_calls = 72
=>
nr_system_calls = 76
在sys.h中添加系统调用的定义:
...
extern int sys_setregid();
//添加的系统调用定义
extern sem_t * sem_open();
extern int sem_wait();
extern int sem_post();
extern int sem_unlink();

//在sys_call_table数组中添加系统调用的引用:
fn_ptr sys_call_table[] = 
{ sys_setup, sys_exit, sys_fork, sys_read,……, sem_open, sem_wait, sem_post, sem_unlink},

2.定义信号量数据结构:

定义在unisted.h文件中:

...
#endif /* __LIBRARY__ */
extern int errno;

//定义的信号量数据结构:
typedef struct sem_t 
{
	char name[20];//信号量的名称
	int value;    //信号量的值
	struct * tast_struct queue;//指向阻塞队列的指针
}sem_t;

/**
    如果自己实现队列(用if语句实现的时候),在这里需要定义数据结构
*/

...(原本的系统调用)

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);

3.实现系统调用:

实现在kernel目录新建的sem.c文件中:

0.头文件及其初始化部分:

#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <asm/segment.h>
#include <asm/system.h>

//信号量最大数量
#define SEM_LIST_LENGTH 5

//信号量数组(都初始化为没有的状态)
sem_t sem_list[SEM_LIST_LENGTH] = {
	{'\0',0,NULL}, {'\0',0,NULL},{'\0',0,NULL},{'\0',0,NULL},{'\0',0,NULL}
};

1.sem_open()信号量的打开or创建

sem_t * sem_open(const char * name,unsigned int value)
{
    //首先将信号量的名称赋值到新建的缓冲区中
    char nbuf[20];
    int i = 0;
    for(i = 0; nbuf[i] = get_fs_byte(name+i); i++);

    //然后开始遍历已有的信号量数组,如果有该名字的信号量,直接返回信号量的地址
    sem_t * result = NULL;
    for(i = 0; i < SEM_LIST_LENGTH; i++)
    {
        if(!strcmp(sem_list[i].name,nbuf))
        {
            result = &sem_list[i];
            printk("sem %s is found\n",result->name);
            return result;
        }
    }

    //如果找不到信号量,就开始新建一个名字为name的信号量,值=value,队列指针=NULL,然后返回信号量的地址
    for(int i = 0; i < SEM_LIST_LENGTH; i++)
    {
        if(sem_list[i].name[0] = '\0')
        {
            strcpy(sem_list[i].name,nbuf);
            sem_list[i].value = value;
            sem_list[i].queue = NULL;
            result = & sem_list[i];
            printk("sem %s is created , value = %d\n",result->name,result->value);
            return result;
        }
    }
}

使用只有正数的信号量实现:

2.sem_wait()- P操作:

int sem_wait(sem_t * sem)
{
    //关中断
    cli();
    //判断:如果传入的信号量不满足要求,P操作失败,返回-1
    if(sem < sem_list || sem > sem_list + SEM_LIST_LENGTH)
    {
        sti();
        printk("sem (V) error\n");
        return -1;
    }
    //由于V操作已经唤醒了所有进程,并且已经通过shedule()调度了其中优先级最大的进程,
    //然后遍历所有的进程,直到找到value不为0的,也就是获得了信号量的,继续执行
    while(sem->value == 0)
    {
        sleep_on(&(sem->queue));
        shedule();
    }
    sem_value--;
    //开中断
    sti();
    return 0;
}

3.sem_post() - V操作:

int sem_post(sem_t * sem)
{
    //关中断
    cli();
    //判断:如果传入的信号量不满足要求,P操作失败,返回-1
    if(sem < sem_list || sem > sem_list + SEM_LIST_LENGTH)
    {
        sti();
        printk("sem (V) error\n");
        return -1;
    }
    //将阻塞队列上所有的进程全部唤醒
    sem->value++;
    wake_up(&(sem->queue));
    //开中断
    sti();
    return 0;
}

使用有正有负的信号量实现:

2.sem_wait()- P操作:

int sem_wait(sem_t * sem)
{
    //关中断
    cli();
    //value--,判断是不是要阻塞
    sem->value--;
    if(sem->value < 0)
    {
        //value-- <0,说明要将这个进程阻塞
        current->state = TASK_UNINTERRUPTIBLE;
        //将当前阻塞的进程加入到阻塞队列,这里的队列和对队列的操作都要自己实现
        insert_task(current,&(sem->wait_queue))
        //由于该进程阻塞,所以需要调度下一个进程
        schedule();
    }
    //开中断
    sti();
    return 0;
}

3.sem_post() - V操作:

int sem_post(sem_t * sem)
{
    //关中断
    cli();
    //value++,判断是不是要唤醒
    sem->value++;
    if(sem->value <= 0)
    {
        //value++ <=0,说明要将阻塞队列中的一个进程唤醒(直接取队首的进程,将进程的PCB赋值给p指针)
        //将取出来的进程的状态设置为就绪态,加入到就绪队列
        struct task_struct * p;
        p = get_task(&(sem->wait_queue));
        if(p != NULL)
        {
            p->state = TASK_RUNNING;
        }
        insert_task(p,&(sem->ready_queue));//这句话好像在(from github的代码没有)
    }
}

4.sem_unlink()信号量的删除:

int sem_unlink(const char * name)
{
    //首先将信号量的名称赋值到新建的缓冲区中
    char nbuf[20];
    int i = 0;
    for(i = 0;  nbuf[i] = get_fs_byte(name+i); i++);

    //在信号量数组中找到和要删除的信号量
    for(i = 0; i < SEM_LIST_LENGTH; i++)
    {
        if(!strcmp(sem_list[i].name,nbuf))
        {
            printk("sem %s is unlinked\n",sem_list[i].name);
            sem_list[i].name[0] = '\0';
            sem_list[i].value = 0;
            sem_list[i].queue = NULL;
            return 0;
        }
    }
    //信号量找不到,返回-1
    printk("sem %s is not exist\n",nbuf);
    return -1;
}

4.修改Makefile编译树:

由于添加了系统调用(在内核中多加了一个sem.c文件),所以原来的编译链接方式不适应了现在了,所以需要进行改动,以便进行下一次make all的时候能够正确的编译内核。

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
改为:
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

在### Dependencies:下面添加sem文件:
sem.s sem.o: sem.c ../include/linux/kernel.h ../include/unistd.h

5.实现生产者-消费者程序pc.c:

1.建立一个共享缓冲区(文件),缓冲区大小为10
2.建立一个生产者进程、五个消费者进程
3.一个生产者和五个消费者并发执行:生产者不断的写一部分数据,然后切到消费者读一部分数据输出,然后继续写,然后又读…

pc.c:(书上的模板)

#include <unistd.h>
#include <string.h>
#include <sys.h>
#include <sem.c>
#include <sched.h>

char buf[500];//缓冲区(其实要搞成文件的形式的)

int main()
{
    //新建三个信号量
    empty = sem_open("empty",10);
    full = sem_open("full",0);
    mutex = sem_open("mutex",1);
    //新建一个生产者进程
    if(!fork())
    {
        int i;  
        for(i = 0; i < 500; i++)
        {
            sem_wait(empty);
            sem_wait(mutex);
            buf[i] = i;//向缓冲区中写数据
            sem_wait(mutex);
            sem_wait(full);
        }
    }
    //新建五个消费者进程
    int i;
    for(i = 0; i < 5; i++)
    {
        if(!fork())
        {
            int i;  
            for(i = 0; i < 10; i++)
            {
                sem_wait(full);
                sem_wait(mutex);
                printk("%d %d\n",p->id,buf[i]);//从缓冲区读数据,输出进程id和数据
                sem_wait(mutex);
                sem_wait(empty);
            }
        }
    }
}

参考代码:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#inlude <sys/types.h>
#include <fcntl.h>

//四个系统调用
//_syscall(传参个数)(返回值,系统调用名,参数1类型,参数1名称,参数2类型,参数2名称...)
_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 int consumerNum = 5;//消费者个数
const int itemNum = 500;//写入的数据量
const int bufSize = 10;//缓冲区大小

int main()
{
    //定义三个指向信号量结构体的指针
    sem_t * sem_empty;
    sem_t * sem_full;
    sem_t * sem_mutex;

    //给三个信号量指针分配信号量空间
    sem_empty = sem_open("empty",10);
    if(sem_empty == NULL)
    {
        perror("empty信号量分配失败!\n");
        return -1;
    }

    sem_full = sem_open("full",0);
    if(sem_full == NULL)
    {
        perror("full信号量分配失败!\n");
        return -1;
    }

    sem_mutex = sem_open("mutex",1);
    if(sem_mutex == NULL)
    {
        perror("mutex信号量分配失败!\n");
        return -1;
    }

    int data;//从文件中读出的数据临时存在在data中
    int buf_in = 0;//写文件buf_in++
    int buf_out = 0;//读文件buf_out++
    int fd;//文件句柄,也就是标识已打开文件的变量

    //打开文件,返回句柄给fd
    fd = open("buffer.dat",0_RDWE|O_CREAT|O_TRUNC,777);
    
    //根据后面两个参数重新定位被打开的fd文件的位移量
    lseek(fd,bufSize * sizeof(int),SEEK_SET);

    //写fd文件,但是此时啥也没写
    write(fd,%buf_out,sizeof(int));

    //指向进程结构体的指针
    pid_t p;

    //建立一个生产者进程
    if(!(p = fork()))
    {
        //子进程进入

        //循环500次来写文件
        int i;
        for(i = 0; i < itemNum; i++)
        {
            sem_wait(sem_empty);
            sem_wait(sem_mutex);

            lseek(fd,buf_in*sizeof(int),SEEK_SET);
            write(fd,(char*)&i,sizeof(int));
            buf_in = (buf_in+1) % bufSize;

            sem_post(sem_mutex);
            sem_post(sem_full);
        }
        return 0;
    }
    else if(p < 0)
    {
        //既不是子进程也不是父进程
        perror("生产者进程创建失败!\n");
        return -1;
    }

    //建立五个消费者进程
    int i;
    for(i = 0; i < consumerNum; i++)
    {
        if(!(p = fork()))
        {
            //子进程进入

            int k;//itemNum/consumerNum表示每个消费者进程消费的量(读出的数据个数)
            for(k = 0; k < itemNum/consumerNum; i++)
            {
                //消费者开始读文件
                sem_wait(sem_full);
                sem_wait(sem_mutex);

                lseek(fd,bufSize*sizeof(int),SEEK_SET);
                read(fd,(char*)&data,sizeof(int));
                buf_out = (buf+1)%bufSize;
                
                lseek(fd,bufSize*sizeof(int),SEEK_SET);
                write(fd,(char*)&data,sizeof(int));

                sem_post(sem_mutex);
                sem_post(sem_empty);
            }
            return 0;
        }
        else if(p < 0)
        {
            //既不是子进程也不是父进程
            perror("消费者进程创建失败!\n");
            return -1;
        }
    }
    //删除信号量
    sem_unlink("empty");
    sem_unlink("full");
    sem_unlink("mutex");
    //关闭文件
    close(fd);
    return 0;
}

回答问题:

1.去掉pc.c中所有与信号量有关的代码,再运行程序,执行效果有什么变化吗?为啥会这样呢?

答案(自己想的,不知道对不对):
信号量的作用是为了使不同的进程在并发执行时能够协调有序的向前推进。
此处如果有信号量可以保证生产者和消费者进程有序向前推进,生产者写一堆数据,切到消费者读一部分,然后再写,再读…
如果没有信号量的话,如果文件中还没有数据,然后消费者进程就一调度就会阻塞,生产者开始写数据了,就需要唤醒消费者进程了,但是没有信号量,就达不到唤醒的作用。

2.实验的设计者在第一次编写生产者——消费者程序的时候,是这么做的(如下代码),这样可行吗?如果可行,那么它和标准解法在执行效果上会有什么不同?如果不可行,那么它有什么问题使它不可行?

Producer()
{
    P(Mutex);  //互斥信号量
    生产一个产品item;
    P(Empty);  //空闲缓存资源
    将item放到空闲缓存中;
    V(Full);  //产品资源
    V(Mutex);
}

Consumer()
{
    P(Mutex);  
    P(Full);  
    从缓存区取出一个赋值给item;
    V(Empty);
    消费产品item;
    V(Mutex);
} 

答案:
不可行。对于某生产者,当mutex=1,empty=0时,申请以后变为:mutex=0,empty=-1,阻塞。
同时对于某消费者,此时mutex=0,申请后变为mutex=-1,也发生阻塞,所以会产生死锁。
(生产者被阻塞等待消费者唤醒,但是消费者因为mutex没有被释放也阻塞)


HIT-OS-LAB参考资料:
1.《操作系统原理、实现与实践》-李治军、刘宏伟 编著
2.《Linux内核完全注释》
3.两个哈工大同学的实验源码
4.Linux-0.11源代码
(上述资料,如果有需要的话,请主动联系我))

该实验的参考资料
网课
官方文档
参考实验报告

 类似资料: