1. 程式人生 > >《現代作業系統》讀書筆記 — 程序和執行緒

《現代作業系統》讀書筆記 — 程序和執行緒

一、程序和執行緒

什麼是程序

程序是程式執行的一個過程,程式指的是我們通常意義上的程式碼,一個程式可以被執行多次,也就產生了多個程序。程序是作業系統資源分配和排程的基本單位,是作業系統結構的基礎。每個程序都有屬於自己的地址空間。

作業系統中的程序一般由三部分組成:①程序控制塊PCB;②資料段;③正文段。

UNIX系統為了節省程序控制塊所佔的記憶體空間,把每個程序控制塊分成兩部分。一部分常駐記憶體,不管程序是否正佔有處理器執行,系統經常會對這部分內容進行查詢和處理,常駐部分內容包括:程序狀態、優先數、過程特徵、資料段始址、等待原因和佇列指標等,這是進行處理器排程時必須使用的一些主要資訊。另一部分非常駐記憶體,當程序不佔有處理器時,系統不會對這部分內容進行查詢和處理,因此這部分內容可以存放在磁碟的對換區中,它隨使用者的程式和資料部分換進或換出記憶體。

UNIX系統把程序的資料段又劃分成三部分:使用者棧區(供使用者程式使用的資訊區);使用者資料區(包括使用者工作資料和非可重入的程式段);系統資料區(包括系統變數和對換資訊)。

正文段是可重入的程式,能被若干程序共享。為了管理可共享的正文段,UNIX設定了一張正文表,每個正文段都佔用一個表目,用來指出該正文段在記憶體和磁碟上的位置、段的大小以及呼叫該段的程序數等情況。

什麼是執行緒

執行緒是作業系統能夠進行運算排程的最小單位。它被包含在程序之中,是程序中的實際運作單位。一個程序中可以有多個執行緒,這些執行緒都共享該程序的系統資源。其中核心級的執行緒實現又被稱為輕量級程序。程序和執行緒都是為了並行而存在,那麼為什麼要有執行緒的概念呢?

  1. 多個並行的例項之間想要共享同一個地址空間以及資料,這是多程序無法做到的。
  2. 執行緒比程序更輕量,建立和銷燬的代價更低。在許多系統中,建立一個執行緒比建立一個程序要快10~100倍。

執行緒的實現型別

執行緒的實現型別有三種,分別是使用者級執行緒和核心級執行緒,以及他們組合混合型執行緒。

1. 使用者級執行緒

在使用者級執行緒的實現中,對於作業系統核心來說,一個程序只有一個執行緒。作業系統排程的時候只負責排程這個程序,程序內部的多執行緒有程序自己來管理並排程。

優點:

  • 由於使用者級執行緒僅存在於使用者空間中,所以不需要在核心態和使用者態之間切換,建立、銷燬、切換執行緒的速度非常快
  • 我們可以寫程式自己實現執行緒的排程演算法
  • 獨立於作業系統,可以在不支援執行緒的作業系統中使用

缺點:

  • 由於作業系統核心無法識別程序中的多執行緒,因此無法充分的利用多核CPU的特性
  • 程式執行時,一個執行緒的阻塞可能會導致整個程序的阻塞
  • 在一個程序的內部,沒有時鐘中斷,無法用輪轉排程的方式排程執行緒

2. 核心級執行緒

核心級執行緒也被稱為是輕量級程序,由作業系統核心來建立和撤銷,因此作業系統可以感知到執行緒的存在,並針對執行緒進行排程。在核心級執行緒模型中,作業系統會維護一個建立的執行緒表用來排程執行緒。

優點:

  • 充分利用多核CPU的特性
  • 程序中的一個執行緒阻塞時,不會造成程序阻塞

缺點:

  • 排程執行緒時需要切換到核心態,代價稍微比較高
  • 如果程式依賴於核心級執行緒,但是作業系統不支援的話,則可能導致程式無法執行

3. 混合型執行緒

