操作系统第三次实验作业 16281027

实验三 同步问题

1.实验目的

系统调用的进一步理解
进程上下文切换
同步的方法

2.实验题目

1)通过fork的方式,产生4个进程P1,P2,P3,P4,每个进程打印输出自己的名字,例如P1输出“I am the process P1”。要求P1最先执行,P2、P3互斥执行,P4最后执行。通过多次测试验证实现是否正确。

fork函数说明:
fork函数通过系统调用创建一个与原来进程几乎完全相同(除pid)的进程。
一个进程调用fork函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。即调用一次,返回两次,一次是父进程,一次是新创建的子进程,且这两个进程的地址空间是独立的,互不影响。
调用fork(),当控制转移到内核中的fork代码后,内核开始做以下工作:
1.分配新的内存块和内核数据结构给子进程。
2.将父进程部分数据结构内容拷贝至子进程。
3.将子进程添加到系统进程列表。
4.fork返回开始调度器,进行调度。
需要注意的是,子进程是从fork函数的位置开始执行的,但拥有父进程的变量。

在这里插入图片描述
以下代码为举例。

#include <unistd.h>  
#include <stdio.h>   
int main ()   
{   
    pid_t fpid; //fpid表示fork函数返回的值  
    int count=0;  
    fpid=fork();   
    if (fpid < 0)   
        printf("error in fork!");   
    else if (fpid == 0) {  
        printf("i am the child process, my process id is %d/n",getpid());   
        printf("我是儿子/n");
        count++;  
    }  
    else {  
        printf("i am the parent process, my process id is %d/n",getpid());   
        printf("我是父亲/n");  
        count++;  
    }  
    printf("统计结果: %d/n",count);  
    return 0;  
}  

运行结果:
i am the child process, my process id is 6674
我是父亲
统计结果: 1
i am the parent process, my process id is 6673
我是儿子
统计结果: 1

这是由于执行了fpid=fork(),使子进程的fpid为fork函数的返回值0,父进程的fpid为子进程的pid进程号,这是fork函数的性质。

本实验要求四个子进程应满足如下的执行状态前趋图:
在这里插入图片描述
实验代码:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>
#include <sys/types.h>
#include <sys/wait.h>

int main(void){
pthread_mutex_t mutex;
pthread_mutex_init(&mutex,NULL); //初始化互斥锁mutex
printf("I'm father,PID is %d.\n",getpid());//输出父进程的进程号

pid_t pid1 = fork();	//fork方式创建子进程
if(pid1 == 0){
	printf("I am the process P1. PID is %d.\n",getpid());
	return 0;
}

waitpid(pid1, NULL, 0);	//等待P1进程结束再继续
pid_t pid2 = fork();
if(pid2 == 0){
	pthread_mutex_lock(&mutex);	//加锁实现P2、P3进程互斥
	printf("I am the process P2. PID is %d.\n",getpid());
	pthread_mutex_unlock(&mutex);
	return 0;
}

waitpid(pid1, NULL, 0);
pid_t pid3 = fork();
if(pid3 == 0){
	pthread_mutex_lock(&mutex);
	printf("I am the process P3. PID is %d.\n",getpid());
	pthread_mutex_unlock(&mutex);
	return 0;
}

waitpid(pid2, NULL, 0);
waitpid(pid3, NULL, 0);
pid_t pid4 = fork();
if(pid4 == 0){
	printf("I am the process P4. PID is %d.\n",getpid());
	return 0;
}

else if(pid1 != -1){
	waitpid(pid1, NULL, 0);
	waitpid(pid2, NULL, 0);
	waitpid(pid3, NULL, 0);
	waitpid(pid4, NULL, 0);	//等待所有子进程结束
	printf("Process exited.\n");
}
 
pthread_mutex_destroy(&mutex);//销毁互斥锁
return 0;
}

实验结果:
在这里插入图片描述
在这里插入图片描述
通过在每个进程在fork系统调用创建之前加入等待前一进程结束的前提条件,可以使得四个进程顺序执行,满足题目要求,p2和p3的互斥执行通过加入互斥锁mutex可以实现。

2)火车票余票数ticketCount 初始值为1000,有一个售票线程,一个退票线程,各循环执行多次。添加同步机制,使得结果始终正确。要求多次测试添加同步机制前后的实验效果。
说明:为了更容易产生并发错误,可以在适当的位置增加一些pthread_yield(),放弃CPU,并强制线程频繁切换。

实验代码:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <semaphore.h>
#include <unistd.h>
#include <assert.h>

