操作系统——实验三

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

实验代码文件:1.c

根据题意我们可以知道,他们相互之间的前驱关系为P1-->P2、P1-->P3、P2-->P4、P3-->P4。由此,我们可以通过添加适当的信号量来完成这些关系操作。

在这里,我们添加了三个信号量:

S1=sem_open("1",O_CREAT,0666,0);

S2=sem_open("2",O_CREAT,0666,0);

S3=sem_open("3",O_CREAT,0666,0);

根据我们设置的信号量关系:

sem_wait(S1); printf("I am the process P2\n"); sem_post(S1); sem_post(S2);

sem_wait(S1); printf("I am the process P3\n"); sem_post(S1); sem_post(S3);

printf("I am the process P1\n"); sem_post(S1); pid_4=fork();

sem_wait(S2); sem_wait(S3); printf("I am the process P4\n"); sem_post(S2); sem_post(S3);

我们可以看出来,P2和P3都在等待P1完成后对S1信号量的V操作。但是由于一次只产生一个资源单位的信号量,所以每次只能P2 P3中的一个进程来执行,因此他们是互斥的,且前驱为P1。在此之后,P2 P3执行完毕之后会分别对S2 S3进行V操作产生各一个资源单位,因此他们是P4的前驱。

在进程产生方面,我们采用fork()调用的方式来实现。首先在主进程中进行fork()调用,产生子进程2。通过判断pid,之后依次产生子进程3、子进程4:

pid_1=getpid();

pid_2=fork();

if(pid_2==0)//子进程2

{ sem_wait(S1); printf("I am the process P2\n"); sem_post(S1); sem_post(S2); }

if(pid_2>0)//主进程1

{ pid_3=fork();

if(pid_3==0)//子进程3

{ sem_wait(S1); printf("I am the process P3\n"); sem_post(S1); sem_post(S3); }

if(pid_3>0)//主进程1

{ printf("I am the process P1\n"); sem_post(S1); pid_4=fork();

if(pid_4==0)//子进程4

{ sem_wait(S2); sem_wait(S3); printf("I am the process P4\n"); sem_post(S2); sem_post(S3); } } }

通过编译执行,我们可以得到两种执行结果,如下图所示:

1->2->3->4
-1->3->2->4

这是因为题目中间仅要求P2P3互斥,并未指定先后顺序。根据上述的前驱关系,我们可以知道,P2与P3谁先得到了P1之行结束之后对S1进行V操作得到的资源谁便先执行。

2、火车票余票数ticketCount初始值为1000,有一个售票线程,一个退票线程,各循环执行多次。添加同步机制,使得结果始终正确。要求多次测试添加同步机制前后的实验效果。

未添加同步机制实验代码文件:2_1.c

添加同步机制实验代码文件:2_2.c

程序的大体结构是典型的生产消费者问题模型。首先我们来看未添加同步机制之前的程序运行结果:

可以看到,结果并不是我们预期得到的,因为票总数是一定的1000,因此无论如何总票数只会<=1000。

我们再看添加了同步机制之后的程序:

通过上面的测试结果可以看出,添加同步机制之后不会发生类似前面的问题,最终的票数是期待得到的结果。上面的实验证实了增加了同步机制之后的多线程并发程序有效的解决了脏数据的读取问题。以下为同步机制:

sem_t empty,full; //定义全局同步信号量empty,full

pthread_mutex_t mutex; //定义一个全局互斥量,在不同函数中

sem_init (&empty, 0, 0); //初始化empty信号量

sem_init (&full, 0, 1000); //初始化full信号量

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

未添加同步机制实验代码文件:3_1.c

添加同步机制实验代码文件:3_2.c

未添加同步机制时的实验结果:

添加同步机制后的实验结果:

通过对比结果以及分析问题,我们可以知道:

此题是一个经典的生产者和消费者问题:输入线程产生字符,输出线程消耗字符。

如果不考虑同步机制就让两个进程同时运行的话便会出现如下问题:

    输入进程产生字符过快,buffer数组的资源被用尽,继续输入会导致数组越界或者之前输入的字符还未打印便被覆盖。

    输出进程消耗字符过快,继续输出则会访问到为初始化的数组元素或者将之前打印过的字符再次打印

根据以上存在的问题,我们可以通过使用信号量来实现两个进程之间的同步。经分析我们需要初始化两个信号量:full和empty,其中full保证未打印的字符不超过10,empty保证存在需要打印的字符再进行打印。

