操作系統之進程和線程

LZ水平有限,如果發現有錯誤之處,歡迎大家指出,或者覺得那塊說的不好,歡迎建議。希望和大家一塊討論學習
LZ QQ:1310368322


進程和線程理解起來比較抽象,是因爲它本來就是一種抽象,一種操作系統層面的抽象。
我們先來看看什麼是進程?
第一章:進程
什麼是進程
我覺得《深入理解計算機系統》這本書對進程解釋的比較到位
進程:進程是操作系統對一個正在運行的程序的一種抽象
其實抽象是計算機科學中最重要的概念之一,抽象無處不在,比如指令集結構提供了對實際處理器硬件的抽象,下圖展示了計算機系統提供的一些抽象
這裏寫圖片描述
我們可以看到進程其實是由虛擬存儲器加上處理器構成的(有關虛擬存儲器的內容參考虛擬存儲器
如果大家還是不太理解進程,讓我們來看一個比較形象的比喻
我們可以把廚師做蛋糕這一系列動作看做一個進程
廚師在做蛋糕的時候會有一個食譜(假設這個廚師的廚藝不怎麼樣,做蛋糕還要看食譜),這個食譜中就包含了做蛋糕的步驟[指令]以及所需要的原料[所需要的數據],而做蛋糕的原料就相當於輸入數據,廚師就相當於一個CPU廚師閱讀食譜,用原料做蛋糕的一系列動作的總和就是一個進程
假設這麼一個場景:廚師的兒子跑進來,說是被蜜蜂蟄了,這時候廚師記錄下當前做到了哪一步(記錄當前進程的狀態),然後拿出急救手冊開始對兒子進行處理(開始了另外的一個進程)
說道這裏,大家應該能猜到上面的場景就是進程間進行切換
下來我們來看看進程間的切換
進程間切換
我們先來看一看一個進程所擁有的虛擬內存張什麼樣,如下[大家不要管下圖中的指令具體代表着什麼含義]:
這裏寫圖片描述
簡單地介紹一下,程序數據裏面包含了全局變量的一些信息,程序代碼中包含了運行這個程序的所有指令,PC是CPU中的一個寄存器,裏面存的是下一條指令的地址,棧中存放的是一些函數調用的信息(比如局部變量),堆中是運行時動態分配的信息(比如new)。好了,接下來我們重點來看進程之間是如何進行切換的。
話不多說,上圖
這裏寫圖片描述
從圖中我們可以看出在程序1在和程序2進行切換(準確地來講是進程1和進程2進行切換)的時候,操作系統(OS)會保存進程1的上下文和設置進程2的上下文,這裏提到了一個名詞:上下文,簡單地理解就是進程運行所雪的所有狀態信息,比如在運行的時候,CPU中寄存器的值。簡單地說一下切換的過程:
當進程1的時間片用完的時候,操作系統會保存進程1的上下文,然後操作系統會根據一些調度算法來調度下一個將要執行的程序,然後設置進程2的上下文,根據進程2的PC去執行進程2。
注意:
這裏進程的上下文信息是保存在內存中的一個稱爲PCB的進程控制塊中,我們仔細想想就會明白,物理的寄存器只有一套,當前進程時間片用完後當然要保存寄存器中的值和其它信息到內存中,以便下次執行時恢復數據
進程間的切換是由操作系統來負責的
進程的五種狀態
進程有五種狀態,分別是:新建、就緒、執行、等待和退出,他們的關係如下:
這裏寫圖片描述
進程剛一開始被創建還沒有創建好的狀態就是新建狀態,等創建好了就變成了就緒狀態,就緒狀態就代表該進程具備運行條件,等待系統分配處理器(CPU)以便運行,當就緒狀態的進程獲得CPU使用權的時候,該進程就進入了運行態,如果正在運行的進程出現了等待事件(如有IO操作,等待鍵盤輸入),那麼這個進程就進入了等待狀態,當這個等待的事件完成(如鍵盤輸入完成),這個進程又會進入到就緒狀態,進程運行完畢或者其他原因進入到退出狀態
注意:
①進程處於等待態的時候是不佔用CPU的
②操作系統會維護若干個等待列表,會把不同類的等待進程放入列表中,比如進程A等待磁盤,就會被放到磁盤列表,進程B等待鍵盤輸入,就會放到等待鍵盤列表中去

上面我們提到了一個進程會從就緒狀態得到CPU後變爲運行狀態,那麼如果有多個進程會怎麼辦,是按什麼順序分配CPU給進程的,這就牽涉到進程調度算法,進程調度算法有很多,也比較複雜,這裏我們大概地介紹一下,有關具體內容,請大家參考有關資料
進程調度
進程調度根據不同類型的操作系統會分爲兩大類
* 非搶佔式:調度程序把CPU分配給進程後,該進程就一直運行,一直 到該進程運行完畢或者發生某件事情而不能運行,適用於批處理系統,優點是簡單、系統開銷小
* 搶佔式:當一個進程正在運行,操作系統可以基於某種策略剝奪CPU給其他進程,適用於交互式系統
批處理系統中的調度有:先來先服務和最短作業優先策略
交互式系統中的調度策略有:時間片輪轉、優先級和多級隊列反饋
接下來我們來詳細地討論進程間同步
進程間同步
我們先來看一個很經典的問題:生產者與消費者
現在有一個打印機進程(消費者)和多個進程(生產者)生產待打印的文件,如下圖:
這裏寫圖片描述
每個打印機都有一個緩存,這個緩存裏存放的是待打印的隊列,生產者生產文檔,消費者(打印機)打印文檔,這裏就牽扯一個問題,就是生產者與消費者之間的同步問題,即:當生產者再生產文檔的時候,如果隊列中已經填滿了文檔,就要等待打印機打印文件,以便騰出空位,同樣當隊列爲空的時候,打印機就要等待生產者去生產文件了,解決辦法如下(僞代碼[注意:這個僞代碼是程序員寫的僞代碼]):
這裏寫圖片描述
僞碼中的Item就是我們上面所說的各種文件,buffer就是那個緩衝隊列,in是生產者要放入隊列時的下標,out是消費者從隊列中所取出的文件的下標,count是隊列中文件的總數
我們簡單地看一個生產者,進入循環,如果隊列滿(count==5),那就只能等待消費者消費產品了(啥也不幹,繼續循環),如果隊列不滿的話,生產者就把生產出來的東西放到以下標in爲下標的緩存隊列中,然後下標加一再對五取餘[in = (in + 1)%5 ],這裏解釋一下這個式子,加一的目的是讓下標往後移動一個位置,而對五取餘是因爲這個隊列能容納5個元素,我要構成一個循環隊列,比如說,當生產者生產的東西是要往以4爲下標存放的時候,就會buffer[4]=item,這個時候如果消費者把下標爲0的文件打印了,那麼下一個生產出來的東西應該放在下標爲0的隊列中,就是(4+1)%5=0
當一個或多個生產者進程與消費者併發執行的時候,這時候就會出錯,這是因爲我們的一些高級語言(比如Java、C)中的一行代碼在編譯成機器碼或者class文件的時候,會變成多行代碼【比如說i++】,如下:
這裏寫圖片描述
如上圖所示:隊列中有三個文件,counter=3,這時候如果是併發執行,那麼會出現上圖的情況,我們在代碼中寫的count++,其實在機器碼層次(C語言)是,先將count的值賦值給寄存器(register),然後將寄存器的值加一,最後再將寄存器中的值賦值給count變量,那麼多進程的時候就會出錯,如上圖的併發執行,當一個進程P剛把寄存器中的值加一了,這時候進程P的時間片用完了,發生了進程間切換,進程C去執行,這個時候,counter的值還是3,然後進程C把count的值賦值到寄存器中,這時寄存器的值就變成了3(覆蓋了之前進程P中register的值4,但是進程P中所使用的寄存器中的值會保存到進程控制塊中),然後寄存器中的值減一變成2,然後這個時候進程P又執行了,把它中寄存器中的值4賦給了count,count變爲4,然後又切換到進程C,進程C把它的寄存器中的值[值爲2]賦給了counter,最終count爲2,很顯然這是個錯誤的值,本來應該是3。說了一大堆,讓我們來反思一下,出現這個問題的核心是什麼?
①進程間的執行順序不可控,你根本不知道下一個進程是誰
②在機器層面,count++、count–等操作不是原子操作
那麼我們應該如何解決這類問題?
在說解決方法之前,我們先來看一個概念:臨界區
臨界區:簡單地理解就是一個程序中訪問公共資源的程序片段,也就是這個程序片段中的指令將要訪問一個公共資源,這個公共資源有無法被多線程或者多進程訪問的特性[如果訪問會出錯]
這裏寫圖片描述
下來我們看一下解決多線程問題的解決方案注意:下面討論的方案中的代碼或僞代碼都是操作系統層面的(由操作系統實現的)或者硬件層面的
方案一:暴力手段,關閉時鐘中斷
在說這個方案之前我們先來了解一些底層的東西
①中斷:中斷是指計算機在執行期間CPU對系統某個事件做出的反應,CPU暫時中斷當前執行的程序而轉去執行相應的事件處理程序,待事件處理完畢之後又返回被中斷處理處繼續執行或調度新的進程
②硬件時鐘:硬件時鐘是指主機板上的時鐘設備
③軟件時鐘:軟件時鐘是建立在硬件時鐘的基礎上,記錄了將來某一時刻要執行的函數,當這一時刻到來,就會去執行這個函數,簡單地理解就是軟件概念上的時鐘
④時鐘中斷:時鐘中斷是軟件時鐘到時而引起的中斷
瞭解了這些概念後,我們看看這個暴力的手段,我們CPU會定期的接收到時鐘中斷,當接收到時鐘中斷後,就會通知操作系統去檢查當前進程的時間片是否用完,用完則切換,從這裏我們可以想到,如果在進程在進入臨界區的時候,我們不讓進程切換,不就避免了指令錯亂執行的問題了嗎?是的,我們可以這樣做
這裏寫圖片描述
但是這樣把關閉中斷交給應用程序是十分危險的,假設一個程序有bug,忘記關中斷,那麼電腦就會出現死機(單核)。
方案二:用硬件指令來實現鎖
上面關閉中斷的方案不太可行,我們想着能不能用一個鎖去鎖住臨界區所要訪問的資源,也就是說當一個進程在臨界區對公共資源進行訪問,我就加一把鎖,當我時間片用完了,別的進程也想訪問這個公共資源的時候,因爲有了這把鎖,它是訪問不了的。這個鎖,我們用硬件指令來實現,爲了更清楚地展現這個鎖,我們用C語言來描述一下這個鎖

boolean TestAndSet(boolean * lock){
    boolean rv = *lock;
    *lock = true;// 將鎖鎖住
    return rv;// 如果傳進來的是false返回false,傳進來true,返回true
}

注意:這個TestAndSet函數裏面的三條語句是原子執行的,也就是說調用這個函數,這三條會全部執行[硬件指令]

接下來我們看一下一個進程是如何調度這個鎖的
這裏寫圖片描述
注意:圖中的lock是各個進程公用的,默認爲FALSE
當一個進程要進入臨界區的時候,他會去做一個while(TestAndSet(&lock))循環,如果跳出循環,就說明傳進來的所爲false,沒人加鎖,那在跳出循環的時候,TestAndSet函數就會把lock置爲true[TestAndSet函數中的*lock=true],然後這個進程才進入臨界區,當在臨界區的操作全部執行完了之後,這個進程就會再次的把鎖置爲false,這個時候,別的進程纔可以用。
對應到我們的生產者和消費者中就是,當生產者在進入到臨界區,也就是要進行count++操作的時候,生產者進程就會把lock置爲true,然後執行P.register = counter; P.register = P.register+1; 這是後就算生產者進程P的時間片用完了,消費者進程執行,那消費者相對臨界區所操作的資源[寄存器]進行操作,因爲生產者已經加鎖,所以只能一直循環(佔着CPU啥也不幹,直到CPU時間片用完),這樣的話就會避免發生錯誤
方案三:信號量
前面的方法都比較偏硬件,比較麻煩,荷蘭學家迪傑斯特拉發明了一種算法,只用一個整數變量就把併發時的數據出錯問題解決了,這就是我們常聽到的P/V操作,這被廣泛地應用到各個操作系統中。P&V是荷蘭語,這裏我們用wait和signal來代替它。
信號量簡單地來說就是一個變量,我們用大寫S(semaphore)表示,程序對其訪問都是原子操作
wait函數張什麼樣?

wait(S){
    while(S<=0){
        啥也不做;//信號量小於等於0,其絕對值表示等待該資源的進程數
    }
    S--;
}

進入臨界區

signal函數張什麼樣?

離開臨界區

signal(S){
    S++;
}

我們看一個實例,看看這個wait函數和signal函數是如何運作的

semaphore mutex = 1;// 互斥信號量
wait(mutex);
進入臨界區
signal(mutex);
剩餘區

從上面的僞代碼可以看出,當一個進程進入到臨界區的時候,會調用wait函數,如果互斥信號量爲1,S–;[mutex–],mutex變爲0;這時候如果其他進程也訪問同樣的資源,這時候mutex爲0,所以一直循環,這和用硬件指令加鎖非常的像
注意:wait函數和signal函數是操作系統實現的一個原子性的函數
問題:忙等問題
前面我們看到的幾個方案中的方案二和方案三都有忙等問題【進程佔着CPU什麼事情都不做】,比如用硬件指令實現加鎖的while(TestAndSet(&lock))循環,如果條件爲真,循環體就什麼都不做。那如何解決這個問題呢?
這個時候只需要給信號量定義一個結構體就OK了

typedef struct{
    int value;// 信號量的值
    struct process * list;// 進程等待列表
}semaphore;

我們再來看看wait函數

wait(semaphore * s){
    s->value--;
    if(s->value < 0){
        把當前進程加到s->list中;
        block();// 進程進入等待狀態,不佔用CPU
    }
}

----如果傳進來的s->value爲0,那麼s->value--後變爲-1,這時候就把這個進程加入到等待列表中去,然後讓這個進程等待

我們再看看signal函數

signal(semaphore * s){
    s->value++;
    if(s->value <= 0){
        從s->list中取出一個進程p;
        wakeup(p);// 喚醒這個進程
    }
}

s->value++之後如果s->value還是小於等於0,說明等待列表中還有等待進程,比如說s->value=-1,這個時候代表等待列表中有一個進程在等待這個資源,然後有一個進程離開臨界區的時候,調用了signal函數,s->value++;這個時候s->value=0,進入if(),取出那個正在等待的進程並喚醒它。
接下來我們來看利用信號量如何解決消費者和生產者的併發執行錯誤的
用信號量解決打印問題
僞代碼如下:
這裏寫圖片描述
我們可以看到,生產者生產一個東西之前要佔據一個空位(wait(empty)),然後設置互斥量(加鎖),最後要給消費者消費的東西數量full加一。這樣進程之間操作公共資源就安全了(有互斥量)。這種方案有一個很大的問題,就是這樣這樣會很容易導致死鎖:在生產者進程運行的過程中,生產者持有公共資源A的鎖,但是在持有A的鎖後想獲得公共資源B的鎖,但是消費者進程運行後持有公共資源B的鎖,這時候想獲取資源A的鎖,雙方都持有各自想要的鎖,這樣一來,就互不相讓,導致死鎖
第二章:線程
什麼是線程?
根據維基百科中的解釋是:線程是操作系統能夠進行運算調度的最小單元。這句話比較官方,也比較難懂,其實通俗地理解,線程其實就是代碼的一個執行而已,只不過它執行代碼中的一部分,如果一個進程是單線程的,進程就相當於線程。
我們看一個圖來看看線程
這裏寫圖片描述
從圖中我們可以看到,線程中保存着有自己的寄存器和堆棧的值[在內存中],線程共享進程的代碼、數據和文件。我們來理一下,一個進程中有棧區、堆區、數據區和代碼區等等,當一個進程在執行的時候,CPU會讀取代碼區的指令,去執行它,如果需要數據會從數據區去拿,如果有函數調用就會在棧上分配空間,如果有動態的分配空間(如new),會在堆中分配內存。線程只不過是執行代碼區不同的指令,去共享進程的一些數據,代碼,然後在調用函數的時候,因爲每個線程調用的函數不同,就會保存自己的堆棧和寄存器的值。這就是線程和進程的關係。下面的圖也許更能直觀地說明這個問題
這裏寫圖片描述
從圖中我們可以看到,每一個線程執行的指令不一樣,那麼也許所需要的數據就不一樣,如果執行函數調用,在棧中就會分配不同內存,每個線程都必須保存這個數據,但是線程所保存的數據很少,如自己所用的寄存器的狀態、調用的函數棧,所以說線程之間的切換開銷比進程要小的多【系統保存的東西少】
最後我們總結一下:一個進程最少有一個線程,進程是資源擁有的基本單位,線程是CPU調度和分派的基本單位
注:本文參考劉欣老師講課以及網絡資源,圖片大多來自上課的PPT

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章