Linux 下的 copy-on-write
一. 什么是 copy-on-write
写入时复制(copy-on-write,简称 COW)是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。
这过程对其他的调用者都是透明的(transparently)。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被建立,因此多个调用者只是读取操作时可以共享同一份资源。
COW 在很多场景下都会使用,他们利用 COW 解决不同的问题:
- Linux 通过 copy-on-write 技术极大地减少了 Fork 的开销。
- 文件系统通过 copy-on-write 技术一定程度上保证数据的完整性。
- jdk 通过 copy-on-write 技术在读多写少的场景下,提供并发能力。
一. Linux COW
1.1 进程 fork
在说明 Linux 下的 copy-on-write 机制前,我们首先要知道两个函数: fork()
和 exec()
。需要注意的是 exec()
并不是一个特定的函数, 它是一组函数的统称, 它包括了 execl()
、 execlp()
、 execv()
、 execle()
、 execve()
、 execvp()
。
fork 是类 Unix 操作系统上创建进程的主要方法。fork 用于创建子进程(等同于当前进程的副本)。
- 新的进程要通过老的进程复制自身得到,这就是 fork!
如果接触过 Linux,我们会知道 Linux 下init 进程是所有进程的爹(相当于 Java 中的 Object 对象)
- Linux 的进程都通过 init 进程或 init 的子进程 fork(vfork) 出来的。
一个进程,包括代码、数据和分配给进程的资源。fork()函数通过系统调用创建一个与原来进程几乎完全相同的进程,也就是两个进程可以做完全相同的事,但如果初始参数或者传入的变量不同,两个进程也可以做不同的事。
一个进程调用 fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。
我们来看一个例子:
#include <unistd.h>
#include <stdio.h>
int main() {
pid_t fpid; //fpid 表示 fork 函数返回的值
int count = 0;
fpid = fork();
if (fpid < 0)
printf("error in fork!");
else if (fpid == 0) {
printf("i am the child process, my process id is %d\n", getpid());
count++;
} else {
printf("i am the parent process, my process id is %d\n", getpid());
count++;
}
printf("统计结果是: %d\n", count);
return 0;
}
运行结果是:
在语句 fpid=fork() 之前,只有一个进程在执行这段代码,但在这条语句之后,就变成两个进程在执行了,这两个进程的几乎完全相同,将要执行的下一条语句都是 if(fpid<0)……
为什么两个进程的 fpid 不同呢,这与 fork 函数的特性有关。fork 调用的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:
- 在父进程中,fork 返回新创建子进程的进程 ID;
- 在子进程中,fork 返回 0;
- 如果出现错误,fork 返回一个负值;
在 fork 函数执行完毕后,如果创建新进程成功,则出现两个进程,一个是子进程,一个是父进程。在子进程中,fork 函数返回 0,在父进程中,fork 返回新创建子进程的进程 ID。我们可以通过 fork 返回的值来判断当前进程是子进程还是父进程。
fork 出错可能有两种原因:
- 当前的进程数已经达到了系统规定的上限,这时 errno 的值被设置为 EAGAIN。
- 系统内存不足,这时 errno 的值被设置为 ENOMEM。
创建新进程成功后,系统中出现两个基本完全相同的进程,这两个进程执行没有固定的先后顺序,哪个进程先执行要看系统的进程调度策略。
每个进程都有一个独特(互不相同)的进程标识符(process ID),可以通过 getpid()函数获得,还有一个记录父进程 pid 的变量,可以通过 getppid()函数获得变量的值。
fork 执行完毕后,出现两个进程:
有人说两个进程的内容完全一样啊,怎么打印的结果不一样啊,那是因为判断条件的原因,上面列举的只是进程的代码和指令,还有变量啊。
执行完 fork 后,进程 1 的变量为 count=0,fpid!=0(父进程)。进程 2 的变量为 count=0,fpid=0(子进程),这两个进程的变量都是独立的,存在不同的地址中,不是共用的,这点要注意。可以说,我们就是通过 fpid 来识别和操作父子进程的。
还有人可能疑惑为什么不是从#include 处开始复制代码的,这是因为 fork 是把进程当前的情况拷贝一份,执行 fork 时,进程已经执行完了 int count=0;fork 只拷贝下一个要执行的代码到新的进程。
需要注意的是,在 fork 之后两个进程用的是相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,也就是说,两者的虚拟空间不同,其对应的物理空间是一个。这是出于效率的考虑,在 Linux 中被称为“写时复制”(copy-on-write)技术,只有当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间。另外 fork 之后内核会将子进程排在队列的前面,以让子进程先执行,以免父进程执行导致写时复制,而后子进程执行 exec 系统调用,因无意义的复制而造成效率的下降。
1.2 代码分析
先看一份代码:
#include <unistd.h>
#include <stdio.h>
int main() {
pid_t cld_pid;
int a = 1, b = 2;
for (int i = 0; i < 2; i++) {
if ((cld_pid = fork()) == 0) {
a += 1;
printf("a=%d b=%d\n", a, b);
} else {
b += 1;
printf("a=%d b=%d\n", a, b);
}
}
return 0;
}
代码结果:
执行流程:
图中实线箭头代表进程内变量的变化过程,虚线箭头代表进程之间的 fork 操作,产生子进程。
三. 文件系统 COW
当我要修改数据块 A 的内容的时候,我先把 A 读出来,写到 B 块里,如果写的过程掉电了,原来 A 的内容还在,如果是还写到原来的位置上,那么写入的数据究竟写了多少就不确定了,会不会破坏原来的数据也不好说。
很多老的文件系统都不支持 COW,比如 FAT 家族都不支持,但主流的文件系统都多多少少支持一点 COW,比如 BTRFS,NTFS 等。
其实 COW 对于用户数据来说,并不是特别重要,多数文件系统也不能承诺掉电以后究竟能恢复多少数据(我印象里只有 tffs 能做到恢复)。COW 的好处是对文件系统元数据的保护,元数据包括文件名、文件大小、属性、路径结构等等,这些数据如果损坏,轻则丢失文件,重则分区完蛋。比如在 FAT 分区上,如果在大规模删除、复制文件的时候突然断电,文件夹结构可能就有破坏的风险,而如果支持 COW 的话,文件系统能很容易回滚数据到前一个状态(虽然也会丢失文件),保证文件系统自身结构稳定。
COW 是一定会降低性能的,因为访问的数据量不同,还可能要扫描元数据的 bitmap 之类的东西,当然设计合理的话用户基本感觉不到(NTFS 最早的版本就被用户抱怨说速度太慢,好像就是这个原因)。
跟 COW 相关的还有一个叫 Allocate-on-flush 或者 Delayed Allocation,大概可以称为申请时刷新或者延迟分配,好处就是降低碎片,坏处就是有点费内存。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
上一篇: 编程语言的自举
下一篇: 谈谈自己对于 AOP 的了解
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论