操作系統——實驗三

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 <stdio.h>

#include <unistd.h> //for pipe()

#include <string.h> //for memset()

#include <stdlib.h> //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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容