我們在平時的計算機課上學習過進程,知道程序的執行的背後其實就是進程在進行一些操作。大家都知道打開windows的任務管理器可以看到正在運行的進程,當程序卡死時,可以在任務管理器裡強制關閉相關程序的進程,這樣就可以關閉卡死的程序,所以我們知道進程就是程序執行所產生的,但是我們對進程沒有很清楚的認識。什麼是進程?進程在程序的執行過程中到底起了什麼樣的作用?我們在toyix平台上來對進程進行研究學習。
Toyix是王爽老師為了進行操作系統基礎理論教學而開發的一個系統。它的特點是既能提供良好的編程體驗,又不太復雜。Toyix小巧簡單,安裝包只有幾百kb,解壓即可使用;兼容Dos的大多數命令,使用的是tc的編譯器。系統部分與UNIX、標准c庫函數兼容,具有很好兼容性的編程接口。
進程是程序在計算機上的一次執行活動,當運行一個程序,就啟動了一個進程。程序運行活動可以用一個進程模型來描述。就是說進程是我們對程序執行過程的描述,舉個例子,程序就像是一台音響,進程就是播放音樂這個動作過程。程序是儲存在存儲空間中的二進制文件,就像音響一樣,是看得見、摸得著的,而進程是一個動作、一個過程,是我們只能想象、只能感受和想象的。我們在任務管理器裡停止了進程,只是停止了程序的執行,程序本身並沒有被刪除,這是因為程序是靜態的,進程是動態的,停止進程並不會刪除它所使用的數據,就像停止播放音樂並不會損壞音響一樣。
我們在電腦上所能看到的所有文件都是存儲在電腦中的,就比如下面這個程序:
執行過程如下:
在命令行輸入do 1運行程序,發現這裡需要輸入一個字符才能繼續執行,toyix的進程監視器顯示ready的進程是1.
按回車程序繼續執行,輸出一行字符a,這時進程監視器裡的running顯示的是1.
最後輸出完畢,程序執行完畢,進程監視器裡的running顯示的是0.
在這個程序的執行過程中,我們可以看到在屏幕上輸出a時,進程監視器的running顯示的是1,它表示這個程序正在執行中,因為程序裡有延時函數delay,所以我們可以很清楚地看到輸出a的過程,這就是進程處於執行態。我們可以看到,這個過程是動態的,而且它的狀態和結果與程序的代碼沒有關系,不管它是ready、running,不管程序執行成功還是失敗,程序的代碼還是在那裡,不會有任何改變,因為它是靜態的。即進程的狀態和結果與程序本身無關。
再輸入do 1 1執行兩次程序:
這時ready顯示1和2兩個進程號,它表示現在有兩個進程等待執行。
按回車讓程序繼續執行,發現輸出了160個a,在輸出a的過程中有時running是1,ready是2,有時running是2,ready是1,這表示這兩個進程在交替執行。如果沒有延時函數,也就是如果程序執行的很快,我們會感覺到這兩個程序是同時執行的,這就是為什麼我們用計算機可以一邊聽歌一邊用文檔打字,在計算機上可以同時使用不同的程序,因為程序是通過進程來執行的,而進程的執行有並發性。
為什麼進程的執行有並發性?因為進程可以交替執行。為什麼進程可以交替執行?因為進程有不同的狀態,它可以在不同的狀態之間轉換達到暫停的效果。那麼進程有哪幾種狀態呢?上圖中的進程監視器有running、ready、blocked三個字段,它們表示進程的三種執行狀態:執行態、就緒態、阻塞態,這三種狀態之間的轉換關系可以用下圖表示:
我們在上一個程序執行的過程中發現,我們在執行程序後程序的內容還沒有開始執行,這時進程監視器顯示進程處於ready就緒態,在我們輸入一個字符後進程就進入了運行態,運行完畢後退出,也就是說進程在程序開始是就緒態,之後進入運行態,運行完畢後從運行態退出,即進程的起點是就緒態,終點是運行態,我們來看看這三種狀態的具體含義:
1、就緒態:進程分配到除CPU以外的所有資源,只要能獲得cpu就能執行。
2、執行態:進程獲得cpu正在執行。
3、阻塞態:進程因等待某事件而暫停執行。
所以,我們調用一個程序,就創建了一個進程,這時進程分配了所要的內存空間等資源,但是這時還有沒執行,因為沒有cpu來計算進程的數據得到結果。如果進程分配到了cpu資源就開始執行,進入運行態,如果這時進程執行過程中要等待某事件才能繼續執行,比如要用戶進行輸入,就會進入阻塞態,即處於暫停的狀態,直到事件發生,但是這時cpu還在執行其它進程的計算任務,所以這個進程進入到就緒態,直到分配到cpu的資源再進入執行態繼續執行。也就是說,就緒態和阻塞態都是缺少一些東西,就緒態是缺少cpu資源,阻塞態是缺少某件事情的發生,只有運行態是具備一切條件,可以順利執行程序的,所以在正常情況下運行態是進程的出口。
那麼為什麼進程要從就緒態開始進入,而不是阻塞態進入呢?因為我們在執行程序後,創建進程並分配好所需的一切資源,這時候是不可能缺少外部的事件的,只有當程序開始執行後,才會需要某些事件的發生。也就是說,就緒態需要的是內部資源,即cpu資源,而阻塞態需要的是外部資源,即某些事件的發生。所以在正常情況下就緒態是進程的入口。
所以我們在執行do 1 1時會發現進程1、2在運行態和就緒態之間相互轉化,這說明兩個進程在交替使用cpu資源。如果一個處於執行態在使用cpu資源,那麼另一個就處於就緒態在等待cpu資源。
我們在程序中加入一個getch函數:
執行結果如下:
當程序執行到getch()時等待鍵盤輸入,我們看到這時進程的狀態還是運行態,這是為什麼呢?為什麼這時是等待外部事件發生,但是進程沒有轉到阻塞態?查詢函數手冊可知,getch函數是調用進程循環等待,不會使進程進入阻塞態,而get_char會使進程進入阻塞態來等待輸入,我們把程序裡的getch換成get_char函數執行來看看結果:
可以看到此時的進程處於阻塞態來等待輸入。
所以我們在使用相關函數時要注意它是否是一個阻塞函數,要注意阻塞和循環等待的區別。類似的函數還有gets和get_str、wait和sleep等。要注意我們使用get_str時在進行鍵盤輸入就會喚醒進程,即讓進程從阻塞態進入運行態,那麼對於阻塞的進程,除��進程需要的事件發生,可以用其他的方式將進程喚醒嗎?查詢函數手冊可知wakeup函數為喚醒原語,那麼可以用wakeup函數喚醒阻塞的進程嗎?我在上面程序的getch()改成get_char後調用wakeup函數,結果發現沒有起作用,這是因為進程在執行get_char函數後就進入了阻塞態,這時程序不往下執行,所以不會執行wakeup語句。但是因為wakeup是喚醒原語,所以要喚醒一個進程一定會使用它,但是可能是在get_char函數中調用,這涉及到進程的三態模型是通過什麼來控制的,進程是操作系統執行程序的一種方式和過程,所以是操作系統控制進程在三種狀態之間轉換。
我們可以體會一下三態模型的思想:進程執行需要一些外部的操作和內部的資源,就緒態是缺少內部的資源(cpu),這是內因,阻塞態是缺少外部的資源(事件的發生),這是外因,當內因和外因都具備時就可以轉到運行態執行了。在這裡,cpu資源是最寶貴的,它直接決定了計算機和程序的運行速度,所以進程思想和三態模型的出現都是為了最大程度地分配和利用cpu資源。
進程的創建也是操作系統所要做的工作,當我們執行一個程序時,操作系統就會創建與這個程序完成相關的進程,不需要我們手動創建。我們可以用get_pid函數得到調用進程的進程號:
用do 1執行程序,結果如下:
發現結果進程的序列號為1,在進程就緒狀態進程監視器顯示的進程號也為1。
我們再用do 1 1 1執行三個進程:
可以看到程序創建了三個進程,它們的進程號分別為1、2、3.
也就是說,這裡進程號是從1開始連續分配的,每執行一次程序就創建一個進程,那麼一個程序可以創建開啟多個進程嗎?我們可以使用fork函數和frk函數來創建多個進程。
Fork函數的作用是創建一個與父進程相同的子進程:
執行結果為:
Fork創建了一個子進程,所以打印出了2個進程號。我們把創建新進程前的進程叫做父進程,把創建新進程後的進程叫做子進程,那麼這兩個進程哪個是父進程,哪個是子進程呢?我們是無法通過它們的進程號來區分的,但是我們知道fork函數在創建進程成功後,在父進程中會返回子進程的進程號,在子進程中會返回0,也就是說,在父進程中fork()的值是一個非0值,而在子進程中,fork()的值為0.那麼我們可以通過判斷fork()的值是否為0來判斷這個進程是父進程還是子進程:
執行結果如下:
有程序的執行結果可知,進程1是父進程,進程2是子進程。
Fork函數有兩個特點:1、父子進程不共享數據段。2、父進程結束後不撤銷子進程。
什麼是數據段呢?進程是程序的執行過程,它的執行需要調用程序裡設置的數據來給cpu進行計算得到需要的結果,這些程序裡設置的數據就存儲在一段存儲空間裡,這個空間就叫做這個進程的數據段,它是進程所需資源的一種,是在創建進程時由操作系統分配給進程的。
進程一般由控制信息和本體信息組成,進程號就屬於進程的控制信息,本體信息一般是由代碼段、數據段、棧段所組成。棧段是用來保存如cpu現場等進程的特有信息,我們知道局部變量就是用棧段存儲的,比如上面的變量i,它在父子進程中的值是不同的,所以我們可以知道棧段是不可能被父子進程共享的。我們知道全局變量是存放在數據段裡的,要驗證數據段是否共享,我們可以對全局變量進行操作和打印:
執行結果為:
所以這裡打印的a的值都為1,而如果兩個進程共享一個數據段,那麼a會自加2次,輸出的結果應該為2。所以這裡的兩個進程沒有共享數據段。
要判斷兩個進程的代碼段是否共享,只需要看看它們代碼段的地址是否相同即可,我們打印這兩個進程的地址:
執行結果如下:
我們可以看到這兩個進程的代碼段並不一樣,所以它們的代碼段不是共享的。所以fork函數創建的兩個進程的代碼段、數據段、棧段都是不共享的。而且我們注意到父進程是先打印的,也就是說父進程是先執行的,在父進程執行後子進程並沒有被撤銷。
那麼frk函數和fork函數有什麼區別呢?由函數手冊可知,frk函數也是創建一個與父進程相同的子進程,而它的不同之處是這兩個進程是共享數據段的,而且如果父進程結束了,子進程也會被撤銷。
我們將上面的函數裡的fork函數改成frk函數來執行:
第一個程序修改如下,這裡在a++後面調用delay函數是為了在父進程a++執行完後,子進程有足夠的時間去完成a++操作,如果父進程的a++執行後就執行下面的打印語句,子進程可能還沒有執行a++,那麼可能的結果就是父進程打印出來的a的值為1,子進程打印出來的值為2:
執行結果如下:
這裡打印出來a的值都為2,說明了這兩個進程是共享數據段的。
我們把第二個程序修改為如下:
這裡程序最後的delay(100)的作用也是讓父進程暫停等待子進程執行,否則父進程結束後會撤銷子進程,那麼子進程的打印語句就無法執行。
執行結果如下:
這裡的打印出來的兩個函數的代碼段地址不同,所以frk創建的子進程也不與父進程共享代碼段。我們注意到子進程打印出來的地址是一個錯誤的地址,因為我們是用十六進制打印的地址,而r是不屬於十六進制的數的,如果用十進制打印,結果如下:
這個地址應該是正確地地址,那麼為什麼用十六進制打印會出現錯誤的地址呢?按理說%x不應該輸出大於f的值,那麼我覺得可能是這裡printf裡對%x的實現有問題。
如果上面的程序最後不加delay(100),執行結果如下:
因為父進程在執行完後撤銷了子進程,所以只有父進程執行了printf函數。
所以我們得到的結論是frk函數創建的子進程與父進程共享數據段,不共享代碼段和棧段。
我們之前在討論進程的三態模型時發現在正常情況下,進程是從運行態退出的,但是frk函數創建的子進程可能沒有執行完就因為父進程的結束而被撤銷了,這是為什麼呢?查詢資料發現,實際上還存在從就緒態或者阻塞態到結束狀態的釋放轉換。進程的退出可以分為正常退出和異常退出,異常退出的原因包括進程執行超時、內存不足、非法指令或地址訪問、I/O操作失敗、被其他進程所終止等,比如父進程可以在任何時間終止子進程,只是fork中設置的是父進程退出不撤銷子進程,而frk中設置的是父進程結束時撤銷子進程,我們也可以寫一個函數創建子進程,並在父進程執行時就撤銷它。
那麼為什麼fork函數和frk函數的功能差不多,但是一個共享數據段、一個不共享數據段呢?我們知道進程包含數據段,而fork創建的子進程不與父進程共享數據段,所以在創建時系統要把父進程數據段的內容復制到子進程的數據段中,這會造成一定的開銷,而且也不利於父子進程間交換數據,所以fork和frk創建的進程各有特點。為了區別fork創建的進程,我們把frk創建的進程叫做輕權進程。
雖然frk和fork創建的子進程都不與父進程共享代碼段,但是父子進程代碼段的內容都是一樣的,怎麼讓子進程的代碼段的內容與父進程不一樣呢?我們可以用exec函數,它的功能是執行一個可執行文件,創建一個進程覆蓋當前的進程,這樣我們就可以在生成的子進程中創建一個新的進程,而這個新的進程可以執行與父進程完全不同的功能。如下,編寫程序1為:
程序2為:
執行結果為:
程序中子進程在執行exec函數後被新建的2.prg的進程所覆蓋,從而執行2.prg的內容,這樣就可以使進程1的子進程運行時啟動2.prg的進程了。
如果把exec放到父進程裡面會怎麼樣呢?
執行結果如下:
父進程被覆蓋了,那麼再把fork換成frk試試看:
執行結果如下:
只執行了主進程的內容,這是因為frk的父進程在結束時會撤銷輕權進程,這是因為主進程和輕權進程共享一個數據段,主進程結束後會釋放數據段,如果這時輕權進程還存在的話,就會繼續使用數據段,但是這時數據段已經被釋放了,可能內存空間已經被別的程序使用,如果繼續使用會出錯,所以必須要撤銷輕權進程。但是如果我們在主進程裡添加延時函數或者輸入函數,等到輕權進程執行,就可以達到之前程序的結果。
那麼如果exec是在輕權進程中執行,它所創建的進程還是輕權進程嗎?Exec函數是執行一個完全不同的程序,那麼這個程序的數據段肯定與當前程序不同,即新的進程不與主進程共享數據段,那麼它就不是一個輕權進程。所以我們在輕權進程中用exec創建的進程是一個普通進程而不是輕權進程。我們可以實現程序來證明:
程序1為:
程序2為:
執行結果為:
結果發現在執行2.prg的過程中,也就是輸出a的過程中我們按回車結束主進程,但是此時還是在繼續輸出a,這說明當主進程結束後,exec創建的進程並沒有被撤銷,而是繼續執行。這說明exec創建的不是輕權進程。
那麼如果exec函數在主進程中,能夠用新創建的進程覆蓋主進程嗎?我們將程序1修改為如下:
執行結果為:
可見exec並沒有創建執行新的進程,主進程也沒有被覆蓋。查看函數手冊發現exec如果執行失敗了exec會返回-1.用printf函數打印exec的值,發現果然是-1:
那麼就說明exec執行失敗了,為什麼它會執行失敗呢?因為現在主進程如果被覆蓋,那麼它的數據段就會釋放,但是此時輕權進程還要繼續使用這個數據段,會發生錯誤,所以主進程無法被覆蓋。要使exec能夠執行成功,第一可以使輕權進程在主進程被覆蓋前就執行完畢,第二可以使輕權進程通過exec升級成為一個普通進程,第三可以使用fork來創建子進程。我們修改程序,用getch()使主進程等待直到輕權進程執行完畢。
執行結果如下:
果然,輕權進程結束後,主進程執行exec成功創建新進程覆蓋了主進程。
用do 1 1將上面的程序執行兩個進程,結果過如下:
發現在這個過程中當打印第一行a的時候進程監視器顯示進程1在執行,進程2是就緒態,但是當第一行a輸出完了顯示的還是進程1在執行,進程2沒有了。那麼難道第一行a是進程2創建的新進程輸出的?,但是這個過程中進程1才是處在執行態。我們來分析一下過程:首先進程1和2的輕權進程都執行完,進程1的getch()收到了輸入的消息,執行exec創建一個新的進程覆蓋原來的進程1,這個新的進程的進程號就是1,然後它執行輸出,執行完畢後結束,這時就只有處於就緒態的進程2了,進程2執行exec創建一個新的進程,因為之前的進程1不存在了,所以這一個新的進程的進程號就是1,所以我們才會看到進程1一直處於執行態。
我們可以用fork、frk、cobegin函數創建多個進程並使它們並發執行,進程的執行需要一些資源,那麼當多個進程並發執行時要使用相同的資源怎麼辦?我們把一個全局變量當作資源,設計兩個進程都對這個全局變量進行操作:
執行結果如下:
如果這兩個進程並發執行的話,第一個進程先使用資源,對a加1並輸出,那麼先輸出的應該是a=1,第二個進程輸出的是a=2。為什麼這兩個進程輸出的都是a=2呢?因為f1中有延時函數delay(50),在f1延時時f2的進程對a加了1,所以f1輸出a的時候a的值已經變成了2.也就是說f1和f2的進程並發執行有可能導致f1和f2的公共資源使用出錯。那麼怎麼解決這個問題呢?要得到正確的結果,就要讓一個進程的執行過程中不會被其他進程所干擾,形成進程互斥。即讓一個進程的執行內容像一個原子操作,具有原子性。
我們可以使用PV原語來保證進程的原子性,PV原語是對於信號量的操作,信號量的值代表有幾個資源,P操作是將信號量減1,如果執行後信號量大於等於0,就繼續執行,否則將該進程堵塞並置於該信號量的阻塞隊列中。V操作是對信號量加1,如果執行後信號量的值大於0,那麼就將繼續執行,否則從該信號量的等待隊列中喚醒一個等待的進程。也就是說,P操作是將當前進程由運行態轉為阻塞態,而V操作是將一個阻塞態的進程轉成就緒態。而他們對進程的阻塞和喚醒是根據當前信號量的值來判斷的,因為���號量的值就代表了當前資源的數目。
我們使用PV操作來對上面的程序進行修改:
執行結果為:
執行結果是正確的。我們先將信號量的值設置為1,然後將f1、f2並發執行,這時如果f1先執行,執行p操作,s=0,進程繼續執行,此時如果執行進程2,執行p操作,s=-1<0,進程就會被阻塞放入等待隊列中,f1的進程就會繼續執行,當執行到v操作,s=0,不符合執行條件,則喚醒等待隊列中的f2的進程,當f2的v操作執行完,這時s=1可以通過,則進程執行完成。所以在f1進程執行過過程中,不管有多少進程執行了,都會被轉到阻塞態,s可能會很小,但是在當前進程執行v操作時會不停喚醒等待隊列中的進程直到s大於0,所以在當前進程執行時不會有其他的進程來執行,這樣就實現了進程的互斥性。
這裡我們所說的進程間的通信,指的就是進程能夠一個一個來執行,不會“搶”,通過信號量還有PV操作,我們實現了進程的互斥性,就是進程間像是能夠通信一樣,當一個進程運行時,其他進程全部阻塞。
生產者-消費者問題也稱有限緩沖問題,是一個多線程同步的經典案例。該問題描述了兩個共享固定大小緩沖區的線程——即所謂的“生產者”和“消費者”——在實際運行時會發生的問題。生產者的主要作用是生成一定量的數據放到緩沖區中,然後重復此過程。與此同時,消費者也在緩沖區消耗這些數據。該問題的關鍵就是要保證生產者不會在緩沖區滿時加入數據,消費者也不會在緩沖區中空時消耗數據。
生產者、消費者問題有幾種形式,我們來分別討論:
(1)一個生產者、一個消費者,公用一個緩沖區
這裡我們設置三個信號量s、empty、full,s表示緩沖區的個數,初值為1;empty表示緩沖區的大小,初值為20,即我們假設緩沖區最多能放20個產品;full表示緩沖區是否為空,初值為0。實現的程序如下:
程序中,我們用producer()來表示生產者,用consumer()來表示消費者,生產者生產一件產品要占用要判斷緩沖區是否未滿,即剩余空間empty減一之後是否還大於等於0,所以先對empty進行p操作,之後用p(&s)判斷緩沖區是否被占用,再進行生產(product加一並輸出)後用v操作釋放緩沖區,再執行v操作對full加1判斷是否大於0,即緩沖區是否為空,如果為不為空則繼續執行,因為full的初值為0,而生產者進程執行的時間片完後輪到消費者進程,對full進行p操作是-1,消費者進程被堵塞再執行生產者進程,執行到v(&full)時,full加1變成0,所以喚醒消費者進程,p(&full),full變成-1,再被堵塞,執行生產者進程,所以生產者進程和消費者進程會不斷交替執行。為了使產品累加起來便於觀察,我用延時函數delay使生產者生產產品的周期比消費者消費產品的周期要短。我們來觀察程序的執行過程:
觀察結果:首先生產者不停生產產品到10個,然後生產者一邊生產、消費者一邊消費,因為生產者的生產的速度比消費者消費的速度要快,所以產品還是在逐漸地累加一直到20,之後就是生產者生產一件產品、消費者消費一件產品,達到一個比較平衡的狀態。
(2)一個生產者、一個消費者、公用n個環形緩沖區
這裡比較特別的是緩沖池被分為多個環形緩沖區,也就是說,如果第一個緩沖區滿了,那麼生產者生產的產品只能放在第二個緩沖區中,如果第n個緩沖區滿了,那麼生產者就要再從第一個緩沖區開始放,消費者也是一樣,如果第一個緩沖區裡有產品而第二個裡面沒有,那麼消費者就只能從第一個緩沖區中拿產品。因為緩沖區是環形的,要確定當前緩沖區是哪一個,我們可以用模運算求得。程序如下:
#include<toyix.h>
semaphore s,empty,full;
int product[10]={0};
producer()
{
int in=0;
while(1)
{
delay(20);
p(&empty);
p(&s);
printf("Producer: There are %d products in NO.%d buffer\n",++product[in],in);
in=(in+1)%10;
v(&s);
v(&full);
}
}
consumer()
{
int out=0;
while(1)
{
delay(400);
p(&full);
p(&s);
printf("Consumer: There are %d products in NO.%d buffer\n",--product[out],out);
out=(out+1)%10;
v(&s);
v(&empty);
}
}
main()
{
set(&s,1);
set(&empty,20);
set(&full,0);
cobegin(producer,consumer,0);
getch();
}
(3)一組生產者、一組消費者,公用n個環形緩沖區
因為有多個生產者和多個消費者,生產者之間存在互斥關系,消費者之間也存在互斥關系。所以我們要用信號量來控制一個時間只能有一個生產者在生產,一個消費者在消費。所以我們設mutex1為生產者之間的互斥信號量,初值為1,mutex2為消費者之間的互斥信號量,初值為2.,
程序如下:
#include<toyix.h>
semaphore s,empty,full,mutex1,mutex2;
int product[10]={0};
producer()
{
int in=0;
while(1)
{
delay(20);
p(&empty);
p(&mutex1);
p(&s);
printf("Producer: There are %d products in NO.%d buffer\n",++product[in],in);
in=(in+1)%10;
v(&s);
v(&mutex1);
v(&full);
}
}
consumer()
{
int out=0;
while(1)
{
delay(400);
p(&full);
p(&mutex2);
p(&s);
printf("Consumer: There are %d products in NO.%d buffer\n",--product[out],out);
out=(out+1)%10;
v(&s);
v(&mutex2);
v(&empty);
}
}
main()
{
set(&s,1);
set(&empty,20);
set(&full,0);
set(&mutex1,1);
set(&mutex2,1);
cobegin(producer,consumer,0);
getch();
}