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的原理,我还是有一些疑问的。
- 是父进程持有原品、子进程持有复制品,还是反之?
- 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的大小是一个页大小!