同步机制如下所示:

pthread_mutex_init( &mutex , NULL ); //初始化互斥量

empty=*sem_open("E",O_CREAT,0666,10);

full=*sem_open("F",O_CREAT,0666,0);

因此在添加同步机制之后,在程序运行的一开始,输入线程未输入任何字符,输出线程被阻塞,不会打印任何字符。当输入字符串长度大于10时,输出线程仍能按序输出这12个字符。经过测试不同的输入速度,均能满足题意。

两个进程如下所示:

void *producer( void *arg){ for(int i=0;;i++)

{

    sem_wait(&empty);

    scanf("%c",&buffer[i%10]);

    sem_post(&full); } }

void *consumer( void *arg )

{

for(int i=0;;i++)

{

sem_wait(&full);

printf("输出:%c\n",buffer[i%10]);

sem_post(&empty); sleep(1);

} }

4、

a)通过实验测试,验证共享内存的代码中,receiver能否正确读出sender发送的字符串?如果把其中互斥的代码删除,观察实验结果有何不同?如果在发送和接收进程中打印输出共享内存地址,他们是否相同,为什么?

原Sender程序:4_Sender.c

原Receiver程序:4_Receiver.c

删除互斥Sender程序:4_Sender_nosem.c

删除互斥Receiver程序:4_Receiver_nosem.c

打印共享内存地址Sender程序:4_Sender_print.c

打印共享内存地址Receiver程序:4_Receiver_print.c

编译执行原Sender与Receiver程序:

我们可以发现Receiver进程已经接收到响应字符串,功能正常。

我们删除互斥代码再次进行测试,查看代码得知这两个进程也是使用信号量实现的同步机制,其核心的函数为semop()和semctl()。

semop()函数它的作用是改变信号量的值,原型为:

int semop(int sem_id, struct sembuf *sem_opa, size_t num_sem_ops);

sem_id是由semget()返回的信号量标识符,sembuf结构的定义如下:

struct sembuf{    

short sem_num; // 除非使用一组信号量,否则它为0    

short sem_op;  // 信号量在一次操作中需要改变的数据,通常是两个数,一个是-1,即P(等待)操作,                   

                        // 一个是+1,即V(发送信号)操作。    

short sem_flg; // 通常为SEM_UNDO,使操作系统跟踪信号,                   

                        // 并在进程没有释放该信号量而终止时,操作系统释放信号量};

semctl()函数该函数用来直接控制信号量信息,它的原型为:

int semctl(int sem_id, int sem_num, int command, ...);

如果有第四个参数,它通常是一个union semum结构,定义如下:

union semun {    

int val;    

struct semid_ds *buf;    

unsigned short *arry;};

前两个参数与前面一个函数中的一样,command通常是下面两个值中的其中一个SETVAL:用来把信号量初始化为一个已知的值。p 这个值通过union semun中的val成员设置,其作用是在信号量第一次使用前对它进行设置。IPC_RMID:用于删除一个已经无需继续使用的信号量标识符。

两个进程通过共享内存传输数据,因共享内存不可同时读写,因此采用二元信号量进行进程互斥,具体操作如下:

init: 设置信号量为0,此时只允许写入,不允许读取(因为共享内存没有数据);

Sender: 在sem=0时,写入数据到共享内存(阻塞读);写入完成后,sem=1,此时可以读取,不可以写入;

Receiver: 在sem=1时,读取数据;读取完成后,sem=0,此时只允许写入。

下面对删除互斥代码的程序进行编译运行:

可以看到,如果删去相关的互斥代码Sender进程由于scanf的占用并无具体问题,但Rec进程并不会等待Sender进程,一直在扫描共享内存并打印到屏幕上,因此当Sender发送消息时,Rec也会及时更新,并不断打印出来。

我们在进程输出时将其内存打印出,下面为修改程序后编译执行的结果:

如图所示,两个进程显示的内存地址并不一致,这与我们内存共享的预期的机制不符。经过之前的实验与查询资料,这是由于虚拟内存机制与内存随机化导致的。

对于进程来说,使用的都是虚拟地址。每个进程维护一个单独的页表。

页表是一种数组结构,存放着各虚拟页的状态,是否映射,是否缓存。

1)数组的索引号,表示虚拟页号

