fork()后copy on write的一些特性

Linux上,通过fork()创建一个子进程之后,kernel并不会急于为子进程分配新的内存空间(比如建立页表,拷贝父进程内存空间等)。这是因为,很多时候,子进程会立即调用exec()加载一个新的可执行文件。如果kernel在fork()之后就为子进程复制内存空间,那么子进程调用exec()之后所有的空间又要重新建立,之前的操作全部作废。所以Linux的fork()机制引入了copy on write技术。简单的说就是,fork()之后的子进程和父进程共享同一个地址空间。当父进程或子进程对内存进行修改时,kernel才复制一份内存空间。

所以copy on write技术的初衷是为了加速fork()调用,事实上效果也非常显著!但是,redis算是把copy on write技术用到极致了吧。redis在某些条件下会触发dump,也就是把某个瞬间的内存快照保存到磁盘上。那么,一边在写内存(处理请求),一边把内存中的内容写到磁盘上,会有一致性问题。此时,redis就利用了copy on write技术——它fork()一个子进程,在子进程中把内存写到一个磁盘镜像中。这是因为,fork()之后的子进程所能访问到的内存,就是fork()之前的那个瞬间的内存快照。如果fork()之后父进程(也就是redis的主进程)对内存进行了修改,kernel会复制一份,所以修改对子进程是不可见的。这么神奇的用法,我算是开眼界了!

至于实现原理,是这样的:fork()之后,kernel把父进程中所有的内存页的权限都设为read-only,然后子进程的地址空间指向父进程。当父子进程都只读内存时,相安无事。当其中某个进程写内存时,CPU硬件检测到内存页是read-only的,于是触发页异常中断(page-fault),陷入kernel的一个中断例程。中断例程中,kernel就会把触发的异常的页复制一份,于是父子进程各自持有独立的一份。

大致理解了copy on write的原理,我还是有一些疑问的。

  1. 是父进程持有原品、子进程持有复制品,还是反之?
  2. kernel进行复制的单位是一个内存页吗?

===================问题一:复制品归属==================

要判断复制品归属,就需要能够根据虚拟地址获取实际的物理地址,所以我才机智地先写了《Linux 获取虚拟地址对应的物理地址》。接下来做这么一个实验就一目了然了:

#include <fcntl.h>
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

// 参见《Linux 获取虚拟地址对应的物理地址》
size_t virtual_to_physical(size_t addr)
{
    int fd = open("/proc/self/pagemap", O_RDONLY);
    if(fd < 0)
    {
        printf("open '/proc/self/pagemap' failed!\n");
        return 0;
    }
    size_t pagesize = getpagesize();
    size_t offset = (addr / pagesize) * sizeof(uint64_t);
    if(lseek(fd, offset, SEEK_SET) < 0)
    {
        printf("lseek() failed!\n");
        close(fd);
        return 0;
    }
    uint64_t info;
    if(read(fd, &info, sizeof(uint64_t)) != sizeof(uint64_t))
    {
        printf("read() failed!\n");
        close(fd);
        return 0;
    }
    if((info & (((uint64_t)1) << 63)) == 0)
    {
        printf("page is not present!\n");
        close(fd);
        return 0;
    }
    size_t frame = info & ((((uint64_t)1) << 55) - 1);
    size_t phy = frame * pagesize + addr % pagesize;
    close(fd);
    return phy;
}

int main()
{
    char* str = malloc(128);
    strcpy(str,"hello,world!");
    printf("original, vir = %p, phy = %p, val = '%s'\n",
        str, (void*)virtual_to_physical((size_t)str), str);
    pid_t pid = fork();
    if(pid < 0)
    {
        printf("fork() failed!\n");
        return 1;
    }
    else if(pid > 0) 
    {
        printf("father,   vir = %p, phy = %p, val = '%s'\n",
            str, (void*)virtual_to_physical((size_t)str), str);
        wait(0);
    }
    else
    {
        printf("child,    vir = %p, phy = %p, val = '%s'\n",
            str, (void*)virtual_to_physical((size_t)str), str);
    }
    return 0;
}

代码逻辑其实很简单,就是开辟一段内存str,然后查看其虚拟地址、物理地址和内容。接着,fork(),在父子进程中分别查看其虚拟地址、物理地址和内容。

以root权限执行代码,可以看到如下输出:

当然,每次运行可能地址有所不同。不过可以肯定的是,当str没有被修改时,其虚拟地址、物理地址、内容都是一样的,说明确实是父子进程共享内存。

