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 結構可以參考
https://docs.huihoo.com/doxygen/linux/kernel/3.7/arch_2avr32_2include_2asm_2thread__info_8h_source.html
這個結構中包含進程描述符,進程描述符位於固定的位置,使得 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 程序要求輸入另一個用戶名。
整個系統啟動過程如下