漫談程式和執行緒

木可大大發表於2018-04-20

為了幫助大家理解什麼是程式,以廚師做蛋糕為例。廚師做蛋糕,首先需要廚師(CPU),其次,需要食譜(程式)和原料(輸入資料),而用原料做蛋糕的一些列動作的總和就是程式。某天廚師正在後廚做著蛋糕,突來聽到兒子哭著跑進後廚,說自己被蜜蜂蟄了 ,廚師放下手中工具,並記錄下當前做到哪一步了(儲存上下文資訊) ,然後拿出急救手冊,按其中的說明為兒子進行處理(開始另外一個程式)。

程式概覽

我們知道檔案是對I/O裝置的抽象,虛擬儲存器是對檔案和主存的抽象,指令集是對CPU的抽象,程式是對指令集和虛擬儲存器的抽象。如下圖所示 。

image.png

程式在記憶體的邏輯佈局

從上可知,程式包括指令集和虛擬儲存器。我們著重介紹程式在虛擬儲存器中的邏輯佈局,它包括使用者棧、堆、程式資料和程式程式碼,其中,使用者棧從上往下生長,堆從下往上生長,程式資料和程式程式碼從可執行檔案載入而來,將程式程式碼改寫成彙編指令就是類似於movl、imul、addl等指令。如下圖所示

image.png

此時,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排程是一個很關鍵的流程。
    image.png

CPU排程

像上文描述的那樣,CPU排程就是到底哪個程式佔有CPU,它可以分為非搶佔式搶佔式。非搶佔式是指排程程式一旦把CPU分配給某一程式後便讓它一直執行下去,直到程式完成或發生某件事件而不能執行時,才將CPU分配給其他程式。它適合批處理系統,簡單、系統開銷小。搶佔式是指當一個程式正在執行時,系統可以基於某種策略剝奪CPU給其他程式。剝奪的原則有優先權原則、端程式優先原則、時間片原則,它適用於互動式系統。

  • 評價標準
  1. 公平:合理的分配CPU
  2. 響應時間:從使用者輸入到產生反映的時間
  3. 吞吐量:單位時間完成的任務數量
  4. 但是這些目標是矛盾的,例如:我們希望前端程式能夠快速得到響應,這樣一來後端程式就不能得到快速響應。
  • 批處理系統中的排程
  1. 先來先服務(公平、FIFO佇列、非搶佔式)
  2. 最短作業優先(系統的平均等待時間最短,但是需要預先知道每個任務的執行時間)
  • 互動式排程策略
  1. 輪轉,每個程式分配一個固定的時間片,但是定義時間片長度是個問題,假設程式切換一次的開銷為1ms,如果時間片太短,那麼很多時間都浪費在切換上,例如時間片為4ms,那麼20%的時間浪費在切換上;如果時間片太長,浪費時間就減少了,但是最後一個經常等待的時間就非常久,譬如,時間片100ms,浪費的時間1%,假設有50個程式,最後一個需要等待5s。
  2. 靜態優先順序,給每個程式賦予優先順序,優先順序高的先執行,優先順序低的後執行,但是該方法存在一定問題:低優先順序的程式存在被餓死的情況,例如新來的程式的優先順序都比原來的高,怎麼辦呢?我們根據等待時間的增加而調整優先順序大小---多級反饋佇列
  3. 動態優先順序---多級反饋佇列,即程式的優先順序會隨著等待時間的增長而增長。

程式間同步

我們知道,印表機有一個快取,叫做列印佇列,如下圖所示,列印佇列有5個空格,就是說這個列印佇列最多可以容納5個待列印檔案,印表機程式就是消費者,而其他待列印程式是生產者,生產者不斷地向佇列中放資料,例如:A.java、B.doc等。

  • 臨界區:多個程式需要互斥的訪問共享資源,共享資源可以是變數、表和檔案等,例如列印佇列就是共享資源。

  • 當生產者將佇列放滿時,需要等待消費者;如果消費者把所有檔案都列印完了,則需要等待生產者,這就是程式間的同步問題

image.png

  • 程式間同步的本質
  1. 程式排程是不可控的
  2. 在機器層面,count++,count--並不是原子操作,即一條程式碼,對應彙編層面多條指令。兩者缺一不可,如果程式排程是可控的,那麼,即使count++對應多條指令,當執行完第一條指令時,發生CPU切換,程式排程控制接下來的程式還是原來的程式控制CPU。
  • 解決方案
  1. 關閉中斷 缺點:把中斷操作(CPU收到時鐘中斷以後,會檢查當前程式的時間片是否用完,用完則切換)開放給應用程式,這是極其危險的事情,例如:當某個程式關閉中斷之後,執行完畢之後,忘記開啟中斷,導致整個系統都終止了。

  2. 用硬體指令來實現鎖

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函式中的三條指令是原子執行的
  1. 訊號量
  • 訊號量S是個整形變數,除了初始化外,有兩個操作,wait()、signal()。
  • 為了確保訊號量操作,需要用一種原子的方式實現它,作業系統來實現原子性。
wait(S){
  while(S<=0){
  ...//啥也不做
  }
  S--;
}
signal(S){
  S++;
}

//
semaphore mutext = 1;
wait(mutex);
進入臨界區
signal(mutex);
剩餘區
複製程式碼
  1. 不能忙等問題

用硬體指令實現鎖的方案和訊號量方案都有忙等問題,即某個程式獲得了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);
  }
}
複製程式碼

執行緒

由於程式之間是相互獨立的,所以程式間資料共享只能通過核心實現,開銷很麻煩,因此我們提出了執行緒這個概念。執行緒之間的資料是共享的;一個程式可以只有一個執行緒,也可以有多個執行緒(一個程式至少有一個執行緒);當一個程式有多個執行緒時,每個執行緒都有一套獨立的暫存器和堆疊資訊,而程式碼、資料和檔案是共享的,如下圖所示。

image.png

執行緒的實現

  1. 完全在使用者層實現(當使用者要執行硬體裝置,必須從使用者空間到核心空間,這是一種保護措施,保護作業系統不被惡意程式所破壞),執行緒在應用層實現有一個優點就是執行緒切換不用核心介入,執行緒切換會非常的快。也就是說執行緒的排程策略是自己實現的。但是這裡也有一個巨大的缺陷:由於核心只知道程式而不知道執行緒,那麼程式1中的任何一個執行緒被阻塞,導致程式1中的其他執行緒也被阻塞

  2. 核心實現執行緒和使用者空間一一對應,可以有效的解決方案一中的缺點,但是由於在核心中實現使用者空間相同數量的執行緒數,開銷比較大

  3. 使用者空間中多個執行緒對映到核心中的一個執行緒,這樣一來,核心中的執行緒就不用建立那麼多, 而且阻塞的概率也降低了,這是一種平衡和折中的方式。JVM就是實現了這種方式 。JVM本身就是一個程式,JVM可以建立很多執行緒,然後對應核心中的執行緒,核心中的執行緒排程CPU。


image

歡迎關注微信公眾號:木可大大,所有文章都將同步在公眾號上。

相關文章