Linux進程管理原理筆記

一、程序從編譯(編譯彙編、鏈接、裝載到內存)到運行爲進程

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

yum -y groupinstall "Development Tools"

接下來就可以開始寫程序了。在Windows上寫的程序,都會被保存成.h或者.c文件,容易讓人感覺這是某種有特殊格式的文件,但其實這些文件暫時還只是普普通通的文本文件。因而在Linux上用Vim來創建並編輯一個文件就行了。先來創建一個文件,裏面用一個函數封裝通用的創建進程的邏輯,名字叫process.c,代碼如下:

#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 ();
    }
}

這裏面用到了fork系統調用,通過這裏面的if-else,可以看到根據fork的返回值不同,父進程和子進程就此分道揚鑣了。在子進程裏面,需要通過execvp運行一個新的程序。接下來創建第二個文件createprocess.c,調用上面這個函數:

#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;
}

在這裏,創建的子程序運行了一個最簡單的命令ls。

2. 上面這兩個文件只是文本文件,CPU是不能執行文本文件裏面的指令的,這些指令只有人能看懂,CPU能夠執行的命令是二進制的,所以這些指令還需要翻譯一下,這個翻譯的過程就是編譯(Compile)。編譯成的二進制格式稱爲ELF(Executeable and Linkable Format,可執行與可鏈接格式),這個格式可以根據編譯的結果不同,分爲不同的格式。從文本文件編譯成二進制格式的流程如下所示:

在上面兩段代碼中,上面include的部分是頭文件,而寫的.c結尾的是源文件。接下來編譯這兩個程序:

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

在編譯的時候,系統會先做預處理工作,例如將頭文件嵌入到正文中,將定義的宏展開,然後就是真正的編譯過程,最終編譯成爲.o文件,這就是ELF的第一種類型,叫可重定位文件(Relocatable File)。這個文件的格式如下,由一ELF文件頭和多個section組成:

(1)ELF文件頭:用於描述整個文件。這個文件格式在內核中有定義,分別爲struct elf32_hdr和struct elf64_hdr。

(2).text:放編譯好的二進制可執行代碼。

(3).data:已經初始化好的全局變量。

(4).rodata:只讀數據,例如字符串常量、const的變量。

(5).bss:未初始化全局變量,運行時會置0。

(6).symtab:符號表,記錄的則是函數和變量。

(7).strtab:字符串表、字符串常量和變量名。

爲啥這裏只有全局變量呢?因爲局部變量是放在棧裏的,是程序運行過程中隨時分配空間,隨時釋放的,這裏二進制文件還沒開始啓動,所以只需要討論在哪裏保存全局變量。這些節(section)的元數據信息也需要有一個地方保存,就是最後的節頭部表(Section Header Table)。在這個表裏面,每一個section都有一項,在代碼裏的定義在struct elf32_shdr和struct elf64_shdr。在ELF的頭裏面,描述了這個文件的節頭部表的位置,有多少個表項等等信息。

爲啥叫可重定位呢?這個編譯好的代碼和變量,將來加載到內存裏面的時候,都是要加載到一定位置的。比如說調用一個函數,其實就是跳到這個函數所在的代碼位置執行;再比如修改一個全局變量,也是要到變量的位置那裏去修改。但是現在這個時候還是.o文件,不是一個可以直接運行的程序,這裏面只是部分代碼片段。例如上面的create_process函數,將來被誰調用,在哪裏調用都不清楚,就更別提確定位置了。所以,.o裏面的位置是不確定的,但是必須是可重新定位的,因爲它將來是要做函數庫的,搬到哪裏用就重新定位這些代碼、變量的位置。

有的section,例如.rel.text, .rel.data就與重定位有關。例如這裏的createprocess.o,裏面調用了create_process函數,但是這個函數在另外一個process.o裏,因而createprocess.o里根本不可能知道被調用函數的位置,所以只好在rel.text裏面標註,這個函數是需要重定位的。要想讓create_process這個函數作爲庫文件被重用,不能以.o的形式存在,而是要形成庫文件,最簡單的類型是靜態鏈接庫.a文件(Archives),僅僅將一系列對象文件(.o)歸檔爲一個文件,即使用命令ar創建,如下所示:

ar cr libstaticprocess.a process.o

雖然這裏libstaticprocess.a裏面只有一個.o,但是實際情況可以有多個.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,並且被節頭表描述。只不過這些section是多個.o文件合併過的。這個時候,這個文件已經是馬上可以加載到內存裏面執行的文件了,因而這些section被分成了需要加載到內存裏面的代碼段、數據段和不需要加載到內存裏面的部分,將小的section合成了大的段segment,並且在最前面加一個段頭表(Segment Header Table)。段頭表在代碼裏面的定義爲struct elf32_phdr和struct elf64_phdr,這裏面除了有對於段的描述之外,最重要的是p_vaddr,是這個段加載到內存的虛擬地址

在ELF頭裏面有一項e_entry,也是個虛擬地址,是這個程序運行的入口。當程序運行起來之後,就是下面的樣子:

# ./staticcreateprocess
# total 40
-rw-r--r--. 1 root root 1572 Oct 24 18:38 CentOS-Base.repo
......

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

gcc -shared -fPIC -o libdynamicprocess.so process.o

當一個動態鏈接庫被鏈接到一個程序文件中時,最後的程序文件並不包括動態鏈接庫中的代碼,而僅僅包括對動態鏈接庫的引用,並且不保存動態鏈接庫的全路徑,僅僅保存動態鏈接庫的名稱。將代碼和動態鏈接庫進行連接的命令如下:

gcc -o dynamiccreateprocess createprocess.o -L. -ldynamicprocess

當運行這個程序的時候,首先尋找動態鏈接庫,然後加載它。默認情況下,系統在/lib和/usr/lib文件夾下尋找動態鏈接庫,如果找不到就會報錯。可以設定LD_LIBRARY_PATH環境變量,程序運行時會在此環境變量指定的文件夾下尋找動態鏈接庫,如下所示:

# export LD_LIBRARY_PATH=.
# ./dynamiccreateprocess
# total 40
-rw-r--r--. 1 root root 1572 Oct 24 18:38 CentOS-Base.repo
......

動態鏈接庫就是ELF的第三種類型,叫共享對象文件(Shared Object)。基於動態連接庫創建出來的二進制文件格式還是ELF,但是稍有不同。首先多了一個.interp的Segment,這裏面是ld-linux.so,這是動態鏈接器,也就是說運行時的鏈接動作都是它做的。另外,ELF文件中還多了兩個section,一個是.plt,叫過程鏈接表(Procedure Linkage Table,PLT),一個是.got.plt,叫全局偏移量表(Global Offset Table,GOT)。

3. PLT和GOT是怎麼工作的,使得程序運行時,可以將so文件動態鏈接到進程空間呢?上面dynamiccreateprocess這個程序要調用libdynamicprocess.so裏的create_process函數。由於是運行時纔去找,編譯的時候並不知道這個函數在哪裏,所以就在PLT裏面建立一項PLT[x]。這一項也是一些代碼,有點像一個本地的代理,在二進制程序裏面不直接調用create_process函數,而是調用PLT[x]裏面的代理代碼,這個代理代碼會在運行的時候找真正的create_process函數。

去哪裏找代理代碼呢?這就用到了GOT表,這裏面也會爲create_process函數創建一項 GOT[y]。這一項是運行時create_process函數在內存中真正的地址。如果這個地址存在,dynamiccreateprocess調用PLT[x]裏面的代理代碼,代理代碼調用GOT表中對應項GOT[y],調用的就是加載到內存中的libdynamicprocess.so裏面的create_process函數了。

但是GOT一開始也不知道,對於create_process函數,GOT一開始就會創建一項GOT[y],但是這裏面沒有真正的地址,因爲它也還不知道代理代碼的位置,它又回調 PLT,讓PLT先自己想辦法。PLT這個時候會轉而調用PLT[0],也即第一項,PLT[0]再調用GOT[2],這裏面是ld-linux.so的入口函數,這個函數會找到加載到內存中的libdynamicprocess.so裏面的create_process函數的地址,然後把這個地址放在GOT[y]裏面。下次,PLT[x]的代理函數就能夠直接調用GOT[y]了。

4. 知道了ELF這個格式,這個時候它還是個程序,那怎麼把這個文件加載到內存裏面呢?在內核中有這樣一個數據結構,用來定義加載二進制文件的方法:

struct linux_binfmt {
        struct list_head lh;
        struct module *module;
        int (*load_binary)(struct linux_binprm *);
        int (*load_shlib)(struct file *);
        int (*core_dump)(struct coredump_params *cprm);
        unsigned long min_coredump;     /* minimal dump size */
} __randomize_layout;

對於ELF文件格式,有對應的實現。

static struct linux_binfmt elf_format = {
        .module         = THIS_MODULE,
        .load_binary    = load_elf_binary,
        .load_shlib     = load_elf_library,
        .core_dump      = elf_core_dump,
        .min_coredump   = ELF_EXEC_PAGESIZE,
};

這裏看到了之前熟悉的load_elf_binary,加載內核鏡像的時候,用的也是這種格式。調用load_elf_binary函數的調用鏈當初是這樣的:do_execve->do_execveat_common->exec_binprm->search_binary_handler。那do_execve又是被誰調用的呢?看下面的代碼:

SYSCALL_DEFINE3(execve,
    const char __user *, filename,
    const char __user *const __user *, argv,
    const char __user *const __user *, envp)
{
  return do_execve(getname(filename), argv, envp);
}

原理是exec這個系統調用最終調用的load_elf_binary。exec比較特殊,它是一組函數:

(1)包含p的函數(execvp, execlp)會在PATH路徑下面尋找程序;

(2)不包含p的函數需要輸入程序的全路徑;包含v的函數(execv, execvp, execve)以數組的形式接收參數;

(3)包含l的函數(execl, execlp, execle)以列表的形式接收參數;

(4)包含e的函數(execve, execle)以數組的形式接收環境變量。如下圖所示:

在上面process.c的代碼中,創建ls進程也是通過exec。

5. 既然所有的進程都是從父進程fork過來的,那總歸有一個祖宗進程,這就是系統啓動的init進程。如下圖所示:

在解析Linux啓動過程時,1號進程是/sbin/init。如果在centOS 7裏面,ls一下可以看到,這個進程是被軟鏈接到systemd的:

/sbin/init -> ../lib/systemd/systemd

系統啓動之後,init進程會啓動很多的daemon進程,爲系統運行提供服務,然後就是啓動getty,讓用戶登錄,登錄後運行shell,用戶啓動的進程都是通過shell運行的,從而形成了一棵進程樹。可以通過ps -ef命令查看當前系統啓動的進程,會發現有三類進程:

[root@deployer ~]# ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0  2020 ?        00:00:29 /usr/lib/systemd/systemd --system --deserialize 21
root         2     0  0  2020 ?        00:00:00 [kthreadd]
root         3     2  0  2020 ?        00:00:00 [ksoftirqd/0]
root         5     2  0  2020 ?        00:00:00 [kworker/0:0H]
root         9     2  0  2020 ?        00:00:40 [rcu_sched]
......
root       337     2  0  2020 ?        00:00:01 [kworker/3:1H]
root       380     1  0  2020 ?        00:00:00 /usr/lib/systemd/systemd-udevd
root       415     1  0  2020 ?        00:00:01 /sbin/auditd
root       498     1  0  2020 ?        00:00:03 /usr/lib/systemd/systemd-logind
......
root       852     1  0  2020 ?        00:06:25 /usr/sbin/rsyslogd -n
root      2580     1  0  2020 ?        00:00:00 /usr/sbin/sshd -D
root     29058     2  0 Jan03 ?        00:00:01 [kworker/1:2]
root     29672     2  0 Jan04 ?        00:00:09 [kworker/2:1]
root     30467     1  0 Jan06 ?        00:00:00 /usr/sbin/crond -n
root     31574     2  0 Jan08 ?        00:00:01 [kworker/u128:2]
......
root     32792  2580  0 Jan10 ?        00:00:00 sshd: root@pts/0
root     32794 32792  0 Jan10 pts/0    00:00:00 -bash
root     32901 32794  0 00:01 pts/0    00:00:00 ps -ef

其中PID爲1的進程就是init進程systemd,PID爲2的進程是內核線程kthreadd,這兩個在內核啓動的時候都見過。其中用戶態的不帶中括號,內核態的帶中括號。接下來進程號依次增大,但是會看到所有帶中括號的內核態的進程,祖先(PPID)都是2號進程。而用戶態的進程,祖先都是1號進程。tty那一列是問號的,說明不是前臺啓動的,一般都是後臺的服務。pts的父進程是sshd,bash的父進程是pts,ps -ef這個命令的父進程是bash。

6. 一個進程從代碼到二進制到運行時的過程如下所示:

首先通過圖右邊的文件編譯過程,生成.so文件和可執行文件放在硬盤上。上圖左邊的用戶態進程A執行fork,創建進程B,在進程B的處理邏輯中執行exec系列系統調用。這個系統調用會通過load_elf_binary方法,將剛纔生成的可執行文件,加載到進程B的內存中執行

二、線程

7. 對於任何一個進程來講,即便沒有主動去創建線程,進程也是默認有一個主線程的。線程是負責執行二進制指令的,它會根據代碼一行一行執行下去。進程要比線程管的寬得多,除了執行指令之外,內存、文件系統等等都要它來管。所以,進程相當於一個項目,而線程就是爲了完成項目需求,而建立的一個個開發任務。可以建一個大的任務完成某功能,然後交給一個人讓它從頭做到尾,這就是主線程。但是有時候發現任務是可以拆解的,如果沒有相關性非常大的前後關聯關係,就可以並行執行

當然,使用進程實現並行執行的問題也有兩個。第一,創建進程佔用資源太多;第二,進程之間的通信需要數據在不同的內存空間傳來傳去,無法共享。除了希望任務能夠並行執行,系統肯定還要預留一點資源做其他突發的任務,比如有時主線程正在一行一行執行二進制命令,突然收到一個通知要做一點小事情,肯定不能停下主線程來做,這樣太耽誤事情了,應該創建一個單獨的線程,單獨處理這些事件。

在Linux中,有時候希望將前臺的任務和後臺的任務分開。因爲有些任務是需要馬上返回結果的,例如輸入了一個字符,不可能五分鐘再顯示出來;而有些任務是可以默默執行的,例如將本機的數據同步到服務器上去,這個就沒剛纔那麼着急。因此這樣兩個任務就應該在不同的線程處理,以保證互不耽誤。

8. 假如現在有N個非常大的視頻需要下載,一個個下載需要的時間太長了。按照多線程的思路,可以拆分給N個線程各自去下載。線程的執行需要一個函數,將要執行的子任務放在這個函數裏面,比如下載任務。這個函數參數是void類型的指針,用於接收任何類型的參數,就可以將要下載的文件的文件名通過這個指針傳給它。當然,這裏的代碼不是真的下載文件,而僅僅打印日誌並生成一個一百以內的隨機數,作爲下載時間返回。如下所示:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

#define NUM_OF_TASKS 5

