《趣談Linux》總結三:進程/線程

8 進程管理

有了系統調用,就可以開始創建進程了

8.1 通過寫代碼使用系統調用創建一個進程

在 Linux 上寫程序和編譯程序,也需要一系列的開發套件,就像 IDEA 一樣;
運行下面的命令,就可以在 centOS 7 操作系統上安裝開發套件:

yum -y groupinstall "Development Tools"

在 Windows 上寫的程序,都會被保存成.h 或者.c 文件,容易讓人感覺這是某種有特殊格式的文件,但其實這些文件只是普普通通的文本文件。
因而在 Linux上,用 Vim 來創建並編輯一個文件就行了。

接下來開始寫創建一個進程的程序:

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

extern int create_process (char* program, char** arg_list);

int create_process (char* program, char** arg_list)
{
    pid_t child_pid;
    child_pid = fork ();//創建
    if (child_pid != 0)
    	return child_pid;
    else {
    	execvp (program, arg_list);//運行
    abort ();
}

接下來創建第二個文件,調用上面這個函數:

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

extern int create_process (char* program, char** arg_list);

int main ()
{
    char* arg_list[] = {"ls","-l","/etc/yum.repos.d/",NULL};
    create_process ("ls", arg_list);
    return 0;
}

8,2 進行編譯:程序的二進制格式

程序寫完了,但是這兩個文件只是文本文件,CPU 是不能執行文本文件裏面的指令的,這些指令只有人能看懂;
CPU 能夠執行的命令是二進制的,比如“0101”這種,所以這些指令還需要翻譯一下,這個翻譯的過程就是編譯(Compile)。
編譯好的二進制文件纔是我們的程序。

在 Linux 下面,二進制的程序要有嚴格的格式,這個格式我們稱爲ELF(Executeable and Linkable Format,可執行與可鏈接格式);
這個格式可以根據編譯的結果不同,分爲不同的格式;
這樣才能保證無論分配給程序的資源是怎麼樣的,都能以固定的流程執行;按照裏面的指令來,程序也能達到預期的效果。

如何從文本文件編譯成二進制格式呢?
在這裏插入圖片描述
編譯這兩個程序:

gcc -c -fPIC process.c
gcc -c -fPIC createprocess.c

在編譯的時候,先做預處理工作,例如將頭文件嵌入到正文中,將定義的宏展開;
然後就是真正的編譯過程,最終編譯成爲.o 文件,這就是 ELF 的第一種類型:可重定位文件(RelocatableFile)
在這裏插入圖片描述
.text:放編譯好的二進制可執行代碼
.data:已經初始化好的全局變量
.rodata:只讀數據,例如字符串常量、const 的變量
.bss:未初始化全局變量,運行時會置 0
.symtab:符號表,記錄的則是函數和變量
.strtab:字符串表、字符串常量和變量名

接下來的每一段被稱爲一個一個的 section,也叫節。

這裏只有全局變量,因爲局部變量是放在棧裏面的,是程序運行過程中隨時分配空間,隨時釋放的,現在討論的是二進制文件,還沒啓動,所以只需要討論在哪裏保存全局變量。

這些節的元數據信息也需要有一個地方保存,就是最後的節頭部表(Section Header Table);
在這個表裏面,每一個 section 都有一項,在代碼裏面也有定義 struct elf32_shdr 和 structelf64_shdr;
在 ELF 的頭裏面,有描述這個文件的節頭部表的位置,有多少個表項等等信息。

要想讓 create_process 這個函數作爲庫文件被重用,不能以.o 的形式存在,而是要形成庫文件;
最簡單的類型是靜態鏈接庫.a 文件(Archives),僅僅將一系列對象文件(.o)歸檔爲一個文件,使用命令 ar 創建。

ar cr libstaticprocess.a process.o

這樣,當有程序要使用這個靜態連接庫的時候,會將.o 文件提取出來,鏈接到程序中:

gcc -o staticcreateprocess createprocess.o -L. -lstaticprocess

在這個命令裏,-L 表示在當前目錄下找.a 文件,-lstaticprocess 會自動補全文件名,比如加前綴lib,後綴.a,變成 libstaticprocess.a;
找到這個.a 文件後,將裏面的 process.o 取出來,和createprocess.o 做一個鏈接,形成二進制執行文件 staticcreateprocess;
這個鏈接的過程,重定位就起作用了;
原來 createprocess.o 裏面調用了 create_process 函數,但是不能確定位置,現在將 process.o 合併了進來,就知道位置了。
形成的二進制文件叫可執行文件,是 ELF 的第二種格式,格式如下:
在這裏插入圖片描述
這個格式和.o 文件大致相似,還是分成一個個的 section,並且被節頭表描述;
在 ELF 頭裏面,有一項 e_entry,也是個虛擬地址,是這個程序運行的入口;
只不過這些section 是多個.o 文件合併過的;
但是這個時候,這個文件已經是馬上就可以加載到內存裏面執行的文件了
因而這些 section 被分成了需要加載到內存裏面的代碼段、數據段和不需要加載到內存裏面的部分;
將小的 section 合成了大的段 segment,並且在最前面加一個段頭表(Segment Header Table);
在代碼裏面的定義爲 struct elf32_phdr 和 struct elf64_phdr,這裏面除了有對於段的描述之外,最重要的是 p_vaddr,這個是這個段加載到內存的虛擬地址。

靜態鏈接庫一旦鏈接進去,代碼和變量的 section 都合併了,因而程序運行的時候,就不依賴於這個庫是否存在;
但是這樣有一個缺點,就是相同的代碼段,如果被多個程序使用的話,在內存裏面就有多份,而且一旦靜態鏈接庫更新了,如果二進制執行文件不重新編譯,也不隨着更新;
因而就出現了動態鏈接庫(Shared Libraries),不僅僅是一組對象文件的簡單歸檔,而是多個對象文件的重新組合,可被多個程序共享。

當一個動態鏈接庫被鏈接到一個程序文件中的時候,最後的程序文件並不包括動態鏈接庫中的代碼,而僅僅包括對動態鏈接庫的引用,並且不保存動態鏈接庫的全路徑,僅僅保存動態鏈接庫的名稱。

動態鏈接庫,就是 ELF 的第三種類型,共享對象文件(Shared Object)。

8.3 運行程序爲進程

這個時候它還是個程序,那怎麼把這個文件加載到內存裏面呢?

在內核中,有一個數據結構linux_binfmt,用來定義加載二進制文件的方法。

對於 ELF 文件格式,有對應的實現,使用load_elf_binary來加載,原理是使用exec:
在這裏插入圖片描述

8.4 進程樹

所有的進程都是從父進程 fork 過來的,而祖宗進程則是系統啓動的 init進程。
在這裏插入圖片描述
1號進程是用戶態進程的祖先,2號進程則爲內核態進程的祖先

8.5 總結

一個進程從代碼到二進制到運行時的一個過程:
首先通過圖右邊的文件編譯過程,生成 so 文件和可執行文件,放在硬盤上;
下圖左邊的用戶態的進程 A 執行 fork,創建進程 B,在進程 B 的處理邏輯中,執行 exec 系列系統調用;
這個系統調用會通過 load_elf_binary 方法,將剛纔生成的可執行文件,加載到進程 B 的內存中執行
在這裏插入圖片描述

9 線程:讓進程並行執行

9.1 爲什麼要有線程

進程相當於一個項目,而線程就是爲了完成項目需求,而建立的一個個開發任務;
1.有時候,任務是可以拆解的,如果相關性沒有非常大前後關聯關係,就可以並行執行,加快速度;
2.進程要管控意外。例如,主線程正在一行一行執行二進制命令,突然收到一個通知,要做一點小事情,應該停下主線程來做麼?太耽誤事情了,應該創建一個單獨的線程,單獨處理這些事件;
3.在 Linux 中,有時候我們希望將前臺的任務和後臺的任務分開;
因爲有些任務是需要馬上返回結果的,例如你輸入了一個字符,不可能五分鐘再顯示出來;
而有些任務是可以默默執行的,例如將本機的數據同步到服務器上去,這個就沒剛纔那麼着急;
因此這樣兩個任務就應該在不同的線程處理,以保證互不耽誤。

而使用進程實現並行執行的問題有兩個:
第一,創建進程佔用資源太多;
第二,進程之間的通信需要數據在不同的內存空間傳來傳去,無法共享。

9.2 如何創建線程

在這裏插入圖片描述

9.3 線程的數據

在這裏插入圖片描述
把線程訪問的數據細分成三類:線程棧上的本地數據、在整個進程裏共享的全局數據、線程私有數據

第一類是線程棧上的本地數據,比如函數執行過程中的局部變量;
函數的調用會使用棧的模型,這在線程裏面是一樣的。只不過每個線程都有自己的棧空間;
爲了避免線程之間的棧空間踩踏,線程棧之間還會有小塊區域,用來隔離保護各自的棧空間;
一旦另一個線程踏入到這個隔離區,就會引發段錯誤。

第二類數據是在整個進程裏共享的全局數據。例如全局變量,雖然在不同進程中是隔離的,但
是在一個進程中是共享的;
如果同一個全局變量,兩個線程一起修改,那肯定會有問題,有可能把數據改的面目全非;
這就需要有一種機制來保護他們,比如誰先用誰後用;

第三類數據是線程私有數據(Thread Specific Data),使得線程像進程一樣,也有自己的私有數據;

9.4 數據的保護

  • 第一種方式:Mutex,全稱 Mutual Exclusion,中文叫互斥。

顧名思義,有你沒我,有我沒你;
它的模式就是在共享數據訪問的時候,去申請加把鎖,誰先拿到鎖,誰就拿到了訪問權限,其他人就只好在門外等着,等這個人訪問結束,把鎖打開,其他人再去爭奪,還是遵循誰先拿到誰訪問

使用流程:
在這裏插入圖片描述
如果使用 pthread_mutex_lock(),那就需要一直在那裏等着;
如果是 pthread_mutex_trylock(),就可以不用等着,去幹點兒別的,但是我怎麼知道什麼時候回
來再試一下,是不是輪到我了呢?能不能在輪到我的時候,通知我一下呢?
這其實就是條件變量,也就是說如果沒事兒,就讓大家歇着,有事兒了就去通知,別讓人家沒事兒就來問問,浪費大家的時間。
但是當它接到了通知,來操作共享資源的時候,還是需要搶互斥鎖,因爲可能很多人都受到了通知,都來訪問了,所以條件變量和互斥鎖是配合使用的。

使用流程:
在這裏插入圖片描述

10 進程數據結構

10.1 task_struct解析1

內核如何管理進程/線程體系?
即有的進程只有一個線程,有的進程有多個線程,它們都需要由內核分配CPU來幹活。可是CPU總共就這麼幾
個,應該怎麼管理,怎麼調度?

在Linux裏面,無論是進程,還是線程,到了內核裏面,我們統一都叫任務(Task),由一個統一的結構task_struct進行管理
在這裏插入圖片描述
Linux的任務管理都應該幹些什麼?

1.應該先弄一個鏈表,將所有的task_struct串起來。

2.每一個任務task_struct的字段如下:

任務ID:作爲這個任務的唯一標識;

涉及的字段有:

pid_t pid;
pid_t tgid;
struct task_struct *group_leader;

爲什麼需要三個?因爲上面的進程和線程到了內核這裏,統一變成了任務,這就帶來兩個問題:任務展示、給任務下發指令

任務展示中:使用ps命令可以展示出所有的進程。
但是如果你是這個命令的實現者,到了內核,按照上面的任務列表把這些命令都顯示出來,把所有的線程全都平攤開來顯示給用戶,用戶肯定覺得既複雜又困惑;
複雜在於,列表這麼長;困惑在於,裏面出現了很多並不是自己創建的線程。

給任務下發指令中:使用kill命令來給進程發信號,通知進程退出。
如果發給了其中一個線程,我們就不能只退出這個線程,而是應該退出整個進程;
當然,有時候,我們希望只給某個線程發信號。
總的來說,即發信號的時候,需要區分進程和線程。

所以在內核中,它們雖然都是任務,但是應該加以區分。其中,pid是process id,tgid是thread group ID;
任何一個進程,如果只有主線程,那pid是自己,tgid是自己,group_leader指向的還是自己;
如果創建了其他線程,那麼線程有自己的pid,tgid就是進程的主線程的pid,group_leader指向的就是進程的主線程;
有了tgid,就知道tast_struct代表的是一個進程還是代表一個線程了。

信號處理

/* Signal handlers: */
//定義了哪些信號被阻塞暫不處理(blocked),哪些信號尚等待處理(pending),哪些信號正在通過信號處理函數進行處理(sighand)。
//處理的結果可以是忽略,可以是結束進程等等
struct signal_struct *signal;
struct sighand_struct *sighand;
sigset_t blocked;
sigset_t real_blocked;
sigset_t saved_sigmask;
struct sigpending pending;
//信號處理函數默認使用用戶態的函數棧,當然也可以開闢新的棧專門用於信號處理,這就是sas_ss_xxx這三個變量的作用。
unsigned long sas_ss_sp;
size_t sas_ss_size;
unsigned int sas_ss_flags;

task_struct裏面有一個struct sigpending pending;
struct signal_struct *signal裏面有一個struct sigpending shared_pending;
它們一個是本任務的,一個是線程組共享的。

任務狀態
在這裏插入圖片描述
相關變量:

/* -1 unrunnable, 0 runnable, >0 stopped 
state(狀態)可以取的值定義在include/linux/sched.h頭文件中*/
volatile long state; 
int exit_state;
//flags是通過bitset的方式設置的。也就是說,當前是什麼狀態,哪一位就置一
unsigned int flags;

進程狀態:
在這裏插入圖片描述
進程狀態解析:

TASK_RUNNING並不是說進程正在運行,而是表示進程在時刻準備運行的狀態;
當處於這個狀態的進程獲得時間片的時候,就是在運行中;
如果沒有獲得時間片,就說明它被其他進程搶佔了,在等待再次分配時間片。

在運行中的進程,一旦要進行一些I/O操作,需要等待I/O完畢,這個時候會釋放CPU,進入睡眠狀態。
在Linux中,有兩種睡眠狀態:
一種是TASK_INTERRUPTIBLE:可中斷的睡眠狀態;
這是一種淺睡眠的狀態,也就是說,雖然在睡眠,等待I/O完成,但是這個時候一個信號來的時候,進程還是要被喚醒;
只不過喚醒後,不是繼續剛纔的操作,而是進行信號處理;
當然程序員可以根據自己的意願,來寫信號處理函數,例如收到某些信號,就放棄等待這個I/O操作完成,直接退出,也可也收到某些信息,繼續等待。
另一種睡眠是TASK_UNINTERRUPTIBLE,不可中斷的睡眠狀態;
這是一種深度睡眠狀態,不可被信號喚醒,只能死等I/O操作完成;
一旦I/O操作因爲特殊原因不能完成,這個時候,誰也叫不醒這個進程了;
而kill本身也是一個信號,既然這個狀態不可被信號喚醒,kill信號也被忽略了。除非重啓電腦,沒有其他辦法;
因此,這其實是一個比較危險的事情,除非極其有把握,不然還是不要設置成TASK_UNINTERRUPTIBLE。
於是,我們就有了一種新的進程睡眠狀態:TASK_KILLABLE,可以終止的新睡眠狀態;
進程處於這種狀態中,它的運行原理類似TASK_UNINTERRUPTIBLE,只不過可以響應致命信號:

//TASK_WAKEKILL用於在接收到致命信號時喚醒進程
#define TASK_KILLABLE (TASK_WAKEKILL | TASK_UNINTERRUPTIBLE)

TASK_STOPPED是在進程接收到SIGSTOP、SIGTTIN、SIGTSTP或者SIGTTOU信號之後進入該狀態。

TASK_TRACED表示進程被debugger等進程監視,進程執行被調試程序所停止;
當一個進程被另外的進程所監視,每一個信號都會讓進程進入該狀態。

一旦一個進程要結束,先進入的是EXIT_ZOMBIE狀態,但是這個時候它的父進程還沒有使用wait()等系統調用來獲知它的終止信息,此時進程就成了殭屍進程;
EXIT_DEAD是進程的最終狀態。
EXIT_ZOMBIE和EXIT_DEAD也可以用於exit_state。

上面的進程狀態和進程的運行、調度有關係,還有其他的一些狀態,我們稱爲標誌,放在flags字段中,這些字段都被定義爲宏,以PF開頭,例子:

//表示正在退出。當有這個flag的時候,在函數find_alive_thread中,找活着的線程,遇到有這個flag的,就直接跳過。
#define PF_EXITING 0x00000004
//表示進程運行在虛擬CPU上。在函數account_system_time中,統計進程的系統運行時間,如果有這個flag,就調用account_guest_time,按照客戶機的時間進行統計。
#define PF_VCPU 0x00000010
//表示fork完了,還沒有exec。在_do_fork函數裏面調用copy_process,這個時候把flag設置爲PF_FORKNOEXEC。當exec中調用了load_elf_binary的時候,又把這個flag去掉。
#define PF_FORKNOEXEC 0x00000040

進程調度

進程的狀態切換往往涉及調度,下面這些字段都是用於調度的:

//是否在運⾏隊列上
int on_rq;
//優先級
int prio;
int static_prio;
int normal_prio;
unsigned int rt_priority;
//調度器類
const struct sched_class *sched_class;
//調度實體
struct sched_entity se;
struct sched_rt_entity rt;
struct sched_dl_entity dl;
//調度策略
unsigned int policy;
//可以使⽤哪些CPU
int nr_cpus_allowed;
cpumask_t cpus_allowed;
struct sched_info sched_info;

運行統計信息

u64 utime;//⽤⼾態消耗的CPU時間
u64 stime;//內核態消耗的CPU時間
unsigned long nvcsw;//⾃願(voluntary)上下⽂切換計數
unsigned long nivcsw;//⾮⾃願(involuntary)上下⽂切換計數
u64 start_time;//進程啓動時間,不包含睡眠時間
u64 real_start_time;//進程啓動時間,包含睡眠時間

進程親緣關係

任何一個進程都有父進程,所以,整個進程其實就是一棵進程樹,而擁有同一父進程的所有進程都具有兄弟關係:

//通常情況下,real_parent和parent是一樣的,但是也會有另外的情況存在。
//例如,bash創建一個進程,那進程的parent和real_parent就都是bash。如果在bash上使用GDB來debug一個進程,這個時候GDB是real_parent,bash是這個進程的parent。
struct task_struct __rcu *real_parent; 
struct task_struct __rcu *parent;//指向其父進程。當它終止時,必須向它的父進程發送信號。
struct list_head children;//表示鏈表的頭部。鏈表中的所有元素都是它的子進程。
struct list_head sibling;//用於把當前進程插入到兄弟鏈表中。

進程間關係如圖:
在這裏插入圖片描述
進程權限:我能操縱誰,誰能操縱我。

//Objective
const struct cred __rcu *real_cred;//誰能操作這個進程
//Subjective
const struct cred __rcu *cred;//這個進程能夠操作誰。

cred結構大部分是關於用戶和用戶所屬的用戶組信息,即以用戶和用戶組控制權限

面試:setuid原理
問題
比如,用戶A想玩一個遊戲,這個遊戲的程序是用戶B安裝的。遊戲這個程序文件的權限爲rwxr–r–。A是沒有權限運行這個程序的,因而用戶B要給用戶A權限纔行;
於是用戶B就給這個程序設定了所有的用戶都能執行的權限rwxr-xr-x,用戶A就獲得了運行這個遊戲的權限;
當遊戲運行起來之後,遊戲進程的uid、euid、fsuid都是用戶A;
後來,想保存通關數據的時候,發現這個遊戲的玩家數據是保存在另一個文件裏面的;
這個文件權限rw-------,只給用戶B開了寫入權限,而遊戲進程的euid和fsuid都是用戶A,A是寫不進去的。
解決
可以通過chmod u+s program命令,給這個遊戲程序設置set-user-ID的標識位,把遊戲的權限變成rwsr-xr-x;
這個時候,用戶A再啓動這個遊戲的時候,創建的進程uid當然還是用戶A,但是euid和fsuid就不是用戶A了,因爲看到了set-user-id標識,就改爲文件的所有者的ID,也就是說,euid和fsuid都改成用戶B了,這樣就能夠將通關結果保存下來;
在Linux裏面,一個進程可以隨時通過setuid設置用戶ID,所以,遊戲程序的用戶B的ID還會保存在一個地方,這就是suid和sgid,也就是saved uid和save gid;
這樣就可以很方便地使用setuid,通過設置uid或者suid來改變權限。

還有另一個權限機制就是capabilities:用位圖表示權限,對於普通用戶運行的進程,當有這個權限的時候,就能做這些操作;沒有的時候,就不能做,這樣粒度要比“只有普通用戶和root用戶”小很多。

內存管理:每個進程都有自己獨立的虛擬內存空間,用一個數據結構來表示,爲mm_struct。

struct mm_struct *mm;
struct mm_struct *active_mm;

文件與文件系統:每個進程有一個文件系統的數據結構,還有一個打開文件的數據結構。

/* Filesystem information: */
struct fs_struct *fs;
/* Open file information: */
struct files_struct *files;

總結:下圖爲task_struct的組織和字段:
在這裏插入圖片描述

10.2 用戶態的執行和內核態的執行

在程序執行過程中,一旦調用到系統調用,就需要進入內核繼續執行;
那如何將用戶態的執行和內核態的執行串起來呢?

需要以下兩個成員變量:

struct thread_info thread_info;//用戶棧
void *stack;//內核棧

10.2.1 用戶態函數棧

在進程的內存空間裏面,棧是一個從高地址到低地址,往下增長的結構;
即上面是棧底,下面是棧頂,入棧和出棧的操作都是從下面的棧頂開始的。

  • 32位的情況

在CPU裏,ESP(Extended Stack Pointer)是棧頂指針寄存器,入棧操作Push和出棧操作Pop指令,會自動調整ESP的值;

另外有一個寄存器EBP(Extended Base Pointer),是棧基地址指針寄存器,指向當前棧幀的最底部。

例如,A調用B,A的棧裏面包含A函數的局部變量,然後是調用B的時候要傳給它的參數,然後返回A的地址,這個地址也應該入棧,這就形成了A的棧幀。

接下來就是B的棧幀部分了,先保存的是A棧幀的棧底位置,也就是EBP;
因爲在B函數裏面獲取A傳進來的參數,就是通過這個指針獲取的;
接下來保存的是B的局部變量等等;
當B返回的時候,返回值會保存在EAX寄存器中,從棧中彈出返回地址,將指令跳轉回去,參數也從棧中彈出,然後繼續執行A。
在這裏插入圖片描述

  • 64位的情況

因爲64位操作系統的寄存器數目比較多,所以會更多地利用寄存器;
rax用於保存函數調用的返回結果;
棧頂指針寄存器變成了rsp,指向棧頂位置,堆棧的Pop和Push操作會自動調整rsp;
棧基指針寄存器變成了rbp,指向當前棧幀的起始位置。

改變比較多的是參數傳遞:rdi、rsi、rdx、rcx、r8、r9這6個寄存器,用於傳遞存儲函數調用時的6個參數。如果超過6的時候,還是需要放到棧裏面。
然而,前6個參數有時候需要進行尋址,但是如果在寄存器裏面,是沒有地址的,因而還是會放到棧裏面,只不過放到棧裏面的操作是被調用函數做的。
在這裏插入圖片描述

10.2.2 內核態函數棧

通過系統調用,從進程的內存空間到內核中了;
內核中也有各種各樣的函數調用來調用去的,也需要這樣一個機制,此時,stack屬性就派上了用場

Linux給每個task都分配了內核棧:

32位:

#define THREAD_SIZE_ORDER 1
//一個PAGE_SIZE是4K,左移一位就是乘以2,也就是8K。
#define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER)

64位:

#ifdef CONFIG_KASAN
#define KASAN_STACK_ORDER 1
#else
#define KASAN_STACK_ORDER 0
#endif
#define THREAD_SIZE_ORDER (2 + KASAN_STACK_ORDER)
//在PAGE_SIZE的基礎上左移兩位,也即16K,並且要求起始地址必須是8192的整數倍。
#define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER)

在這裏插入圖片描述
這段空間的最低位置,是一個thread_info結構,這個結構是對task_struct結構的補充;
因爲task_struct結構龐大但是通用,不同的體系結構就需要保存不同的東西,所以往往與體系結構有關的,都放在thread_info裏面。

在內核棧的最高地址端,存放的是另一個結構pt_regs
當系統調用從用戶態到內核態的時候,首先要做的第一件事情,就是將用戶態運行過程中的CPU上下文保存起來;
其實主要就是保存在這個結構的寄存器變量裏;
這樣當從內核系統調用返回的時候,才能讓進程在剛纔的地方接着運行下去。

預留的8個字節是32位機器使用的,因爲壓棧pt_regs有兩種情況:
CPU用ring來區分權限,從而Linux可以區分內核態和用戶態;
因此,第一種情況,拿涉及從用戶態到內核態的變化的系統調用來說;
因爲涉及權限的改變,會壓棧保存SS、ESP寄存器的,這兩個寄存器共佔用8個byte。
另一種情況是,不涉及權限的變化,就不會壓棧這8個byte;
這樣就會使得兩種情況不兼容。如果沒有壓棧還訪問,就會報錯,所以就預留在這裏,保證安全;
在64位上,修改了這個問題,變成了定長的。

10.2.2.1 通過task_struct找內核棧

通過函數task_stack_page,使用個task_struct的stack指針來找到對應的線程內核棧

如何找到相應的pt_regs?先從task_struct找到內核棧的開始位置,然後這個位置加上THREAD_SIZE就到了最後的位置,然後轉換爲struct pt_regs,再減一,就相當於減少了一個pt_regs的位置,就到了這個結構的首地址。

10.2.2.2 通過內核棧找task_struct

一個當前在某個CPU上執行的進程,想知道自己的task_struct在哪裏,則需要通過給thread_info這個結構.

32位實現.它裏面有個成員變量task指向task_struct,通過此結構來找到task_struct

64位實現.每個CPU運行的task_struct不通過thread_info獲取了,而是直接放在Per CPU 變量裏面了;
因爲多核情況下,CPU是同時運行的,但是它們會共同使用其他的硬件資源的時候,所以我們需要解決多個CPU之間的同步問題;
而Per CPU變量就是內核中一種重要的同步機制:顧名思義,Per CPU變量就是爲每個CPU構造一個變量的副
本,這樣多個CPU各自操作自己的副本,互不干涉。

  • 總結

如果說task_struct的其他成員變量都是和進程管理有關的,那麼內核棧是和進程運行有關係的。

在用戶態,應用程序進行了至少一次函數調用;3
2位和64的傳遞參數的方式稍有不同,32位的就是用函數棧,64位的前6個參數用寄存器,其他的用函數棧。

在內核態,32位和64位都使用內核棧,格式也稍有不同,主要集中在pt_regs結構上。

在內核態,32位和64位的內核棧和task_struct的關聯關係不同;
32位主要靠thread_info,64位主要靠Per-CPU變量。
在這裏插入圖片描述

11 調度(一):如何制定進程管理體系

task_struct解決了“看到”的問題,接下來解決“做到”的問題。

11.1 調度策略與調度類

一種稱爲實時進程,也就是需要儘快執行返回結果的那種;
另一種是普通進程,大部分進程都是這種

兩種不同的進程,調度策略也不同,在task_struct中,有一個成員變量,就是叫調度策略:

unsigned int policy;

配合調度策略的,還有優先級,也在task_struct中:

int prio, static_prio, normal_prio;
unsigned int rt_priority;

11.2 實時調度策略

policy有以下取值:

#define SCHED_NORMAL 0
//高優先級的進程可以搶佔低優先級的進程,而相同優先級的進程遵循先來先得
#define SCHED_FIFO 1
//時間片,相同優先級的任務當用完時間片會被放到隊列尾部,以保證公平性
//而高優先級的任務也是可以搶佔低優先級的任務。
#define SCHED_RR 2
#define SCHED_BATCH 3
#define SCHED_IDLE 5
//按照任務的deadline進行調度
//當產生一個調度點的時候,DL調度器總是選擇其deadline距離當前時間點最近的那個任務,並調度它執行。
#define SCHED_DEADLINE 6

11.3 普通調度策略

SCHED_NORMAL:普通的進程

SCHED_BATCH:後臺進程,這類進程可以默默執行,不要影響需要交互的進程,可以降低他的優先級。

SCHED_IDLE:特別空閒的時候才跑的進程

11.4 具體執行者

policy和priority都設置了一個變量,表示了應該怎麼幹,那麼交給誰幹呢?
在task_struct裏面,還有這樣的成員變量,調度策略的執行邏輯,就封裝在這裏面,它是真正幹活的那個:

const struct sched_class *sched_class;

其實現爲:

stop_sched_class優先級最高的任務會使用這種策略,會中斷所有其他線程,且不會被其他任務打斷;

dl_sched_class就對應上面的deadline調度策略;

rt_sched_class就對應RR算法或者FIFO算法的調度策略,具體調度策略由進程的task_struct->policy指定;

fair_sched_class就是普通進程的調度策略;

idle_sched_class就是空閒進程的調度策略。

11.5 完全公平調度算法

普通進程使用的調度策略是fair_sched_class,顧名思義,對於普通進程來講,公平是最重要的。

在Linux裏面,實現了一個基於CFS的調度算法。CFS全稱Completely Fair Scheduling,叫完全公平調度。

首先,你需要記錄下進程的運行時間。
CPU會提供一個時鐘,過一段時間就觸發一個時鐘中斷,叫Tick。
CFS會爲每一個進程安排一個虛擬運行時間vruntime。
如果一個進程在運行,隨着時間的增長,也就是一個個tick的到來,進程的vruntime將不斷增大。
沒有得到執行的進程vruntime不變。
顯然,那些vruntime少的,就說明受到了不公平的對待,需要給它補上,所以會優先運行這樣的進程。

如果加上優先級,這就相當於N個口袋,優先級高的袋子大,優先級低的袋子小。這樣球就不能按照個數分配了,要按照比例來,大口袋的放了一半和小口袋放了一半,裏面的球數目雖然差很多,也認爲是公平的。

綜合起來就是:

得到當前的時間,以及這次的時間片開始的時間,兩者相減就是這次運行的時間delta_exec ;

但是得到的這個時間其實是實際運行的時間,需要做一定的轉化才作爲虛擬運行時間vruntime。

轉化方法如下:
虛擬運行時間vruntime += 實際運行時間delta_exec * NICE_0_LOAD/權重

這就是說,同樣的實際運行時間,給高權重的算少了,低權重的算多了,但是當選取下一個運行進程的時
候,還是按照最小的vruntime來的,這樣高權重的獲得的實際運行時間自然就多了

11.6 調度隊列與調度實體

可以看出,CFS和其他的調度策略需要一個數據結構來對vruntime進行排序,找出最小的那個。
這個能夠排序的數據結構不但需要查詢的時候,能夠快速找到最小的,更新的時候也需要能夠快速的調整排序;
因爲vruntime是經常在變的,變了再插入這個數據結構,就需要重新排序。

能夠平衡查詢和更新速度的是樹,cfs_rq使用的是紅黑樹;
紅黑樹的的節點是應該包括vruntime的,稱爲調度實體。

task_struct中有如下成員變量:

struct sched_entity se;//完全公平算法調度實體
struct sched_rt_entity rt;//實時調度實體
struct sched_dl_entity dl;//Deadline調度實體

存放普通進程的調度實體的紅黑樹位置:每個CPU都有自己的 struct rq 結構,其用於描述在此CPU上所運行的所有進程,其包括一個實時進程隊列rt_rq和一個CFS運行隊列cfs_rq;
在調度時,調度器首先會先去實時進程隊列rt_rq找是否有實時進程需要運行,如果沒有才會去CFS運行隊列cfs_rq找是否有進程需要運行。

普通進程調度相關的數據結構如下:
在這裏插入圖片描述

11.6.1 調度類是如何工作的?

調度類爲sched_class,定義了很多種方法,用於在隊列上操作任務;
也定義了一個指針,指向下一個調度類:11.4說過有5種調度類,它們其實是放在一個鏈表上的;
調度的時候是從優先級最高的調度類到優先級低的調度類,依次執行,不同的調度類有自己的實現。

即:當某個CPU需要找下一個任務執行的時候,會按照優先級依次調用調度類,不同的調度類操作不同
的隊列。
rt_sched_class先被調用,它會在實時進程隊列rt_rq上找下一個任務,只有找不到的時候,才輪到fair_sched_class被調用,它會在CFS運行隊列cfs_rq上找下一個任務。
這樣保證了實時任務的優先級永遠大於普通任務。

11.7 總結

一個CPU上有一個隊列;
CFS的隊列是一棵紅黑樹,樹的每一個節點都是一個sched_entity,每個sched_entity都屬於一個task_struct,task_struct裏面有指針指向這個進程屬於哪個調度類:
在這裏插入圖片描述
上下文切換的任務:一是切換進程空間,也即虛擬內存;二是切換寄存器和CPU上下文

使用時鐘中斷處理函數來進行搶佔式調度,調用task_struct對應的調度類的task_tick函數來處理時鐘事件

12 調度(二):主動調度是如何發生的?

爲調度準備好了數據結構之後,調度是如何發生的呢?

調度概念:CPU在運行A進程,在某個時刻,換成運行B進程去了,有兩種方式:

方式一:A進程運行着的時候,發現裏面有一條指令sleep,也就是要休息一下,或者在等待某個I/O事件。那沒
辦法了,就要主動讓出CPU,然後可以開始運行B進程。(主動調度
方式二:A項目運行着的時候,曠日持久,實在受不了了。CPU介入了,說這個進程A先停停,B進程也要運行一下,要不然B進程該投訴了

12.1 主動調度

計算機主要處理計算、網絡、存儲三個方面。
計算主要是CPU和內存的合作;
網絡和存儲則多是和外部設備的合作;
在操作外部設備的時候,往往會等待數據,需要讓出CPU,選擇調用schedule()函數。

接下來學習schedule函數的調用過程:
首先在當前的CPU上取出任務隊列rq;
第二步,獲取下一個任務,爲11.6.1所說的先獲取調度類再獲取任務;
第三步,當選出的繼任者進程和前任進程不同,就要進行上下文切換,繼任者進程正式進入運行。

12.2 進程上下文切換

主要任務:
一是切換進程空間,也即虛擬內存;
二是切換寄存器和CPU上下文。

12.3 總結

一個運行中的進程主動調用__ schedule讓出CPU。
在 __schedule裏面會做兩件事情
第一是選取下一個進程,第二是進行上下文切換。
而上下文切換又分用戶態進程空間的切換和內核態的切換。
在這裏插入圖片描述
proc文件系統裏面可以看運行時間和切換次數,還可以看自願切換和非自願切換次數。

13 調度(三)搶佔式調度是如何發生的?

主動調度是第一種方式,第二種方式,就是搶佔式調度。

什麼情況下會發生搶佔呢?
最常見的現象就是一個進程執行時間太長了,是時候切換到另一個進程了。

怎麼衡量一個進程的運行時間呢?
在計算機裏面有一個時鐘,會過一段時間觸發一次時鐘中斷,通知操作系統,時間又過去一個時鐘週期,這是個很好的方式,可以查看是否是需要搶佔的時間點。

另外一個可能搶佔的場景是當一個進程被喚醒的時候。
當一個進程在等待一個I/O的時候,會主動放棄CPU。
但是當I/O到來的時候,進程往往會被喚醒。這個時候是一個時機。
當被喚醒的進程優先級高於CPU上的當前進程,就會觸發搶佔。

搶佔時,首先會標識當前運行中的進程應該被搶佔了,然後就需要真正的搶佔動作,但是這需要一個讓正在運行中的進程有機會調用一下__schedule的時機

13.1 用戶態的搶佔時機

對於用戶態的進程來講,從系統調用中返回的那個時刻,是一個被搶佔的時機。

13.2 內核態的搶佔時機

對內核態的執行中,被搶佔的時機一般發生在在preempt_enable()中。

在內核態的執行中,有的操作是不能被中斷的,所以在進行這些操作之前,總是先調用preempt_disable()
關閉搶佔,當再次打開的時候,就是一次內核態代碼被搶佔的機會。

在內核態也會遇到中斷的情況,當中斷返回的時候,返回的仍然是內核態。這個時候也是一個執行搶佔的時

13.3 總結

整個進程的調度體系如圖:

第一條總結了進程調度第一定律的核心函數__schedule的執行過程,爲主動調度的流程

第二條總結了標記爲可搶佔的場景,第三條是所有的搶佔發生的時機,這裏是真正驗證了進程調度第一定律
的。
在這裏插入圖片描述

14 進程的創建

問題:創建進程這個動作在內核裏都做了什麼事情?

fork是一個系統調用,流程的最後會在sys_call_table中找到相應的系統調用sys_fork;
sys_fork會調用_do_fork。

14.1 fork做的第一件事

_do_fork裏面做的第一件大事就是copy_process,因爲如果所有數據結構都從頭創建一份太麻煩了,直接複製就可以了

需要將整個task_struct複製一份,而且內核棧也要創建好。

然後就是權限相關

接下來是調度相關的變量

隨後開始初始化文件和文件系統相關的變量

緊接着就是初始化與信號相關的變量

下一步就開始複製進程內存空間

複製完後,就開始分配pid,設置tid,group_leader,並且建立進程之間的親緣關係

14.2 fork做的第一件事

_do_fork做的第二件大事是wake_up_new_task:喚醒新進程,因爲新任務剛剛建立,看看有沒有機會搶佔別人,獲得CPU

首先,需要將進程的狀態設置爲TASK_RUNNING。

如果是CFS的調度類,則執行相應的enqueue_task_fair,在enqueue_task_fair中取出的隊列就是cfs_rq,然後調用enqueue_entity函數

此函數會更新運行的統計量,然後將節點加入到紅黑樹裏面

回到enqueue_task_fair後,將這個隊列上運行的進程數目加一,然後會看此進程是否能夠搶佔當前進程;
如果要,會將父進程標記爲TIF_NEED_RESCHED

如果新創建的進程應該搶佔父進程,在什麼時間搶佔呢?
因爲fork是一個系統調用,而從系統調用返回的時候,是搶佔的一個好時機;
如果父進程判斷自己已經被設置爲TIF_NEED_RESCHED,就讓子進程先跑,搶佔自己。

14.3 總結

fork系統調用的過程包含兩個重要的事件:
一個是將task_struct結構複製一份並且初始化
另一個是試圖喚醒新創建的子進程。

15 線程的創建

創建一個線程調用的是pthread_create

線程不是一個完全由內核實現的機制,它是由內核態和用戶態合作完成的。pthread_create不是一個系統調用,而是Glibc庫的一個函數

  • 線程的生命週期(pthread_create)

1
處理線程的屬性參數

2
就像在內核裏一樣,每一個進程或者線程都有一個task_struct結構,在用戶態也有一個用於維護線程的結構,就是一個pthread結構的變量

每個線程有自己的棧,所以此步就是傳入屬性參數和pthread結構來創建線程棧

線程棧是在進程的堆裏面創建的

3

內核態創建任務:clone

第一步是標誌位設定,將五大結構的引用計數加1,表示多了一個線程;
第二步是設置親緣關係,因爲要識別多個線程是不是屬於一個進程;
第三部是處理信號,要保證發給進程的信號雖然可以被一個線程處理,但是影響範圍應該是整個進程

用戶態執行任務:

在start_thread入口函數中,真正的調用用戶提供的函數

執行完用戶的函數後,會釋放這個線程相關的數據。

  • 總結

創建進程調用的系統調用是fork,在copy_process函數裏面,會將五大結構files_struct、fs_struct、
sighand_struct、signal_struct、mm_struct都複製一遍,從此父進程和子進程各用各的數據結構。

創建線程調用的系統調用是clone,在copy_process函數裏面, 五大結構僅僅是引用計數加一,也即線程共
享進程的數據結構。
在這裏插入圖片描述

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