2)数组的值若为null,表示未映射的页若非null,第一位表示有效位,为1,表明缓存的页;为0,表明未缓存的页。其余位表示缓存到的物理页号。

我们运行的两个进程在初始化的时候使用了shmat函数,此函数的作用是将共享内存空间挂载到进程中,实则就是对进程分配字符串的虚拟内存映射到共享内存的物理内存,从而实现内存的共享。所以虽然我们打印出来的内存地址不一样,但是它们实际映射的物理内存地址是一致的。

ASLR(Address Space Layout Randomization)在2005年被引入到Linux的内核 kernel 2.6.12 中,当然早在2004年就以patch的形式被引入。随着内存地址的随机化,使得响应的应用变得随机。这意味着同一应用多次执行所使用内存空间完全不同,也意味着简单的缓冲区溢出攻击无法达到目的。

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

无名管道原pipe程序:4_pipe.c

有名管道原rcv程序:4_fifo_rcv.c

有名管道原snd程序:4_fifo_snd.c

这里为了研究同步机制,对原代码进行了一些修改,首先我们来研究无名管道:

/* * Filename: pipe.c */

#include

#include //for pipe()

#include //for memset()

#include //for exit()

int main(){

    int fd[2];

    char buf[20];

    if(-1 == pipe(fd)) { perror("pipe"); exit(EXIT_FAILURE); }

    pid_t pid; pid = fork();

    if(!pid){ write(fd[1], "hello,world", 12);

    printf("发送已完成,内容为:hello,world\n");

   memset(buf, '\0', sizeof(buf)); }

    else if(pid>0){ read(fd[0], buf, 12);

    printf("The message is: %s\n", buf); }

    else{ perror("fork"); exit(1); } return 0;}

可以看出来,程序通过pipe函数创建管道,函数传递一个整形数组fd,fd的两个整形数表示的是两个文件描述符,其中第一个用于读取数据,第二个用于写数据。两个描述符相当远管道的两端,一段负责写数据,一段负责读数据。我们这里将父进程设置为读进程,子进程设置为写进程,结果如下:


由此我们可以知道,通信功能正常。我们使发送进程sleep 2s,接收进程sleep 1s来查看研究进程是否正常:

我们继续修改代码,连续发送三次消息,查看结果:

可以看到输出进程是按照输入进程输入的顺序输出数据,并且当输入进程没有数据输入,即管道中没有数据的时候,输出进程会阻塞。实现了同步机制。

通过实验,我们对无名管道的总结如下:

无名管道由一个在基本文件系统存储设备上的INODE,一个与其相连的内存INODE,两个打开文件控制块(分别对应管道的信息发送端和信息接收端)及其所属进程的描述信息来标识,在系统执行PIPE(P)命令行之后生成。并在P[0]中返回管道的读通道打开文件描述等,在P[1]中返回管道的写通道打开文件描述符。

从结构上看,无名管道没有文件路径名,不占用文件目录项,因此文件目录结构中的链表不适用于这种文件,它只是存在于打开文件结构中的一个临时文件,随其所依附的进程的生存而生存,当进程终止时,无名管道也随之消亡。送入管道的信息一旦被读进程取用就从管道中消失了,读写操作之间符合先进先出的队列原则。   

管道文件是进程间通信的工具,为了尽量少的占用系统存储资源,一般系统均将其限制为最大长度为4096(PIPSIZ)字节的小型文件。当欲写入的消息超过4096字节时,就产生了读、写进程之间的同步问题。

首先写操作查找PIPE文件中当前指针的偏移量F-OFFSET,然后从此位置开始尽量写入信息,当长度达到4096字节时,系统控制写进程进入睡眠状态,一直等待读进程取走全部信息时,文件长度指针置0,写进程才被唤醒继续工作。  

 为防止多个进程同时读写一个管道文件而产生混乱,在管道文件的INODE标志字I-FLAY项中设置了ILOCK标志项,以设置软件锁的方式实现多进程间对管道文件的互斥使用。

无名管道存在着如下两个严重的缺点:

第一,无名管道只能用于连接具有共同祖先的进程。   

第二,无名管道是依附进程而临时存在的。