void *downloadfile(void *filename)
{
   printf("I am downloading the file %s!\n", (char *)filename);
   sleep(10);
   long downloadtime = rand()%100;
   printf("I finish downloading the file within %d minutes!\n", downloadtime);
   pthread_exit((void *)downloadtime);
}

int main(int argc, char *argv[])
{
   char files[NUM_OF_TASKS][20]={"file1.avi","file2.rmvb","file3.mp4","file4.wmv","file5.flv"};
   pthread_t threads[NUM_OF_TASKS];
   int rc;
   int t;
   int downloadtime;

   pthread_attr_t thread_attr;
   pthread_attr_init(&thread_attr);
   pthread_attr_setdetachstate(&thread_attr,PTHREAD_CREATE_JOINABLE);

   for(t=0;t<NUM_OF_TASKS;t++){
     printf("creating thread %d, please help me to download %s\n", t, files[t]);
     rc = pthread_create(&threads[t], &thread_attr, downloadfile, (void *)files[t]);
     if (rc){
       printf("ERROR; return code from pthread_create() is %d\n", rc);
       exit(-1);
     }
   }

   pthread_attr_destroy(&thread_attr);

   for(t=0;t<NUM_OF_TASKS;t++){
     pthread_join(threads[t],(void**)&downloadtime);
     printf("Thread %d downloads the file %s in %d minutes.\n",t,files[t],downloadtime);
   }

   pthread_exit(NULL);
}

個運行中的線程可以調用pthread_exit退出線程。這個函數可以傳入一個參數轉換爲 (void *) 類型。這是線程退出的返回值。接下來來看主線程,在這裏面列了五個文件名,接下來聲明瞭一個數組,裏面有五個pthread_t類型的線程對象。接下來聲明一個線程屬性pthread_attr_t。通過pthread_attr_init初始化這個屬性,並且設置屬性PTHREAD_CREATE_JOINABLE。這表示將來主線程程等待這個線程的結束,並獲取退出時的狀態

接下來是一個循環。對於每一個文件和每一個線程,可以調用pthread_create創建線程。一共有四個參數,第一個參數是線程對象,第二個參數是線程的屬性,第三個參數是線程運行函數,第四個參數是線程運行函數的參數。主線程就是通過第四個參數,將自己的任務派給子線程。任務分配完畢,每個線程下載一個文件,接下來主線程要做的事情就是等待這些子任務完成。當一個線程退出的時候,就會發送信號給其他所有同進程的線程。一個線程使用pthread_join獲取這個線程退出的返回值。線程的返回值通過pthread_join傳給主線程,這樣子線程就將自己下載文件所耗費的時間,告訴給主線程。

程序寫完了可以開始編譯。多線程程序要依賴於libpthread.so,編譯命令如下所示:

gcc download.c -lpthread

執行後得到以下結果:

# ./a.out
creating thread 0, please help me to download file1.avi
creating thread 1, please help me to download file2.rmvb
I am downloading the file file1.avi!
creating thread 2, please help me to download file3.mp4
I am downloading the file file2.rmvb!
creating thread 3, please help me to download file4.wmv
I am downloading the file file3.mp4!
creating thread 4, please help me to download file5.flv
I am downloading the file file4.wmv!
I am downloading the file file5.flv!
I finish downloading the file within 83 minutes!
I finish downloading the file within 77 minutes!
I finish downloading the file within 86 minutes!
I finish downloading the file within 15 minutes!
I finish downloading the file within 93 minutes!
Thread 0 downloads the file file1.avi in 83 minutes.
Thread 1 downloads the file file2.rmvb in 86 minutes.
Thread 2 downloads the file file3.mp4 in 77 minutes.
Thread 3 downloads the file file4.wmv in 93 minutes.
Thread 4 downloads the file file5.flv in 15 minutes.

因此,一個普通線程的創建和運行過程如下所示:

9. 線程可以將項目並行起來加快進度,但是也會帶來負面影響,過程是並行起來了,那數據呢?線程訪問的數據可以細分成三類:

(1)線程棧上的本地數據,比如函數執行過程中的局部變量。函數的調用會使用棧的模型,這在線程裏面是一樣的,只不過每個線程都有自己的棧空間。棧的大小可以通過命令ulimit -a查看,默認情況下線程棧大小爲8192(8MB),可以使用命令ulimit -s修改。對於線程棧可以通過下面這個函數pthread_attr_t,修改線程棧的大小:

int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);

主線程在內存中有一個棧空間,其他線程棧也擁有獨立的棧空間。爲了避免線程之間的棧空間踩踏,線程棧之間還會有小塊區域,用來隔離保護各自的棧空間。一旦另一個線程踏入到這個隔離區,就會引發段錯誤

(2)在整個進程裏共享的全局數據。例如全局變量,雖然在不同進程中是隔離的,但是在一個進程中是共享的。如果同一個全局變量,兩個線程一起修改,有可能把數據改的面目全非。這就需要有一種機制來保護他們,比如你先用我再用。

(3)線程私有數據(Thread Specific Data)。比如想聲明一個線程級別,而非進程級別的全局變量。線程私有數據可以通過如下命令創建:

int pthread_key_create(pthread_key_t *key, void (*destructor)(void*))

可以看到,創建一個key伴隨着一個析構函數。key一旦被創建,所有線程都可以訪問它,但各線程可根據自己的需要往key中填入不同的值,這相當於提供了一個同名而不同值的全局變量。可以通過下面的函數設置 key對應的value:

void *pthread_getspecific(pthread_key_t key)

還可以通過下面的函數獲取key對應的value:

void *pthread_getspecific(pthread_key_t key)

等到線程退出的時候,就會調用析構函數釋放value。

10. 關於共享的數據保護問題,有一種方式叫Mutex(Mutual Exclusion,互斥)。它的模式就是在共享數據訪問的時候,去申請加把鎖,誰先拿到鎖,誰就拿到了訪問權限,其他人就只好在門外等着,等這個人訪問結束,把鎖打開,其他人再去爭奪,還是遵循誰先拿到誰訪問。例如下面“轉賬”的例子:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

#define NUM_OF_TASKS 5

int money_of_tom = 100;
int money_of_jerry = 100;
//第一次運行去掉下面這行
pthread_mutex_t g_money_lock;

void *transfer(void *notused)
{
  pthread_t tid = pthread_self();
  printf("Thread %u is transfering money!\n", (unsigned int)tid);
  //第一次運行去掉下面這行
  pthread_mutex_lock(&g_money_lock);
  sleep(rand()%10);
  money_of_tom+=10;
  sleep(rand()%10);
  money_of_jerry-=10;
  //第一次運行去掉下面這行
  pthread_mutex_unlock(&g_money_lock);
  printf("Thread %u finish transfering money!\n", (unsigned int)tid);
  pthread_exit((void *)0);
}

int main(int argc, char *argv[])
{
  pthread_t threads[NUM_OF_TASKS];
  int rc;
  int t;
  //第一次運行去掉下面這行
  pthread_mutex_init(&g_money_lock, NULL);

  for(t=0;t<NUM_OF_TASKS;t++){
    rc = pthread_create(&threads[t], NULL, transfer, NULL);
    if (rc){
      printf("ERROR; return code from pthread_create() is %d\n", rc);
      exit(-1);
    }
  }
  
  for(t=0;t<100;t++){
    //第一次運行去掉下面這行
    pthread_mutex_lock(&g_money_lock);
    printf("money_of_tom + money_of_jerry = %d\n", money_of_tom + money_of_jerry);
    //第一次運行去掉下面這行
    pthread_mutex_unlock(&g_money_lock);
  }
  //第一次運行去掉下面這行
  pthread_mutex_destroy(&g_money_lock);
  pthread_exit(NULL);
}

這裏說有兩個員工Tom和Jerry,公司食堂的飯卡里面各自有100元,並行啓動5個線程,都是Jerry轉10元給Tom,主線程不斷打印Tom和Jerry的資金之和。按說這樣的話總和應該永遠是200元。在上面的程序中,先去掉mutex相關的行。在沒有鎖的保護下,在Tom的賬戶裏面加上10元,在Jerry的賬戶裏面減去10元,這不是一個原子操作。編譯運行後的結果如下所示:

[root@deployer createthread]# ./a.out
Thread 508479232 is transfering money!
Thread 491693824 is transfering money!
Thread 500086528 is transfering money!
Thread 483301120 is transfering money!
Thread 516871936 is transfering money!
money_of_tom + money_of_jerry = 200
money_of_tom + money_of_jerry = 200
money_of_tom + money_of_jerry = 220
money_of_tom + money_of_jerry = 220
money_of_tom + money_of_jerry = 230
money_of_tom + money_of_jerry = 240
Thread 483301120 finish transfering money!
money_of_tom + money_of_jerry = 240
Thread 508479232 finish transfering money!
Thread 500086528 finish transfering money!
money_of_tom + money_of_jerry = 220
Thread 516871936 finish transfering money!
money_of_tom + money_of_jerry = 210
money_of_tom + money_of_jerry = 210
Thread 491693824 finish transfering money!
money_of_tom + money_of_jerry = 200
money_of_tom + money_of_jerry = 200

可以看到,中間有很多狀態不正確,兩個人的賬戶之和出現了超過200的情況,也就是Tom轉入了,Jerry還沒轉出。接下來在上面代碼中加上mutex,然後編譯運行,就得到了下面的結果:

[root@deployer createthread]# ./a.out
Thread 568162048 is transfering money!
Thread 576554752 is transfering money!
Thread 551376640 is transfering money!
Thread 542983936 is transfering money!
Thread 559769344 is transfering money!
Thread 568162048 finish transfering money!
Thread 576554752 finish transfering money!
money_of_tom + money_of_jerry = 200
money_of_tom + money_of_jerry = 200
money_of_tom + money_of_jerry = 200
Thread 542983936 finish transfering money!
Thread 559769344 finish transfering money!
money_of_tom + money_of_jerry = 200
money_of_tom + money_of_jerry = 200
Thread 551376640 finish transfering money!
money_of_tom + money_of_jerry = 200
money_of_tom + money_of_jerry = 200
money_of_tom + money_of_jerry = 200
money_of_tom + money_of_jerry = 200

這個結果就正常了,兩個賬號之和永遠是200。使用Mutex,首先要使用pthread_mutex_init函數初始化這個mutex,初始化後就可以用它來保護共享變量了。pthread_mutex_lock()就是去搶那把鎖的函數,如果搶到了就可以執行下一行程序,對共享變量進行訪問;如果沒搶到,就被阻塞在那裏等待。如果不想被阻塞,可以使用pthread_mutex_trylock去搶那把鎖,如果搶到了就可以執行下一行程序,對共享變量進行訪問;如果沒搶到不會被阻塞,而是返回一個錯誤碼。當共享數據訪問結束了,別忘了使用pthread_mutex_unlock釋放鎖,讓給其他人使用,最終調用pthread_mutex_destroy銷燬掉這把鎖。Mutex的使用流程如下:

11. 在使用Mutex時,如果使用pthread_mutex_lock(),那就需要一直在那裏等着。如果是pthread_mutex_trylock(),就可以不用等着去幹點兒別的,那能不能在輪到我的時候,通知我一下呢?這其實就是條件變量,也就是說如果沒事就讓大家歇着,有事了就去通知。但是當某個線程接到了通知,來操作共享資源的時候,還是需要搶互斥鎖,因爲可能很多人都受到了通知,都來訪問了,所以條件變量和互斥鎖是配合使用的

例如下面這個例子,老闆招聘了三個員工,但是不是有了活纔去招聘員工,而是先把員工招來,沒有活的時候員工需要在那裏等着,一旦有了活要去通知他們,他們要去搶活幹(績效),幹完了再等待,再有活就再通知他們。代碼如下所示:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

#define NUM_OF_TASKS 3
#define MAX_TASK_QUEUE 11

char tasklist[MAX_TASK_QUEUE]="ABCDEFGHIJ";
int head = 0;
int tail = 0;

int quit = 0;

pthread_mutex_t g_task_lock;
pthread_cond_t g_task_cv;

void *coder(void *notused)
{
  pthread_t tid = pthread_self();

  while(!quit){

    pthread_mutex_lock(&g_task_lock);
    while(tail == head){
      if(quit){
        pthread_mutex_unlock(&g_task_lock);
        pthread_exit((void *)0);
      }
      printf("No task now! Thread %u is waiting!\n", (unsigned int)tid);
      pthread_cond_wait(&g_task_cv, &g_task_lock);
      printf("Have task now! Thread %u is grabing the task !\n", (unsigned int)tid);
    }
    char task = tasklist[head++];
    pthread_mutex_unlock(&g_task_lock);
    printf("Thread %u has a task %c now!\n", (unsigned int)tid, task);
    sleep(5);
    printf("Thread %u finish the task %c!\n", (unsigned int)tid, task);
  }

  pthread_exit((void *)0);
}

int main(int argc, char *argv[])
{
  pthread_t threads[NUM_OF_TASKS];
  int rc;
  int t;

  pthread_mutex_init(&g_task_lock, NULL);
  pthread_cond_init(&g_task_cv, NULL);

  for(t=0;t<NUM_OF_TASKS;t++){
    rc = pthread_create(&threads[t], NULL, coder, NULL);
    if (rc){
      printf("ERROR; return code from pthread_create() is %d\n", rc);
      exit(-1);
    }
  }

  sleep(5);

  for(t=1;t<=4;t++){
    pthread_mutex_lock(&g_task_lock);
    tail+=t;
    printf("I am Boss, I assigned %d tasks, I notify all coders!\n", t);
    pthread_cond_broadcast(&g_task_cv);
    pthread_mutex_unlock(&g_task_lock);
    sleep(20);
  }

  pthread_mutex_lock(&g_task_lock);
  quit = 1;
  pthread_cond_broadcast(&g_task_cv);
  pthread_mutex_unlock(&g_task_lock);

  pthread_mutex_destroy(&g_task_lock);
  pthread_cond_destroy(&g_task_cv);
  pthread_exit(NULL);
}

首先創建了10個任務,每個任務一個字符,放在一個數組裏面,另外有兩個變量head和tail,表示當前分配的工作從哪裏開始,到哪裏結束。如果head等於tail,則當前的工作分配完畢;如果tail加N,就是新分配了N個工作。接下來聲明的pthread_mutex_t g_task_lock和pthread_cond_t g_task_cv是用於通知和搶任務的,工作模式如下圖所示:

然後,要判斷有沒有任務,也就是說head和tail是否相等。如果不相等的話就是有任務,則取出head位置代表的任務task,然後將head加一,這樣整個任務就給了這個員工,下個員工來搶活的時候,也需要獲取鎖,獲取之後搶到的就是下一個任務了。當這個員工搶到任務後,pthread_mutex_unlock解鎖,讓其他員工可以進來搶任務。搶到任務後就開始幹活了,這裏是sleep也就是摸魚了5秒。

如果發現head和tail相當,也就是沒有任務,則需要調用pthread_cond_wait進行等待,這個函數會把鎖也作爲變量傳進去,這是因爲等待的過程中需要解鎖,不然就像一個人不幹活在等待,還把門鎖了別人也幹不了活,而且老闆(主線程main)也沒辦法獲取鎖來分配任務。