int ticketcount = 1000;	//共享变量,表示票的总数
int temp = 0;
sem_t blanks;	//信号量1,表示票余量
sem_t datas;	//信号量2,表示售票量

void *worker1(void *arg){
	while(1){
	sem_wait(&blanks);	//判断是否还有票
	temp = ticketcount;
	pthread_yield();	//诱发并发错误,但是看不出来是否有效
	temp = temp - 1;
	pthread_yield();
	ticketcount = temp;
	printf("Sale:ticketCount now is: %d\n",ticketcount);
	usleep(500);		//当一个进程执行完一次操作后,睡眠500微秒
	sem_post(&datas);	//执行完一次操作后,售票数+1
	}
	return NULL;
}

void *worker2(void *arg){
	while(1){
	sem_wait(&datas);	//判断是否有退票
	temp = ticketcount;
	pthread_yield();
	temp = temp + 1;
	pthread_yield();
	ticketcount = temp;
	printf("Refund:ticketCount now is: %d\n",ticketcount);
	usleep(500);
	sem_post(&blanks);	//完成一次退票操作后,票余量+1
	}
	return NULL;
}

int main(int argc, char *argv[]){	
	sem_init(&blanks, 0, 1000);
	sem_init(&datas, 0, 0);

	pthread_t p1, p2;
	pthread_create(&p1, NULL, worker1, NULL);
	pthread_create(&p2, NULL, worker2, NULL);

	pthread_join(p1, NULL);
	pthread_join(p2, NULL);

	sem_destroy(&blanks);
	sem_destroy(&datas);

	return 0;
}

实验结果:
在这里插入图片描述
这是一个典型的生产者和消费者问题。
售票线程在每次售票前需要检查票是否还有余量,退票线程在每次退票前需检查是否有票售出。
同步机制能够有效的避免由于并发操作而导致的票总数出错的问题(即使得售票量+退票量始终为1000)。

3)一个生产者一个消费者线程同步。设置一个线程共享的缓冲区, char buf[10]。一个线程不断从键盘输入字符到buf,一个线程不断的把buf的内容输出到显示器。要求输出的和输入的字符和顺序完全一致。(在输出线程中,每次输出睡眠一秒钟,然后以不同的速度输入测试输出是否正确)。要求多次测试添加同步机制前后的实验效果。

实验代码;

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <semaphore.h>
#include <unistd.h>
#include <assert.h>

char buf[10] = {0};

sem_t blanks;	//信号量1,表示buf缓存区中空闲单元的个数
sem_t datas;	//信号量2,表示buf缓存区中非空单元的个数

void *worker1(void *arg){
	for (int i = 0; i < 10;) {
	sem_wait(&blanks);	//判断是否有空闲单元可供输入
	scanf("%c",&buf[i]);
	i++;
	i %= 10;			//模拟环形队列,以防i++超过10导致数据丢失
	sem_post(&datas);	//非空单元数+1
	}
	return NULL;
}

void *worker2(void *arg){
	for (int i = 0; i < 10;) {
	sem_wait(&datas);	//判断buf中是否有数据
	printf("%c ",buf[i]);
	sleep(1);			//每次输出睡眠1s
	i++;
	i %= 10;
	sem_post(&blanks);	//输出一个数据后,空闲单元数+1
	}
	return NULL;
}

int main(int argc, char *argv[]){	
	sem_init(&blanks, 0, 10);//在多线程之前初始化信号量
	sem_init(&datas, 0, 0);

	pthread_t p1, p2;
	pthread_create(&p1, NULL, worker1, NULL);	//建立多线程
	pthread_create(&p2, NULL, worker2, NULL);

	pthread_join(p1, NULL);
	pthread_join(p2, NULL);

	sem_destroy(&blanks);
	sem_destroy(&datas);

	return 0;
}

这里使用了两个信号量Semaphore实现线程同步,表示(不)可用资源数,并用数组模拟一个环形队列存储输入数据。若不添加同步机制,则当输入字符过快时,会导致超出数组大小的输入数据丢失,即出现覆盖性输入,过慢时会导致空输出。

实验结果:
在这里插入图片描述