接下来稍微修改一下main(),让父进程修改一下str:

int main()
{
    char* str = malloc(128);
    strcpy(str,"hello,world!");
    printf("original, vir = %p, phy = %p, val = '%s'\n",
        str, (void*)virtual_to_physical((size_t)str), str);
    pid_t pid = fork();
    if(pid < 0)
    {
        printf("fork() failed!\n");
        return 1;
    }
    else if(pid > 0)
    {
        str[0] = 'H';
        printf("father,   vir = %p, phy = %p, val = '%s'\n",
            str, (void*)virtual_to_physical((size_t)str), str);
        wait(0);
    }
    else
    {
        sleep(1);
        printf("child,    vir = %p, phy = %p, val = '%s'\n",
            str, (void*)virtual_to_physical((size_t)str), str);
    }
    return 0;
}

以root权限执行代码,可以看到如下输出:

父进程中物理地址变了,而子进程则继承了本来的物理地址!

再接下来稍微修改一下main(),让子进程修改一下str:

int main()
{
    char* str = malloc(128);
    strcpy(str,"hello,world!");
    printf("original, vir = %p, phy = %p, val = '%s'\n",
        str, (void*)virtual_to_physical((size_t)str), str);
    pid_t pid = fork();
    if(pid < 0)
    {
        printf("fork() failed!\n");
        return 1;
    }
    else if(pid > 0)
    {
        sleep(1);
        printf("father,   vir = %p, phy = %p, val = '%s'\n",
            str, (void*)virtual_to_physical((size_t)str), str);
        wait(0);
    }
    else
    {
        str[0] = 'H';
        printf("child,    vir = %p, phy = %p, val = '%s'\n",
            str, (void*)virtual_to_physical((size_t)str), str);
    }
    return 0;
}

以root权限执行代码,可以看到如下输出:

子进程中物理地址变了,而父进程则沿用了本来的物理地址!

所以,第一个问题的答案是:谁修改内存,谁就持有复制品!

===================问题二:copy大小==================

因为硬件上内存读写权限的单位是页,所以copy的大小肯定也是页的整数倍。我猜就是一页,不过得验证一下:

#include <fcntl.h>
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>

void dump_pfns(char* addr, size_t len)
{
    int fd = open("/proc/self/pagemap", O_RDONLY);
    if(fd < 0)
    {
        printf("open '/proc/self/pagemap' failed!\n");
        return;
    }
    size_t pagesize = getpagesize();
    size_t offset = ((size_t)addr / pagesize) * sizeof(uint64_t);
    if(lseek(fd, offset, SEEK_SET) < 0)
    {
        printf("lseek() failed!\n");
        close(fd);
        return;
    }
    size_t pages = (len - 1) / pagesize + 1;
    uint64_t info[pages];
    if(read(fd, info, sizeof(info)) != sizeof(info))
    {
        printf("read() failed!\n");
        close(fd);
        return;
    }
    close(fd);
    for(size_t i = 0; i < pages; i++)
    {
        size_t frame = info[i] & ((((uint64_t)1) << 55) - 1);
        if((info[i] & (((uint64_t)1) << 63)) == 0)
            printf("page is not present!\n");
        else
            printf("physical page number: %lu\n", frame);
    }
}

int main()
{
    size_t len = 65536;
    char* str = malloc(len);
    memset(str, 0, len);
    pid_t pid = fork();
    if(pid < 0)
    {
        printf("fork() failed!\n");
        return 1;
    }
    else if(pid > 0)
    {
        printf("father:\n");
        dump_pfns(str, len);
        wait(0);
    }
    else
    {
        sleep(1);
        printf("child:\n");
        dump_pfns(str, len);
    }
    return 0;
}

在不修改的时候,物理页号肯定都是一样的。现在来修改str的第一个字节:

int main()
{
    size_t len = 65536;
    char* str = malloc(len);
    memset(str, 0, len);
    pid_t pid = fork();
    if(pid < 0)
    {
        printf("fork() failed!\n");
        return 1;
    }
    else if(pid > 0)
    {
        printf("father:\n");
        dump_pfns(str, len);
        wait(0);
    }
    else
    {
        sleep(1);
        str[0] = 1;
        printf("child:\n");
        dump_pfns(str, len);
    }
    return 0;
}

第一个页的物理页号变了,但是其他的页都没变!

所以,第二个问题的答案是:copy的大小是一个页大小!