• 学习交流加(可免费帮忙下载CSDN资源):
  • 个人微信: liu1126137994
  • 学习交流资源分享qq群1(已满): 962535112
  • 学习交流资源分享qq群2: 780902027

上一篇文章 点击链接【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进程
  • 进程的睡眠与等待队列