4)进程通信问题。阅读并运行共享内存、管道、消息队列三种机制的代码
参考资料:
https://www.cnblogs.com/Jimmy1988/p/7706980.html
https://www.cnblogs.com/Jimmy1988/p/7699351.html
https://www.cnblogs.com/Jimmy1988/p/7553069.html
实验测试:
a)通过实验测试,验证共享内存的代码中,receiver能否正确读出sender发送的字符串?如果把其中互斥的代码删除,观察实验结果有何不同?如果在发送和接收进程中打印输出共享内存地址,他们是否相同,为什么?
在这里插入图片描述
可见receiver能够正确读出sender发出的字符串,且在输入end后两个进程都会停止。
在这里插入图片描述
在这里插入图片描述
由代码可知,sender和receiver通过判断value = semctl(sem_id, 0, GETVAL)的值来进行互斥操作,当value=0时写入数据到共享内存(阻塞读),当value=1时读取数据。若将判断前提删除,实验结果如下:
在这里插入图片描述
可以看到,在删除互斥代码之后,在sender进程还未输入字符时,receiver进程就已经输出了“123456”,这是错误的,因为字符串“123456”是sender进程上一次运行时输入的数据。
使两个进程都输出共享内存的地址shm_id,结果如下:
在这里插入图片描述
可以看到,共享内存地址是相同的。
通过查阅资料得知,若开启地址空间随机化ASLR(这里默认是关闭的),则系统内核为共享内存分配的首地址将会不同,这是现代操作系统为了防止缓冲区遭受攻击导致系统崩溃出错的一种机制。

b)有名管道和无名管道通信系统调用是否已经实现了同步机制?通过实验验证,发送者和接收者如何同步的。比如,在什么情况下,发送者会阻塞,什么情况下,接收者会阻塞?

无名管道为单向阻塞读写,但多进程使用同一管道通信时容易造成交叉读写的问题。一个管道实际上就是一个只存在于内存中的文件,对这个文件的操作需要通过两个已经打开的文件进行,它们分别代表管道的两端。无名管道可以通过pipe()函数创建。
为了克服无名管道的不足(只能用于具有亲缘关系的进程如父子进程),有名管道FIFO被提出,它可以使得互不相关的两个进程互相通信。FIFO也属于单向进程通信,当通信双方进程一方不存在时则阻塞。
无论是pipe还是FIFO,都实现了同步机制。在没有receiver接收的情况下,sender的发送动作将会被阻塞;同时在没有sender发送的情况下,receiver的接受动作也不能执行。所以发送和接收这两个动作在管道中总是成对出现,防止了重复写/读现象的出现。
发送者(写进程)和接收者(读进程)的阻塞情况如下:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
通过运行代码得知,在父进程尚未向pipe中写入数据前,子进程原地等待(阻塞);当父进程的写操作执行完毕后,子进程才能将pipe内的数据输出。

c)消息通信系统调用是否已经实现了同步机制?通过实验验证,发送者和接收者如何同步的。比如,在什么情况下,发送者会阻塞,什么情况下,接收者会阻塞?

消息队列是消息的链表,存放在系统内核中并由消息队列标识符表示,它提供了一个从一个进程向另一个进程发送不同类型数据块的方法。同pipeline类似,每个消息的最大长度MSGMAX是有上限的,所以消息队列可存放的数据是有限的,适合用于少量的数据传递。消息队列支持双向通信,克服了管道只能承载无格式字节流的缺点,在进程间只有少量数据传输的前提下实现了同步机制。事实上,当消息队列已满时,发送进程将会被阻塞。
在这里插入图片描述
5)阅读Pintos操作系统,找到并阅读进程上下文切换的代码,说明实现的保存和恢复的上下文内容以及进程切换的工作流程。

进程切换就是从正在运行的进程中收回处理器,然后再使待运行进程来占用处理器。所谓从某个进程收回处理器,实质上就是将此进程存放在处理器的寄存器中的数据暂时存储起来,从而让其他进程使用寄存器。被中止运行进程的中间数据将被保存在进程的私有堆栈中让其他进程来占用处理器,实质上是把某个进程存放在私有堆栈中寄存器的数据(前一次此进程被中止时的数据)再恢复到处理器的寄存器中去,并把待运行进程的断点送入处理器的程序指针,于是待运行进程就开始被处理器运行了,即此进程已占有处理器的使用权了。

一个标准的Linux内核可以支持运行50~50000个进程运行,对于普通的CPU,内核会调度和执行这些进程。每个进程都会分到CPU的时间片来运行,当一个进程用完时间片或者被更高优先级的进程抢占后,它会备份到CPU的运行队列中,同时其他进程在CPU上运行。这个进程切换的过程被称作上下文切换
在切换时,一个进程存储在处理器各寄存器中的中间数据叫做进程的上下文,所以进程的切换实质上就是被中止运行进程与待运行进程上下文的切换。在进程未占用处理器时,进程的上下文是存储在进程的私有堆栈中的。