一開始三個員工都是在等待的狀態,因爲初始化的時候head和tail相等都爲零。現在主線程初始化了條件變量和鎖,然後創建三個線程,也就是招聘了三個員工。接下來要開始分配總共10個任務。主線程分四批分配,第一批分配一個任務給三個人搶,第二批分配兩個任務,第三批分配三個任務,正好每人搶到一個,第四批四個任務,可能有一個員工搶到兩個任務。

主線程分配工作的時候,也是要先獲取鎖pthread_mutex_lock,然後通過tail加一來分配任務,這個時候head和tail已經不一樣了,但是這個時候三個員工還在pthread_cond_wait那裏睡着,接下來老闆要調用pthread_cond_broadcast通知所有的員工。這個時候三個員工醒來後先搶鎖,當然搶鎖這個動作是pthread_cond_wait在收到通知的時候自動做的,不需要另外寫代碼。搶到鎖的員工就通過while再次判斷head和tail是否相同。這次因爲有了任務不相同了,所以就搶到了任務。

而第一批中沒有搶到任務的員工,由於搶鎖失敗,只好等待搶到任務的員工釋放鎖,搶到任務的員工在tasklist裏面拿到任務後,將head加一然後就釋放鎖。這個時候,另外兩個員工才能從pthread_cond_wait中返回,然後也會再次通過while判斷head和tail是否相同,不過已經晚了,第一批的1個任務已經被搶走了,head和tail又一樣了,所以只好再次進入pthread_cond_wait接着等任務。

上述過程只是第一批一個任務的工作過程。如果運行上面的程序,可以得到下面的結果:

[root@deployer createthread]# ./a.out
//招聘三個員工,一開始沒有任務,大家睡大覺
No task now! Thread 3491833600 is waiting!
No task now! Thread 3483440896 is waiting!
No task now! Thread 3475048192 is waiting!
//老闆開始分配任務了,第一批任務就一個,告訴三個員工醒來搶任務
I am Boss, I assigned 1 tasks, I notify all coders!
//員工一先發現有任務了,開始搶任務
Have task now! Thread 3491833600 is grabing the task !
//員工一搶到了任務A,開始幹活
Thread 3491833600 has a task A now! 
//員工二也發現有任務了,開始搶任務,不好意思,就一個任務,讓人家搶走了,接着等吧
Have task now! Thread 3483440896 is grabing the task !
No task now! Thread 3483440896 is waiting!
//員工三也發現有任務了,開始搶任務,你比員工二還慢,接着等吧
Have task now! Thread 3475048192 is grabing the task !
No task now! Thread 3475048192 is waiting!
//員工一把任務做完了,又沒有任務了,接着等待
Thread 3491833600 finish the task A !
No task now! Thread 3491833600 is waiting!
//老闆又有新任務了,這次是兩個任務,叫醒他們
I am Boss, I assigned 2 tasks, I notify all coders!
//這次員工二比較積極,先開始搶,並且搶到了任務B
Have task now! Thread 3483440896 is grabing the task !
Thread 3483440896 has a task B now! 
//這次員工三也聰明瞭,趕緊搶,要不然沒有年終獎了,終於搶到了任務C
Have task now! Thread 3475048192 is grabing the task !
Thread 3475048192 has a task C now! 
//員工一上次搶到了,這次搶的慢了,沒有搶到,是不是飄了
Have task now! Thread 3491833600 is grabing the task !
No task now! Thread 3491833600 is waiting!
//員工二做完了任務B,沒有任務了,接着等待
Thread 3483440896 finish the task B !
No task now! Thread 3483440896 is waiting!
//員工三做完了任務C,沒有任務了,接着等待
Thread 3475048192 finish the task C !
No task now! Thread 3475048192 is waiting!
//又來任務了,這次是三個任務,人人有份
I am Boss, I assigned 3 tasks, I notify all coders!
//員工一搶到了任務D,員工二搶到了任務E,員工三搶到了任務F
Have task now! Thread 3491833600 is grabing the task !
Thread 3491833600 has a task D now! 
Have task now! Thread 3483440896 is grabing the task !
Thread 3483440896 has a task E now! 
Have task now! Thread 3475048192 is grabing the task !
Thread 3475048192 has a task F now! 
//三個員工都完成了,然後都又開始等待
Thread 3491833600 finish the task D !
Thread 3483440896 finish the task E !
Thread 3475048192 finish the task F !
No task now! Thread 3491833600 is waiting!
No task now! Thread 3483440896 is waiting!
No task now! Thread 3475048192 is waiting!
//公司活越來越多了,來了四個任務,趕緊幹呀
I am Boss, I assigned 4 tasks, I notify all coders!
//員工一搶到了任務G,員工二搶到了任務H,員工三搶到了任務I
Have task now! Thread 3491833600 is grabing the task !
Thread 3491833600 has a task G now! 
Have task now! Thread 3483440896 is grabing the task !
Thread 3483440896 has a task H now! 
Have task now! Thread 3475048192 is grabing the task !
Thread 3475048192 has a task I now! 
//員工一和員工三先做完了,發現還有一個任務開始搶
Thread 3491833600 finish the task G !
Thread 3475048192 finish the task I !
//員工三沒搶到,接着等
No task now! Thread 3475048192 is waiting!
//員工一搶到了任務J,多做了一個任務
Thread 3491833600 has a task J now! 
//員工二這才把任務H做完,黃花菜都涼了,接着等待吧
Thread 3483440896 finish the task H !
No task now! Thread 3483440896 is waiting!
//員工一做完了任務J,接着等待
Thread 3491833600 finish the task J !
No task now! Thread 3491833600 is waiting!

12. 寫多線程的程序是有套路的,需要記住的是創建線程的套路、mutex使用的套路、條件變量使用的套路,如下所示:

三、進程數據結構

13. 在Linux裏,無論是進程還是線程,到了內核裏面統一都叫任務(Task),由一個統一的結構task_struct進行管理。如下所示:

Linux的任務管理都應該幹些啥?首先所有執行的項目應該有個項目列表,所以Linux內核也應該弄一個鏈表,將所有的task_struct串起來,如下所示:

struct list_head    tasks;

每一個任務都應該有一個ID,作爲這個任務的唯一標識。task_struct裏面涉及任務ID 的,有下面幾個:

pid_t pid;
pid_t tgid;
struct task_struct *group_leader;

既然是ID,有一個就足以做唯一標識了,這個怎麼看起來這麼麻煩?這是因爲上面的進程和線程到了內核這裏,統一變成了任務,這就帶來兩個問題:

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

(2)給任務下發指令。kill用來給進程發信號,通知進程退出。如果發給了其中一個線程,就不能只退出這個線程,而是應該退出整個進程。當然,有時候也希望只給某個線程發信號。

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

14. task_struct裏面關於信號處理的字段如下所示:

/* Signal handlers: */
struct signal_struct    *signal;
struct sighand_struct    *sighand;
sigset_t      blocked;
sigset_t      real_blocked;
sigset_t      saved_sigmask;
struct sigpending    pending;
unsigned long      sas_ss_sp;
size_t        sas_ss_size;
unsigned int      sas_ss_flags;

這裏定義了哪些信號被阻塞暫不處理(blocked),哪些信號尚等待處理(pending),哪些信號正在通過信號處理函數進行處理(sighand)。處理的結果可以是忽略或結束進程等等。信號處理函數默認使用用戶態的函數棧,當然也可以開闢新的棧專門用於信號處理,這就是sas_ss_xxx這三個變量的作用。

上面提到下發信號的時候,需要區分進程和線程,從這裏其實也能看出一些端倪。task_struct裏面有一個struct sigpending pending。如果進入struct signal_struct *signal去看,還有一個struct sigpending shared_pending。它們一個是本任務的,一個是線程組共享的。

15. 在task_struct裏面,涉及任務狀態的是下面這幾個變量:

volatile long state;    /* -1 unrunnable, 0 runnable, >0 stopped */
 int exit_state;
 unsigned int flags;

state(狀態)可以取的值定義在 include/linux/sched.h 頭文件中。如下所示:

/* Used in tsk->state: */
#define TASK_RUNNING                    0
#define TASK_INTERRUPTIBLE              1
#define TASK_UNINTERRUPTIBLE            2
#define __TASK_STOPPED                  4
#define __TASK_TRACED                   8
/* Used in tsk->exit_state: */
#define EXIT_DEAD                       16
#define EXIT_ZOMBIE                     32
#define EXIT_TRACE                      (EXIT_ZOMBIE | EXIT_DEAD)
/* Used in tsk->state again: */
#define TASK_DEAD                       64
#define TASK_WAKEKILL                   128
#define TASK_WAKING                     256
#define TASK_PARKED                     512
#define TASK_NOLOAD                     1024
#define TASK_NEW                        2048
#define TASK_STATE_MAX                  4096

從定義的數值可以看出,flags是通過二進制比特位的方式設置的,也就是說當前是什麼狀態,哪一位就置一。進程運行的狀態圖如下所示:

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

在運行中的進程,進行一些I/O操作時需要等待磁盤I/O完畢,這個時候會釋放佔用的CPU,進入睡眠狀態。在Linux中有以下幾種睡眠狀態:

(1)TASK_INTERRUPTIBLE,可中斷的睡眠狀態。這是一種淺睡眠的狀態,也就是說雖然在睡眠等待I/O完成,但是這個時候一個信號來了,進程還是要被喚醒。只不過喚醒後不是繼續剛纔的操作,而是進行信號處理。當然程序員可以根據自己的意願,來寫信號處理函數,例如收到某些信號,就放棄等待這個I/O操作完成,直接退出,也可也收到某些信息,繼續等待。

(2)TASK_UNINTERRUPTIBLE,不可中斷的睡眠狀態。這是一種深度睡眠狀態,不可被信號喚醒,只能死等I/O操作完成。一旦I/O操作因爲特殊原因不能完成,這個時候誰也叫不醒這個進程了。Kill命令本身也是一個信號,既然這個狀態不可被信號喚醒,kill信號也被忽略了。除非重啓電腦沒有其他辦法。因此這其實是一個比較危險的事情,除非程序員極其有把握,不然不要設置成 TASK_UNINTERRUPTIBLE

(3)TASK_KILLABLE,可以終止的新睡眠狀態。它的運行原理類似TASK_UNINTERRUPTIBLE,只不過可以響應kill信號。從定義可以看出,TASK_WAKEKILL用於在接收到kill信號時喚醒進程,而TASK_KILLABLE相當於這兩位都設置了,如下所示:

#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。

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

#define PF_EXITING    0x00000004
#define PF_VCPU      0x00000010
#define PF_FORKNOEXEC    0x00000040

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

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

//是否在運行隊列上
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;//進程啓動時間,包含睡眠時間

從創建進程的過程(fork)可以看出,任何一個進程都有父進程(0、1、2號進程除外)。所以整個進程其實就是一棵進程樹。而擁有同一父進程的所有進程都具有兄弟關係,如下所示:

struct task_struct __rcu *real_parent; /* real parent process */
struct task_struct __rcu *parent; /* recipient of SIGCHLD, wait4() reports */
struct list_head children;      /* list of my children */
struct list_head sibling;       /* linkage in my parent's children list */

其中parent指向其父進程,當它終止時必須向它的父進程發送信號。children表示鏈表的頭部,鏈表中的所有元素都是它的子進程。sibling用於把當前進程插入到兄弟鏈表中。這裏其實也解釋了爲什麼task_struct要用鏈表結構,是爲了維護多個task之間的關係。一個task節點的parent指針指向其父進程task,children指針指向子進程所有task的頭部,然後又靠sibling指針來維護統一級兄弟task。

通常情況下real_parent和parent是一樣的,但是也會有另外的情況存在,例如bash創建一個進程,那進程的parent和real_parent就都是bash。如果在bash上使用GDB來debug一個進程,這個時候GDB是parent,bash是這個進程的real_parent

17. 在Linux裏對於進程權限(能否訪問某個文件、其他某進程、本進程是否可被其他進程訪問)的定義如下:

/* Objective and real subjective task credentials (COW): */
const struct cred __rcu         *real_cred;
/* Effective (overridable) subjective task credentials (COW): */
const struct cred __rcu         *cred;

這個結構的註釋裏,當本進程是被操作的對象,就是Objective,想操作自己的進程就是Subjective。當操作別的進程時,本進程就是Subjective,要被自己操作的進程就是Objectvie。其中real_cred就是說明誰能操作本進程,而cred就是說明本進程能夠操作誰。這裏cred的定義如下:

struct cred {
......
        kuid_t          uid;            /* real UID of the task */
        kgid_t          gid;            /* real GID of the task */
        kuid_t          suid;           /* saved UID of the task */
        kgid_t          sgid;           /* saved GID of the task */
        kuid_t          euid;           /* effective UID of the task */
        kgid_t          egid;           /* effective GID of the task */
        kuid_t          fsuid;          /* UID for VFS ops */
        kgid_t          fsgid;          /* GID for VFS ops */
......
        kernel_cap_t    cap_inheritable; /* caps our children can inherit */
        kernel_cap_t    cap_permitted;  /* caps we're permitted */
        kernel_cap_t    cap_effective;  /* caps we can actually use */
        kernel_cap_t    cap_bset;       /* capability bounding set */
        kernel_cap_t    cap_ambient;    /* Ambient capability set */
......
} __randomize_layout;

從定義可以看出,大部分是關於用戶和用戶所屬的用戶組信息。第一個是uid和gid,註釋是real user/group id。一般情況下誰啓動的進程,就是誰的ID但是權限審覈的時候往往不比較這兩個值。第二個是euid和egid,註釋是effective user/group id,一看這個名字就知道這個是起“作用”的,當這個進程要操作消息隊列、共享內存、信號量等對象的時候,其實就是在比較這個用戶和組是否有權限。第三個是fsuid和fsgid,也就是filesystem user/group id,這個是對文件操作會審覈的權限。一般fsuid、euid和uid是一樣的,fsgid、egid和gid也是一樣的。因爲誰啓動的進程,就應該審覈啓動的用戶到底有沒有這個權限。

但是也有特殊的情況,如下面的例子:

例如用戶A想玩一個遊戲,這個遊戲的程序是用戶B安裝的。遊戲這個程序文件的權限爲rwxr--r--。A是沒有權限運行這個程序的,於是用戶B就給這個程序設定了所有的用戶都能執行的權限rwxr-xr-x。於是用戶A就獲得了運行這個遊戲的權限。當遊戲運行起來之後,遊戲進程的uid、euid、fsuid都是用戶A。看起來沒有問題,用戶A好不容易通關,想保存通關數據的時候,發現這個遊戲的玩家數據是保存在另一個文件裏面的,這個文件權限是rw-------,只給用戶B開了寫入權限,而遊戲進程的euid和fsuid都是用戶 A,當然寫不進去了。

