进程的创建

fork 函数可以帮助我们了解很多进程创建时做的一些事情。

我们先看一个例子:

arduino 复制代码
#include <unistd.h>
#include <stdio.h>
#include <stdint.h>
#include <sys/types.h>

int main()
{
    pid_t pid;
    int x = 1;

    pid = fork();

    if (pid == 0)
    {
        /* Child */
        printf("child : x=%d, pid = %d\n", ++x, getpid());
        while (1)
        {
        }
        return 0;
    }

    /* Parent */
    printf("parent: x=%d, pid = %d\n", --x, getpid());
    while (1)
    {
    }
    return 0;
}

该程序的输入为:

ini 复制代码
child : x=2, pid = 8040
parent: x=0, pid = 8035

可以看到,感觉是 fork 返回了两次,也就是所谓的:Call once, return twice。那么真的是这个函数返回了两次吗???

我们可以使用 top 命令查看一下:

出现了两个进程。所以该程序的输出实际上是由两个程序构成的,一个是我们的程序进程,一个是 fork 出来的新进程。return twice 指的是一次是在当前进程返回,一次是在子进程返回。看一个图:

所以,为啥这个函数叫 fork 呢?如果多fork几次的话,上面这个图,看起来像不像一个叉子。

fork函数介绍

www.geeksforgeeks.org/fork-system... 里面摘出来一部分,如下:

The Fork system call is used for creating a new process in Linux, and Unix systems, which is called the child process, which runs concurrently with the process that makes the fork() call (parent process). After a new child process is created, both processes will execute the next instruction following the fork() system call.

The child process uses the same pc(program counter), same CPU registers, and same open files which use in the parent process. It takes no parameters and returns an integer value.

Below are different values returned by fork().

  • Negative Value: The creation of a child process was unsuccessful.
  • Zero: Returned to the newly created child process.
  • Positive value: Returned to parent or caller. The value contains the process ID of the newly created child process.

大致的内容就是,fork 函数会创建一个子进程,子进程拥有与父进程有同样的 pc,cpu 寄存器,打开的文件等等。

fork 函数的返回值:

  • 负数表示创建进程失败
  • 0返回给子进程
  • 正数返回给父进程,值为子进程的 id。

这个返回值就很神奇了,为啥还能给不同的进程返回不同的值???我们下面说到。

从虚拟内存的角度看fork函数

学习虚拟内存的时候,有这样的一张图:

当一个程序执行的时候,os 会将该程序加载到内存里面,然后给该程序分配虚拟内存空间,将不同的段放到不同的虚拟位置。

fork 函数其实做了一件很简单的事情,就是将父进程的虚拟内存里面的东西都同步到子进程里面来:

fork 出来的子进程与父进程几乎是一模一样的。

学习进程的调度的时候,我们知道 syscall 会导致用户态陷入到内核态,然后可能会导致进程切换。fork 也是一个 syscall,假设 os 决定下一个调度的程序是刚 fork 出来的子进程,那么由于它的 pc 与 父进程的 pc 一样,.text 段也一样,所以,子进程调度的时候,也是执行 fork 的下一个指令。

但是还有一些问题,第一个是,就算子进程拷贝了父进程的 .text 段,那么他们如何区分自己应该走那个分支呢?这个就是 fork 返回值设计的很奇怪的原因。

我们知道,函数的返回值由 rax 寄存器储存,所以,在 os 调度决定调度子进程的时候,将 rax 寄存器的值改为 0 即可,并且将子进程的 pid 赋值给父进程的 pcb 的 context 储存起来,后面调度 pcb 的时候,pop 出来 context,里面的 rax 的值就是子进程的 pid。所以,对于子进程来说,fork 返回了 0,对于父进程来说, fork 返回了子进程的 pid。

第二个问题就是,我们拷贝了父进程的页表,页表是这样的一个结构:

对于一个四级页表来说,假设我们的父进程就只占用了一个物理页,1 号物理页。

由于子进程拷贝了页表,那么子进程同样的虚拟地址也指向了同样的物理页:

假设,这个物理页对应的虚拟地址是 stack 所在的范围,stack 是可读可写的,那么就会发生一个问题:父进程往 stack 上 push 一个变量,子进程的物理页也发生了变化。就好像你开了两个程序,一个浏览器,一个word文档,当你在 word 上打了一行字,发现浏览器的页面上蹦出一个奇怪的图片一样。

这个就是没有做到所谓的进程隔离。所以我们在拷贝父进程的数据时,也需要将页表对应的物理页也copy一遍。当然,这个过程需要寻找一些空闲的物理页,存放从父进程 copy 过来的数据。

拷贝父进程的数据的时候,可能会遇到内存不足的情况,甚至会导致父进程的 clean 的 page 被弹出。但是这里我们不考虑这些情况,真正的操作系统肯定是设计的非常精妙,我们只是简单的讨论以下就行了。

假设,我们fork的时候,物理的内存非常的充足,我们就可以把父进程的 .text 与 stack 对应的物理页面都拷贝一份,如下图:

这里,os 发现 3 号物理页是空闲的,所以将父进程的 .stack 对应的物理页数据拷贝过来。

但是,仔细想一下,这个过程其实有个可以优化的地方。比如,拷贝 .stack 对应的物理页可以理解,但是拷贝 .text 就不太好,因为 .text 是只读的。那么能不能考虑让子进程与父进程的 .text 段共享同样的物理页面呢?显然也是不行的,因为有些程序的 .text 会变,比如调用 execve 去执行一个别的程序,或者搞什么黑科技之类的。

