【Linux進程、線程、任務調度】二 fork/vfork與寫時拷貝 線程的本質 託孤 進程睡眠和等待隊列

  • 學習交流加(可免費幫忙下載CSDN資源):
  • 個人微信: liu1126137994
  • 學習交流資源分享qq羣1(已滿): 962535112
  • 學習交流資源分享qq羣2(已滿): 780902027
  • 學習交流資源分享qq羣3:893215882

上一篇文章 點擊鏈接【Linux進程、線程、任務調度】一
講了

  • Linux進程生命週期(就緒、運行、睡眠、停止、殭屍)
  • 殭屍的含義
  • 停止狀態與作業控制, cpulimit
  • 內存泄漏的真實含義
  • task_struct以及task_struct之間的關係
  • 初見fork和殭屍

本篇接着上一篇文章主要記錄以下學習內容:

  • fork vfork clone 的含義
  • 寫時拷貝技術
  • Linux線程的實現本質
  • 進程0 和 進程1
  • 進程的睡眠和等待隊列
  • 孤兒進程的託孤 ,SUBREAPER

1、fork

fork(),創建子進程,實際上就是將父進程的task_struct結構進行一份拷貝(注意拷貝的都是指針),假設有p1進程,fork後產生p2子進程:

上面的mm ,fs,files,signal等都是task_struct結構體裏的指針,分別指向進程的內存資源,文件系統資源,文件資源,信號資源等,當父進程p1 fork後,內核把p1的task_struct拷貝一份,作爲子進程p2的描述符。此時p1和p2所指向的資源實際上是一樣的,這並不與進程是佔有獨立的空間矛盾,因爲後面對資源進型任何的修改將導致資源分裂,比如當p1(或p2)對fs,files,signal等資源執行操作,將導致fs,files,signal各分裂一份作爲p1(或p2)的資源。

其中對於mm(內存)的情況,就比較複雜,有一種技術:寫時拷貝(copy on write)

2、寫時拷貝(Copy on write)

看下圖:

最開始的時候進程p1,假設某一塊的虛擬內存爲virt1,virt1所映射的物理內存爲phy1,原則上virt1與phy1是可讀可寫的。當p1調用fork()後,產生了新的虛存和物理內存表示子進程p2的某一塊地址,實際上此時p1和p2的是指向同樣的物理內存地址,並且這塊內存變得只讀了 。假設p2(p1)要對這塊內存進行寫操作,就會產生page fault,此時就會重新開闢一塊物理內存空間,讓p2(p1)的virt1映射到新的物理內存phy2,然後重新對phy2的內存進行寫操作。

我們注意到,這個過程中,需要有MMU進行地址翻譯,所以寫時拷貝技術必須要有MMU才能實現。

無MMU,不能寫時拷貝,不能fork

  • 實驗
#include <sched.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int data = 10;

int child_process()
{
	printf("Child process %d, data %d\n",getpid(),data);
	data = 20;
	printf("Child process %d, data %d\n",getpid(),data);
    sleep(15);
    printf("Child process %d exit\n",getpid());
    _exit(0);
}

int main(int argc, char* argv[])
{
	int pid;
	pid = fork();

	if(pid==0) {
		child_process();
	}
	else{
		sleep(1);
		printf("Parent process %d, data %d\n",getpid(), data);
        sleep(20);
        printf("Parent process %d exit\n",getpid());
		exit(0);
	}
}

編譯運行結果:
在這裏插入圖片描述

  • 結果分析

以上程序父進程fork後,子進程對全局變量進行寫,物理內存部分進行分裂,使得子進程與父進程data變量對應的物理內存部分分離(寫時拷貝)。從此以後父子進程各自讀寫data將不會影響彼此,且父子進程的運行是獨立的,可以同時運行。

3、vfork

那麼如果沒有MMU,該如何呢?vfork在無MMU的場合下使用。

無MMU時只能使用vfork

vfork在以下情況下使用:

父進程阻塞直到子進程:

  • exit 或 _exit
  • exec

vfork實際上內部是對mm(內存資源)進行一個clone,而不是copy,其他資源還是copy(因爲其他資源不受MMU影響),見下圖:

  • 實驗
#include <sched.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int data = 10;

int child_process()
{
	printf("Child process %d, data %d\n",getpid(),data);
	data = 20;
	printf("Child process %d, data %d\n",getpid(),data);
    sleep(15);
    printf("Child process %d exit\n",getpid());
    _exit(0);
}