這時可以通過chmod u+s命令,給這個遊戲程序設置set-user-ID的標識位,把遊戲的權限變成rwsr-xr-x這時用戶A再啓動這個遊戲的時候,創建的進程uid當然還是用戶A,但是euid和fsuid就不是用戶A了,因爲看到了set-user-id標識,就改爲文件的所有者的ID,即改成用戶B,這樣就能夠將通關結果保存下來。在 Linux 裏面,一個進程可以隨時通過setuid設置用戶ID,所以遊戲程序的用戶B的ID還會保存在另一個地方,這就是suid和sgid,即saved uid和save gid。這樣就可以很方便地使用setuid,通過設置uid或者suid來改變權限。

18. 除了以用戶和用戶組控制權限,Linux還有另一個機制就是capabilities。原來控制進程的權限,要麼是高權限的root用戶,要麼是一般權限的普通用戶,這時候的問題是root用戶權限太大,而普通用戶權限太小。有時候一個普通用戶想做一點高權限的事情,必須給他整個root的權限,這太不安全了。於是引入了新的機制capabilities,用位圖表示權限,在capability.h可以找到定義的權限:

#define CAP_CHOWN            0
#define CAP_KILL             5
#define CAP_NET_BIND_SERVICE 10
#define CAP_NET_RAW          13
#define CAP_SYS_MODULE       16
#define CAP_SYS_RAWIO        17
#define CAP_SYS_BOOT         22
#define CAP_SYS_TIME         25
#define CAP_AUDIT_READ          37
#define CAP_LAST_CAP         CAP_AUDIT_READ

對於普通用戶運行的進程,當有這個權限的時候,就能做這些操作;沒有的時候就不能做,這樣粒度要小很多。cap_permitted表示進程能夠使用的權限。但是真正起作用的是cap_effective,cap_permitted中可以包含cap_effective中沒有的權限。一個進程可以在必要的時候,放棄自己的某些權限,這樣更加安全。假設自己因爲代碼漏洞被攻破了,但是如果沒權限啥也幹不了,黑客就沒辦法進一步突破。

cap_inheritable表示當可執行文件的擴展屬性設置了inheritable位時,調用exec執行該程序會繼承調用者的inheritable 集合,並將其加入到permitted集合。但在非root用戶下執行exec時,通常不會保留inheritable集合,但是往往又是非root用戶纔想保留權限,所以非常雞肋。

cap_bset也就是capability bounding set,是系統中所有進程允許保留的權限。如果這個集合中不存在某個權限,那麼系統中的所有進程都沒有這個權限。即使以超級用戶權限執行的進程,也是沒有的。這樣有很多好處,例如系統啓動以後,將加載內核模塊的權限去掉,那所有進程都不能加載內核模塊。這樣即便這臺機器被攻破,也做不了太多有害的事情。

cap_ambient是新加入內核的,就是爲了解決cap_inheritable雞肋的狀況,也就是非root用戶進程使用exec執行一個程序的時候,如何保留權限的問題。當執行exec的時候,cap_ambient會被添加到cap_permitted中,同時設置到cap_effective中。

19. 進程列表的數據結構組成如下所示:

在程序執行過程中,一旦調用到系統調用,就需要進入內核繼續執行。如何將用戶態的執行和內核態的執行串起來?這就需要以下兩個重要的成員變量:

struct thread_info    thread_info;
void  *stack;

在用戶態中,程序的執行往往是一個函數調用另一個函數,函數調用都是通過棧來進行的。如果去看彙編語言的代碼,其實就是指令跳轉,從代碼的一個地方跳到另外一個地方。在進程的內存空間裏面,棧是一個從高地址到低地址,往下增長的結構,也就是上面是棧底,下面是棧頂,入棧和出棧的操作都是從下面的棧頂開始的,如下所示:

先來看32位操作系統的情況。在CPU裏,ESP(Extended Stack Pointer)是棧頂指針寄存器,入棧操作Push和出棧操作Pop指令會自動調整ESP的值。另外有一個寄存器 EBP(Extended Base Pointer),是棧基地址指針寄存器指向當前棧幀的最底部

例如A調用B,A的棧幀裏面包含A函數的局部變量,然後是調用B的時候要傳給它的參數,然後是返回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個參數有時候需要進行尋址,但是如果在寄存器裏面,是沒有地址的,因而還是會放到棧裏面,只不過放到棧裏的操作是被調用函數做的。64位系統的棧結構如下:

20. 以上的棧操作,都是在進程的內存空間裏面進行的。接下來通過系統調用,從進程的內存空間到內核中了。內核中也有各種各樣的函數調用的,也需要這樣一個機制,這時候上面的成員變量stack,也就是內核棧,就派上了用場。Linux給每個task都分配了內核棧。在32位系統上arch/x86/include/asm/page_32_types.h是這樣定義的:一個PAGE_SIZE是4K,THREAD_SIZE左移一位就是乘以2,也就是8K,如下所示:

#define THREAD_SIZE_ORDER  1
#define THREAD_SIZE    (PAGE_SIZE << THREAD_SIZE_ORDER)

內核棧在64位系統上的arch/x86/include/asm/page_64_types.h,略有不同:在PAGE_SIZE的基礎上左移兩位(乘以4)即大小是16K,並且要求起始地址必須是8192的整數倍。如下所示:

#ifdef CONFIG_KASAN
#define KASAN_STACK_ORDER 1
#else
#define KASAN_STACK_ORDER 0
#endif


#define THREAD_SIZE_ORDER  (2 + KASAN_STACK_ORDER)
#define THREAD_SIZE  (PAGE_SIZE << THREAD_SIZE_ORDER)

內核棧是一個非常特殊的結構,如下圖所示:

這段空間的最低位置,是一個thread_info結構。這個結構是對task_struct結構的補充,因爲task_struct結構龐大但是通用,不同的體系結構就需要保存不同的東西,所以往往與體系結構有關的都放在thread_info裏面。在內核代碼裏面有這樣一個union,將thread_info和stack放在一起,在include/linux/sched.h文件中就有:

union thread_union {
#ifndef CONFIG_THREAD_INFO_IN_TASK
  struct thread_info thread_info;
#endif
  unsigned long stack[THREAD_SIZE/sizeof(long)];
};

這個union就是這樣定義的,開頭是thread_info,後面是stack。在內核棧的最高地址端,存放的是另一個結構pt_regs,定義如下所示,其中32位和64位的定義不一樣:

#ifdef __i386__
struct pt_regs {
  unsigned long bx;
  unsigned long cx;
  unsigned long dx;
  unsigned long si;
  unsigned long di;
  unsigned long bp;
  unsigned long ax;
  unsigned long ds;
  unsigned long es;
  unsigned long fs;
  unsigned long gs;
  unsigned long orig_ax;
  unsigned long ip;
  unsigned long cs;
  unsigned long flags;
  unsigned long sp;
  unsigned long ss;
};
#else 
struct pt_regs {
  unsigned long r15;
  unsigned long r14;
  unsigned long r13;
  unsigned long r12;
  unsigned long bp;
  unsigned long bx;
  unsigned long r11;
  unsigned long r10;
  unsigned long r9;
  unsigned long r8;
  unsigned long ax;
  unsigned long cx;
  unsigned long dx;
  unsigned long si;
  unsigned long di;
  unsigned long orig_ax;
  unsigned long ip;
  unsigned long cs;
  unsigned long flags;
  unsigned long sp;
  unsigned long ss;
/* top of stack page */
};
#endif

在講系統調用的時候,已經多次見過這個結構。當系統調用從用戶態到內核態的時候,首先要做的第一件事情,就是將用戶態運行過程中的CPU上下文保存起來,其實主要就是保存在這個結構的寄存器變量裏。這樣當從內核系統調用返回的時候,才能讓進程在剛纔的地方接着運行下去。系統調用的時候,壓棧的值的順序和struct pt_regs中寄存器定義的順序是一樣的。在內核中CPU的寄存器ESP或者RSP,已經指向內核棧的棧頂,在內核態裏的調用都有和用戶態相似的過程。

21. 如果知道一個task_struct的stack指針,可以通過下面的函數找到這個線程的內核棧:

static inline void *task_stack_page(const struct task_struct *task)
{
  return task->stack;
}

從task_struct如何得到相應的pt_regs呢?可以通過下面的函數:

/*
 * TOP_OF_KERNEL_STACK_PADDING reserves 8 bytes on top of the ring0 stack.
 * This is necessary to guarantee that the entire "struct pt_regs"
 * is accessible even if the CPU haven't stored the SS/ESP registers
 * on the stack (interrupt gate does not save these registers
 * when switching to the same priv ring).
 * Therefore beware: accessing the ss/esp fields of the
 * "struct pt_regs" is possible, but they may contain the
 * completely wrong values.
 */
#define task_pt_regs(task) \
({                  \
  unsigned long __ptr = (unsigned long)task_stack_page(task);  \
  __ptr += THREAD_SIZE - TOP_OF_KERNEL_STACK_PADDING;    \
  ((struct pt_regs *)__ptr) - 1;          \
})

可以看到,這是先從task_struct找到內核棧的開始位置。然後這個位置加上THREAD_SIZE就到了最後的位置,然後轉換爲struct pt_regs,再減一,就相當於減少了一個 pt_regs 的位置,就到了這個結構的首地址。這裏面有一個 TOP_OF_KERNEL_STACK_PADDING,這個的定義如下:

#ifdef CONFIG_X86_32
# ifdef CONFIG_VM86
#  define TOP_OF_KERNEL_STACK_PADDING 16
# else
#  define TOP_OF_KERNEL_STACK_PADDING 8
# endif
#else
# define TOP_OF_KERNEL_STACK_PADDING 0
#endif

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

22. 如果知道task_struct的值,就能夠輕鬆得到內核棧和內核寄存器。那如果一個當前在某個CPU上執行的進程,想知道自己的task_struct在哪裏,又該怎麼辦呢?可以交給thread_info這個結構,如下所示:

struct thread_info {
  struct task_struct  *task;    /* main task structure */
  __u32      flags;    /* low level flags */
  __u32      status;    /* thread synchronous flags */
  __u32      cpu;    /* current CPU */
  mm_segment_t    addr_limit;
  unsigned int    sig_on_uaccess_error:1;
  unsigned int    uaccess_err:1;  /* uaccess failed */
};

這裏面有個成員變量task指向task_struct,所以常用current_thread_info()->task來獲取task_struct,如下所示:

static inline struct thread_info *current_thread_info(void)
{
  return (struct thread_info *)(current_top_of_stack() - THREAD_SIZE);
}

而thread_info的位置就是內核棧的最高位置減去THREAD_SIZE,就到了thread_info的起始地址。而thread_info數據結構裏只有一個flags:

struct thread_info {
        unsigned long           flags;          /* low level flags */
};

這時候怎麼獲取當前運行中的task_struct呢?current_thread_info有了新的實現方式,在include/linux/thread_info.h中定義了current_thread_info。如下所示:

#include <asm/current.h>
#define current_thread_info() ((struct thread_info *)current)
#endif

current又是什麼呢?在arch/x86/include/asm/current.h中定義了,如下所示:

struct task_struct;


DECLARE_PER_CPU(struct task_struct *, current_task);


static __always_inline struct task_struct *get_current(void)
{
  return this_cpu_read_stable(current_task);
}


#define current get_current

這裏的get_current就是current,會發現新的機制裏面,每個CPU運行的task_struct不通過thread_info獲取了,而是直接放在Per CPU變量裏面了。多核情況下CPU是同時運行的,但是它們共同使用其他硬件資源時,需要解決多個CPU之間的同步問題。Per CPU變量是內核中一種重要的同步機制,顧名思義就是爲每個CPU core構造一個變量的副本,這樣多個CPU core各自操作自己的副本互不干涉。比如,當前進程的變量current_task就被聲明爲Per CPU變量。要使用Per CPU變量,首先要聲明這個變量,在arch/x86/include/asm/current.h中:

DECLARE_PER_CPU(struct task_struct *, current_task);

然後是定義這個變量,在arch/x86/kernel/cpu/common.c中有:

DEFINE_PER_CPU(struct task_struct *, current_task) = &init_task;

也就是說,系統剛剛初始化時,current_task都指向init_task。當某個CPU上的進程進行切換的時候,current_task被修改爲將要切換到的目標進程。例如,進程切換函數__switch_to就會改變current_task,如下所示:

__visible __notrace_funcgraph struct task_struct *
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
......
this_cpu_write(current_task, next_p);
......
return prev_p;
}

其中this_cpu_write函數獲取到了下一個要執行的進程,當要獲取當前的運行中的task_struct的時候,就需要調用this_cpu_read_stable進行讀取,如下所示:

#define this_cpu_read_stable(var)       percpu_stable_op("mov", var)

23. 如果說task_struct的其他成員變量都是和進程管理有關的,內核棧是和進程運行有關係的。總結一下32位和64位系統的函數棧工作模式如下,左邊是32位的,右邊是64位的:

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

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

(3)在內核態,32位和64位的內核棧和task_struct的關聯關係不同,32位主要靠thread_info,64位主要靠Per-CPU變量。

四、進程的調度

24. task_struct數據結構就像項目管理系統一樣,可以幫項目經理維護項目運行過程中的各類信息,但task_struct僅能夠解決“看到”的問題,還要解決如何制定流程,進行進程調度的問題。對於操作系統來講,CPU的數量是有限的,但是進程數目遠遠超過CPU的數目,因而需要進行進程的調度,有效地分配CPU的時間,既要保證進程的最快響應,也要保證進程之間的公平。在Linux裏面,進程大概可以分成兩種:

(1)實時進程,也就是需要儘快執行返回結果的那種,優先級較高。

(2)普通進程,大部分的進程其實都是這種,優先級沒實時進程這麼高。

很顯然,對於這兩種進程,調度策略肯定是不同的。在task_struct中,有一個成員變量叫調度策略,如下所示:

unsigned int policy;

它有以下幾個定義:

#define SCHED_NORMAL    0
#define SCHED_FIFO    1
#define SCHED_RR    2
#define SCHED_BATCH    3
#define SCHED_IDLE    5
#define SCHED_DEADLINE    6

配合調度策略的,還有剛纔說的優先級,也在task_struct中。如下所示:

int prio, static_prio, normal_prio;
unsigned int rt_priority;

優先級其實就是一個數值,對於實時進程,優先級的範圍是0~99;對於普通進程,優先級的範圍是100~139。數值越小,優先級越高

25. 對於實時進程的調度策略,有以下幾種:

(1)SCHED_FIFO,相同優先級的進程先來先服務,高優先級的進程可以搶佔低優先級的進程。

(2)SCHED_RR輪流調度算法,採用時間片,相同優先級的任務當用完時間片會被放到隊列尾部,以保證公平性,高優先級的任務也是可以搶佔低優先級的任務。

(3)SCHED_DEADLINE,按照任務的deadline進行調度。當產生一個調度點的時候,DL調度器總是選擇其deadline距離當前時間點最近的那個任務,並調度它執行。

對於普通進程的調度策略,大家都不緊急優先級不高,有以下幾種:

(1)SCHED_NORMAL,普通的進程。

(2)SCHED_BATCH,後臺進程,幾乎不需要和前端進行交互。這有點像公司在接項目同時,開發一些可以複用的模塊,作爲公司的技術積累,從而使得在之後接新項目的時候,能夠減少工作量。這類項目可以默默執行,不要影響需要交互的進程,可以降低他的優先級。