混合型執行緒就是使用者級執行緒和核心級執行緒的混合實現。程式在自己的使用者空間建立管理執行緒,但是可以將一些執行緒對映在核心級執行緒上面。這樣就同時擁有了兩個執行緒模型的優點。

Java 就是使用的混合型執行緒:

java在jdk 1.2之前基於使用者執行緒實現,在1.2之後,基於作業系統的原生執行緒模型來實現,在每個平臺上都不盡相同,比如在windows和linux下都是採用一對一的執行緒模型實現,在Solaris平臺,採用都是一對一或者多對多來實現(solaris 同時支援一對一和多對多)。

程序和執行緒的區別

  • 程序和執行緒可以理解為父子關係,一個程序可以有多個執行緒,但是一個執行緒只會屬於一個程序。
  • 程序是資源分配和排程的基本單位,而執行緒是作業系統進行排程的最小單位(有的作業系統沒有實現執行緒,因此就是以程序為單位進行排程)。
  • 每個程序都有自己的一個使用者地址空間,而執行緒則共享程序的使用者地址空間
  • 程序建立排程開銷比較大,執行緒建立銷燬排程開銷較小

二、程序間通訊

程序間通訊方式(IPC)

由於每個程序都有自己的一個地址空間,因此程序間無法共享資料。那麼程序間要怎麼實現通訊呢?下面介紹一下linux使用的幾種程序間通訊的方式

1. 管道

管道這種通訊方式有兩種限制,一是半雙工的通訊,資料只能單向流動,二是隻能在具有親緣關係的程序間使用。程序的親緣關係通常是指父子程序關係。

管道是核心管理的一個緩衝區,一般為4K大小,它被設計成為環形的資料結構,以便管道可以被迴圈利用。可以理解為阻塞佇列,一端由一個程序輸入,一端由一個程序輸出。

在linux中,又分為無名管道和有名管道。無名管道用於用於有親緣關係的程序間使用,使用的塊是記憶體緩衝區。有名管道可以突破管道只能用於具有親緣關係的程序間使用的限制,主要是將管道放到磁碟檔案中來實現的。

2. 共享記憶體

就是拿出一塊能被所有程序訪問的記憶體塊,來達到資料共享的目的。共享記憶體是最快的 IPC 方式,它是針對其他程序間通訊方式執行效率低而專門設計的。由於多個程序同時訪問同一塊記憶體,所以需要做好程序間同步的措施。

3. 訊號量

訊號量就是一個計數器,主要用來同步各個程序之間對共享資源的訪問,也就是一種鎖機制。我們可以通過down操作將訊號量減1,當訊號量是0時,程序進入休眠。通過up將訊號量加1,同時喚醒在這個訊號量上面休眠的程序。

互斥量就是訊號量中的其中一種,它的值只有0和1,一般用來加鎖和釋放鎖。加鎖成功則設定為1,加鎖失敗則休眠。釋放鎖的時候將互斥量的值設定為0,並喚醒那些等待的程序中的一個程序。

4. 訊息佇列

訊息佇列是由訊息的連結串列,有足夠許可權的程序可以向佇列中新增訊息,被賦予讀許可權的程序則可以讀走佇列中的訊息。訊息佇列克服了訊號承載資訊量少,管道只能承載無格式位元組流以及緩衝區大小受限等缺點。

5. socket

我們常用的socket也可以實現程序之間的通訊。只要程序監聽作業系統的某個埠,其他程序就可以通過ip+埠來和這個程序進行通訊。

上面四種的通訊方式其實歸根究底都是和共享記憶體有關,但是socket卻是使用網路介面來實現的通訊。另外socket還可以跨機器進行程序通訊。

程序間同步

當多個程序共同訪問共享資料時,就要考慮程序同步問題。

一個典型的例子就是假設A、B程序都要訪問一個變數a=0,然後將a從記憶體取出來之後+1,再放回記憶體。如果A和B沒有做同步,可能會導致A和B同時去獲取變數a的資訊,然後+1,再放回去的情況,最後結果還是1(正常情況應該是2)。這樣就會造成最終資料和預想的不一致的情況。

臨界區

