目录
一、实验目的
1、加深对进程同步与互斥概念的认识。
2、掌握信号量的使用,并应用它解决生产者——消费者问题。
3、掌握信号量的实现原理。
二、实验内容
(一)用信号量解决生产者—消费者问题
在 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 开始递增加 1 的
另外 pc.c
中将会用到 sem_open()
、sem_close()
、sem_wait()
和 sem_post()
等和信号量相关的系统调用,需要我们自己在 Linux 0.11 中进行实现。
(二)实现信号量,用生产者—消费者程序检验
Linux 0.11 版本还没有实现信号量,Linus 把这件富有挑战的工作留给了你。如果能实现一套山寨版的完全符合 POSIX(Portable Operating System Interface of UNIX)规范的信号量,无疑是很有成就感的。但时间暂时不允许我们这么做,所以可以先实现一套缩水版的类 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);
以上四个函数的具体功能和相关参数解释如下:
sem_open()
功能 | 创建一个信号量,或打开一个已经存在的信号量。 |
参数 |
|
返回值 | 当 sem_open() 新建或打开成功时,返回值是该信号量的唯一标识(如:在内核的地址、ID 等),由另两个系统调用使用;失败时,返回值是 NULL。 |
sem_wait()
功能 | 就是信号量的 P 原子操作,其功能就是对信号量的值减 1 。如果继续运行的条件不满足,则令调用进程等待在信号量 sem 上。 |
参数 | sem :指向信号量的指针。 |
返回值 | 返回 0 表示成功,返回 -1 表示失败。 |
sem_post()
功能 | 就是信号量的 V 原子操作,其功能就是对信号量的值加 1 。如果有等待在 sem 上的进程,它会唤醒其中的一个。 |
参数 | sem :指向信号量的指针。 |
返回值 | 返回 0 表示成功,返回 -1 表示失败。 |
sem_unlink()
功能 | 删除名为 name 的信号量。 |
参数 | name :信号量的名字。 |
返回值 | 返回 0 表示成功,返回 -1 表示失败。 |
【实验提示】
我们可以在 Linux 0.11 的 kernel
目录下新建一个 sem.c
文件实现如上四个系统调用的功能。然后将 pc.c
从 Ubuntu 移植到 Linux 0.11 下运行,测试实现的信号量。
三、实验准备
1、信号量
信号量,英文为 semaphore,最早由荷兰科学家、图灵奖获得者 E. W. Dijkstra 设计,任何操作系统教科书的 “进程同步” 部分都会有详细叙述,信号量保证多个进程的合作变得合理有序。
Linux 的信号量秉承 POSIX 规范,用 man sem_overview
可以查看相关信息。
本次实验涉及到的信号量相关的系统调用包括:sem_open()
、
sem_wait()
、
sem_post()
和 sem_unlink()
。
生产者—消费者问题
生产者—消费者问题的解法几乎在所有操作系统教科书上都有,其基本结构为:
Producer()
{
// 生产一个产品 item;
/* 空闲缓存资源 */
P(Empty);
/* 互斥信号量 */
P(Mutex);
// 将item放到空闲缓存中;
V(Mutex);
/* 产品资源 */
V(Full);
}
Consumer()
{
P(Full);
P(Mutex);
//从缓存区取出一个赋值给item;
V(Mutex);
// 消费产品item;
V(Empty);
}
- 显然在演示这一过程时需要创建两类进程,一类执行函数
Producer()
,另一类执行函数Consumer()
2、多进程共享文件
Linux 下使用 C 语言,可以通过以下三种方法进行文件的读写(但在 Linux 0.11 上只能使用前两种方法):
(1)使用标准 C 的 fopen()
、fread()
、fwrite()
、fseek()
和 fclose()
等。
(2)使用系统调用 open()
、read()
、write()
、lseek()
和 close()
等。
(3)通过内存镜像文件,使用 mmap()
系统调用。
fork()
调用成功后,创建的子进程会继承父进程拥有的大多数资源,包括父进程打开的文件。所以子进程可以直接使用父进程创建的文件指针/描述符/句柄,访问的是与父进程相同的文件。
使用标准 C 的文件操作函数时要注意,它们使用的是进程空间内的文件缓冲区,父进程和子进程之间并不共享这个缓冲区。因此,任何一个进程做完写操作后,必须 fflush()
一下,将数据强制更新到磁盘,其它进程才能读到所需数据。
综上所诉,建议直接使用系统调用进行文件操作。
3、终端也是临界资源
用 printf()
向终端输出信息是很自然的事,但是当多个进程同时输出时,终端也成为了一个临界资源,所以也需要做好互斥保护,否则输出的信息可能错乱。
另外,printf()
之后,信息只是保存在输出缓冲区内,还没有真正输出到标准输出(通常为终端控制台),这也可能造成输出信息的时序不一致。所以每次 printf()
后都调用一下 stdio.h
中的 fflush(stdout)
,以确保数据送到终端。
4、原子操作、睡眠和唤醒
Linux 0.11 是一个支持并发的现代操作系统,虽然它还没有面向应用实现任何锁或者信号量,但它内部一定使用了锁机制,即在多个进程访问共享的内核数据时一定需要通过锁来实现互斥和同步。
锁必然是一种原子操作(不会被调度机制打断的操作,这种操作一旦开始,就一直运行到结束)。通过模仿 Linux 0.11 的锁,就可以实现信号量。
比如,多个进程对磁盘的并发访问就是一个需要锁的地方。Linux 0.11 访问磁盘的基本处理办法是在内存中划出一段磁盘缓存,用来加快对磁盘的访问。进程提出的磁盘访问请求首先要到磁盘缓存中去找,如果找到就直接返回;如果没有找到则申请一段空闲的磁盘缓存,以这段磁盘缓存为参数发起磁盘读写请求。请求发出后,进程要睡眠等待(因为磁盘读写很慢,这时应该让出 CPU 给其他进程执行)。这种方法是许多操作系统(包括现代 Linux、UNIX 等)采用的较通用的方法。这里涉及到多个进程共同操作磁盘缓存,而进程在操作过程可能会被调度而失去 CPU,因此操作磁盘缓存时需要考虑互斥问题,所以其中必定用到了锁,而且也一定用到了让进程睡眠和唤醒。
【例】下面是从 kernel/blk_drv/ll_rw_blk.c
文件中取出的两个函数:
static inline void lock_buffer(struct buffer_head * bh)
{
// 关中断
cli();
// 将当前进程睡眠在 bh->b_wait
while (bh->b_lock)
sleep_on(&bh->b_wait);
bh->b_lock = 1;
// 开中断
sti();
}
static inline void unlock_buffer(struct buffer_head * bh)
{
if (!bh->b_lock)
printk("ll_rw_block.c: buffer not locked\n\r");
bh->b_lock = 0;
// 唤醒睡眠在 bh->b_wait 上的进程
wake_up(&bh->b_wait);
}
分析 lock_buffer()
可以看出,访问锁变量 b_lock 时通过开、关中断来实现原子操作,阻止进程切换的发生。当然这种方法也有缺点,不适合用于多处理器环境中,但对于 Linux 0.11,它是一种简单、直接而有效的机制。因为我们实验中 bochs 模拟出的 Linux 0.11 就是一个单 CPU 的系统。
另外,上面的函数表明 Linux 0.11 提供了这样的接口:用 sleep_on()
实现进程的睡眠,用 wake_up()
实现进程的唤醒。它们的参数都是一个结构体指针 —— struct task_struct *
(即进程的 PCB ,在 sched.h 中定义),也就是说进程都睡眠或唤醒在该参数指向的一个进程 PCB 结构链表上。
因此,在本次的实验中,我们也可以用开关中断的方式实现原子操作,还可以通过调用 Linux 0.11 自带的 sleep_on()
和 wake_up()
实现进程的睡眠和唤醒。
【注】
sleep_on()
的功能是将当前进程睡眠在参数指定的链表上(注意,这个链表是一个非常隐蔽的链表,详见《注释》一书)wake_up()
的功能是唤醒链表上睡眠的所有进程。这些进程都会被调度运行,所以它们被唤醒后,还要重新判断一下是否可以继续运行。可参考lock_buffer()
中的那个 while 循环
四、实验过程
总的来说,本次实验的基本内容就是在 Linux 0.11 的内核中实现信号量,并向用户提供使用信号量的接口,用户使用该接口解决一个实际的进程同步问题。
(一)编写生产者—消费者检验程序
1. 编写 pc.c
在 oslab/exp_06
目录下新建一个 pc.c
。
【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>
/* 添加系统调用API */
_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"; /* 消费or生产的产品存放的缓冲文件的路径 */
const int NR_CONSUMERS = 5; /* 消费者数量 */
const int NR_ITEMS = 520; /* 产品最大量 */
const int BUFFER_SIZE = 10; /* 缓冲区大小,表示可同时存在的产品数量 */
sem_t *mutex, *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 表示:文件只写(前面的0是八进制标识)
* 0444 表示:文件只读
*/
/* 以只写方式打开文件给生产者写入产品编号 */
fi = open(filename, O_CREAT| O_TRUNC| O_WRONLY, 0222);
/* 以只读方式打开文件给消费者读出产品编号 */
fo = open(filename, O_TRUNC| O_RDONLY, 0444);
mutex = sem_open("MUTEX", 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); /* P(empty) */
sem_wait(mutex); /* P(mutex) */
/*
* 生产完一轮产品(文件缓冲区只能容纳 BUFFER_SIZE 个产品编号)后,
* 将缓冲文件的位置指针重新定位到文件首部。
*/
if( !(item_pro % BUFFER_SIZE) ) /* item_pro = 10 */
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(mutex);
}
}
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(mutex);
/* 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(mutex);
if(item_used == NR_ITEMS) /* 如果已经消费完最后一个商品,则结束 */
goto OK;
}
}
}
}
OK:
close(fi);
close(fo);
return 0;
}
2. 挂载 pc.c
将 pc.c
拷贝到虚拟机 Linux 0.11 /usr/root/
目录下。
// oslab 目录下
sudo ./mount-hdc
cp ./exp_06/pc.c ./hdc/usr/root/
sudo umount hdc/
(二)实现信号量
这部分内容可以参考实验 3 的系统调用:《操作系统》by李治军 | 实验3 - 系统调用_Amentos的博客-CSDN博客
1. 添加系统调用 API
以下代码添加到应用程序 pc.c
中(上面已添加)。
_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)
2. 新建 sem.h
在 linux-0.11/include/linux
目录下新建 sem.h
,定义信号量的数据结构,包括信号量名称、信号量值和一个等待进程队列。
【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
这里 #ifndef、#define、#endif 的作用是防止头文件被重复引用而导致重复编译。具体原理可以看看这篇文章:头文件为什么要加#ifndef #define #endif
3. 新建 sem.c
在 linux-0.11/kernel
目录下,新建源代码文件 sem.c
,实现四个信号量函数。
【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");
}
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;
}
int sys_sem_wait(sem_t *sem)
{
cli(); /* 关中断 */
while( sem->value <= 0 )
sleep_on( &(sem->queue) ); /* 所有小于0的进程都阻塞 */
sem->value--;
sti(); /* 开中断 */
return 0;
}
int sys_sem_post(sem_t *sem)
{
cli();
sem->value++;
if( (sem->value) <= 1 )
wake_up( &(sem->queue) );
sti();
return 0;
}
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;
}
4. 修改 unistd.h
新增了四个系统调用,进入 linux-0.11/include
目录,打开 unistd.h
,增添新的系统调用编号。
#define __NR_sem_open xx
#define __NR_sem_wait xx
#define __NR_sem_post xx
#define __NR_sem_unlink xx
5. 修改 system_call.s
进入 linux-0.11/kernel
目录,打开 system_call.s
,修改系统调用总数。
6. 修改 sys.h
进入 linux-0.11/include/linux
,打开 sys.h
,为新增的四个系统调用添加系统调用函数名并维护系统调用表。
注 系统调用函数名在 sys_call_table 数组中的位置必须和 unistd.h 中 __NR_name 的值相同
7. 修改 Makefile
对 linux-0.11/kernel
目录下的 Makefile
进行如下修改。
第一处,在【OBJS】后添加:
sem.o
第二处,在【Dependencies】后添加:
sem.s sem.o: sem.c ../include/linux/sem.h ../include/linux/kernel.h \
../include/unistd.h
8. 挂载文件
将编写的 sem.h
和修改后的 unistd.h
拷贝到 Linux 0.11 系统中,这和实验三 “系统调用” 的原理是一样的。
// oslab 目录下
sudo ./mount-hdc
cp ./linux-0.11/include/unistd.h ./hdc/usr/include/
cp ./linux-0.11/include/linux/sem.h ./hdc/usr/include/linux/
sudo umount hdc/
9. 重新编译
// linux-0.11 目录下
make all
(三)运行生产者—消费者程序
1. 编译运行 pc.c
oslab 目录下 ./run
进入 Linux 0.11,编译并运行
pc.c
,将输出信息重定向到 pc.txt
文件。
gcc -o pc pc.c
./pc > pc.txt
sync
注意最后一定要 sync !
2. 查看输出
将 pc.txt
拷贝到 Ubuntu 下查看。
sudo ./mount-hdc
sudo cp ./hdc/usr/root/pc.txt ./exp_06
sudo chmod 777 exp_06/pc.txt
cat exp_06/pc.txt | more
可以在终端通过 cat
命令查看,也可以直接双击 pc.txt
打开查看。
注意,如果显示 “您没有打开文件所需的权限” ,就通过下以下命令修改权限:
sudo chmod 777 exp_06/pc.txt
3. 输出结果
……
【实验提示】
1、应对混乱的 bochs 虚拟屏幕
不知是 Linux 0.11 还是 bochs 的 bug,如果向终端输出的信息较多,bochs 的虚拟屏幕会产生混乱。此时按 Ctrl+L 可以重新初始化一下屏幕,但输出信息一多,还是会混乱。比如一开始直接通过 ./pc
运行程序,结果显示如下。
所以建议把输出信息重定向到一个文件: ./pc > pc.txt (即重定向到 pc.txt)
,然后用 vi、more 等工具按屏查看这个文件,可以基本解决此问题。也可以把文件拷贝到 Ubuntu 系统下进行查看。
vi pc.txt:
2、关于 string.h 的提示
下面描述的问题未必具有普遍意义,仅做为提醒,请实验者注意。
include/string.h 实现了全套的 C 语言字符串操作,而且都是采用汇编 + inline 方式优化。但在使用中,某些情况下可能会遇到一些奇怪的问题。比如某人就遇到 strcmp() 会破坏参数内容的问题。如果调试中遇到有些 “诡异” 的情况,可以试试不包含头文件,一般都能解决。因为不包含 string.h,就不会用 inline 方式调用这些函数,它们工作起来就趋于正常了。