上一篇文章只是簡單的描述了一下 Linux 基本概念,通過幾個例子來說明 Linux 基本應用程式,然後以 Linux 基本核心構造來結尾。那麼本篇文章我們就深入理解一下 Linux 核心來理解 Linux 的基本概念之程式和執行緒。系統呼叫是作業系統本身的介面,它對於建立程式和執行緒,記憶體分配,共享檔案和 I/O 來說都很重要。
我們將從各個版本的共性出發來進行探討。
基本概念
Linux 一個非常重要的概念就是程式,Linux 程式和我們在
程式和執行緒 這篇文章
中探討的程式模型非常相似。每個程式都會執行一段獨立的程式,並且在初始化的時候擁有一個獨立的控制執行緒。換句話說,每個程式都會有一個自己的程式計數器,這個程式計數器用來記錄下一個需要被執行的指令。Linux 允許程式在執行時建立額外的執行緒。
Linux 是一個多道程式設計系統,因此係統中存在彼此相互獨立的程式同時執行。此外,每個使用者都會同時有幾個活動的程式。因為如果是一個大型系統,可能有數百上千的程式在同時執行。
在某些使用者空間中,即使使用者退出登入,仍然會有一些後臺程式在執行,這些程式被稱為 守護程式(daemon)
。
Linux 中有一種特殊的守護程式被稱為 計劃守護程式(Cron daemon)
,計劃守護程式可以每分鐘醒來一次檢查是否有工作要做,做完會繼續回到睡眠狀態等待下一次喚醒。
Cron 是一個守護程式,可以做任何你想做的事情,比如說你可以定期進行系統維護、定期進行系統備份等。在其他作業系統上也有類似的程式,比如 Mac OS X 上 Cron 守護程式被稱為
launchd
的守護程式。在 Windows 上可以被稱為計劃任務(Task Scheduler)
。
在 Linux 系統中,程式通過非常簡單的方式來建立,fork
系統呼叫會建立一個源程式的拷貝(副本)
。呼叫 fork 函式的程式被稱為 父程式(parent process)
,使用 fork 函式建立出來的程式被稱為 子程式(child process)
。父程式和子程式都有自己的記憶體映像。如果在子程式建立出來後,父程式修改了一些變數等,那麼子程式是看不到這些變化的,也就是 fork 後,父程式和子程式相互獨立。
雖然父程式和子程式保持相互獨立,但是它們卻能夠共享相同的檔案,如果在 fork 之前,父程式已經開啟了某個檔案,那麼 fork 後,父程式和子程式仍然共享這個開啟的檔案。對共享檔案的修改會對父程式和子程式同時可見。
那麼該如何區分父程式和子程式呢?子程式只是父程式的拷貝,所以它們幾乎所有的情況都一樣,包括記憶體映像、變數、暫存器等。區分的關鍵在於 fork
函式呼叫後的返回值,如果 fork 後返回一個非零值,這個非零值即是子程式的 程式識別符號(Process Identiier, PID)
,而會給子程式返回一個零值,可以用下面程式碼來進行表示
pid = fork(); // 呼叫 fork 函式建立程式
if(pid < 0){
error() // pid < 0,建立失敗
}
else if(pid > 0){
parent_handle() // 父程式程式碼
}
else {
child_handle() // 子程式程式碼
}
父程式在 fork 後會得到子程式的 PID,這個 PID 即能代表這個子程式的唯一識別符號也就是 PID。如果子程式想要知道自己的 PID,可以呼叫 getpid
方法。當子程式結束執行時,父程式會得到子程式的 PID,因為一個程式會 fork 很多子程式,子程式也會 fork 子程式,所以 PID 是非常重要的。我們把第一次呼叫 fork 後的程式稱為 原始程式
,一個原始程式可以生成一顆繼承樹
Linux 程式間通訊
Linux 程式間的通訊機制通常被稱為 Internel-Process communication,IPC
下面我們來說一說 Linux 程式間通訊的機制,大致來說,Linux 程式間的通訊機制可以分為 6 種
下面我們分別對其進行概述
訊號 signal
訊號是 UNIX 系統最先開始使用的程式間通訊機制,因為 Linux 是繼承於 UNIX 的,所以 Linux 也支援訊號機制,通過向一個或多個程式傳送非同步事件訊號
來實現,訊號可以從鍵盤或者訪問不存在的位置等地方產生;訊號通過 shell 將任務傳送給子程式。
你可以在 Linux 系統上輸入 kill -l
來列出系統使用的訊號,下面是我提供的一些訊號
程式可以選擇忽略傳送過來的訊號,但是有兩個是不能忽略的:SIGSTOP
和 SIGKILL
訊號。SIGSTOP 訊號會通知當前正在執行的程式執行關閉操作,SIGKILL 訊號會通知當前程式應該被殺死。除此之外,程式可以選擇它想要處理的訊號,程式也可以選擇阻止訊號,如果不阻止,可以選擇自行處理,也可以選擇進行核心處理。如果選擇交給核心進行處理,那麼就執行預設處理。
作業系統會中斷目標程式的程式來向其傳送訊號、在任何非原子指令中,執行都可以中斷,如果程式已經註冊了新號處理程式,那麼就執行程式,如果沒有註冊,將採用預設處理的方式。
例如:當程式收到 SIGFPE
浮點異常的訊號後,預設操作是對其進行 dump(轉儲)
和退出。訊號沒有優先順序的說法。如果同時為某個程式產生了兩個訊號,則可以將它們呈現給程式或者以任意的順序進行處理。
下面我們就來看一下這些訊號是幹什麼用的
- SIGABRT 和 SIGIOT
SIGABRT 和 SIGIOT 訊號傳送給程式,告訴其進行終止,這個 訊號通常在呼叫 C標準庫的abort()
函式時由程式本身啟動
- SIGALRM 、 SIGVTALRM、SIGPROF
當設定的時鐘功能超時時會將 SIGALRM 、 SIGVTALRM、SIGPROF 傳送給程式。當實際時間或時鐘時間超時時,傳送 SIGALRM。 當程式使用的 CPU 時間超時時,將傳送 SIGVTALRM。 當程式和系統代表程式使用的CPU 時間超時時,將傳送 SIGPROF。
- SIGBUS
SIGBUS 將造成匯流排中斷
錯誤時傳送給程式
- SIGCHLD
當子程式終止、被中斷或者被中斷恢復,將 SIGCHLD 傳送給程式。此訊號的一種常見用法是指示作業系統在子程式終止後清除其使用的資源。
- SIGCONT
SIGCONT 訊號指示作業系統繼續執行先前由 SIGSTOP 或 SIGTSTP 訊號暫停的程式。該訊號的一個重要用途是在 Unix shell 中的作業控制中。
- SIGFPE
SIGFPE 訊號在執行錯誤的算術運算(例如除以零)時將被髮送到程式。
- SIGUP
當 SIGUP 訊號控制的終端關閉時,會傳送給程式。許多守護程式將重新載入其配置檔案並重新開啟其日誌檔案,而不是在收到此訊號時退出。
- SIGILL
SIGILL 訊號在嘗試執行非法、格式錯誤、未知或者特權指令時發出
- SIGINT
當使用者希望中斷程式時,作業系統會向程式傳送 SIGINT 訊號。使用者輸入 ctrl - c 就是希望中斷程式。
- SIGKILL
SIGKILL 訊號傳送到程式以使其馬上進行終止。 與 SIGTERM 和 SIGINT 相比,這個訊號無法捕獲和忽略執行,並且程式在接收到此訊號後無法執行任何清理操作,下面是一些例外情況
殭屍程式無法殺死,因為殭屍程式已經死了,它在等待父程式對其進行捕獲
處於阻塞狀態的程式只有再次喚醒後才會被 kill 掉
init
程式是 Linux 的初始化程式,這個程式會忽略任何訊號。
SIGKILL 通常是作為最後殺死程式的訊號、它通常作用於 SIGTERM 沒有響應時傳送給程式。
- SIGPIPE
SIGPIPE 嘗試寫入程式管道時發現管道未連線無法寫入時傳送到程式
- SIGPOLL
當在明確監視的檔案描述符上發生事件時,將傳送 SIGPOLL 訊號。
- SIGRTMIN 至 SIGRTMAX
SIGRTMIN 至 SIGRTMAX 是實時訊號
- SIGQUIT
當使用者請求退出程式並執行核心轉儲時,SIGQUIT 訊號將由其控制終端傳送給程式。
- SIGSEGV
當 SIGSEGV 訊號做出無效的虛擬記憶體引用或分段錯誤時,即在執行分段違規時,將其傳送到程式。
- SIGSTOP
SIGSTOP 指示作業系統終止以便以後進行恢復時
- SIGSYS
當 SIGSYS 訊號將錯誤引數傳遞給系統呼叫時,該訊號將傳送到程式。
- SYSTERM
我們上面簡單提到過了 SYSTERM 這個名詞,這個訊號傳送給程式以請求終止。與 SIGKILL 訊號不同,該訊號可以被過程捕獲或忽略。這允許程式執行良好的終止,從而釋放資源並在適當時儲存狀態。 SIGINT 與SIGTERM 幾乎相同。
- SIGTSIP
SIGTSTP 訊號由其控制終端傳送到程式,以請求終端停止。
- SIGTTIN 和 SIGTTOU
當 SIGTTIN 和SIGTTOU 訊號分別在後臺嘗試從 tty 讀取或寫入時,訊號將傳送到該程式。
- SIGTRAP
在發生異常或者 trap 時,將 SIGTRAP 訊號傳送到程式
- SIGURG
當套接字具有可讀取的緊急或帶外資料時,將 SIGURG 訊號傳送到程式。
- SIGUSR1 和 SIGUSR2
SIGUSR1 和 SIGUSR2 訊號被髮送到程式以指示使用者定義的條件。
- SIGXCPU
當 SIGXCPU 訊號耗盡 CPU 的時間超過某個使用者可設定的預定值時,將其傳送到程式
- SIGXFSZ
當 SIGXFSZ 訊號增長超過最大允許大小的檔案時,該訊號將傳送到該程式。
- SIGWINCH
SIGWINCH 訊號在其控制終端更改其大小(視窗更改)時傳送給程式。
管道 pipe
Linux 系統中的程式可以通過建立管道 pipe 進行通訊
在兩個程式之間,可以建立一個通道,一個程式向這個通道里寫入位元組流,另一個程式從這個管道中讀取位元組流。管道是同步的,當程式嘗試從空管道讀取資料時,該程式會被阻塞,直到有可用資料為止。shell 中的管線 pipelines
就是用管道實現的,當 shell 發現輸出
sort <f | head
它會建立兩個程式,一個是 sort,一個是 head,sort,會在這兩個應用程式之間建立一個管道使得 sort 程式的標準輸出作為 head 程式的標準輸入。sort 程式產生的輸出就不用寫到檔案中了,如果管道滿了系統會停止 sort 以等待 head 讀出資料
管道實際上就是 |
,兩個應用程式不知道有管道的存在,一切都是由 shell 管理和控制的。
共享記憶體 shared memory
兩個程式之間還可以通過共享記憶體進行程式間通訊,其中兩個或者多個程式可以訪問公共記憶體空間。兩個程式的共享工作是通過共享記憶體完成的,一個程式所作的修改可以對另一個程式可見(很像執行緒間的通訊)。
在使用共享記憶體前,需要經過一系列的呼叫流程,流程如下
- 建立共享記憶體段或者使用已建立的共享記憶體段
(shmget())
- 將程式附加到已經建立的記憶體段中
(shmat())
- 從已連線的共享記憶體段分離程式
(shmdt())
- 對共享記憶體段執行控制操作
(shmctl())
先入先出佇列 FIFO
先入先出佇列 FIFO 通常被稱為 命名管道(Named Pipes)
,命名管道的工作方式與常規管道非常相似,但是確實有一些明顯的區別。未命名的管道沒有備份檔案:作業系統負責維護記憶體中的緩衝區,用來將位元組從寫入器傳輸到讀取器。一旦寫入或者輸出終止的話,緩衝區將被回收,傳輸的資料會丟失。相比之下,命名管道具有支援檔案和獨特 API ,命名管道在檔案系統中作為裝置的專用檔案存在。當所有的程式通訊完成後,命名管道將保留在檔案系統中以備後用。命名管道具有嚴格的 FIFO 行為
寫入的第一個位元組是讀取的第一個位元組,寫入的第二個位元組是讀取的第二個位元組,依此類推。
訊息佇列 Message Queue
一聽到訊息佇列這個名詞你可能不知道是什麼意思,訊息佇列是用來描述核心定址空間內的內部連結列表。可以按幾種不同的方式將訊息按順序傳送到佇列並從佇列中檢索訊息。每個訊息佇列由 IPC 識別符號唯一標識。訊息佇列有兩種模式,一種是嚴格模式
, 嚴格模式就像是 FIFO 先入先出佇列似的,訊息順序傳送,順序讀取。還有一種模式是 非嚴格模式
,訊息的順序性不是非常重要。
套接字 Socket
還有一種管理兩個程式間通訊的是使用 socket
,socket 提供端到端的雙相通訊。一個套接字可以與一個或多個程式關聯。就像管道有命令管道和未命名管道一樣,套接字也有兩種模式,套接字一般用於兩個程式之間的網路通訊,網路套接字需要來自諸如TCP(傳輸控制協議)
或較低階別UDP(使用者資料包協議)
等基礎協議的支援。
套接字有以下幾種分類
順序包套接字(Sequential Packet Socket)
: 此類套接字為最大長度固定的資料包提供可靠的連線。此連線是雙向的並且是順序的。資料包套接字(Datagram Socket)
:資料包套接字支援雙向資料流。資料包套接字接受訊息的順序與傳送者可能不同。流式套接字(Stream Socket)
:流套接字的工作方式類似於電話對話,提供雙向可靠的資料流。原始套接字(Raw Socket)
: 可以使用原始套接字訪問基礎通訊協議。
Linux 中程式管理系統呼叫
現在關注一下 Linux 系統中與程式管理相關的系統呼叫。在瞭解之前你需要先知道一下什麼是系統呼叫。
作業系統為我們遮蔽了硬體和軟體的差異,它的最主要功能就是為使用者提供一種抽象,隱藏內部實現,讓使用者只關心在 GUI 圖形介面下如何使用即可。作業系統可以分為兩種模式
- 核心態:作業系統核心使用的模式
- 使用者態:使用者應用程式所使用的模式
我們常說的上下文切換
指的就是核心態模式和使用者態模式的頻繁切換。而系統呼叫
指的就是引起核心態和使用者態切換的一種方式,系統呼叫通常在後臺靜默執行,表示計算機程式向其作業系統核心請求服務。
系統呼叫指令有很多,下面是一些與程式管理相關的最主要的系統呼叫
fork
fork 呼叫用於建立一個與父程式相同的子程式,建立完程式後的子程式擁有和父程式一樣的程式計數器、相同的 CPU 暫存器、相同的開啟檔案。
exec
exec 系統呼叫用於執行駐留在活動程式中的檔案,呼叫 exec 後,新的可執行檔案會替換先前的可執行檔案並獲得執行。也就是說,呼叫 exec 後,會將舊檔案或程式替換為新檔案或執行,然後執行檔案或程式。新的執行程式被載入到相同的執行空間中,因此程式的 PID
不會修改,因為我們沒有建立新程式,只是替換舊程式。但是程式的資料、程式碼、堆疊都已經被修改。如果當前要被替換的程式包含多個執行緒,那麼所有的執行緒將被終止,新的程式映像被載入執行。
這裡需要解釋一下程式映像(Process image)
的概念
什麼是程式映像呢?程式映像是執行程式時所需要的可執行檔案,通常會包括下面這些東西
- 程式碼段(codesegment/textsegment)
又稱文字段,用來存放指令,執行程式碼的一塊記憶體空間
此空間大小在程式碼執行前就已經確定
記憶體空間一般屬於只讀,某些架構的程式碼也允許可寫
在程式碼段中,也有可能包含一些只讀的常數變數,例如字串常量等。
- 資料段(datasegment)
可讀可寫
儲存初始化的全域性變數和初始化的 static 變數
資料段中資料的生存期是隨程式持續性(隨程式持續性)
隨程式持續性:程式建立就存在,程式死亡就消失
- bss 段(bsssegment):
可讀可寫
儲存未初始化的全域性變數和未初始化的 static 變數
bss 段中的資料一般預設為 0
- Data 段
是可讀寫的,因為變數的值可以在執行時更改。此段的大小也固定。
- 棧(stack):
可讀可寫
儲存的是函式或程式碼中的區域性變數(非 static 變數)
棧的生存期隨程式碼塊持續性,程式碼塊執行就給你分配空間,程式碼塊結束,就自動回收空間
- 堆(heap):
可讀可寫
儲存的是程式執行期間動態分配的 malloc/realloc 的空間
堆的生存期隨程式持續性,從 malloc/realloc 到 free 一直存在
下面是這些區域的構成圖
exec 系統呼叫是一些函式的集合,這些函式是
- execl
- execle
- execlp
- execv
- execve
- execvp
下面來看一下 exec 的工作原理
- 當前程式映像被替換為新的程式映像
- 新的程式映像是你做為 exec 傳遞的燦睡
- 結束當前正在執行的程式
- 新的程式映像有 PID,相同的環境和一些檔案描述符(因為未替換程式,只是替換了程式映像)
- CPU 狀態和虛擬記憶體受到影響,當前程式映像的虛擬記憶體對映被新程式映像的虛擬記憶體代替。
waitpid
等待子程式結束或終止
exit
在許多計算機作業系統上,計算機程式的終止是通過執行 exit
系統呼叫命令執行的。0 表示程式能夠正常結束,其他值表示程式以非正常的行為結束。
其他一些常見的系統呼叫如下
系統呼叫指令 | 描述 |
---|---|
pause | 掛起訊號 |
nice | 改變分時程式的優先順序 |
ptrace | 程式跟蹤 |
kill | 向程式傳送訊號 |
pipe | 建立管道 |
mkfifo | 建立 fifo 的特殊檔案(命名管道) |
sigaction | 設定對指定訊號的處理方法 |
msgctl | 訊息控制操作 |
semctl | 訊號量控制 |
Linux 程式和執行緒的實現
Linux 程式
Linux 程式就像一座冰山,你看到的只是冰山一角。
在 Linux 核心結構中,程式會被表示為 任務
,通過結構體 structure
來建立。不像其他的作業系統會區分程式、輕量級程式和執行緒,Linux 統一使用任務結構來代表執行上下文。因此,對於每個單執行緒程式來說,單執行緒程式將用一個任務結構表示,對於多執行緒程式來說,將為每一個使用者級執行緒分配一個任務結構。Linux 核心是多執行緒的,並且核心級執行緒不與任何使用者級執行緒相關聯。
對於每個程式來說,在記憶體中都會有一個 task_struct
程式描述符與之對應。程式描述符包含了核心管理程式所有有用的資訊,包括 排程引數、開啟檔案描述符等等。程式描述符從程式建立開始就一直存在於核心堆疊中。
Linux 和 Unix 一樣,都是通過 PID
來區分不同的程式,核心會將所有程式的任務結構組成為一個雙向連結串列。PID 能夠直接被對映稱為程式的任務結構所在的地址,從而不需要遍歷雙向連結串列直接訪問。
我們上面提到了程式描述符,這是一個非常重要的概念,我們上面還提到了程式描述符是位於記憶體中的,這裡我們省略了一句話,那就是程式描述符是存在使用者的任務結構中,當程式位於記憶體並開始執行時,程式描述符才會被調入記憶體。
程式位於記憶體
被稱為PIM(Process In Memory)
,這是馮諾伊曼體系架構的一種體現,載入到記憶體中並執行的程式稱為程式。簡單來說,一個程式就是正在執行的程式。
程式描述符可以歸為下面這幾類
排程引數(scheduling parameters)
:程式優先順序、最近消耗 CPU 的時間、最近睡眠時間一起決定了下一個需要執行的程式記憶體映像(memory image)
:我們上面說到,程式映像是執行程式時所需要的可執行檔案,它由資料和程式碼組成。訊號(signals)
:顯示哪些訊號被捕獲、哪些訊號被執行暫存器
:當發生核心陷入 (trap) 時,暫存器的內容會被儲存下來。系統呼叫狀態(system call state)
:當前系統呼叫的資訊,包括引數和結果檔案描述符表(file descriptor table)
:有關檔案描述符的系統被呼叫時,檔案描述符作為索引在檔案描述符表中定位相關檔案的 i-node 資料結構統計資料(accounting)
:記錄使用者、程式佔用系統 CPU 時間表的指標,一些作業系統還儲存程式最多佔用的 CPU 時間、程式擁有的最大堆疊空間、程式可以消耗的頁面數等。核心堆疊(kernel stack)
:程式的核心部分可以使用的固定堆疊其他
: 當前程式狀態、事件等待時間、距離警報的超時時間、PID、父程式的 PID 以及使用者識別符號等
有了上面這些資訊,現在就很容易描述在 Linux 中是如何建立這些程式的了,建立新流程實際上非常簡單。為子程式開闢一塊新的使用者空間的程式描述符,然後從父程式複製大量的內容。為這個子程式分配一個 PID,設定其記憶體對映,賦予它訪問父程式檔案的許可權,註冊並啟動。
當執行 fork 系統呼叫時,呼叫程式會陷入核心並建立一些和任務相關的資料結構,比如核心堆疊(kernel stack)
和 thread_info
結構。
關於 thread_info 結構可以參考
這個結構中包含程式描述符,程式描述符位於固定的位置,使得 Linux 系統只需要很小的開銷就可以定位到一個執行中程式的資料結構。
程式描述符的主要內容是根據父程式
的描述符來填充。Linux 作業系統會尋找一個可用的 PID,並且此 PID 沒有被任何程式使用,更新程式標示符使其指向一個新的資料結構即可。為了減少 hash table 的碰撞,程式描述符會形成連結串列
。它還將 task_struct 的欄位設定為指向任務陣列上相應的上一個/下一個程式。
task_struct : Linux 程式描述符,內部涉及到眾多 C++ 原始碼,我們會在後面進行講解。
從原則上來說,為子程式開闢記憶體區域併為子程式分配資料段、堆疊段,並且對父程式的內容進行復制,但是實際上 fork 完成後,子程式和父程式沒有共享記憶體,所以需要複製技術來實現同步,但是複製開銷比較大,因此 Linux 作業系統使用了一種 欺騙
方式。即為子程式分配頁表,然後新分配的頁表指向父程式的頁面,同時這些頁面是隻讀的。當程式向這些頁面進行寫入的時候,會開啟保護錯誤。核心發現寫入操作後,會為程式分配一個副本,使得寫入時把資料複製到這個副本上,這個副本是共享的,這種方式稱為 寫入時複製(copy on write)
,這種方式避免了在同一塊記憶體區域維護兩個副本的必要,節省記憶體空間。
在子程式開始執行後,作業系統會呼叫 exec 系統呼叫,核心會進行查詢驗證可執行檔案,把引數和環境變數複製到核心,釋放舊的地址空間。
現在新的地址空間需要被建立和填充。如果系統支援對映檔案,就像 Unix 系統一樣,那麼新的頁表就會建立,表明記憶體中沒有任何頁,除非所使用的頁面是堆疊頁,其地址空間由磁碟上的可執行檔案支援。新程式開始執行時,立刻會收到一個缺頁異常(page fault)
,這會使具有程式碼的頁面載入進入記憶體。最後,引數和環境變數被複制到新的堆疊中,重置訊號,暫存器全部清零。新的命令開始執行。
下面是一個示例,使用者輸出 ls,shell 會呼叫 fork 函式複製一個新程式,shell 程式會呼叫 exec 函式用可執行檔案 ls 的內容覆蓋它的記憶體。
Linux 執行緒
現在我們來討論一下 Linux 中的執行緒,執行緒是輕量級的程式,想必這句話你已經聽過很多次了,輕量級
體現在所有的程式切換都需要清除所有的表、程式間的共享資訊也比較麻煩,一般來說通過管道或者共享記憶體,如果是 fork 函式後的父子程式則使用共享檔案,然而執行緒切換不需要像程式一樣具有昂貴的開銷,而且執行緒通訊起來也更方便。執行緒分為兩種:使用者級執行緒和核心級執行緒
使用者級執行緒
使用者級執行緒避免使用核心,通常,每個執行緒會顯示呼叫開關,傳送訊號或者執行某種切換操作來放棄 CPU,同樣,計時器可以強制進行開關,使用者執行緒的切換速度通常比核心執行緒快很多。在使用者級別實現執行緒會有一個問題,即單個執行緒可能會壟斷 CPU 時間片,導致其他執行緒無法執行從而 餓死
。如果執行一個 I/O 操作,那麼 I/O 會阻塞,其他執行緒也無法執行。
一種解決方案是,一些使用者級的執行緒包解決了這個問題。可以使用時鐘週期的監視器來控制第一時間時間片獨佔。然後,一些庫通過特殊的包裝來解決系統呼叫的 I/O 阻塞問題,或者可以為非阻塞 I/O 編寫任務。
核心級執行緒
核心級執行緒通常使用幾個程式表在核心中實現,每個任務都會對應一個程式表。在這種情況下,核心會在每個程式的時間片內排程每個執行緒。
所有能夠阻塞的呼叫都會通過系統呼叫的方式來實現,當一個執行緒阻塞時,核心可以進行選擇,是執行在同一個程式中的另一個執行緒(如果有就緒執行緒的話)還是執行一個另一個程式中的執行緒。
從使用者空間 -> 核心空間 -> 使用者空間的開銷比較大,但是執行緒初始化的時間損耗可以忽略不計。這種實現的好處是由時鐘決定執行緒切換時間,因此不太可能將時間片與任務中的其他執行緒佔用時間繫結到一起。同樣,I/O 阻塞也不是問題。
混合實現
結合使用者空間和核心空間的優點,設計人員採用了一種核心級執行緒
的方式,然後將使用者級執行緒與某些或者全部核心執行緒多路複用起來
在這種模型中,程式設計人員可以自由控制使用者執行緒和核心執行緒的數量,具有很大的靈活度。採用這種方法,核心只識別核心級執行緒,並對其進行排程。其中一些核心級執行緒會被多個使用者級執行緒多路複用。
Linux 排程
下面我們來關注一下 Linux 系統的排程演算法,首先需要認識到,Linux 系統的執行緒是核心執行緒,所以 Linux 系統是基於執行緒的,而不是基於程式的。
為了進行排程,Linux 系統將執行緒分為三類
- 實時先入先出
- 實時輪詢
- 分時
實時先入先出執行緒具有最高優先順序,它不會被其他執行緒所搶佔,除非那是一個剛剛準備好的,擁有更高優先順序的執行緒進入。實時輪轉執行緒與實時先入先出執行緒基本相同,只是每個實時輪轉執行緒都有一個時間量,時間到了之後就可以被搶佔。如果多個實時執行緒準備完畢,那麼每個執行緒執行它時間量所規定的時間,然後插入到實時輪轉執行緒末尾。
注意這個實時只是相對的,無法做到絕對的實時,因為執行緒的執行時間無法確定。它們相對分時系統來說,更加具有實時性
Linux 系統會給每個執行緒分配一個 nice
值,這個值代表了優先順序的概念。nice 值預設值是 0 ,但是可以通過系統呼叫 nice 值來修改。修改值的範圍從 -20 - +19。nice 值決定了執行緒的靜態優先順序。一般系統管理員的 nice 值會比一般執行緒的優先順序高,它的範圍是 -20 - -1。
下面我們更詳細的討論一下 Linux 系統的兩個排程演算法,它們的內部與排程佇列(runqueue)
的設計很相似。執行佇列有一個資料結構用來監視系統中所有可執行的任務並選擇下一個可以執行的任務。每個執行佇列和系統中的每個 CPU 有關。
Linux O(1)
排程器是歷史上很流行的一個排程器。這個名字的由來是因為它能夠在常數時間內執行任務排程。在 O(1) 排程器裡,排程佇列被組織成兩個陣列,一個是任務正在活動的陣列,一個是任務過期失效的陣列。如下圖所示,每個陣列都包含了 140 個連結串列頭,每個連結串列頭具有不同的優先順序。
大致流程如下:
排程器從正在活動陣列中選擇一個優先順序最高的任務。如果這個任務的時間片過期失效了,就把它移動到過期失效陣列中。如果這個任務阻塞了,比如說正在等待 I/O 事件,那麼在它的時間片過期失效之前,一旦 I/O 操作完成,那麼這個任務將會繼續執行,它將被放回到之前正在活動的陣列中,因為這個任務之前已經消耗一部分 CPU 時間片,所以它將執行剩下的時間片。當這個任務執行完它的時間片後,它就會被放到過期失效陣列中。一旦正在活動的任務陣列中沒有其他任務後,排程器將會交換指標,使得正在活動的陣列變為過期失效陣列,過期失效陣列變為正在活動的陣列。使用這種方式可以保證每個優先順序的任務都能夠得到執行,不會導致執行緒飢餓。
在這種排程方式中,不同優先順序的任務所得到 CPU 分配的時間片也是不同的,高優先順序程式往往能得到較長的時間片,低優先順序的任務得到較少的時間片。
這種方式為了保證能夠更好的提供服務,通常會為 互動式程式
賦予較高的優先順序,互動式程式就是使用者程式
。
Linux 系統不知道一個任務究竟是 I/O 密集型的還是 CPU 密集型的,它只是依賴於互動式的方式,Linux 系統會區分是靜態優先順序
還是 動態優先順序
。動態優先順序是採用一種獎勵機制來實現的。獎勵機制有兩種方式:獎勵互動式執行緒、懲罰佔用 CPU 的執行緒。在 Linux O(1) 排程器中,最高的優先順序獎勵是 -5,注意這個優先順序越低越容易被執行緒排程器接受,所以最高懲罰的優先順序是 +5。具體體現就是作業系統維護一個名為 sleep_avg
的變數,任務喚醒會增加 sleep_avg 變數的值,當任務被搶佔或者時間量過期會減少這個變數的值,反映在獎勵機制上。
O(1) 排程演算法是 2.6 核心版本的排程器,最初引入這個排程演算法的是不穩定的 2.5 版本。早期的排程演算法在多處理器環境中說明了通過訪問正在活動陣列就可以做出排程的決定。使排程可以在固定的時間 O(1) 完成。
O(1) 排程器使用了一種 啟發式
的方式,這是什麼意思?
在電腦科學中,啟發式是一種當傳統方式解決問題很慢時用來快速解決問題的方式,或者找到一個在傳統方法無法找到任何精確解的情況下找到近似解。
O(1) 使用啟發式的這種方式,會使任務的優先順序變得複雜並且不完善,從而導致在處理互動任務時效能很糟糕。
為了改進這個缺點,O(1) 排程器的開發者又提出了一個新的方案,即 公平排程器(Completely Fair Scheduler, CFS)
。 CFS 的主要思想是使用一顆紅黑樹
作為排程佇列。
資料結構太重要了。
CFS 會根據任務在 CPU 上的執行時間長短而將其有序地排列在樹中,時間精確到納秒級。下面是 CFS 的構造模型
CFS 的排程過程如下:
CFS 演算法總是優先排程哪些使用 CPU 時間最少的任務。最小的任務一般都是在最左邊的位置。當有一個新的任務需要執行時,CFS 會把這個任務和最左邊的數值進行對比,如果此任務具有最小時間值,那麼它將進行執行,否則它會進行比較,找到合適的位置進行插入。然後 CPU 執行紅黑樹上當前比較的最左邊的任務。
在紅黑樹中選擇一個節點來執行的時間可以是常數時間,但是插入一個任務的時間是 O(loog(N))
,其中 N 是系統中的任務數。考慮到當前系統的負載水平,這是可以接受的。
排程器只需要考慮可執行的任務即可。這些任務被放在適當的排程佇列中。不可執行的任務和正在等待的各種 I/O 操作或核心事件的任務被放入一個等待佇列
中。等待佇列頭包含一個指向任務連結串列的指標和一個自旋鎖。自旋鎖對於併發處理場景下用處很大。
Linux 系統中的同步
下面來聊一下 Linux 中的同步機制。早期的 Linux 核心只有一個 大核心鎖(Big Kernel Lock,BKL)
。它阻止了不同處理器併發處理的能力。因此,需要引入一些粒度更細的鎖機制。
Linux 提供了若干不同型別的同步變數,這些變數既能夠在核心中使用,也能夠在使用者應用程式中使用。在地層中,Linux 通過使用 atomic_set
和 atomic_read
這樣的操作為硬體支援的原子指令提供封裝。硬體提供記憶體重排序,這是 Linux 屏障的機制。
具有高階別的同步像是自旋鎖的描述是這樣的,當兩個程式同時對資源進行訪問,在一個程式獲得資源後,另一個程式不想被阻塞,所以它就會自旋,等待一會兒再對資源進行訪問。Linux 也提供互斥量或訊號量這樣的機制,也支援像是 mutex_tryLock
和 mutex_tryWait
這樣的非阻塞呼叫。也支援中斷處理事務,也可以通過動態禁用和啟用相應的中斷來實現。
Linux 啟動
下面來聊一聊 Linux 是如何啟動的。
當計算機電源通電後,BIOS
會進行開機自檢(Power-On-Self-Test, POST)
,對硬體進行檢測和初始化。因為作業系統的啟動會使用到磁碟、螢幕、鍵盤、滑鼠等裝置。下一步,磁碟中的第一個分割槽,也被稱為 MBR(Master Boot Record)
主開機記錄,被讀入到一個固定的記憶體區域並執行。這個分割槽中有一個非常小的,只有 512 位元組的程式。程式從磁碟中調入 boot 獨立程式,boot 程式將自身複製到高位地址的記憶體從而為作業系統釋放低位地址的記憶體。
複製完成後,boot 程式讀取啟動裝置的根目錄。boot 程式要理解檔案系統和目錄格式。然後 boot 程式被調入核心,把控制權移交給核心。直到這裡,boot 完成了它的工作。系統核心開始執行。
核心啟動程式碼是使用組合語言
完成的,主要包括建立核心堆疊、識別 CPU 型別、計算記憶體、禁用中斷、啟動記憶體管理單元等,然後呼叫 C 語言的 main 函式執行作業系統部分。
這部分也會做很多事情,首先會分配一個訊息緩衝區來存放除錯出現的問題,除錯資訊會寫入緩衝區。如果除錯出現錯誤,這些資訊可以通過診斷程式調出來。
然後作業系統會進行自動配置,檢測裝置,載入配置檔案,被檢測裝置如果做出響應,就會被新增到已連結的裝置表中,如果沒有相應,就歸為未連線直接忽略。
配置完所有硬體後,接下來要做的就是仔細手工處理程式0,設定其堆疊,然後執行它,執行初始化、配置時鐘、掛載檔案系統。建立 init 程式(程式 1 )
和 守護程式(程式 2)
。
init 程式會檢測它的標誌以確定它是否為單使用者還是多使用者服務。在前一種情況中,它會呼叫 fork 函式建立一個 shell 程式,並且等待這個程式結束。後一種情況呼叫 fork 函式建立一個執行系統初始化的 shell 指令碼(即 /etc/rc)的程式,這個程式可以進行檔案系統一致性檢測、掛載檔案系統、開啟守護程式等。
然後 /etc/rc 這個程式會從 /etc/ttys 中讀取資料,/etc/ttys 列出了所有的終端和屬性。對於每一個啟用的終端,這個程式呼叫 fork 函式建立一個自身的副本,進行內部處理並執行一個名為 getty
的程式。
getty 程式會在終端上輸入
login:
等待使用者輸入使用者名稱,在輸入使用者名稱後,getty 程式結束,登陸程式 /bin/login
開始執行。login 程式需要輸入密碼,並與儲存在 /etc/passwd
中的密碼進行對比,如果輸入正確,login 程式以使用者 shell 程式替換自身,等待第一個命令。如果不正確,login 程式要求輸入另一個使用者名稱。
整個系統啟動過程如下