Pintos is a simple operating system framework for the 80x86 architecture. It supports kernel threads, loading and running user programs, and a file system, but it implements all of these in a very simple way. In the Pintos projects, you and your project team will strengthen its support in all three of these areas. You will also add a virtual memory implementation.

pintos中实现schedule功能的代码部分如下:

/* Schedules a new process.  At entry, interrupts must be off and
   the running process's state must have been changed from
   running to some other state.  This function finds another
   thread to run and switches to it.

   It's not safe to call printf() until thread_schedule_tail()
   has completed. */
static void
schedule (void)
{
  struct thread *cur = running_thread ();
  struct thread *next = next_thread_to_run ();
  struct thread *prev = NULL;

  ASSERT (intr_get_level () == INTR_OFF);
  ASSERT (cur->status != THREAD_RUNNING);
  ASSERT (is_thread (next));

  if (cur != next)
    prev = switch_threads (cur, next);
  thread_schedule_tail (prev);
}

首先解释下三个thread结构体。
cur表示当前线程的PCB起始地址;
next表示下一个要执行的线程的PCB起始地址;
prev表示前一个线程的PCB起始地址。
schedule函数首先获取了当前线程cur和调用next_thread_to_run函数获取下一个需要执行的进程:

/* Chooses and returns the next thread to be scheduled.  Should
   return a thread from the run queue, unless the run queue is
   empty.  (If the running thread can continue running, then it
   will be in the run queue.)  If the run queue is empty, return
   idle_thread. */
static struct thread *
next_thread_to_run (void)
{
  if (list_empty (&ready_list))
    return idle_thread;
  else
    return list_entry (list_pop_front (&ready_list), struct thread, elem);
}

此时若run queue为空则直接返回一个空闲线程指针idle_thread,否则返回run queue中第一个线程。
若当前线程cur和下一个将要执行的线程不同,则调用switch_threads函数返回给prev。

/* Switches from CUR, which must be the running thread, to NEXT,which must also be running switch_threads(), returning CUR inNEXT's context.
 */
struct thread *switch_threads (struct thread *cur, struct thread *next);

在threads/switch.S中可以看到用汇编语言实现的switch_threads函数(看不太懂)。

#### struct thread *switch_threads (struct thread *cur, struct thread *next);
####
#### Switches from CUR, which must be the running thread, to NEXT,
#### which must also be running switch_threads(), returning CUR in
#### NEXT's context.
####
#### This function works by assuming that the thread we're switching
#### into is also running switch_threads().  Thus, all it has to do is
#### preserve a few registers on the stack, then switch stacks and
#### restore the registers.  As part of switching stacks we record the
#### current stack pointer in CUR's thread structure.

.globl switch_threads
.func switch_threads
switch_threads:
    # Save caller's register state.
    #
    # Note that the SVR4 ABI allows us to destroy %eax, %ecx, %edx,
    # but requires us to preserve %ebx, %ebp, %esi, %edi.  See
    # [SysV-ABI-386] pages 3-11 and 3-12 for details.
    #
    # This stack frame must match the one set up by thread_create()
    # in size.
    pushl %ebx
    pushl %ebp
    pushl %esi
    pushl %edi

    # Get offsetof (struct thread, stack).
.globl thread_stack_ofs
    mov thread_stack_ofs, %edx

    # Save current stack pointer to old thread's stack, if any.
    movl SWITCH_CUR(%esp), %eax
    movl %esp, (%eax,%edx,1)

    # Restore stack pointer from new thread's stack.
    movl SWITCH_NEXT(%esp), %ecx
    movl (%ecx,%edx,1), %esp

    # Restore caller's register state.
    popl %edi
    popl %esi
    popl %ebp
    popl %ebx
        ret
.endfunc

总之这个函数的功能应该是保存当前线程的状态,并恢复在新线程之前已保存的线程状态(应该)
thread_schedule_tail (prev)函数的功能应该是获取当前线程,分配恢复之前执行的状态和线程,如果当前线程的生命周期已结束,就清空并释放资源。
schedule的功能是上下文context切换,将当前正在执行的线程替换为下一个。在宏观上来看,线程们不断地在cpu的就绪队列和执行队列之间来回切换,这无疑占用了大量的cpu资源,而且pintos的调度里好像也没有涉及到时间片用完引发的进程切换。
只能说操作系统这个东西真的是太复杂了。。

本次实验代码已上传至
https://github.com/Bismarck0116/OS-homework-experiment
多有不足,还请指出,谢谢!

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值