execve 简单来说,就是执行指定的程序,但是将调用进程的虚拟内存内容替换为执行程序的内容。就是小说里面的身体被穿越了。

那么,要怎么解决这个问题呢?fork 的时候,完全 copy 一份父进程的数据不太好,因为这些数据有可能就根本用不到。

一种牛逼的思想就出现了,那就是 cow,翻译一下就是牛。显然不对,cow 是 copy on write 的简写。就是说,我先只拷贝页表,让父进程与子进程都使用同一份物理内存,当某一个进程试图去对一个物理页面进行写操作的时候,os 就触发一个中断,然后将这个物理页面 copy 一份,重新映射给写这个物理页面的进程,还要将 pte 改成可写的:

这样就避免了fork时需要copy大量物理页的问题了。

copy on write

fork 出的子进程与父进程的 pte 指向同样的物理页,我们称这个物理页是共享的。物理页就有4个状态:读,写,共享,私有。嗯,这个好像不是并列关系,算了,意思到了就行。

cow 的思想很简单,要实现它也需要一个小诀窍。我们回忆一下页表项的结构:

第4级页表的每一项都是一个 64 bit 的值。每一位代表的意义上图都有说明。我们可以看到第 1 位标识的是读写权限。

但是它并没有某一位用来标识这个 pte 对应的物理页是不是共享的。也就是说,单凭页表,我们无法判断某个物理页是不是被多个进程共享。

我们说的小诀窍就是,就是在拷贝父进程的页表之前,将所有的 pte 的读写位改成只读的,然后再拷贝。

这样,当父进程对 stack 进行写操作的时候,就会触发一个 protection fault,陷入到内核态,os 处理这个错误的时候,会检查这个物理页对应的 vma,vma 里面是正常的读写权限,对于 stack 来说,它的 vma 是可写的,这个时候 os 就知道是发生了一个 cow,就会拷贝这个物理页的数据到新的物理页,然后重新做页表映射。

VMA 是什么

vma 是 Virtual Memory Area 的简写。其实很多人就见过这个玩意,只不过是以另一种形式。代码段,数据段,堆,共享库,用户栈都是 vma。

对于Android 开发者来说,页面就是一个 Activity 类。对于 os 来说,进程就是一个结构体。

task_struct 就是进程的结构体,里面有个 mm 指针。

mm_struct 有两个指针,pgd 指向第一级页表的基址。mmap 指向一个 vm_area_struct 的链表(红黑树)。

每个 vm_area_struct 对应着该进程所拥有的某一个段。它的字段意义如下:

vbnet 复制代码
fvm_start: Points to the beginning of the area.
vm_end: Points to the end of the area.
vm_prot: Describes the read/write permissions for all of the pages contained
in the area.
vm_flags: Describes (among other things) whether the pages in the area are
shared with other processes or private to this process.
vm_next: Points to the next area struct in the list.

我们写程序遇到的 segment fault 就是因为访问某个虚拟地址的时候,会先查询这个 vma 链表,发现这个虚拟地址在 vma 里面的每个节点的范围都找不到,就会报这个错误。

举个例子:

arduino 复制代码
#include <stdio.h>
#include <stdlib.h>

int main()
{
    // 分配 10 个 byte
    char * buf = malloc(10);
    buf[12] = '9';
    printf("ok\n");
    buf[135168] = '9';
    printf("ok\n");
    while (1)
    {
        /* code */
    }
    
    return 0;
}

我们有这样的一个程序,我们在堆上分配了一个 10 字节的内存,但是由于os会预先分配一个大的堆给进程使用。所以 buf[12] = '9'; 这行代码不会出错,因为 12 虽然超出我们手动分配的范围,但是没有超出整个堆的范围。但是 buf[135168] = '9'; 会报错,是因为这个地址超出了进程的堆地址。我们可以看一下该进程堆的范围:

maps 里面的这些内容,其实就是 vma 了。我们可以看到 heap 分配的大小是 02000-23000。我们如果访问这个范围以外的地址,就会报错:

上面我们介绍了 vma,其实它除了我们写出来的那些字段,还有一些字段,比如标识该 vma 区域的读写权限字段等。有了这个字段,就和我们上面说的对起来了。需要注意的是,这个读写权限标识的是整个区域的读写权限。所以,假设我们新分配了两个 vma(他们的读写权限一样,路径一样),第一个 vma 的 vm_end 等于第二个 vma 的 vm_start,那么就会将这两个 vma 合并。vma 的合并与拆分还是一个很蛋疼的事情,具体可看 《understanding linux kenel》。

相关推荐
摇光932 分钟前
promise
前端·面试·promise
麻花201325 分钟前
WPF学习之路,控件的只读、是否可以、是否可见属性控制
服务器·前端·学习
.54826 分钟前
提取双栏pdf的文字时 输出文件顺序混乱
前端·pdf
jyl_sh34 分钟前
WebKit(适用2024年11月份版本)
前端·浏览器·客户端·webkit
狼叔1 小时前
前端潮流KK:科技达人与多面手,如何找到自己的乐趣?-浪说回顾
前端
zhanghaisong_20151 小时前
Caused by: org.attoparser.ParseException:
前端·javascript·html·thymeleaf
Eric_见嘉1 小时前
真的能无限试(白)用(嫖)cursor 吗?
前端·visual studio code
DK七七2 小时前
多端校园圈子论坛小程序,多个学校同时代理,校园小程序分展示后台管理源码
开发语言·前端·微信小程序·小程序·php
老赵的博客2 小时前
QSS 设置bug
前端·bug·音视频
Chikaoya2 小时前
项目中用户数据获取遇到bug
前端·typescript·vue·bug