接下来我们研究有名管道,通过阅读代码我们可以发现:有名管道可用于更为广泛的进程之间的通信,但其区别于无名通道的一点则是通信双方必须同时存在,否则便会阻塞。有名管道的开启需要创建相关文件,随后两个文件再对文件进行操作,但写入和读取的数据并不会存储在文件中,而是直接置于内存中。由此我们也可以知道其读写操作是同时进行的。写进程fifo_send分为四个步骤执行,首先判断当前目录下是否已经存在my_fifo文件,不存在的话在当前目录下通过mkfifo()函数创建FIFO类型的文件my_fifo;再通过open()函数打开my_fifo文件,最后向文件中写入消息;读进程的过程和写进程的类似,没有了创建fifo文件的过程。

为了更好的研究有名管道的机制,我们分别研究以下情况:

如图所示,当写进程单独运行时,尽管管道中不存在数据,但其仍处于阻塞状态,随后读进程进入之后,读写进程之间实现了通信,进程得以工作并结束,这与预期想法相符合。

接下来我们研究另一种情况,先单独运行读进程,再运行写进程,发现读进程单独运行时被阻塞,但当写进程运行后仍然被阻塞,这是因为我们上次实验的管道文件并未删除,所以读进程使用的是之前的文件,而写进程在创建文件之前会先检查是否已有同名文件存在(此文件一般由上次运行该程序时留下,因为该程序在退出时并没有删除相关文件),存在则删除原文件再创建新文件。所以,由于文件的残留,读进程先运行之后先访问了原先的文件,进入阻塞状态,而写进程在运行之后检测到原文件的存在,将其进行了删除并创建了新文件,如此便相当于两个进程并不在一个有名管道两边,处于阻塞状态。

由此,我们可以看出,有名管道实现了同步机制,但较依赖于管道文件。

有名管道和无名管道基本相同,但也有不同点:无名管道只能由父子进程使用;但是通过有名管道,不相关的进程也能交换数据。

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

客户端代码:4_Client.c

服务端代码:4_Server.c

我们运行这两个程序查看结果,功能正常执行,在客户端没有发送消息的时候服务端阻塞,当客户端发送消息,服务端及时响应。

我们修改Server.c的代码,使得服务器的运行速度低于客户端输入速度,查看结果,可以看到,虽然在客户端发送消息后,服务器没有及时响应,但是在之后响应的时候其顺序并未错乱,响应正常,这说明其具备同步机制。

在此机制中,发送端传送的消息都会加入一个消息队列,写进程在此机制中不会被阻塞,其写入的字符串会一直被添加至队列的末端,而读进程会从队列的首端一直读取消息,消息节点一旦被读取便会移除队列。当队列中不含其需要类型的消息时便会阻塞。在消息队列提供了一种从一个进程向另一个进程发送一个数据块的方法。  每个数据块都被认为含有一个类型,接收进程可以独立地接收含有不同类型的数据结构。我们可以通过发送消息来避免命名管道的同步和阻塞问题。但是消息队列与命名管道一样,每个数据块都有一个最大长度的限制。Linux用宏MSGMAX和MSGMNB来限制一条消息的最大长度和一个队列的最大长度。消息队列机制如下图所示:

5、阅读Pintos操作系统,找到并阅读进程上下文切换的代码,说明实现的保存和恢复的上下文内容以及进程切换的工作流程。

根据pintos的文件组织方式,与进程相关的定义大都在threads/thread.h部分,于是我们在其中寻找上下文切换方面的代码,首先我们可以看到thread的结构体:

tid_t tid:线程的线程标识符。每个线程必须具有在内核的整个生命周期内唯一的tid。默认情况下,tid_t是int的typedef,每个新线程接收数字上的下一个更高的tid,从初始进程的1开始。

enum thread_status status:线程的状态,一共有以下四种:

    THREAD_RUNNING:线程在给定时间内正在运行。可以通过 thread_current()函数返回正在运行的线程。

    THREAD_READY:该线程已准备好运行,但它现在没有运行。可以选择线程以在下次调用调度程序时运行。就绪线程保存在名为ready_list的双向链表中

    THREAD_BLOCKED:线程正在等待某些事务,例如锁定变为可用,要调用的中断。在通过调用thread_unblock(函数)转换到THREAD_READY状态之前,线程不会再次调度。

    THREAD_DYING:切换到下一个线程后,调度程序将销毁该线程。char name[16]:线程命名的字符串,至少前几个数组单元为字符。uint8_t *stack:线程的栈指针。当线程运行时,CPU的堆栈指针寄存器跟踪堆栈的顶部,并且该成员未使用。但是当CPU切换到另一个线程时,该成员保存线程的堆栈指针。保存线程的寄存器不需要其他成员,因为必须保存的其他寄存器保存在堆栈中。