(3)SCHED_IDLE,特別空閒的時候才跑的進程。

上面無論是policy還是priority,都設置了一個變量,變量僅僅表示了應該這樣幹,但事情總要有人去幹,是誰呢?在task_struct裏面,還有這樣的成員變量:

const struct sched_class *sched_class;

調度策略的執行邏輯,就封裝在這裏面,它是真正幹活的那個。sched_class有幾種實現:

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

(2)dl_sched_class,對應上面的deadline調度策略;

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

(4)fair_sched_class,普通進程的調度策略;

(5)idle_sched_class,空閒進程的調度策略。

26. 由於平常遇到的都是普通進程,在這裏就重點分析普通進程的調度問題。普通進程使用的調度策略是fair_sched_class,顧名思義,對於普通進程來講公平是最重要的。在Linux裏實現了一個基於CFS(Completely Fair Scheduling,完全公平調度)的調度算法。

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

那如何給優先級高的進程多分時間呢?這就相當於N個口袋,優先級高的袋子大,優先級低的袋子小。這樣球就不能按照個數分配了,要按照比例來,大口袋的放了一半和小口袋放了一半,裏面的球數目雖然差很多,也認爲是公平的。在更新進程運行的統計量的時候,其實可以看出這個邏輯,如下所示:

/*
 * Update the current task's runtime statistics.
 */
static void update_curr(struct cfs_rq *cfs_rq)
{
  struct sched_entity *curr = cfs_rq->curr;
  u64 now = rq_clock_task(rq_of(cfs_rq));
  u64 delta_exec;
......
  delta_exec = now - curr->exec_start;
......
  curr->exec_start = now;
......
  curr->sum_exec_runtime += delta_exec;
......
  curr->vruntime += calc_delta_fair(delta_exec, curr);
  update_min_vruntime(cfs_rq);
......
}


/*
 * delta /= w
 */
static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se)
{
  if (unlikely(se->load.weight != NICE_0_LOAD))
        /* delta_exec * weight / lw.weight */
    delta = __calc_delta(delta, NICE_0_LOAD, &se->load);
  return delta;
}

update_curr()這個函數很重要,後面還會很多次看見它。在這裏面得到當前的時間,以及這次的時間片開始的時間,兩者相減就是這次運行的時間 delta_exec ,但是得到的這個時間其實是實際運行的時間,需要做一定的轉化才作爲虛擬運行時間 vruntime。轉化方法如下:

虛擬運行時間vruntime += 實際運行時間delta_exec * NICE_0_LOAD / 權重

也就是說,同樣的實際運行時間,給高權重的算少了,低權重的算多了,但是當選取下一個運行進程的時候,還是按照最小的vruntime來的,這樣高權重的獲得的實際運行時間自然就多了。這就相當於給一個體重 (權重)200斤的胖子喫兩個饅頭,和給一個體重100斤的瘦子喫一個饅頭,然後說兩個喫的是一樣多。

27. 看來CFS需要一個數據結構來對vruntime進行排序,找出最小的那個。這個能夠排序的數據結構不但需要查詢的時候,能夠快速找到最小的,更新的時候也需要能夠快速地調整排序,因爲vruntime經常在變,變了再插入這個數據結構就需要重新排序。能夠平衡查詢和更新速度的是樹,在這裏使用的是紅黑樹。紅黑樹的的節點是應該包括vruntime的,稱爲調度實體。

在task_struct中有這樣的成員變量:

struct sched_entity se;
struct sched_rt_entity rt;
struct sched_dl_entity dl;

這裏有實時調度實體sched_rt_entity,Deadline調度實體sched_dl_entity,以及完全公平算法調度實體sched_entity。看來不光CFS調度策略需要有這樣一個數據結構進行排序,其他的調度策略也同樣有自己的數據結構進行排序,因爲任何一個策略做調度的時候,都是要區分誰先運行誰後運行。而進程根據自己是實時的,還是普通的類型,通過這個成員變量,將自己掛在某一個數據結構裏面,和其他的進程排序,等待被調度。如果這個進程是個普通進程,則通過sched_entity,將自己掛在公平調度算法的這棵紅黑樹上。

對於普通進程的調度實體定義如下,這裏麪包含了vruntime和權重load_weight,以及對於運行時間的統計:

struct sched_entity {
  struct load_weight    load;
  struct rb_node      run_node;
  struct list_head    group_node;
  unsigned int      on_rq;
  u64        exec_start;
  u64        sum_exec_runtime;
  u64        vruntime;
  u64        prev_sum_exec_runtime;
  u64        nr_migrations;
  struct sched_statistics    statistics;
......
};

下圖是一個紅黑樹的例子:

所有可運行的進程通過不斷地插入操作最終都存儲在以時間爲順序的紅黑樹中,vruntime最小的在樹的左側,vruntime最多的在樹的右側。CFS調度策略會選擇紅黑樹最左邊的葉子節點作爲下一個將獲得CPU的任務。這棵紅黑樹放在那裏呢?每個CPU都有自己的struct rq結構,用於描述在此CPU上所運行的所有進程,其包括一個實時進程隊列rt_rq和一個CFS運行隊列cfs_rq,在調度時調度器首先會去實時進程隊列,找是否有實時進程需要運行,如果沒有才會去CFS運行隊列找是否有進行需要運行。rq(run queue)的定義如下所示:

struct rq {
  /* runqueue lock: */
  raw_spinlock_t lock;
  unsigned int nr_running;
  unsigned long cpu_load[CPU_LOAD_IDX_MAX];
......
  struct load_weight load;
  unsigned long nr_load_updates;
  u64 nr_switches;


  struct cfs_rq cfs;
  struct rt_rq rt;
  struct dl_rq dl;
......
  struct task_struct *curr, *idle, *stop;
......
};

對於普通進程公平隊列cfs_rq,定義如下:

/* CFS-related fields in a runqueue */
struct cfs_rq {
  struct load_weight load;
  unsigned int nr_running, h_nr_running;


  u64 exec_clock;
  u64 min_vruntime;
#ifndef CONFIG_64BIT
  u64 min_vruntime_copy;
#endif
  struct rb_root tasks_timeline;
  struct rb_node *rb_leftmost;


  struct sched_entity *curr, *next, *last, *skip;
......
};

這裏面rb_root指向的就是紅黑樹的根節點,這個紅黑樹在CPU看起來就是一個隊列,不斷的取下一個應該運行的進程。rb_leftmost指向的是最左面的節點。到這裏終於湊夠數據結構了,上面這些數據結構的關係如下圖:

28. 湊夠了數據結構,接下來看調度類是如何工作的。調度類的定義如下:

struct sched_class {
  const struct sched_class *next;


  void (*enqueue_task) (struct rq *rq, struct task_struct *p, int flags);
  void (*dequeue_task) (struct rq *rq, struct task_struct *p, int flags);
  void (*yield_task) (struct rq *rq);
  bool (*yield_to_task) (struct rq *rq, struct task_struct *p, bool preempt);


  void (*check_preempt_curr) (struct rq *rq, struct task_struct *p, int flags);


  struct task_struct * (*pick_next_task) (struct rq *rq,
            struct task_struct *prev,
            struct rq_flags *rf);
  void (*put_prev_task) (struct rq *rq, struct task_struct *p);


  void (*set_curr_task) (struct rq *rq);
  void (*task_tick) (struct rq *rq, struct task_struct *p, int queued);
  void (*task_fork) (struct task_struct *p);
  void (*task_dead) (struct task_struct *p);


  void (*switched_from) (struct rq *this_rq, struct task_struct *task);
  void (*switched_to) (struct rq *this_rq, struct task_struct *task);
  void (*prio_changed) (struct rq *this_rq, struct task_struct *task, int oldprio);
  unsigned int (*get_rr_interval) (struct rq *rq,
           struct task_struct *task);
  void (*update_curr) (struct rq *rq)

這個結構定義了很多種方法,用於在隊列上操作任務。這裏注意第一個成員變量,是一個指針,指向下一個調度類。上面講了調度類分爲下面這幾種:

extern const struct sched_class stop_sched_class;
extern const struct sched_class dl_sched_class;
extern const struct sched_class rt_sched_class;
extern const struct sched_class fair_sched_class;
extern const struct sched_class idle_sched_class;

它們其實是放在一個鏈表上的。這裏以調度最常見的操作,取下一個任務爲例。可以看到這裏面有一個for_each_class循環,沿着上面的順序,依次調用每個調度類(class)的方法,如下所示:

/*
 * Pick up the highest-prio task:
 */
static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
  const struct sched_class *class;
  struct task_struct *p;
......
  for_each_class(class) {
    p = class->pick_next_task(rq, prev, rf);
    if (p) {
      if (unlikely(p == RETRY_TASK))
        goto again;
      return p;
    }
  }
}

這就說明,調度的時候是從優先級最高的調度類到優先級低的調度類,依次執行。而對於每種調度類,有自己的實現,例如CFS就有fair_sched_class,如下所示:

const struct sched_class fair_sched_class = {
  .next      = &idle_sched_class,
  .enqueue_task    = enqueue_task_fair,
  .dequeue_task    = dequeue_task_fair,
  .yield_task    = yield_task_fair,
  .yield_to_task    = yield_to_task_fair,
  .check_preempt_curr  = check_preempt_wakeup,
  .pick_next_task    = pick_next_task_fair,
  .put_prev_task    = put_prev_task_fair,
  .set_curr_task          = set_curr_task_fair,
  .task_tick    = task_tick_fair,
  .task_fork    = task_fork_fair,
  .prio_changed    = prio_changed_fair,
  .switched_from    = switched_from_fair,
  .switched_to    = switched_to_fair,
  .get_rr_interval  = get_rr_interval_fair,
  .update_curr    = update_curr_fair,
};

從上面幾個等號來看,對於同樣的pick_next_task,即選取下一個要運行的任務這個動作,不同的調度類有自己的實現。fair_sched_class的實現是pick_next_task_fair,rt_sched_class的實現是pick_next_task_rt。可以發現這兩個函數是操作不同的隊列,pick_next_task_rt操作的是rt_rq,pick_next_task_fair操作的是cfs_rq。其中pick_next_task_rt的邏輯如下所示:

static struct task_struct *
pick_next_task_rt(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
  struct task_struct *p;
  struct rt_rq *rt_rq = &rq->rt;
......
}


static struct task_struct *
pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
  struct cfs_rq *cfs_rq = &rq->cfs;
  struct sched_entity *se;
  struct task_struct *p;
......
}

這樣整個運行的場景就串起來了,在每個CPU上都有一個隊列rq,這個隊列裏面包含多個子隊列,例如rt_rq和cfs_rq,不同的隊列有不同的實現方式,cfs_rq就是用紅黑樹實現的當某個CPU需要找下一個任務執行的時候,會按照優先級依次調用調度類,不同的調度類操作不同的隊列。當然rt_sched_class先被調用,它會在rt_rq上找下一個任務,只有找不到的時候,才輪到fair_sched_class被調用,它會在cfs_rq上找下一個任務。這樣保證了實時任務的優先級永遠大於普通任務

29. 下面仔細看一下sched_class定義的與調度有關的函數:

(1)enqueue_task向就緒隊列中添加一個進程,當某個進程進入可運行狀態時,調用這個函數;

(2)dequeue_task將一個進程從就就緒隊列中刪除;

(3)pick_next_task選擇接下來要運行的進程;

(4)put_prev_task用另一個進程代替當前運行的進程;

(5)set_curr_task用於修改調度策略;

(6)task_tick,每次週期性時鐘到的時候,這個函數被調用,可能觸發調度。

在這裏面重點看fair_sched_class對於pick_next_task的實現pick_next_task_fair,獲取下一個進程。調用路徑如下:pick_next_task_fair->pick_next_entity->__pick_first_entity,如下所示:

struct sched_entity *__pick_first_entity(struct cfs_rq *cfs_rq)
{
  struct rb_node *left = rb_first_cached(&cfs_rq->tasks_timeline);


  if (!left)
    return NULL;


  return rb_entry(left, struct sched_entity, run_node);

從這個函數的實現可以看出,就是從紅黑樹裏面取最左面的節點。因此,調度相關的數據結構還是比較複雜的。一個CPU上有一個隊列,CFS的隊列是一棵紅黑樹,樹的每一個節點都是一個sched_entity,每個sched_entity都屬於一個task_struct,task_struct裏面有指針指向這個進程屬於哪個調度類。在調度的時候,依次調用調度類的函數,從CPU的隊列中取出下一個進程。各結構的關係如下所示:

30. 所謂進程調度,其實就像一個人在做A項目,在某個時刻換成做B項目去了。發生這種情況,主要有兩種方式。

(1)A項目做着做着,發現裏面有一條指令sleep要休息一下,或者在等待某個I/O事件,那就要主動讓出CPU,然後可以開始做B項目。

(2)A項目做着做着,曠日持久,項目經理介入了說這個項目A先停停,B項目也要做一下。

先來看第一種主動調度的方式。例如Btrfs等待一個寫入,寫入需要一段時間,這段時間用不上CPU,還不如調用schedule()主動讓給其他進程。還有一個例子是從Tap網絡設備等待一個讀取。Tap網絡設備是虛擬機使用的網絡設備。當沒有數據到來的時候,它也需要等待,所以也會選擇把CPU讓給其他進程,這裏也調用了schedule(),如下所示:

static ssize_t tap_do_read(struct tap_queue *q,
         struct iov_iter *to,
         int noblock, struct sk_buff *skb)
{
......
  while (1) {
    if (!noblock)
      prepare_to_wait(sk_sleep(&q->sk), &wait,
          TASK_INTERRUPTIBLE);
......
    /* Nothing to read, let's sleep */
    schedule();
  }
......
}

計算主要是CPU和內存的合作;網絡和存儲則多是和外部設備的合作;在操作外部設備的時候,往往需要讓出CPU, schedule() 函數的調用邏輯如下:

asmlinkage __visible void __sched schedule(void)
{
  struct task_struct *tsk = current;


  sched_submit_work(tsk);
  do {
    preempt_disable();
    __schedule(false);
    sched_preempt_enable_no_resched();
  } while (need_resched());
}

這段代碼的主要邏輯是在__schedule函數中實現的。這個函數比較複雜,分幾個部分來看,先看第一部分:

static void __sched notrace __schedule(bool preempt)
{
  struct task_struct *prev, *next;
  unsigned long *switch_count;
  struct rq_flags rf;
  struct rq *rq;
  int cpu;


  cpu = smp_processor_id();
  rq = cpu_rq(cpu);
  prev = rq->curr;
......

首先在當前的CPU上,取出任務隊列rq。task_struct *prev指向這個CPU的任務隊列上面正在運行的那個進程curr,因爲一旦將來它被切換下來,那它就成了前任了。接下來代碼如下:

next = pick_next_task(rq, prev, &rf);
clear_tsk_need_resched(prev);
clear_preempt_need_resched();

第二步,獲取下一個任務,task_struct *next指向下一個任務,這就是繼任。pick_next_task的實現如下:

static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
  const struct sched_class *class;
  struct task_struct *p;
  /*
   * Optimization: we know that if all tasks are in the fair class we can call that function directly, but only if the @prev task wasn't of a higher scheduling class, because otherwise those loose the opportunity to pull in more work from other CPUs.
   */
  if (likely((prev->sched_class == &idle_sched_class ||
        prev->sched_class == &fair_sched_class) &&
       rq->nr_running == rq->cfs.h_nr_running)) {
    p = fair_sched_class.pick_next_task(rq, prev, rf);
    if (unlikely(p == RETRY_TASK))
      goto again;
    /* Assumes fair_sched_class->next == idle_sched_class */
    if (unlikely(!p))
      p = idle_sched_class.pick_next_task(rq, prev, rf);
    return p;
  }
again:
  for_each_class(class) {
    p = class->pick_next_task(rq, prev, rf);
    if (p) {
      if (unlikely(p == RETRY_TASK))
        goto again;
      return p;
    }
  }
}

直接來看again這裏,就是之前講的for_each_class會依次調用調度類,會調用每一個調度類的pick_next_task。但是這裏有了一個優化,因爲大部分進程是普通進程,所以大部分情況下不用每一個調度類都過一遍,即直接調用針對普通進程的公平調度器類,就是 fair_sched_class.pick_next_task。根據之前對於fair_sched_class的定義,它調用的是pick_next_task_fair,代碼如下:

static struct task_struct *
pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
  struct cfs_rq *cfs_rq = &rq->cfs;
  struct sched_entity *se;
  struct task_struct *p;
  int new_tasks;

對於CFS調度類,取出相應的隊列cfs_rq,這就是之前講過的那棵紅黑樹。如下所示:

struct sched_entity *curr = cfs_rq->curr;
    if (curr) {
      if (curr->on_rq)
        update_curr(cfs_rq);
      else
        curr = NULL;
......
    }
    se = pick_next_entity(cfs_rq, curr);

從紅黑樹上取出當前正在運行的任務curr,如果依然是可運行的狀態,也即處於進程就緒狀態,則調用update_curr 更新vruntime。update_curr之前就見過了,它會根據實際運行時間算出vruntime來。接着pick_next_entity從紅黑樹裏面取最左邊的一個節點。這個函數的實現之前也講過。再看下一部分代碼:

 p = task_of(se);


  if (prev != p) {
    struct sched_entity *pse = &prev->se;
......
    put_prev_entity(cfs_rq, pse);
    set_next_entity(cfs_rq, se);
  }


  return p

task_of得到下一個調度實體對應的task_struct,如果發現繼任和前任不一樣,這就說明有一個更需要運行的進程了,就需要更新紅黑樹了。前面前任的vruntime更新過了,put_prev_entity放回紅黑樹,會找到相應的位置,然後set_next_entity將繼任者設爲當前任務。

31. 當選出的繼任者和前任不同,就要進行上下文切換,繼任者進程正式進入運行,調用context_switch,如下所示:

if (likely(prev != next)) {
    rq->nr_switches++;
    rq->curr = next;
    ++*switch_count;
......
    rq = context_switch(rq, prev, next, &rf);

上下文切換主要幹兩件事情,一是切換進程空間,也即虛擬內存;二是切換寄存器和CPU上下文。先來看context_switch的實現,如下所示:

/*
 * context_switch - switch to the new MM and the new thread's register state.
 */
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
         struct task_struct *next, struct rq_flags *rf)
{
  struct mm_struct *mm, *oldmm;
......
  mm = next->mm;
  oldmm = prev->active_mm;
......
  switch_mm_irqs_off(oldmm, mm, next);
......
  /* Here we just switch the register state and the stack. */
  switch_to(prev, next, prev);
  barrier();
  return finish_task_switch(prev);
}

這裏首先是內存空間的切換,調用的是switch_mm_irqs_off。接下來重點看switch_to,它就是寄存器和棧的切換,它調用到了__switch_to_asm。這是一段彙編代碼,主要用於棧的切換。對於32位操作系統來講,切換的是棧頂指針esp。該彙編代碼如下:

/*
 * %eax: prev task
 * %edx: next task
 */
ENTRY(__switch_to_asm)
......
  /* switch stack */
  movl  %esp, TASK_threadsp(%eax)
  movl  TASK_threadsp(%edx), %esp
......
  jmp  __switch_to
END(__switch_to_asm)

對於64位操作系統來講,切換的是棧頂指針rsp,如下所示:

/*
 * %rdi: prev task
 * %rsi: next task
 */
ENTRY(__switch_to_asm)
......
  /* switch stack */
  movq  %rsp, TASK_threadsp(%rdi)
  movq  TASK_threadsp(%rsi), %rsp
......
  jmp  __switch_to
END(__switch_to_asm)

最終都返回了__switch_to 這個函數。這個函數對於32位和64位操作系統雖然有不同的實現,但裏面做的事情是差不多的。這裏僅列出64位操作系統做的事情,如下所示:

__visible __notrace_funcgraph struct task_struct *
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
  struct thread_struct *prev = &prev_p->thread;
  struct thread_struct *next = &next_p->thread;
......
  int cpu = smp_processor_id();
  struct tss_struct *tss = &per_cpu(cpu_tss, cpu);
......
  load_TLS(next, cpu);
......
  this_cpu_write(current_task, next_p);


  /* Reload esp0 and ss1.  This changes current_thread_info(). */
  load_sp0(tss, next);
......
  return prev_p;
}

這裏面有一個Per CPU的結構體tss。這是個什麼呢?x86體系結構中,提供了一種以硬件的方式進行進程切換的模式,對於每個進程,x86希望在內存裏面維護一個TSS(Task State Segment,任務狀態段)結構,這裏面有所有的寄存器值。另外還有一個特殊的寄存器TR(Task Register,任務寄存器),指向某個進程的TSS。更改TR的值,將會觸發硬件保存CPU所有寄存器的值到當前進程的TSS中,然後從新進程的TSS中讀出所有寄存器值,加載到CPU對應的寄存器中。下圖就是32位的TSS結構:

可以看到保存的值還是很多的,這樣有個缺點,做進程切換的時候,沒必要每個寄存器都切換,這樣每個進程一個TSS,就需要全量保存,TR更改後全量切換開銷太大了。於是Linux系統想了一個辦法,系統初始化的時候會調用cpu_init,這裏面會給每一個CPU關聯一個TSS,然後將TR指向這個TSS,然後在操作系統的運行過程中,TR就不切換了,永遠指向這個TSS。TSS用數據結構tss_struct表示,在x86_hw_tss中可以看到和上圖相應的結構,如下所示:

void cpu_init(void)
{
  int cpu = smp_processor_id();
  struct task_struct *curr = current;
  struct tss_struct *t = &per_cpu(cpu_tss, cpu);
    ......
    load_sp0(t, thread);
  set_tss_desc(cpu, t);
  load_TR_desc();
    ......
}


struct tss_struct {
  /*
   * The hardware state:
   */
  struct x86_hw_tss  x86_tss;
  unsigned long    io_bitmap[IO_BITMAP_LONGS + 1];
}

在Linux中,真的參與進程切換的寄存器很少,主要的就是棧頂寄存器。於是在task_struct裏面,還有一個原來沒有注意的成員變量thread,這裏面保留了要切換進程的時候需要修改的寄存器。如下所示:

/* CPU-specific state of this task: */
  struct thread_struct    thread;

所謂的進程切換,就是將某個進程的thread_struct裏面的寄存器的值,寫入到CPU的TR指向的tss_struct,對CPU來講,這就算是完成了切換。例如前面__switch_to中的load_sp0,就是將下一個進程的thread_struct的sp0(棧頂指針)的值加載到tss_struct裏面去。

32. 進程主動調度的過程如下所示,即一個運行中的進程主動調用__schedule讓出CPU,在__schedule裏面會做兩件事情,第一是選取下一個進程,第二是進行上下文切換。而上下文切換又分用戶態進程空間的切換和內核態的切換

主動調度,即由於IO等操作主動讓出CPU是進程調度的第一種方式。第二種是被動的,就是搶佔式調度。最常見的現象就是一個進程執行時間太長了,是時候切換到另一個進程了。那怎麼衡量一個進程的運行時間呢?在計算機裏面有一個時鐘,會過一段時間觸發一次時鐘中斷,通知操作系統,時間又過去一個時鐘週期,可以查看是否是需要搶佔的時間點。時鐘中斷處理函數會調用scheduler_tick(),代碼如下:

void scheduler_tick(void)
{
  int cpu = smp_processor_id();
  struct rq *rq = cpu_rq(cpu);
  struct task_struct *curr = rq->curr;
......
  curr->sched_class->task_tick(rq, curr, 0);
  cpu_load_update_active(rq);
  calc_global_load_tick(rq);
......
}

這個函數先取出當前cpu的運行隊列,然後得到這個隊列上當前正在運行中的進程的task_struct,然後調用這個task_struct的調度類的task_tick函數,顧名思義這個函數就是來處理時鐘事件的。如果當前運行的進程是普通進程,則調度類爲fair_sched_class,調用的處理時鐘的函數爲 task_tick_fair。來看一下它的實現:

static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued)
{
  struct cfs_rq *cfs_rq;
  struct sched_entity *se = &curr->se;


  for_each_sched_entity(se) {
    cfs_rq = cfs_rq_of(se);
    entity_tick(cfs_rq, se, queued);
  }
......
}

根據當前進程的task_struct,找到對應的調度實體sched_entity和cfs_rq隊列,調用entity_tick。entity_tick的實現如下所示:

static void
entity_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr, int queued)
{
  update_curr(cfs_rq);
  update_load_avg(curr, UPDATE_TG);
  update_cfs_shares(curr);
.....
  if (cfs_rq->nr_running > 1)
    check_preempt_tick(cfs_rq, curr);
}

在entity_tick裏面,又見到了熟悉的update_curr,它會更新當前進程的vruntime,然後調用check_preempt_tick,顧名思義,檢查是否是時候該被搶佔了,check_preempt_tick的實現如下所示:

static void
check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
  unsigned long ideal_runtime, delta_exec;
  struct sched_entity *se;
  s64 delta;


  ideal_runtime = sched_slice(cfs_rq, curr);
  delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime;
  if (delta_exec > ideal_runtime) {
    resched_curr(rq_of(cfs_rq));
    return;
  }
......
  se = __pick_first_entity(cfs_rq);
  delta = curr->vruntime - se->vruntime;
  if (delta < 0)
    return;
  if (delta > ideal_runtime)
    resched_curr(rq_of(cfs_rq));
}

check_preempt_tick先是調用sched_slice函數計算出ideal_runtime,它就是一個調度週期中,這個進程應該運行的實際時間。sum_exec_runtime指進程總共執行的實際時間,prev_sum_exec_runtime指上次該進程被調度時已經佔用的實際時間。每次在調度一個新的進程時都會把它的se->prev_sum_exec_runtime = se->sum_exec_runtime,所以sum_exec_runtime-prev_sum_exec_runtime就是這次調度佔用實際時間。如果這個時間大於ideal_runtime,則應該被搶佔了

除了這個條件之外,上面還會通過__pick_first_entity取出紅黑樹中最小的進程。如果當前進程的vruntime大於紅黑樹中最小的進程的vruntime,且這個差值大於ideal_runtime,則也應該被搶佔了

當發現當前進程應該被搶佔,不能直接把它踢下來,而是把它標記爲應該被搶佔。爲什麼呢?因爲所有進程調度必須經過__schedule()函數,一定要等待正在運行的進程調用__schedule纔行,所以這裏只能先標記一下。標記一個進程應該被搶佔,都是調用resched_curr,它會調用set_tsk_need_resched,標記進程應該被搶佔,但是此時此刻並不真的搶佔,而是打上一個標籤TIF_NEED_RESCHED,如下所示:

static inline void set_tsk_need_resched(struct task_struct *tsk)
{
  set_tsk_thread_flag(tsk,TIF_NEED_RESCHED);
}

另外一個可能搶佔的場景是當一個進程被喚醒的時候。前面說過當一個進程在等待一個I/O的時候,會主動放棄CPU。但是當I/O到來的時候,進程往往會被喚醒。這個時候是一個時機,當被喚醒的進程優先級高於CPU上的當前進程,就會觸發搶佔。try_to_wake_up()調用ttwu_queue將這個喚醒的任務添加到隊列當中。ttwu_queue再調用ttwu_do_activate激活這個任務。ttwu_do_activate調用ttwu_do_wakeup,這裏面調用了check_preempt_curr檢查是否應該發生搶佔。如果應該發生搶佔,也不是直接踢走當然進程,而也是將當前進程標記爲應該被搶佔。ttwu_do_wakeup的邏輯如下所示:

static void ttwu_do_wakeup(struct rq *rq, struct task_struct *p, int wake_flags,
         struct rq_flags *rf)
{
  check_preempt_curr(rq, p, wake_flags);
  p->state = TASK_RUNNING;
  trace_sched_wakeup(p);

33. 到這裏會發現,搶佔問題只做完了一半。就是標識當前運行中的進程應該被搶佔了,但是真正的搶佔動作並沒有發生。真正的搶佔還需要時機,也就是需要那麼一個時刻,讓正在運行中的進程有機會調用一下__schedule。當然不可能某個進程代碼運行着,突然要去調用__schedule,代碼裏不可能這麼寫,所以一定要規劃幾個時機,這個時機分爲用戶態和內核態

先來看用戶態的搶佔時機。對於用戶態的進程來講,從系統調用中返回的那個時刻,是一個被搶佔的時機。在系統調用的時候,64 位的系統調用鏈路爲do_syscall_64->syscall_return_slowpath->prepare_exit_to_usermode->exit_to_usermode_loop,現在來看一下exit_to_usermode_loop這個函數:

static void exit_to_usermode_loop(struct pt_regs *regs, u32 cached_flags)
{
  while (true) {
    /* We have work to do. */
    local_irq_enable();


    if (cached_flags & _TIF_NEED_RESCHED)
      schedule();
......
  }
}

在exit_to_usermode_loop函數中,上面打的標記起了作用,如果被打了_TIF_NEED_RESCHED,就會調用schedule()進行調度,調用的過程會選擇一個進程讓出CPU,做上下文切換。

對於用戶態的進程來說,從中斷中返回的那個時刻,也是一個被搶佔的時機。在arch/x86/entry/entry_64.S中有中斷的處理過程,是一段彙編代碼,重點領會它的意思就行,不需每一行看懂:

common_interrupt:
        ASM_CLAC
        addq    $-0x80, (%rsp) 
        interrupt do_IRQ
ret_from_intr:
        popq    %rsp
        testb   $3, CS(%rsp)
        jz      retint_kernel
/* Interrupt came from user space */
GLOBAL(retint_user)
        mov     %rsp,%rdi
        call    prepare_exit_to_usermode
        TRACE_IRQS_IRETQ
        SWAPGS
        jmp     restore_regs_and_iret
/* Returning to kernel space */
retint_kernel:
#ifdef CONFIG_PREEMPT
        bt      $9, EFLAGS(%rsp)  
        jnc     1f
0:      cmpl    $0, PER_CPU_VAR(__preempt_count)
        jnz     1f
        call    preempt_schedule_irq
        jmp     0b

中斷處理調用的是do_IRQ函數,中斷完畢後分爲兩種情況,一個是返回用戶態,一個是返回內核態,這個通過註釋也能看出來。先來看返回用戶態這一部分,先不管返回內核態的那部分代碼,retint_user會調用prepare_exit_to_usermode,最終調用exit_to_usermode_loop,和上面的邏輯一樣,發現有標記則調用schedule()

34. 接下來看內核態的搶佔時機。對內核態的執行中,被搶佔的時機一般發生在preempt_enable()中。在內核態的執行中,有的操作是不能被中斷的,所以在進行這些操作之前,總是先調用preempt_disable()關閉搶佔,當再次打開的時候,就是一次內核態代碼被搶佔的機會。就像下面代碼中展示的一樣,preempt_enable()會調用preempt_count_dec_and_test(),判斷preempt_count和TIF_NEED_RESCHED看是否可以被搶佔。如果可以,就調用preempt_schedule->preempt_schedule_common->__schedule進行調度,這裏還是滿足進程調度必須使用__schedule的規律的:

#define preempt_enable() \
do { \
  if (unlikely(preempt_count_dec_and_test())) \
    __preempt_schedule(); \
} while (0)


#define preempt_count_dec_and_test() \
  ({ preempt_count_sub(1); should_resched(0); })


static __always_inline bool should_resched(int preempt_offset)
{
  return unlikely(preempt_count() == preempt_offset &&
      tif_need_resched());
}


#define tif_need_resched() test_thread_flag(TIF_NEED_RESCHED)


static void __sched notrace preempt_schedule_common(void)
{
  do {
......
    __schedule(true);
......
  } while (need_resched())

在內核態也會遇到中斷的情況,當中斷返回的時候,返回的仍然是內核態,這個時候也是一個執行搶佔的時機。現在再來上面中斷返回的代碼中返回內核的那部分彙編代碼,調用的是preempt_schedule_irq,如下所示:

asmlinkage __visible void __sched preempt_schedule_irq(void)
{
......
  do {
    preempt_disable();
    local_irq_enable();
    __schedule(true);
    local_irq_disable();
    sched_preempt_enable_no_resched();
  } while (need_resched());
......
}

果然,preempt_schedule_irq裏還是調用了__schedule進行進程的實際調度。

35. 整個進程的調度體系如下圖所示:

裏面第一條就是進程調度的核心函數__schedule的執行過程,第二條總結了標記爲可搶佔的場景,第三條是所有的搶佔發生的時機,這裏是真正驗證了進程調度必須經過__schedule這個函數的規律。

五、進程的創建

36. 之前提到過如何使用fork創建進程,那麼來看一看創建進程這個動作在內核裏都做了什麼事情。fork是一個系統調用,調用流程的最後會在sys_call_table中找到相應的系統調用sys_fork。根據SYSCALL_DEFINE0這個宏的定義,下面這段代碼就定義了sys_fork,如下所示:

SYSCALL_DEFINE0(fork)
{
......
  return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
}

可以看到sys_fork會調用_do_fork,_do_fork的邏輯如下所示:

long _do_fork(unsigned long clone_flags,
        unsigned long stack_start,
        unsigned long stack_size,
        int __user *parent_tidptr,
        int __user *child_tidptr,
        unsigned long tls)
{
  struct task_struct *p;
  int trace = 0;
  long nr;


......
  p = copy_process(clone_flags, stack_start, stack_size,
       child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
......
  if (!IS_ERR(p)) {
    struct pid *pid;
    pid = get_task_pid(p, PIDTYPE_PID);
    nr = pid_vnr(pid);


    if (clone_flags & CLONE_PARENT_SETTID)
      put_user(nr, parent_tidptr);


......
    wake_up_new_task(p);
......
    put_pid(pid);
  } 
......

_do_fork裏面做的第一件事就是copy_process,即通過複製父進程的方式來創建進程。這裏再把task_struct的結構圖拿出來,對比着看如何一個個複製,如下所示:

static __latent_entropy struct task_struct *copy_process(
          unsigned long clone_flags,
          unsigned long stack_start,
          unsigned long stack_size,
          int __user *child_tidptr,
          struct pid *pid,
          int trace,
          unsigned long tls,
          int node)
{
  int retval;
  struct task_struct *p;
......
  p = dup_task_struct(current, node);

首先copy_process 調用的是dup_task_struct,它主要做了下面幾件事情:

(1)調用alloc_task_struct_node分配一個task_struct結構;

(2)調用alloc_thread_stack_node來創建內核棧,這裏面調用__vmalloc_node_range分配一個連續的THREAD_SIZE的內存空間,賦值給task_struct的void *stack成員變量;

(3)調用arch_dup_task_struct(struct task_struct *dst, struct task_struct *src),將task_struct進行復制,其實就是調用memcpy;

(4)調用setup_thread_stack設置thread_info。

到這裏整個task_struct複製了一份,而且內核棧也創建好了。再接着看copy_process,如下所示:

retval = copy_creds(p, clone_flags);

輪到權限相關了,copy_creds主要做了下面兩件事情:

(1)調用prepare_creds,準備一個新的struct cred *new。如何準備呢?其實還是從內存中分配一個新的struct cred結構,然後調用memcpy複製一份父進程的cred;

(2)接着p->cred = p->real_cred = get_cred(new),將新進程的“我能操作誰”和“誰能操作我”兩個權限都指向新的cred。

接下來,copy_process重新設置進程運行的統計量。如下所示:

p->utime = p->stime = p->gtime = 0;
p->start_time = ktime_get_ns();
p->real_start_time = ktime_get_boot_ns();

接下來,copy_process開始設置調度相關的變量。如下所示:

retval = sched_fork(clone_flags, p);

sched_fork主要做了下面幾件事情:

(1)調用__sched_fork,在這裏面將on_rq設爲0,初始化sched_entity,將裏面的exec_start、sum_exec_runtime、prev_sum_exec_runtime、vruntime都設爲 0,這幾個變量涉及進程的實際運行時間和虛擬運行時間。是否到時間應該被調度了,就靠它們幾個;

(2)設置進程的狀態p->state = TASK_NEW;

(3)初始化優先級prio、normal_prio、static_prio;

(4)設置調度類,如果是普通進程,就設置爲p->sched_class = &fair_sched_class;

(5)調用調度類的task_fork函數,對於CFS來講,就是調用task_fork_fair。在這個函數裏,先調用update_curr,對於當前的進程進行統計量更新,然後把子進程和父進程的vruntime設成一樣,最後調用place_entity,初始化sched_entity。這裏有一個變量sysctl_sched_child_runs_first,可以設置父進程和子進程誰先運行。如果設置了子進程先運行,即便兩個進程的vruntime一樣,也要把子進程的sched_entity放在前面,然後調用resched_curr標記當前運行的父進程爲TIF_NEED_RESCHED,也就是說,把父進程設置爲應該被調度,這樣下次調度的時候,父進程會被子進程搶佔。

接下來,copy_process開始初始化與文件和文件系統相關的變量。如下所示:

retval = copy_files(clone_flags, p);
retval = copy_fs(clone_flags, p);

copy_files主要用於複製一個進程打開的文件信息。這些信息用一個結構files_struct來維護,每個打開的文件都有一個文件描述符。在copy_files函數裏面調用dup_fd,在這裏會創建一個新的files_struct,然後將所有的文件描述符數組fdtable拷貝一份。

copy_fs主要用於複製一個進程的目錄信息。這些信息用一個結構fs_struct來維護。一個進程有自己的根目錄和根文件系統root,也有當前目錄pwd和當前目錄的文件系統,都在fs_struct裏面維護。copy_fs函數裏面調用copy_fs_struct,創建一個新的fs_struct,並複製原來進程的fs_struct。

接下來,copy_process開始初始化與信號相關的變量。如下所示:

init_sigpending(&p->pending);
retval = copy_sighand(clone_flags, p);
retval = copy_signal(clone_flags, p);

copy_sighand會分配一個新的sighand_struct。這裏最主要的是維護信號處理函數,在copy_sighand裏會調用memcpy,將信號處理函數sighand->action從父進程複製到子進程。init_sigpending和copy_signal用於初始化,並且複製用於維護髮給這個進程的信號的數據結構。copy_signal函數會分配一個新的signal_struct,並進行初始化。

接下來,copy_process開始複製進程內存空間。如下所示:

retval = copy_mm(clone_flags, p);

各進程都自己的內存空間,用mm_struct結構來表示。copy_mm函數中調用dup_mm,分配一個新的mm_struct結構,調用memcpy複製這個結構。dup_mmap用於複製內存空間中內存映射的部分。在系統調用中提到過,mmap可以分配大塊的內存,其實mmap也可以將一個文件映射到內存中,方便可以像讀寫內存一樣讀寫文件

接下來,copy_process開始分配pid,設置tid、group_leader,並且建立進程之間的親緣關係。如下所示:

 INIT_LIST_HEAD(&p->children);
  INIT_LIST_HEAD(&p->sibling);
......
    p->pid = pid_nr(pid);
  if (clone_flags & CLONE_THREAD) {
    p->exit_signal = -1;
    p->group_leader = current->group_leader;
    p->tgid = current->tgid;
  } else {
    if (clone_flags & CLONE_PARENT)
      p->exit_signal = current->group_leader->exit_signal;
    else
      p->exit_signal = (clone_flags & CSIGNAL);
    p->group_leader = p;
    p->tgid = p->pid;
  }
......
  if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
    p->real_parent = current->real_parent;
    p->parent_exec_id = current->parent_exec_id;
  } else {
    p->real_parent = current;
    p->parent_exec_id = current->self_exec_id;
  }

經過了這樣一堆流程之後,上面圖中的組件也初始化的差不多了。

37. _do_fork做的第二件事是wake_up_new_task。新任務剛剛建立,有沒有機會搶佔獲得CPU呢?wake_up_new_task的邏輯如下所示:

void wake_up_new_task(struct task_struct *p)
{
  struct rq_flags rf;
  struct rq *rq;
......
  p->state = TASK_RUNNING;
......
  activate_task(rq, p, ENQUEUE_NOCLOCK);
  p->on_rq = TASK_ON_RQ_QUEUED;
  trace_sched_wakeup_new(p);
  check_preempt_curr(rq, p, WF_FORK);
......
}

首先,需要將進程的狀態設置爲TASK_RUNNING,即就緒可以運行的狀態。接下來activate_task函數中會調用enqueue_task,如下所示:

static inline void enqueue_task(struct rq *rq, struct task_struct *p, int flags)
{
.....
  p->sched_class->enqueue_task(rq, p, flags);
}

如果是CFS的調度類,則執行相應的enqueue_task_fair,如下所示:

static void
enqueue_task_fair(struct rq *rq, struct task_struct *p, int flags)
{
  struct cfs_rq *cfs_rq;
  struct sched_entity *se = &p->se;
......
  cfs_rq = cfs_rq_of(se);
  enqueue_entity(cfs_rq, se, flags);
......
  cfs_rq->h_nr_running++;
......
}

在enqueue_task_fair中取出的隊列就是cfs_rq,然後調用enqueue_entity。在enqueue_entity函數裏面,會調用update_curr,更新運行的統計量,然後調用__enqueue_entity,將sched_entity加入到紅黑樹裏面,然後設置se->on_rq = 1代表在隊列上。回到enqueue_task_fair後,將這個隊列上運行的進程數目加一。

然後,上面的wake_up_new_task會調用check_preempt_curr,看是否能夠搶佔當前進程。在check_preempt_curr中,會調用相應調度類的rq->curr->sched_class->check_preempt_curr(rq, p, flags),對於CFS調度類來講,調用的是check_preempt_wakeup,如下所示:

static void check_preempt_wakeup(struct rq *rq, struct task_struct *p, int wake_flags)
{
  struct task_struct *curr = rq->curr;
  struct sched_entity *se = &curr->se, *pse = &p->se;
  struct cfs_rq *cfs_rq = task_cfs_rq(curr);
......
  if (test_tsk_need_resched(curr))
    return;
......
  find_matching_se(&se, &pse);
  update_curr(cfs_rq_of(se));
  if (wakeup_preempt_entity(se, pse) == 1) {
    goto preempt;
  }
  return;
preempt:
  resched_curr(rq);
......
}

在check_preempt_wakeup函數中,前面調用task_fork_fair的時候,如果設置了sysctl_sched_child_runs_first,就已經將當前父進程的TIF_NEED_RESCHED設置了,則直接返回。否則,check_preempt_wakeup還是會調用update_curr更新一次統計量,然後wakeup_preempt_entity將父進程和子進程PK一次,看是不是要搶佔,如果要則調用resched_curr標記父進程爲TIF_NEED_RESCHED。

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

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

這個圖的上半部分是複製task_struct結構,可以對照着右面的task_struct結構圖,看這裏面的成員是如何一部分一部分的被複制的。圖的下半部分是喚醒新創建的子進程,如果條件滿足就會將當前進程設置應該被調度的標識位,就等着當前進程執行__schedule了。

六、線程的創建

38. 創建一個線程調用的是pthread_create,但它背後的機制依然需要分析。無論是進程還是線程,在內核裏面都是任務,管起來不是都一樣嗎?如果不一樣,那怎麼在內核裏面加以區分呢?其實,線程不是一個完全由內核實現的機制,它是由內核態和用戶態合作完成的。pthread_create不是一個系統調用,是Glibc庫的一個函數,在nptl/pthread_create.c裏面可以找到這個函數,如下所示:

int __pthread_create_2_1 (pthread_t *newthread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg)
{
......
}
versioned_symbol (libpthread, __pthread_create_2_1, pthread_create, GLIBC_2_1);

首先它處理的是線程的屬性參數。例如寫程序的時候,通過設置attr來設置線程棧大小。如果沒有傳入線程屬性attr,就取默認值,就像下面的代碼所示:

const struct pthread_attr *iattr = (struct pthread_attr *) attr;
struct pthread_attr default_attr;
if (iattr == NULL)
{
  ......
  iattr = &default_attr;
}

接下來,就像在內核裏一樣,每一個進程或者線程都有一個task_struct結構,在用戶態也有一個用於維護線程的結構,就是這個pthread結構,如下所示:

struct pthread *pd = NULL;

凡是涉及函數的調用,都要使用到棧。每個線程也有自己的棧,那接下來就是創建線程棧了,如下所示:

int err = ALLOCATE_STACK (iattr, &pd);

ALLOCATE_STACK是一個宏,找到它的定義之後,發現它其實就是一個函數。只是這個函數有些複雜,所以這裏只把主要的代碼列一下,如下所示:

# define ALLOCATE_STACK(attr, pd) allocate_stack (attr, pd, &stackaddr)


static int
allocate_stack (const struct pthread_attr *attr, struct pthread **pdp,
                ALLOCATE_STACK_PARMS)
{
  struct pthread *pd;
  size_t size;
  size_t pagesize_m1 = __getpagesize () - 1;
......
  size = attr->stacksize;
......
  /* Allocate some anonymous memory.  If possible use the cache.  */
  size_t guardsize;
  void *mem;
  const int prot = (PROT_READ | PROT_WRITE
                   | ((GL(dl_stack_flags) & PF_X) ? PROT_EXEC : 0));
  /* Adjust the stack size for alignment.  */
  size &= ~__static_tls_align_m1;
  /* Make sure the size of the stack is enough for the guard and
  eventually the thread descriptor.  */
  guardsize = (attr->guardsize + pagesize_m1) & ~pagesize_m1;
  size += guardsize;
  pd = get_cached_stack (&size, &mem);
  if (pd == NULL)
  {
    /* If a guard page is required, avoid committing memory by first
    allocate with PROT_NONE and then reserve with required permission
    excluding the guard page.  */
  mem = __mmap (NULL, size, (guardsize == 0) ? prot : PROT_NONE,
      MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
    /* Place the thread descriptor at the end of the stack.  */
#if TLS_TCB_AT_TP
    pd = (struct pthread *) ((char *) mem + size) - 1;
#elif TLS_DTV_AT_TP
    pd = (struct pthread *) ((((uintptr_t) mem + size - __static_tls_size) & ~__static_tls_align_m1) - TLS_PRE_TCB_SIZE);
#endif
    /* Now mprotect the required region excluding the guard area. */
    char *guard = guard_position (mem, size, guardsize, pd, pagesize_m1);
    setup_stack_prot (mem, size, guard, guardsize, prot);
    pd->stackblock = mem;
    pd->stackblock_size = size;
    pd->guardsize = guardsize;
    pd->specific[0] = pd->specific_1stblock;
    /* And add to the list of stacks in use.  */
    stack_list_add (&pd->list, &stack_used);
  }
  
  *pdp = pd;
  void *stacktop;
# if TLS_TCB_AT_TP
  /* The stack begins before the TCB and the static TLS block.  */
  stacktop = ((char *) (pd + 1) - __static_tls_size);
# elif TLS_DTV_AT_TP
  stacktop = (char *) (pd - 1);
# endif
  *stack = stacktop;
...... 
}

allocate_stack主要做了以下這些事情:

(1)如果在線程屬性裏面設置過棧的大小,需要把設置的值拿出來;

(2)爲了防止棧的訪問越界,在棧的末尾會有一塊空間guardsize,一旦訪問到這裏就錯誤了;

(3)其實線程棧是在進程的堆裏面創建的。如果一個進程不斷地創建和刪除線程,不可能不斷地去申請和清除線程棧使用的內存塊,這樣就需要有一個緩存。get_cached_stack就是根據計算出來的size大小,看一看已經有的緩存中,有沒有已經能夠滿足條件的;如果緩存裏面沒有,就需要調用__mmap創建一塊新的;

(4)線程棧也是自頂向下生長的,每個線程要有一個pthread結構,這個結構也是放在棧的空間裏面的,在棧底的位置,其實是地址最高位;

(5)計算出guard內存的位置,調用setup_stack_prot設置這塊內存是受保護的;

(6)接下來,開始填充 pthread 這個結構裏面的成員變量 stackblock、stackblock_size、guardsize、specific。這裏的 specific 是用於存放 Thread Specific Data 的,也即屬於線程的全局變量;

(7)將這個線程棧放到stack_used鏈表中,其實管理線程棧總共有兩個鏈表,一個是stack_used,也就是這個棧正被使用;另一個是stack_cache,就是上面說的,一旦線程結束先緩存起來不釋放,等有其他的線程創建的時候,給其他的線程用

39. 搞定了用戶態棧的問題,其實用戶態的事情基本搞定了一半。接下來接着pthread_create看。其實有了用戶態的棧,接着需要解決的就是用戶態的程序從哪裏開始運行的問題,如下所示:

pd->start_routine = start_routine;
pd->arg = arg;
pd->schedpolicy = self->schedpolicy;
pd->schedparam = self->schedparam;
/* Pass the descriptor to the caller.  */
*newthread = (pthread_t) pd;
atomic_increment (&__nptl_nthreads);
retval = create_thread (pd, iattr, &stopped_start, STACK_VARIABLES_ARGS, &thread_ran);

start_routine就是給線程的函數,start_routine、start_routine的參數arg、調度策略都要賦值給pthread。接下來__nptl_nthreads加一,說明有多了一個線程。真正創建線程的是調用create_thread函數,這個函數定義如下:

static int
create_thread (struct pthread *pd, const struct pthread_attr *attr,
bool *stopped_start, STACK_VARIABLES_PARMS, bool *thread_ran)
{
  const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM | CLONE_SIGHAND | CLONE_THREAD | CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | 0);
  ARCH_CLONE (&start_thread, STACK_VARIABLES_ARGS, clone_flags, pd, &pd->tid, tp, &pd->tid);
  /* It's started now, so if we fail below, we'll have to cancel it
and let it clean itself up.  */
  *thread_ran = true;
}

這裏面有很長的clone_flags,接下來的過程要特別的關注一下這些標誌位。然後就是ARCH_CLONE,其實調用的是__clone。看到這裏應該就有感覺了,馬上就要到系統調用了,如下所示:

# define ARCH_CLONE __clone


/* The userland implementation is:
   int clone (int (*fn)(void *arg), void *child_stack, int flags, void *arg),
   the kernel entry is:
   int clone (long flags, void *child_stack).


   The parameters are passed in register and on the stack from userland:
   rdi: fn
   rsi: child_stack
   rdx: flags
   rcx: arg
   r8d: TID field in parent
   r9d: thread pointer
%esp+8: TID field in child


   The kernel expects:
   rax: system call number
   rdi: flags
   rsi: child_stack
   rdx: TID field in parent
   r10: TID field in child
   r8:  thread pointer  */
 
        .text
ENTRY (__clone)
        movq    $-EINVAL,%rax
......
        /* Insert the argument onto the new stack.  */
        subq    $16,%rsi
        movq    %rcx,8(%rsi)


        /* Save the function pointer.  It will be popped off in the
           child in the ebx frobbing below.  */
        movq    %rdi,0(%rsi)


        /* Do the system call.  */
        movq    %rdx, %rdi
        movq    %r8, %rdx
        movq    %r9, %r8
        mov     8(%rsp), %R10_LP
        movl    $SYS_ify(clone),%eax
......
        syscall
......
PSEUDO_END (__clone)

如果對於彙編不太熟悉也沒關係,可以重點看上面的註釋,能看到最後調用了syscall,這一點clone和其他系統調用幾乎是一致的。但是,也有少許不一樣的地方。如果在進程的主線程裏面調用其他系統調用,當前用戶態的棧是指向整個進程的棧,棧頂指針也是指向進程的棧,指令指針也是指向進程的主線程的代碼。此時此刻執行調用clone的時候,用戶態的棧、棧頂指針、指令指針和其他系統調用一樣,都是指向主線程的。

但是對於子線程來說,這些都要變。因爲希望clone這個系統調用成功的時候,除了內核裏面有這個線程對應的task_struct,當系統調用返回到用戶態的時候,用戶態的棧應該是剛纔創建的線程的棧,棧頂指針應該指向這個線程的棧,指令指針應該指向線程將要執行的那個函數

所以這些都需要自己做,將線程要執行的函數的參數和指令的位置都壓到棧裏面,當從內核返回,從棧裏彈出來的時候,就從這個函數開始,帶着這些參數執行下去。接下來就要進入內核了。內核裏面對於clone系統調用的定義是這樣的:

SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
     int __user *, parent_tidptr,
     int __user *, child_tidptr,
     unsigned long, tls)
{
  return _do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr, tls);
}

看到這裏,發現了熟悉的面孔_do_fork,這裏重點關注幾個區別

(1)第一個區別是上面複雜的標誌位設定,對於copy_files原來是調用dup_fd複製一個files_struct的,現在因爲CLONE_FILES標識位(clone_flags)變成將原來的files_struct引用計數加一。如下所示:

static int copy_files(unsigned long clone_flags, struct task_struct *tsk)
{
  struct files_struct *oldf, *newf;
  oldf = current->files;
  if (clone_flags & CLONE_FILES) {
    atomic_inc(&oldf->count);
    goto out;
  }
  newf = dup_fd(oldf, &error);
  tsk->files = newf;
out:
  return error;
}

對於copy_fs,原來是調用copy_fs_struct複製一個fs_struct,現在因爲CLONE_FS標識位變成將原來的fs_struct的用戶數加一。如下所示:

static int copy_fs(unsigned long clone_flags, struct task_struct *tsk)
{
  struct fs_struct *fs = current->fs;
  if (clone_flags & CLONE_FS) {
    fs->users++;
    return 0;
  }
  tsk->fs = copy_fs_struct(fs);
  return 0;
}

對於copy_sighand,原來是創建一個新的sighand_struct,現在因爲CLONE_SIGHAND標識位變成將原來的sighand_struct引用計數加一。如下所示:

static int copy_sighand(unsigned long clone_flags, struct task_struct *tsk)
{
  struct sighand_struct *sig;


  if (clone_flags & CLONE_SIGHAND) {
    atomic_inc(&current->sighand->count);
    return 0;
  }
  sig = kmem_cache_alloc(sighand_cachep, GFP_KERNEL);
  atomic_set(&sig->count, 1);
  memcpy(sig->action, current->sighand->action, sizeof(sig->action));
  return 0;
}

對於copy_signal,原來是創建一個新的 signal_struct,現在因爲 CLONE_THREAD 直接返回了。如下所示:

static int copy_signal(unsigned long clone_flags, struct task_struct *tsk)
{
  struct signal_struct *sig;
  if (clone_flags & CLONE_THREAD)
    return 0;
  sig = kmem_cache_zalloc(signal_cachep, GFP_KERNEL);
  tsk->signal = sig;
    init_sigpending(&sig->shared_pending);
......
}

對於copy_mm,原來是調用dup_mm複製一個mm_struct,現在因爲CLONE_VM標識位而直接指向了原來的mm_struct,如下所示:

static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
  struct mm_struct *mm, *oldmm;
  oldmm = current->mm;
  if (clone_flags & CLONE_VM) {
    mmget(oldmm);
    mm = oldmm;
    goto good_mm;
  }
  mm = dup_mm(tsk);
good_mm:
  tsk->mm = mm;
  tsk->active_mm = mm;
  return 0;
}

(2)第二個就是對於親緣關係的影響,畢竟要識別多個線程是不是屬於一個進程。如下所示:

p->pid = pid_nr(pid);
if (clone_flags & CLONE_THREAD) {
  p->exit_signal = -1;
  p->group_leader = current->group_leader;
  p->tgid = current->tgid;
} else {
  if (clone_flags & CLONE_PARENT)
    p->exit_signal = current->group_leader->exit_signal;
  else
    p->exit_signal = (clone_flags & CSIGNAL);
  p->group_leader = p;
  p->tgid = p->pid;
}
  /* CLONE_PARENT re-uses the old parent */
if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
  p->real_parent = current->real_parent;
  p->parent_exec_id = current->parent_exec_id;
} else {
  p->real_parent = current;
  p->parent_exec_id = current->self_exec_id;
}

