- 学习交流加(可免费帮忙下载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进程
- 进程的睡眠与等待队列