c++ fork 进程时 共享内存_Linux内核系列 简析进程生命周期:从生到死的这一生(二)...

9cc9d969fa0cd18aac1708271b325db5.png

本文主要涉及进程创建和创建时写时复制以及线程、父死子托孤等 

内容有点多,建议收藏、备份和转发7678c9a5f3b9a1246edf67b720a891d1.png

fork、vfork和clone

首先需要明确:

fork、vfork和clone会传入不同的参数,调用do_fork()。参数表示父子进程共享的资源是什么。 

673bf18d1c2ade10e289ee6a5e4ffb7a.png

do_fork 参数

fork()会执行:

1、SIGCHLD // 共享信号

首先看两段代码:

int main(void){    fork();    printf("hello world\n");    fork();    printf("hello world\n");    while (1);    return 0;}

上面代码会打印几次hello world?

答案是6次

原因是,每次fork都会创建一个新的进程。

进程a执行到第一次fork,创建出进程b,a和b分别打印一次hello world。

然后a和b再分别执行一次fork,又分别创建了a’ 和 b’ 这时候有4个进程,然后它们向下执行分别打印一次hello world。

int main(void){    pid_t pid;    pid = fork();    if (pid == -1) {        perror("fork() failed\n");        exit(1);    } else if (pid == 0) { // 子进程符合条件,进入        printf("a\n");    } else { // 父进程符合条件,进入        printf("b\n");    }    printf("c\n");    while (1);    return 0;}

这段代码又会打印什么呢?

没错,a、b各一次,c两次。

fork()函数返回进程pid,父进程得到的是子进程的pid,子进程得到的pid为0。

上篇文章有提到,进程是一个资源的分配单位,在内核中用task_struct描述,那么fork出来一个新进程一定有自己的的task_struct和相关资源。

82ee7849741c5ce81abb59d90f0d11c5.png

fork()后发生分裂

当父进程通过fork函数创建出子进程,linux认为父子进程存在一定的联系,所以fork函数内部会将子进程的task_struct结构中的资源指向父进程的资源。在创建出新进程的一瞬间,父子进程是拥有相同资源的两个不同task_struct,当然task_struct结构中部分字段值是独有的,比如进程的pid唯一标识。

当其中一个进程发生资源变动,比如子进程p2打开了新文件,这时候就发生了资源分裂,子进程就会创建新的*files 资源,将父进程的*files拷贝后再加上自己新打开的文件。

task_struct结构发生分裂时,其他资源都好处理,难就难在*mm内存如何分裂。

这时候展现linux著名的的写时拷贝(COW)技术了

写时拷贝

先看段代码:

int data = 1;int child_process(){    printf("child process %d, data %d ptr %p\n", getpid(), data, &data);    data = 2;    printf("child process %d, data %d ptr %p\n", getpid(), data, &data);    _exit(0);}int main(void){    pid_t pid;    pid = fork();    if (pid == -1) {        perror("fork() failed\n");        exit(1);    } else if (pid == 0) { // 子进程符合条件,进入        child_process();    } else { // 父进程符合条件,进入        sleep(1); // 等1秒        printf("master process %d, data=%d ptr %p\n", getpid(), data, &data);    }}

上面代码会输出什么呢?

按上面说的,大家可能会得到结论:

master 打印1  data指针a

child 先打印1 data指针a,再打印 2 然后打印 新data指针b

➜  ~ ./a.outchild process 61875, data 1 ptr 0x10de75040child process 61875, data 2 ptr 0x10de75040master process 61873, data=1 ptr 0x10de75040

data的值符合预期,为什么指针没变呢?不科学啊

因为我们的CPU是支持MMU*的,所以此处打印的指针为虚拟指针。如果CPU不支持MMU,则打印的才是内存真实地址。

内存分裂的原理:

484899fab6e16623603f39e4243c661b.png

虚拟地址A通过MMU查表找到物理地址A,在开始阶段,进程A对内存的操作权限为可读可写。

当fork时,进程A fork 出进程B之后,linux将data全局变量 对应页表里这一页的权限改为只读。一旦页表里有一个地址所在这一页的权限被改为仅读,当cpu去操作写数据时,无论进程A还是B去写,CPU都会收到一个page fault(缺页中断)。

CPU收到page fault后会,调用linux内核在内存中的重新分配一个新的内存,将原物理地址A的数据拷贝到新内存中,然后修改子进程B的页表,将B进程的虚拟地址A指向新物理地址B。然后进程B就可以修改数据。此刻完成写时复制。

注意:根据计算机原理,当子进程出现page fault,执行data = 2 失败,cpu做了上述写时复制操作后,进程被唤醒,然后会再次执行data=2。短短一句赋值语句,计算机底层做了那么多操作,真是“这个需求很简单,怎么实现我不管。今晚上线”。

由上可知,Linux的写时复制(COW)严重依赖CPU的MMU模块,如果CPU不支持MMU,那么在这个CPU下是无法使用fork函数,只能使用vfork函数。而父进程调用vfork时会阻塞父进程,直到子进程执行exit或exec系统调用。

我们把上面代码的fork 改为 vfork 执行下: 

➜  ~ ./a.outchild process 62334, data 1 ptr 0x10649c040child process 62334, data 2 ptr 0x10649c040master process 62333, data=2 ptr 0x10649c040

我们看到vfork 运行后,A和B内存是共享的。

vfork() 会执行:

1、CLONE_VM // 共享内存

2、CLONE_VFORK // 表明是vfork

3、SIGCHLD // 共享信号

由此我们可以想到,线程的定义:资源共享,可调度

pthread_create() -> clone() 

clone() 会执行:

1、CLONE_VM

2、CLONE_FS

3、CLONE_FILES

4、CLONE_SIGCHLD

5、CLONE_THREAD

所以linux系统中,创建一个线程,其实就是将父进程的资源共享,然后创建一个新的task_struct结构,只要是一个task_struct结构就会被linux调度器调度。如果一个进程fork出来子进程,子进程会慢慢复制父进程的资源,这种情况创建的就是进程。如果子进程被创建出来,一直共享父进程的资源,这种叫线程。

所以linux中为什么线程又被称为轻量级进程的原因。

进程的经典定义是:资源的分配和调度的基本单位

线程的经典定义是:调度的单元

实际上,linux非常灵活,clone的时候可以指定任意部分资源共享。这种情况下,派生出来的既不是进程也不是线程,那么它是什么?这只能算是进程和线程的临界态 —— “人妖”。

这时候,我们应该可以对进程和线程有一个明确的认知了。

PID和TGID

当一个进程创建出N个线程,那么每个线程都是一个独立的task_struct,所以每个线程都有一个独立的pid。

但是基于POSIX标准要求,一个进程如果有多个线程,必须看起来像一个整体。

linux对这种情况,使用了一个障眼法,linux在内核里的task_struct结构中增加了一个tgid字段,子线程的tgid是父进程的pid。

4cdedaab48a004fc23c471e905f216dc.png

主进程创建的线程,线程会将主进程的pid写入TGID

来个实验:

#include #include #include #include #include static pid_t gettid( void ){      return syscall(__NR_gettid);}static void *thread_fun(void *param){      // 线程内部 pid、真实pid 和 pthread_self      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;      // 打印线程中 pid 、 真实pid 和 pthread_self      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;}

测试系统为4核处理器

[root@dev ~]$ cat /proc/cpuinfo | grep processorprocessor    : 0processor    : 1processor    : 2processor    : 3
[root@dev shell]$ ./a.outthread pid:15179, tid:15179 pthread_self:140227109390144thread pid:15179, tid:15180 pthread_self:140227101009664thread pid:15179, tid:15181 pthread_self:140227092616960top 命令 进程维度查看  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND15419 renfeng   20   0   22896    384    300 S 206.7  0.0   0:07.77 a.outtop -H 命令 线程维度查看  PID USER      PR  NI    VIRT    RES    SHR S %CPU %MEM     TIME+ COMMAND15420 renfeng   20   0   22896    384    300 R 99.9  0.0   0:23.79 a.out15421 renfeng   20   0   22896    384    300 R 99.9  0.0   0:23.79 a.out[root@dev ~]$ cd /proc/15419/task/[root@dev task]$ ll总用量 0dr-xr-xr-x 6 renfeng renfeng 0 6月   6 22:59 15419dr-xr-xr-x 6 renfeng renfeng 0 6月   6 22:59 15420dr-xr-xr-x 6 renfeng renfeng 0 6月   6 22:59 15421

我们可以看到,线程内和父进程的getpid()函数返回的都是相同的pid,真实的pid就不一样了。

两个线程因为死循环,两个线程的cpu利用率和起来将近200%。

我们可以进入到 /proc/父进程pid/task/ 目录查看所属所有线程,在/proc/文件夹下只能看到进程,线程得去对应的进程文件夹下查看。

所以,pid到底是多少,得看从哪个角度看pid。

如果在内核中看,pid分别是15419、15420、15421

如果在线程或者进程内看pid,则只能拿到父进程的pid。

父死子托孤的“社会化抚养

linux中通常都是“白发人送黑发人”,父进程通过waitpid给子进程收尸,子进程才能从僵尸态完全释放“人间蒸发”。

f65cb63fd9ab673694aceddcd0eb4096.png

linux中进程都是父节点派生出来的

我们后面再说0号进程和1号进程的“鸡生蛋,蛋生鸡”问题。

如果父进程先挂掉,那么父进程创建的子进程将变成孤儿进程,这时候linux就会将孤儿进程做托孤过程,有点像督工所说的“社会化抚养”。

一般会有两种情况:

1、找到子进程最近的SUBREAPER进程,然后将子进程挂在其下

2、如果找不到,则将子进程挂到linux的init进程(1号进程)

// 进程可以在代码中显性的系统调用 将自己设置为 SUBREAPER 进程if (prctl(PR_SET_CHILD_SUBREAPER, 1) < 0) { // linux内核3.4 加入的特性    perror("set subreaper failed\n");}

reaper是进程收割机,也可以理解为孤儿院,不过我理解为给孤儿进程收尸的火葬场。

再来个实验:

#include #include #include #include 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("waitpid failed");              exit(1);          }          if (WIFSIGNALED(status)) {              printf("child process is killed by signal %d\n", WTERMSIG(status));          }          exit(0);      }}

按刚才说的,如果先干掉父进程,那么子进程应该会找最近的“SUBREAPER 孤儿院进程”。

[root@dev shell]$ ./a.outparent process id: 18065child process id: 18066[root@dev ~]$ pstreesystemd─┬─AliYunDun───22*[{AliYunDun}]        ├─sshd─┬─sshd───sshd───bash───a.out───a.out        │      └─sshd───sshd───bash───pstree[root@dev ~]$ kill -9 18065 // 杀掉父进程[root@dev ~]$ pstreesystemd─┬─AliYunDun───22*[{AliYunDun}]        ├─a.out

很遗憾,进程树中没有SUBREAPER进程,只能找根进程了。

深度睡眠和浅度睡眠

深度睡眠只能被资源唤醒

浅度睡眠除了被资源唤醒,还可以被信号唤醒

深度睡眠有时候不能避免,比如发生page fault的时候,linux内核将进程设置为深度睡眠,如果设置为浅度睡眠,进程可能会收到信号后继续出现page fault。

睡眠怎么实现的?

linux中进程睡眠借助等待队列结构实现,有点类似订阅和发布设计模式。

95c5d38587189f09503c29787f332867.png

图红色箭头为错误方案

当p1~p4都在等待资源,如果一个资源就绪了,怎么通知进程呢?拿到资源后挨个给进程通知?太low!p1~p4进程需要等待资源,可以进程自己将自己挂在等待队列上,资源准备好了直接唤醒等待队列,p1~p4进程就会自己醒。

//https://github.com/torvalds/linux/blob/f359287765c04711ff54fbd11645271d8e5ff763/drivers/tty/n_hdlc.c#L489static ssize_t n_hdlc_tty_write(struct tty_struct *tty, struct file *file,                const unsigned char *data, size_t count){    ……     add_wait_queue(&tty->write_wait, &wait); // 加入等待队列    for (;;) {        set_current_state(TASK_INTERRUPTIBLE); // 主动设置为睡眠态        tbuf = n_hdlc_buf_get(&n_hdlc->tx_free_buf_list);        if (tbuf)            break;        if (tty_io_nonblock(tty, file)) {            error = -EAGAIN;            break;        }        schedule(); // 让出cpu        if (signal_pending(current)) { // 醒来后检查是否是信号触发            error = -EINTR;            break;        }    }    __set_current_state(TASK_RUNNING); // 设置为运行态    remove_wait_queue(&tty->write_wait, &wait); // 移除等待队列    ……}

比如 一个键盘驱动程序,启动后等待键盘输入(资源),程序会创建一个等待队列,再将等待队列注册到linux内核,然后将自己设置为睡眠态,调用linux的schedule()函数,让出自己占用的cpu。当有键盘输入事件时(满足条件),linux内核执行__wake_up唤醒对应的等待队列。进程被唤醒后进入就绪态,从就绪态转换到执行态后,恢复进程执行的上下文,然后进程会先检查自己是否被信号唤醒,如果被信号唤醒则说明之前的睡眠态是浅睡眠。

ok,从这里我们可知,到底睡眠是深睡眠还是浅睡眠,是由linux内核决定的,进程只能自主将自己设置为睡眠态。

所以一个进程进入睡眠是进程将自己设置为睡眠,然后把cpu让出来;而暂停态是进程被动的让出cpu。

这块有点复杂,具体可以看文章后面提供的资料链接。

0号进程和1号进程:鸡生蛋还是蛋生鸡

我们都知道,衡量一种语言的成熟与否要看它是否可以自举。

gcc是怎么来的?gcc是由gcc编译而来(鸡生蛋还是蛋生鸡?)

我之前一直说,进程树的根结点是init进程(现在也叫systemd进程),那么systemd进程是怎么来?

[root@dev ~]$ ps aux | grep systemdroot         1  0.0  0.0 191264  4124 ?        Ss    2018  96:58 /usr/lib/systemd/systemd --system --deserialize 23

我们可以看到 1进程的父进程ppid是0

[root@dev 1]$ cat /proc/1/statusName:    systemdUmask:    0000State:    S (sleeping)Tgid:    1Ngid:    0Pid:    1PPid:    0......

但是我们执行pstree命令却找不到0进程,因为0进程把1进程fork出来之后,0进程就退化成了idle进程。

idle进程的优先级最低,早之前linux内核版本是参与cpu进程调度的,当其他进程都睡眠后,idle进程开始执行,将cpu设置为低功耗省电模式。当有任何一个中断,都可以任何唤醒任何一个进程,当有任何一个进程被唤醒,被唤醒的进程都会idle进程优先级高,所以idle就会进入休眠。

现在的linux内核版本idle并不参与调度,而是在执行队列结构中含idle指针,指向idle进程,当调度器发现执行队列为空时,调用idle进程执行。

linux:你们尽管睡,你们都睡了,我idle再起来。

思考下,当所有进程都不需要执行的时候,要将cpu设置为低功耗省电模式,按照一般人思路设计,每个进程在让出cpu的时候都要检查自己是不是最后一个进程,如果是则进程将cpu设置为低功耗,再去让出cpu。这设计就太复杂了,将同样的逻辑耦合到了一起。相比而言,linux的这种设计是相当的优雅,简单即美好!

日常撸码中,能将复杂问题简单化实现的程序员,一定是最牛逼的程序员。

总结一些问题:

谁来通知pid0运行?

不需要通知,linux自己的调度算法直接进行调度。当所有进程都不运行了,pid0就会被调度拉起。

pid0是何时创建的?

系统是从BIOS加电自检,载入MBR的引导程序(LILO/GRUB),再载入linux内核后开始执行,一直到指定的shell开始执行告一段落,这时用户才可以操作linux。而在vmlinux的入口中,pid0的原始进程设置了运行环境,执行了start_kernal()的内核初始化工作,继而fork出pid=1的进程,pid=1的进程会继续完成剩余的初始化工作,而pid=0的进程则会调用cpu_idle() 退化成idle进程。

 pid0也是有task_struct的,再次强调,只要是task_struct就会被cpu调度器调度。

写时拷贝是谁先触发?

父子进程谁先对内存做写操作,谁就会触发写时拷贝,谁先得到新的物理地址。

在/proc/下是看不到线程的,只有进入父进程下的task目录才能看到父进程下的线程。

ls -al  /proc/父进程pid/task/


预告:下一篇 单核CPU时,Linux进程调度策略

MMU 内存管理单元:一种虚拟地址到物理地址的转换机制 https://baike.baidu.com/item/MMU

linux中的阻塞机制及等待队列:https://www.cnblogs.com/gdk-0078/p/5172941.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值