從上面的代碼可以看出,使用了CLONE_THREAD標識位之後,使得親緣關係有了一定的變化:

如果是新進程,那這個進程的group_leader就是他自己,tgid是它自己的pid,自己是線程組的頭。如果是新線程,group_leader是當前進程的group_leader,tgid是當前進程的tgid,也就是當前進程的pid,這個時候還是拜原來進程爲老大。如果是新進程,新進程的real_parent是當前的進程,在進程樹裏面又向下了一個層級;如果是新線程,線程的real_parent是當前進程的real_parent,其實進程和線程是平輩的

(3)第三個區別,就是對於信號的處理,如何保證發給進程的信號雖然可以被一個線程處理,但是影響範圍應該是整個進程的。例如kill一個進程,則所有線程都要被幹掉。如果一個信號是發給一個線程的pthread_kill,則應該只有被髮送的線程能夠收到。在copy_process的主流程裏面,無論是創建進程還是線程,都會初始化struct sigpending pending,也就是每個task_struct都會有這樣一個成員變量,這就是一個信號列表。如果這個task_struct是一個線程,這裏面的信號就是發給這個線程的;如果這個task_struct是一個進程,這裏面的信號是發給進程裏的主線程的。如下所示:

init_sigpending(&p->pending);

另外,上面copy_signal的時候,可以看到在創建進程的過程中,會初始化signal_struct裏面的struct sigpending shared_pending。但是,在創建線程的過程中,連signal_struct都共享了。也就是說,整個進程裏的所有線程共享一個shared_pending,這也是一個信號列表,是發給整個進程的,哪個線程處理都一樣。如下所示:

init_sigpending(&sig->shared_pending);

至此,clone在內核的調用完畢,要返回系統調用,回到用戶態。

39. 根據__clone的第一個參數,回到用戶態也不是直接運行指定的那個函數,而是一個通用的start_thread,這是所有線程在用戶態的統一入口,如下所示:

#define START_THREAD_DEFN \
  static int __attribute__ ((noreturn)) start_thread (void *arg)


START_THREAD_DEFN
{
    struct pthread *pd = START_THREAD_SELF;
    /* Run the code the user provided.  */
    THREAD_SETMEM (pd, result, pd->start_routine (pd->arg));
    /* Call destructors for the thread_local TLS variables.  */
    /* Run the destructor for the thread-local data.  */
    __nptl_deallocate_tsd ();
    if (__glibc_unlikely (atomic_decrement_and_test (&__nptl_nthreads)))
        /* This was the last thread.  */
        exit (0);
    __free_tcb (pd);
    __exit_thread ();
}

在start_thread入口函數中,才真正的調用用戶提供的函數,在用戶的函數執行完畢之後,會釋放這個線程相關的數據。例如線程本地數據thread_local variables,線程數目也減一。如果這是最後一個線程了,就直接退出進程,另外__free_tcb用於釋放pthread,如下所示:

void
internal_function
__free_tcb (struct pthread *pd)
{
  ......
  __deallocate_stack (pd);
}


void
internal_function
__deallocate_stack (struct pthread *pd)
{
  /* Remove the thread from the list of threads with user defined
     stacks.  */
  stack_list_del (&pd->list);
  /* Not much to do.  Just free the mmap()ed memory.  Note that we do
     not reset the 'used' flag in the 'tid' field.  This is done by
     the kernel.  If no thread has been created yet this field is
     still zero.  */
  if (__glibc_likely (! pd->user_stack))
    (void) queue_stack (pd);
}

__free_tcb會調用__deallocate_stack來釋放整個線程棧,這個線程棧要從當前使用線程棧的列表stack_used中拿下來,放到緩存的線程棧列表stack_cache中。這樣,整個線程的生命週期到這裏就結束了

40. 下圖對比了創建進程和創建線程在用戶態和內核態的不同,創建進程調用的系統調用是fork,在copy_process函數裏面,會將五大結構files_struct、fs_struct、sighand_struct、signal_struct、mm_struct都複製一遍,從此父進程和子進程各用各的數據結構。而創建線程調用的是系統調用clone,在copy_process函數裏面, 五大結構僅僅是引用計數加一,也即線程共享進程的數據結構,如下所示:

 

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