int priority:线程优先级,范围从PRI_MIN(0)到PRI_MAX(63)。较低的数字对应较低的优先级,因此优先级0是最低优先级,优先级63是最高优先级。struct list_elem allelem:用于将线程链接到所有线程的列表中。每个线程在创建时都会插入到此列表中,并在退出时删除。应该使用thread_foreach()函数来迭代所有线程。

struct list_elem elem:用于将线程放入双向链表:ready_list(准备好运行的线程列表)或sema_down(等待信号量的线程列表)。

uint32_t *pagedir:页表指针,用于将进程结构的虚拟地址映射到物理地址。

unsigned magic:始终设置为THREAD_MAGIC,它只是threads / thread.c中定义的任意数字,用于检测堆栈溢出。

在这里并没有明显的看到关于上下文切换的函数定义,暂时考虑其在进程函数定义中有所体现,因此接下来我们查看进程函数部分:

通过注释我们可以知道这一个结构体与上下文进程切换相关,于是我们对这个结构体进行分析:schedule()是负责切换线程的主要函数,其主要被thread_block(),thread_exit(),和thread_yield()这三次函数调用。

下面我们仔细分析一下此函数的具体实现:首先其定义了三个thread结构体的指针,均为局部变量,cur指针指向running_thread ()函数的返回值。经过查看实现,它定位了当前进程。因此cur指针就是当前运行进程的指针。

next_thread_to_run()此函数选择并返回要调度的下一个线程。应该从运行队列返回一个线程,除非运行队列为空。如果运行队列为空,返回idle_thread。值得注意的是如果正在运行的线程可以继续运行,它便仍在运行队列中。于是这个函数的作用是返回下一个执行进程的指针。

最后一个prev指针这里定义为了NULL,说明和这里关系不大。

接下来是三个判断,分别保证了此时中断关闭(程序不能被中断)、当前进程不在运行状态以及存在下一个进程。

经过这三个判断后,便是上下文切换的核心部分:如果当前进程和下一个进程不相等,则调用switch_threads (cur, next)将当前进程和下一个进程进行切换。

switch_threads (cur, next)函数定义于switch.S中,.S文件是汇编文件:

 分析一下这个汇编代码: 先4个寄存器压栈保存寄存器状态(保护作用), 这4个寄存器是switch_threads_frame的成员。然后全局变量thread_stack_ofs记录线程和栈之间的间隙, 我们都知道线程切换有个保存现场的过程,来看34,35行, 先把当前的线程指针放到eax中, 并把线程指针保存在相对基地址偏移量为edx的地址中。38,39: 切换到下一个线程的线程栈指针, 保存在ecx中, 再把这个线程相对基地址偏移量edx地址(上一次保存现场的时候存放的)放到esp当中继续执行。这里ecx, eax起容器的作用, edx指向当前现场保存的地址偏移量。简单来说就是保存当前线程状态, 恢复新线程之前保存的线程状态。然后再把4个寄存器拿出来, 这个是硬件设计要求的, 必须保护switch_threads_frame里面的寄存器才可以destroy掉eax, edx, ecx。然后注意到现在eax(函数返回值是eax)就是被切换的线程栈指针。

我们由此得到一个结论, schedule先把当前线程丢到就绪队列,然后把线程切换如果下一个线程和当前线程不一样的话。

然后再看shedule最后一行的函数thread_schedule_tail做了什么, 这里参数prev是NULL或者在下一个线程的上下文中的当前线程指针。根据函数的注释我们可以得知其功能是:通过激活新线程的页表完成线程切换,如果前一个线程正在死亡,则销毁它。接下来一步步观察其具体的执行步骤。首先其也会获取当前运行进程的指针并保证此时程序不能被中断。接着其会将当其运行进程的状态改变为THREAD_RUNNING以及初始化其时间切片,这可以看做切换进程后对新进程的一个激活。最后的部分表示如果我们切换的线程正在死亡,销毁它的struct线程。而我们传入的prev一定为NULL,所以在切换过程中这一部分并不会执行。

下图为进程上下文切换流程图:

代码链接:BJTU_operating-system-lesson/Lab3 at master · Jerlllly/BJTU_operating-system-lesson · GitHub

你可能感兴趣的:(操作系统——实验三)