实验题目
一、实验目的:
- 系统调用的进一步理解。
- 进程上下文切换。
- 同步的方法。
二、实验题目:
- 通过fork的方式,产生4个进程P1,P2,P3,P4,每个进程打印输出自己的名字,例如P1输出“I am the process P1”。要求P1最先执行,P2、P3互斥执行,P4最后执行。通过多次测试验证实现是否正确。
- 火车票余票数ticketCount 初始值为1000,有一个售票线程,一个退票线程,各循环执行多次。添加同步机制,使得结果始终正确。要求多次测试添加同步机制前后的实验效果。(说明:为了更容易产生并发错误,可以在适当的位置增加一些pthread_yield(),放弃CPU,并强制线程频繁切换,例如售票线程的关键代码:
temp=ticketCount;
pthread_yield();
temp=temp-1;
pthread_yield();
ticketCount=temp;
退票线程的关键代码:
temp=ticketCount;
pthread_yield();
temp=temp+1;
pthread_yield();
ticketCount=temp;
) - 一个生产者一个消费者线程同步。设置一个线程共享的缓冲区, char buf[10]。一个线程不断从键盘输入字符到buf,一个线程不断的把buf的内容输出到显示器。要求输出的和输入的字符和顺序完全一致。(在输出线程中,每次输出睡眠一秒钟,然后以不同的速度输入测试输出是否正确)。要求多次测试添加同步机制前后的实验效果。
- 在Pinto操作系统中,增加一个系统调用,系统调用名为test_system_call()。无输入参数,输出为在显示器中打印输出:Hello. This is my test system call.
- 阅读Pintos操作系统,找到并阅读进程上下文切换的代码,说明实现的保存和恢复的上下文内容以及进程切换的工作流程。
三、解答过程:
题目一:
- 通过fork的方式,产生4个进程P1,P2,P3,P4,每个进程打印输出自己的名字,例如P1输出“I am the process P1”。要求P1最先执行,P2、P3互斥执行,P4最后执行。通过多次测试验证实现是否正确。
解:
通过对于实验题目的分析我们可知,我们需要对于程序内部利用fork函数创建四个相同的进程。通过查阅资料我们可以得知:fork()函数是通过系统调用创建一个与原来进程几乎完全相同的进程,也就是两个进程可以做完全相同的事。一个进程调用fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同,相当于克隆了一个自己。在fork函数执行完毕后,如果创建新进程成功,则出现两个进程,一个是子进程,一个是父进程。在子进程中,fork函数返回0,在父进程中,fork返回新创建子进程的进程ID。我们可以通过fork返回的值来判断当前进程是子进程还是父进程。
由于本实验中只是采取fork()创建出四个相同的进程利用信号量实现进程之间的互斥与顺序执行,所以并不需要考虑父进程与子进程之间的关系。
进程之间相互关系如下图:
根据题目要求设计四个信号量如下:
a = sem_open("a", O_CREAT, 0666, 0); //对于进程p1和p2、p3之间实现前驱关系
b = sem_open("b", O_CREAT, 0666, 1); //实现进程p2、p3之间的互斥关系
c = sem_open("c", O_CREAT, 0666, 0); //实现p2和p4之间的前驱关系
d = sem_open("d", O_CREAT, 0666, 0); //实现p3和p4之间的前驱关系
程序代码执行效果如下:
由程序执行结果我们可以看出在程序运行中p1进程首先执行,在之后为p2和p3互斥执行,最后为p4进程。通过信号量a的控制,只有在p1执行完毕后信号量a才能进行释放实现p2和p3的执行,而当信号量a释放后,p2和p3之间通过共享信号量b实现同步时的互斥关系,并且在p2执行完毕后释放信号量c,在p3执行完毕后释放信号量d。由信号量c,d的共同控制从而实现p4进程的最后执行。
在程序多次运行过程中,我们可以发现进程中的互斥进程p2和p3之间执行顺序并不为随机的而如下图所示,均为p3一定比p2先进行。
通过查阅资料可知这种现象是由于代码内部顺序导致的,且两进程完全相同,不会出现进程之间执行速度不同实现执行顺序的变化。
题目二:
- 火车票余票数ticketCount 初始值为1000,有一个售票线程,一个退票线程,各循环执行多次。添加同步机制,使得结果始终正确。要求多次测试添加同步机制前后的实验效果。(说明:为了更容易产生并发错误,可以在适当的位置增加一些pthread_yield(),放弃CPU,并强制线程频繁切换,例如售票线程的关键代码:
temp=ticketCount;
pthread_yield();
temp=temp-1;
pthread_yield();
ticketCount=temp;
退票线程的关键代码:
temp=ticketCount;
pthread_yield();
temp=temp+1;
pthread_yield();
ticketCount=temp;
)
解:对题目进行分析我们可以得知,本题目为一个生产者与消费者问题,其中售票线程为消费者,退票进程为生产者。
- 售票线程能够进行的条件为当前尚且有余票,由此我们设计了一个信号量empty,当empty为0时表示当前无票,则售票线程不能进行必须等待退票线程。
- 退票线程能够进行的条件为当前尚有售出的票,由此我们设计了一个信号量full,当full为0时表示当前所有的票均未售出,无票售出则必不存在退票的问题,所以此时退票线程必须等待售票线程。
由此设计信号量如下:
empty = sem_open("emptyName", O_CREAT, 0666, 1000);//票数是否为空
full = sem_open("fullName", O_CREAT, 0666, 0);//是否有售出票
根据信号量设计生产者和消费者的两个线程如下;
void *worker1(void *arg){//售票进程
for (int i = 0; i<30; i++){
sem_wait(empty);
//售票进程关键代码
temp = ticketCount;
pthread_yield();
temp = temp - 1;
pthread_yield();
ticketCount = temp;
printf("after the Sell,the number of ticketCount is :%d\n", ticketCount);
sem_post(full);
}
}
void *worker2(void *arg){//退票进程
for (int i = 0; i<10; i++){
sem_wait(full);
//退票进程关键代码
temp = ticketCount;
pthread_yield();
temp = temp + 1;
pthread_yield();
ticketCount = temp;
printf("after the back,the number of ticketCount is :%d\n", ticketCount);
sem_post(empty);
}
}
程序执行结果如下:
在主程序中我自行设计了售出30张票,退回10张票的机制,在程序运行结果中我们可以看出,在前期线程同步时,售票线程和退票线程同步运行,即售出一张票退回一张票,此效果产生的原因结合题目要求增加难度的目的,程序中设置了pthread_yield()函数,其作用为使当前的线程自动放弃剩余的CPU时间从而让另一个线程运行。所以在售票函数运行一次之后我们可以看到紧跟其运行的为退票线程,同理退票线程结束后紧跟的为售票线程。由此为售出一张票退回一张票的实验效果。当程序中预设的退票线程达到结束后,则只有售票线程依次运行所以运行后期为售票线程依次执行,效果如图。
同理当不设置pthread_yield()函数的话线程不强制退出,则会出现下图所示效果:程序运行时先执行售票线程当线程执行结束后在执行退票线程。
题目三:
- 一个生产者一个消费者线程同步。设置一个线程共享的缓冲区, char buf[10]。一个线程不断从键盘输入字符到buf,一个线程不断的把buf的内容输出到显示器。要求输出的和输入的字符和顺序完全一致。(在输出线程中,每次输出睡眠一秒钟,然后以不同的速度输入测试输出是否正确)。要求多次测试添加同步机制前后的实验效果。
根据题目要求设计了输入和输出两个线程,即生产者和消费者。考虑buff内存的容量分别对于生产者和消费者设计两个信号量如下:
buf_empty=sem_open("mySemempty",O_CREAT,0666,5); //监测内存中是否为空,是否可以输入
buf_data=sem_open("mySemdata",O_CREAT,0666,0); //监测内存中是否有数据,是否可以输出
根据信号量设计生产者和消费者的两个线程如下;
void *input(void *arg){
for (int i = 0; i<N; i++, i = i%N){
i = i%N;
sem_wait(buf_empty);
printf("input:\n");
scanf("%d", &buf[i]);
sem_post(buf_data);
}
}
void *output(void *arg){
for (int i = 0; i<N; i++, i = i%N){
sem_wait(buf_data);
printf("output:%d\n", buf[i]);
sleep(1);
sem_post(buf_empty);
}
}
程序执行效果如图所示:
对于正常的输入速度,程序的执行结果正确,每一个输出都能够满足上述的输入效果。
对于快速输入的实验效果如下图:
快速输入时,输入数据会打算输出节奏但是仍然能够成功的按序进行输出内容。
题目四:
- a)通过实验测试,验证共享内存的代码中,receiver能否正确读出sender发送的字符串?如果把其中互斥的代码删除,观察实验结果有何不同?如果在发送和接收进程中打印输出共享内存地址,他们是否相同,为什么?
b)有名管道和无名管道通信系统调用是否已经实现了同步机制?通过实验验证,发送者和接收者如何同步的。比如,在什么情况下,发送者会阻塞,什么情况下,接收者会阻塞?
c)消息通信系统调用是否已经实现了同步机制?通过实验验证,发送者和接收者如何同步的。比如,在什么情况下,发送者会阻塞,什么情况下,接收者会阻塞?
解:
a)通过实验测试,验证共享内存的代码中,receiver能否正确读出sender发送的字符串?如果把其中互斥的代码删除,观察实验结果有何不同?如果在发送和接收进程中打印输出共享内存地址,他们是否相同,为什么?
运行测试程序效果如下:观察测试结果可知程序正常运行接收端能够正确的读出发送方发送的字符串。
通过阅读代码我们可知实现互斥功能的代码如下:
//6. Operation procedure
struct sembuf sem_b;
sem_b.sem_num = 0; //first sem(index=0)
sem_b.sem_flg = SEM_UNDO;
sem_b.sem_op = -1; //Increase 1,make sem=1
将上述互斥代码删除后,我们可以发现程序效果如下:
发送方和接受放均能正常运行,且发送方能够通过输入end将接受放进行结束,可见此时接受方也能够接受数据,但是并不能将数据与发送方进行同步,并实现输出。
在程序中添加代码如下:发送和接收进程中打印输出共享内存地址,他们是否相同,为什么
printf("\n[Sender] address:%p\n", shm_ptr); //输出发送方地址
printf("\n[Receiver] address:%p\n", shm_ptr); //输出接受方地址
由下图我们可以看出对于发送方和接受方的共享内存地址并不相同,通过查阅资料我们可以得知sender进程和receiver进程实现了对共享内存shm_ptr的访问,对于shm_ptr存储的物理地址而言其为唯一的,但在程序进程中打印输出的并不为物理地址,而是shm_ptr向进程所映射的虚拟地址,则导致shm_ptr仅有一块物理地址但在不同的进程中却有不同的打印结果。
b)有名管道和无名管道通信系统调用是否已经实现了同步机制?通过实验验证,发送者和接收者如何同步的。比如,在什么情况下,发送者会阻塞,什么情况下,接收者会阻塞?
解:运行无名管道,效果如图所示
无名管道具有以下特点:
- 无名管道是一类特殊的文件,在内核中对应着一段特殊的内存空间,内核在这段内存空间中以循环队列的方式存储数据;
- 无名管道的内核资源在通信双方退出后自动消失,无需人为回收;
- 无名管道主要用于连通亲缘进程(父子进程),用于双方的快捷通信,通信为单向通信
- 无名管道是半双工的,就是对于一个管道来讲,只能读,或者写。
通过对于程序代码分析我们可以得知,程序通过定义fd[2]文件描述符数组来实现消息同步,在程序中fd[0]描述只读,fd[1]描述只写,从而实现读写同步。
阻塞:
对于管道文件而言,可以使用两种读写方式:阻塞读写与非阻塞读写。
对于阻塞读写而言,是无论是先读还是先写都要等到另一个操作才能离开阻塞。也即:如果先读,等待写操作;如果先写,等待读操作。
对于的有非阻塞读写,它们无须等待另一个操作的,直接执行read()或者write()能读就读,能写就写,不能就返回-1。
在执行open,write,read等操作时会发生阻塞现象。
解:运行有名管道,效果如图所示
有名管道分别定义了两个进程来实现消息同步:
- 进程fifo_send实现了创建管道、向管道只写数据的功能。
- 进程fifo_rcv实现了查阅管道,从管道中只读数据的功能。
阻塞:
有名管道的阻塞情况如下图所示:阻塞情况基本与无名通道相同,增加了一种对于双方一者不存在时,另一方必阻塞的情况。
c)消息通信系统调用是否已经实现了同步机制?通过实验验证,发送者和接收者如何同步的。比如,在什么情况下,发送者会阻塞,什么情况下,接收者会阻塞?
运行消息队列程序如下图:
消息队列通过server端和client端的配合操作实现消息同步:进程client中创建了两个进程,其中父进程用于接受键盘键入以及发送数据,子进程用于接收server的返回数据并实现输出。进程server中等待接收client端发送的数据,当收到信息后,打印接收到的数据,并原样的返回client端。
阻塞情况与管道类似,在队列中没有消息时读进程会阻塞,在队列中数据已满时写进程会阻塞。
题目五:
- 阅读Pintos操作系统,找到并阅读进程上下文切换的代码,说明实现的保存和恢复的上下文内容以及进程切换的工作流程。
进程下文切换的代码
解一:查阅资料,发现规定进程上下文切换的代码位于/threads/switch.h和/threads/switch.S中。
switch.h代码如下图所示:
switch.S代码如下图所示:
实现的保存和恢复的上下文内容工作流程:从正在运行的线程切换到下一个线程时,下一个线程也必须运行switch_threads(),在之后的线程中的上下文中返回前一个进程信息。此功能通过运行switch_threads函数来实现。实现原理为在堆栈上保留某些特定寄存器,切换堆栈并恢复寄存器。切换堆栈时,CUR的线程结构中记录当前堆栈指针。
线程切换
解二:
如果一个线程用完了它的时间片,thread_tick就会调用该函数intr_yield_on_return。但是,此时不会产生下一个线程。相反,它修改一个标志,让中断处理程序知道,在从中断返回之前,它应该执行一个上下文切换到另一个线程(这样,当我们从中断返回时,我们这样做与上下文,即,堆栈和程序计数器,不同的线程)。
于是,经过thread_tick和timer_interrupt回报,intr_handler将调用thread_yield,它将调用schedule。schedule选择要运行的下一个线程并调用一个函数switch_threads,在x86程序集中实现,带有两个参数:( cur指向thread 当前线程结构的指针,即被抢占next的指针)和(指向下一个thread结构的指针) 线程运行)。
所以,堆栈看起来像这样:
理解的关键switch_threads是首先要明白,如果我们切换到另一个线程,那么其他线程switch_threads在被抢占时也必须运行。实际上,一个自愿或非自愿地产生CPU的线程将始终具有类似于以下之一的堆栈:
后面的直觉switch_threads是,要切换到另一个线程,我们只需要“切换堆栈”(因为每个线程都保证switch_threads在被抢占的位置运行),我们只需更改值就可以做到这一点esp。让我们仔细看看这是如何发生的。
在调用之后switch_threads,堆栈的底部将如下所示:
switch_threads堆栈帧(0x0C00)的起始地址是任意的,没有深刻的意义。但是,显示的所有其他值将与从中switch_threads开始的堆栈帧一致0x0C00。
首先,switch_threads需要保存一些寄存器(这只是x86架构所要求的):
pushl %ebx
pushl %ebp
pushl %esi
pushl %edi
我们的堆栈现在看起来像这样:
那么,接下来的汇编代码为:
movl SWITCH_CUR(%esp), %eax
movl %esp, (%eax,%edx,1)
movl SWITCH_NEXT(%esp), %ecx
movl (%ecx,%edx,1), %esp
相当于;
cur->stack = esp;
esp = next->stack;
换句话说,我们保存当前线程的堆栈指针,并设置esp为指向要运行的下一个线程的(先前保存的)堆栈指针。
一旦我们完成了这个,我们就切换了线程,剩下的就是恢复我们之前推入堆栈的寄存器,然后返回switch_threads:
popl %edi
popl %esi
popl %ebp
popl %ebx
ret
进程切换:
进程切换只是线程切换和内核空间与用户空间之间切换的组合。基本上,当一个进程正在运行时,一个定时器中断将控制CPU回到内核,这将导致我们前面描述的中断处理过程(最终导致调用thread_tick)。此时,如果我们抢占当前线程(及其关联的进程),我们将切换到不同的内核线程,如前所述,如果此内核线程与进程关联,我们将切换回用户空间。
主要的区别是,切换到一个新的进程也将涉及调用process_activate从thread_schedule_tail(即运行功能之后 switch_threads,但是从中断处理程序返回之前)。process_activate(源代码)更新CPU的cr3寄存器以指向当前正在运行的进程的页面目录,并将值保存esp到TSS(源代码)。
参考文献: