為了幫助大家理解什麼是程式,以廚師做蛋糕為例。廚師做蛋糕,首先需要廚師(CPU),其次,需要食譜(程式)和原料(輸入資料),而用原料做蛋糕的一些列動作的總和就是程式。某天廚師正在後廚做著蛋糕,突來聽到兒子哭著跑進後廚,說自己被蜜蜂蟄了 ,廚師放下手中工具,並記錄下當前做到哪一步了(儲存上下文資訊) ,然後拿出急救手冊,按其中的說明為兒子進行處理(開始另外一個程式)。
程式概覽
我們知道檔案是對I/O裝置的抽象,虛擬儲存器是對檔案和主存的抽象,指令集是對CPU的抽象,程式是對指令集和虛擬儲存器的抽象。如下圖所示 。
程式在記憶體的邏輯佈局
從上可知,程式包括指令集和虛擬儲存器。我們著重介紹程式在虛擬儲存器中的邏輯佈局,它包括使用者棧、堆、程式資料和程式程式碼,其中,使用者棧從上往下生長,堆從下往上生長,程式資料和程式程式碼從可執行檔案載入而來,將程式程式碼改寫成彙編指令就是類似於movl、imul、addl等指令
。如下圖所示
此時,CPU執行到地址為304的指令, 假設CPU時間片剛好用完,就需要進行程式切換,在進行程式切換之前,需要保護現場,即儲存暫存器資訊、PC、開啟的檔案, 程式碼段地址、資料地址、堆疊資訊等,這些資訊稱為程式的上下文。當作業系統切換到程式時,首先將程式2的上下文資訊載入到作業系統中,找到PC,然後接著執行就可以了。
程式控制塊(PCB)
程式的上下文資訊是以某個資料結構儲存在記憶體中的,而這種資料結構就是PCB。在Linux作業系統中PCB對應的資料結構就是task_struct,它儲存著程式的重要資訊。
struct task_struct{
pid_t pid://程式號
long state;//狀態
cputime_t utime,stime;//cpu在使用者態和 核心態下執行的時間
struct files_struct *files;//開啟的檔案
struct mm_struct *mm;//程式使用的記憶體
...
}
複製程式碼
程式的狀態
- 程式的狀態包括新建態、就緒態、等待態、執行態、退出態
- 流程:首先程式被新建,然後進入到就緒狀態,此時,程式並沒有進入到執行狀態,而是等待CPU排程,如果被CPU排程則進入到執行態,而當時間片用完時,程式從執行態返回到就緒態,而當等待I/O操作時,則由執行態進入阻塞態。需要注意的是:只有執行態的程式擁有CPU,而處於就緒態和等待態的程式只是處於記憶體,等待CPU排程,因此CPU排程是一個很關鍵的流程。
CPU排程
像上文描述的那樣,CPU排程就是到底哪個程式佔有CPU,它可以分為非搶佔式和搶佔式。非搶佔式是指排程程式一旦把CPU分配給某一程式後便讓它一直執行下去,直到程式完成或發生某件事件而不能執行時,才將CPU分配給其他程式。它適合批處理系統,簡單、系統開銷小。搶佔式是指當一個程式正在執行時,系統可以基於某種策略剝奪CPU給其他程式。剝奪的原則有優先權原則、端程式優先原則、時間片原則,它適用於互動式系統。
- 評價標準
- 公平:合理的分配CPU
- 響應時間:從使用者輸入到產生反映的時間
- 吞吐量:單位時間完成的任務數量
- 但是這些目標是矛盾的,例如:我們希望前端程式能夠快速得到響應,這樣一來後端程式就不能得到快速響應。
- 批處理系統中的排程
- 先來先服務(公平、FIFO佇列、非搶佔式)
- 最短作業優先(系統的平均等待時間最短,但是需要預先知道每個任務的執行時間)
- 互動式排程策略
- 輪轉,每個程式分配一個固定的時間片,但是定義時間片長度是個問題,假設程式切換一次的開銷為1ms,如果時間片太短,那麼很多時間都浪費在切換上,例如時間片為4ms,那麼20%的時間浪費在切換上;如果時間片太長,浪費時間就減少了,但是最後一個經常等待的時間就非常久,譬如,時間片100ms,浪費的時間1%,假設有50個程式,最後一個需要等待5s。
- 靜態優先順序,給每個程式賦予優先順序,優先順序高的先執行,優先順序低的後執行,但是該方法存在一定問題:低優先順序的程式存在被餓死的情況,例如新來的程式的優先順序都比原來的高,怎麼辦呢?我們根據等待時間的增加而調整優先順序大小---多級反饋佇列
- 動態優先順序---多級反饋佇列,即程式的優先順序會隨著等待時間的增長而增長。
程式間同步
我們知道,印表機有一個快取,叫做列印佇列,如下圖所示,列印佇列有5個空格,就是說這個列印佇列最多可以容納5個待列印檔案,印表機程式就是消費者,而其他待列印程式是生產者,生產者不斷地向佇列中放資料,例如:A.java、B.doc等。
-
臨界區:多個程式需要互斥的訪問共享資源,共享資源可以是變數、表和檔案等,例如列印佇列就是共享資源。
-
當生產者將佇列放滿時,需要等待消費者;如果消費者把所有檔案都列印完了,則需要等待生產者,這就是程式間的同步問題。
- 程式間同步的本質
- 程式排程是不可控的
- 在機器層面,count++,count--並不是原子操作,即一條程式碼,對應彙編層面多條指令。兩者缺一不可,如果程式排程是可控的,那麼,即使count++對應多條指令,當執行完第一條指令時,發生CPU切換,程式排程控制接下來的程式還是原來的程式控制CPU。
- 解決方案
-
關閉中斷 缺點:把中斷操作(CPU收到時鐘中斷以後,會檢查當前程式的時間片是否用完,用完則切換)開放給應用程式,這是極其危險的事情,例如:當某個程式關閉中斷之後,執行完畢之後,忘記開啟中斷,導致整個系統都終止了。
-
用硬體指令來實現鎖
boolean TestAndSet(boolean *lock){
boolean rv = *lock;
*lock = TRUE;
return rv;
}
// 使用TestAndSet
boolean lock = false;
do{
while(TestAndSet(&lock)){
...//什麼也不做
}
臨界區
lock = false;
剩餘區
}while(true);
複製程式碼
- 注意:作業系統會鎖住系統匯流排,防止其他CPU訪問記憶體變數
- 注意TestAndSet函式中的三條指令是原子執行的
- 訊號量
- 訊號量S是個整形變數,除了初始化外,有兩個操作,wait()、signal()。
- 為了確保訊號量操作,需要用一種原子的方式實現它,作業系統來實現原子性。
wait(S){
while(S<=0){
...//啥也不做
}
S--;
}
signal(S){
S++;
}
//
semaphore mutext = 1;
wait(mutex);
進入臨界區
signal(mutex);
剩餘區
複製程式碼
- 不能忙等問題
用硬體指令實現鎖的方案和訊號量方案都有忙等問題,即某個程式獲得了CPU時間片,但是啥事幹不了,while(S < = 0){...}
- 新增程式佇列,當發現value<0,將當前佇列加入到阻塞佇列中,同時,阻塞程式,而不像之前的方法那樣無限等待下去
typedef struct{
int value;
struct process *list;
} semaphore;
wait(semaphore *s){
s -> value--;
if(s->value<0){
//把當前程式加入到s->list中
block();
}
signal(semaphore *s){
s -> value++;
if(s -> value <=0){
//從s->list取出一個程式p
wakeup(p);
}
}
複製程式碼
執行緒
由於程式之間是相互獨立的,所以程式間資料共享只能通過核心實現,開銷很麻煩,因此我們提出了執行緒這個概念。執行緒之間的資料是共享的;一個程式可以只有一個執行緒,也可以有多個執行緒(一個程式至少有一個執行緒);當一個程式有多個執行緒時,每個執行緒都有一套獨立的暫存器和堆疊資訊,而程式碼、資料和檔案是共享的,如下圖所示。
執行緒的實現
-
完全在使用者層實現(當使用者要執行硬體裝置,必須從使用者空間到核心空間,這是一種保護措施,保護作業系統不被惡意程式所破壞),執行緒在應用層實現有一個優點就是執行緒切換不用核心介入,執行緒切換會非常的快。也就是說執行緒的排程策略是自己實現的。但是這裡也有一個巨大的缺陷:由於核心只知道程式而不知道執行緒,那麼程式1中的任何一個執行緒被阻塞,導致程式1中的其他執行緒也被阻塞
-
核心實現執行緒和使用者空間一一對應,可以有效的解決方案一中的缺點,但是由於在核心中實現使用者空間相同數量的執行緒數,開銷比較大
-
使用者空間中多個執行緒對映到核心中的一個執行緒,這樣一來,核心中的執行緒就不用建立那麼多, 而且阻塞的概率也降低了,這是一種平衡和折中的方式。JVM就是實現了這種方式 。JVM本身就是一個程式,JVM可以建立很多執行緒,然後對應核心中的執行緒,核心中的執行緒排程CPU。
歡迎關注微信公眾號:木可大大,所有文章都將同步在公眾號上。