int main(int argc, char* argv[])
{
	int pid;
	pid = vfork();

	if(pid==0) {
		child_process();
	}
	else{
		sleep(1);
		printf("Parent process %d, data %d\n",getpid(), data);
        sleep(20);
        printf("Parent process %d exit\n",getpid());
		exit(0);
	}
}
  • 運行結果:
    在這裏插入圖片描述
  • 結果分析
    由結果可以看出vfork與fork的區別:vfork的mm部分,是clone的而非copy的。父進程在子進程exit或者exec之前一直都處於阻塞狀態(可以自己運行下看看sleep效果)。

4、Linux線程的實現本質

線程,共享進程的所有資源!那麼內部是如何實現的呢?

實際上pthread_create內部就是調用clone,當進程(線程)p1調用pthread_create,內部就是調用clone,新生成的線程p2與原來的線程p1共享所有資源。

其實,此時可以看成是p1和p2的task_struct結構體內的指向資源的指針是一樣的。多線程是共享資源的。

我們可以看到,線程p1和p2都是有task_struct的,而且裏面的資源是一樣的,內核的調度器只看task_struct,所以進程,線程,都是可以被調度的對象。線程也被叫做輕量級進程。

5、PID與TGID

POSIX規定,進程中的多個線程getpid()後應該獲得同一個id(主線程id(TGID)),但是實際上每一個線程都有一個task_struct結構體,這個結構體中存有各個線程的id(PID)。

爲了解決有兩個id的情況,內核搞出了一個TGID,每一個線程的TGID都是相等的,等於主線程的id。

假設現在有進程進程p1,它創建了三個子進程:

其中:
1、top 查看的是進程的視角,查看的id實際上是各個進程(線程)的TGID
2、top -H是線程視角,查看的是各個線程的自己獨有的id即PID

看以下程序:

#include <stdio.h>
#include <pthread.h>
#include <stdio.h>
#include <linux/unistd.h>
#include <sys/syscall.h>

static pid_t gettid( void )
{
	return syscall(__NR_gettid);
}

static void *thread_fun(void *param)
{
	printf("thread pid:%d, tid:%d pthread_self:%lu\n", getpid(), gettid(),pthread_self());
	while(1);
	return NULL;
}

int main(void)
{
	pthread_t tid1, tid2;
	int ret;

	printf("thread pid:%d, tid:%d pthread_self:%lu\n", getpid(), gettid(),pthread_self());

	ret = pthread_create(&tid1, NULL, thread_fun, NULL);
	if (ret == -1) {
		perror("cannot create new thread");
		return -1;
	}

	ret = pthread_create(&tid2, NULL, thread_fun, NULL);
	if (ret == -1) {
		perror("cannot create new thread");
		return -1;
	}

	if (pthread_join(tid1, NULL) != 0) {
		perror("call pthread_join function fail");
		return -1;
	}

	if (pthread_join(tid2, NULL) != 0) {
		perror("call pthread_join function fail");
		return -1;
	}

	return 0;
}

  • 運行結果:
    在這裏插入圖片描述
    可以看出此時程序處於死循環,getpid獲得的id是一樣的,gettid獲得的是線程結構體中的id。所以是不一樣的,而pthread_self 並不是任何id,這裏我們不關心pthread_slef獲得id,我們只關心PID與TGID。

另開一個終端執行命令:

$ top

在這裏插入圖片描述
可得知,只能看到一個thread,實際上就是我們的進程(主線程),它的id也是進程的id。top命令只能看到進程的視角,看到的都是進程與進程的id,看不到線程與線程id

$ top -H

在這裏插入圖片描述

可看到,兩個被創建出來的線程thread,且它們的id都是各自的task_struct裏面的id(PID),而不是進程的id(TGID)。top -H 看到的是線程視角,顯示的id是線程的獨有的id(PID)。這裏id名詞較多,容易弄混,知道原理即可。

6、SUBREAPER與託孤

  • 孤兒進程
    當父進程死掉,子進程就被稱爲孤兒進程

對孤兒進程,有一個問題,就是父進程掛掉了,孤兒進程最後怎門辦,因爲沒有人來回收它了。

在Linux中,當父進程掛掉,子進程會在進程樹上向上找subreaper進程,當找到subreaper進程,孤兒進程就會掛到subreaper進程下面成爲subreaper進程的子進程,後面就由subreaper進程對該孤兒進程進行回收。如果沒有subreaper進程,那麼最終該孤兒進程會掛到init 1號進程下,由init進程回收。如下圖:

此過程,稱爲託孤!

Linux內核中,有一種方法可以將某一進程變爲subreaper進程:

在這裏插入圖片描述

prctl函數可以使當前調用它的進程變爲subreaper進程。
PR_SET_CHILD_SUBREAPER是Linux 3.4引入的新特性,將它設置爲非零值,就可以使當前進程變爲像1號進程那樣的subreaper進程,可以對孤兒進程進行收養了。

  • 實驗
    life_period.c
#include <stdio.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <unistd.h>

int main(void)
{
	pid_t pid,wait_pid;
	int status;

	pid = fork();

	if (pid==-1)	{
		perror("Cannot create new process");
		exit(1);
	} else 	if (pid==0) {
		printf("child process id: %ld\n", (long) getpid());
		pause();
		_exit(0);
	} else {
		printf("parent process id: %ld\n", (long) getpid());
		wait_pid=waitpid(pid, &status, WUNTRACED | WCONTINUED);
		if (wait_pid == -1) {
			perror("cannot using waitpid function");
			exit(1);
		}

		if(WIFSIGNALED(status))
			printf("child process is killed by signal %d\n", WTERMSIG(status));

		exit(0);
	}
}

編譯程序運行結果如下:
在這裏插入圖片描述
此時,子進程處於停止狀態,父進程也處於阻塞狀態(waitpid等待子進程結束)。
輸入以下命令查看當前進程樹,可以看到我們的life_period進程與其子進程在進程樹中的位置:

$ pstree

然後,殺死父進程

   $  kill -9 3532

再看進程樹:

  • 結論
    可以看到,當我們殺死父進程後,子進程被init進程託孤,以後如果該進程退出了,由init進程回收它的task_struct結構。

7、進程0和進程1

這是一個雞生蛋蛋生雞的故事,我們知道Linux系統中所有的進程都是init進程fork而來,init進程是內核中跑的所有進程的祖先,那麼問題來了,init進程哪裏來的?答案是,init進程是由編譯器編譯而來的?那麼編譯器又是哪裏來的?答案是編譯器是由編譯器編譯而來。那麼編譯編譯器的編譯器又是哪裏來的?

可見,這是一個死循環。實際上,最開始,是有一些大牛用0 1寫的編譯器,寫完直接可以在cpu上跑的,然後用最開始的編譯器編譯後面的編譯器。這個不是重點。今天我們的重點是0號進程。0號進程是1號進程父進程。
0號進程也叫IDLE進程。0號進程在什麼時候跑呢?

當所有其他進程,包括init進程都停止運行了,0號進程就會運行。此時0號進程會把CPU置爲低功耗,非常省電。
此時內核被置爲wait_for_interrupt狀態,除非有一箇中斷過來,纔會喚醒其他進程。

8、進程的睡眠和等待隊列

上一篇文章點擊鏈接 簡單講了深度睡眠和淺睡眠。那麼什麼情況下需要將進程置爲深度睡眠呢?
假設有進程p,它正在運行,但是整個程序代碼並沒有完全進入內存,假如p調用一個函數fun(),這個函數代碼也沒有進入到內存,那麼現在就會出現缺頁(page fault),從而內核需要去執行缺頁處理函數,這個階段進程p就會進入到睡眠狀態,假設進入淺睡眠,那麼有可能會來一個信號signal1,假設signal1的信號處理函數也沒有進入到內存,這個時候又會出現缺頁錯誤(page fault) 。。。。這樣的話,就有可能導致程序崩潰。

下面看一段代碼來理解進程的睡眠與調度:

在這裏插入圖片描述


上面程序註解非常的清晰明瞭,我們只需要注意兩點即可:

進程在阻塞讀(或者其他類似於讀的狀態如sleep)時,那個讀的函數內部一定會調用內核調度函數schedule(),讓CPU去執行其他的進程。

當有信號來(或者有資源來)的時候,進程被喚醒,這裏的喚醒,實際上是給等待隊列一個信號,然後讓隊列自己去喚醒隊列中的進程,並不是去直接喚醒進程的,此時等待隊列可以看做一箇中間機構代替我們去做複雜的喚醒工作。具體是如何實現的,在以後的學習中,還會繼續分析。

9、總結

掌握以下內容

  • fork vfork clone的關係
  • 寫時拷貝技術與fork,MMU的關係
  • Linux線程的實現本質,內部是調用clone
  • 0號進程與1號進程
  • 進程的託孤與subreaper進程
  • 進程的睡眠與等待隊列
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章