為了解決這個問題,必須保證A和B在對a變數進行操作時是互斥的。這裡就引入了一個臨界區的概念,我們把對共享記憶體進行訪問的程式片段稱作臨界區。任何兩個程序不能同時處於其臨界區。

要實現臨界區就要保證程序之間的互斥,那麼有哪些辦法可以保證程序之間的互斥呢?

臨界區的實現方案

1. 遮蔽中斷

程序可以在進入臨界區之後遮蔽所有的中斷,保證訪問共享記憶體時不會有其他的程序也進入臨界區中。這個方案並不好,因為這樣要把遮蔽中斷的權利交給使用者程序,有一定的隱患。另外,如果是多核處理器的話,遮蔽中斷也無法保證不會有其他程序進入臨界區。

2. 鎖變數 — TSL指令和XCHG指令

可以設定一個共享變數,其初始值為0。如果一個程序想要進入臨界區,就要先檢查其值是否為0,是的話將值設定為1,並進入臨界區,否則不允許進入。——其實就是一個互斥量(訊號量)

但是共享變數還是會有程序同步的問題,那麼怎麼保證不會出現多個程序同時檢查共享變數的情況呢?這就需要從硬體層面上來解決了。

在某些計算機中,特別是那些設計為多處理器的計算機,都有下面這條指令

TSL RX,LOCK

這個指令被稱為測試並加鎖,它可以先將記憶體地址的值讀出來,然後再寫入一個非0的值到記憶體地址中。讀和寫操作是不可分割的,保證了指令的原子性。執行該指令的時候會鎖住CPU匯流排,以禁止其他的CPU在本指令結束前訪問記憶體。

下面是通過TSL指令實現的訪問臨界區的彙編虛擬碼

enter_region:
	TSL REGISTER,LOCK
	CMP REGISTER,#0		//判斷從記憶體讀出來的數是否為0
	JNE enter_region    //若讀出來的數不是0,說明鎖已經被設定,所以迴圈
	RET     //返回呼叫者,進入臨界區
leave_region:
	MOVE LOCK,#0	//在鎖變數中存入0
	RET		//返回呼叫者

一個可替代TSL的指令是XCHG,它原子性的交換了兩個位置的內容。實現臨界區的原理和TSL差不多。

管程

使用訊號量做程序之間的同步,會使up(S)和down(S)操作大量分散在各個程序中,不易管理,易發生死鎖。因此引入了管程的概念。

管程可以看做一個軟體模組,它是將共享的變數和對於這些共享變數的操作封裝起來,形成一個具有一定介面的功能模組,程序可以呼叫管程來實現程序級別的併發控制。任何一個時刻,管程只能由一個程序使用。

進入管程時的互斥由編譯器負責完成(如何實現互斥都寫好了),相比程式設計師自己去實現互斥,出錯的可能性要小很多。所以我們無須關心編譯器是如何實現互斥的,只需要將所有的臨界區轉換成管程過程即可。

**在管程中執行的程序,如果發現自身無法繼續執行下去(可能要等待外部某些條件達成),可以通過執行wait操作阻塞自己,同時推出管程,這樣其他的程序就可以進入管程中。**另外,其他程序可以通過在某個共享變數上執行signal操作來喚醒那些進入wait的程序。

java的同步原語synchronized就是使用了管程。在java的同步塊中,我們執行Object.wait()時會釋放鎖,這樣其他的執行緒就可以獲取到鎖。同時可以通過Object.notify()來通知在wati中的執行緒。

三、結尾

最近在看《現代作業系統》這本書,剛看完程序和執行緒這一章,邊看邊記錄,也就寫了這篇部落格。這本書太厚了,內容也很多,所以只能把一些自認為關鍵的點寫出來。

看完這一章,特別是程序同步那一塊的內容,讓我對java鎖的實現原理有了更深的瞭解。

本來還想寫一下關於程序排程的演算法,但是想了想這部分看的時候也沒有很認真去鑽研過,真寫下來內容也有點多,可能大部分還是複製過來的,加上網上資料一查一大堆,實在沒有寫下來的必要。