用最简单的方式带你走进写时复制技术

Posted by WGrape的博客 on January 23, 2022

文章内容更新请以 WGrape GitHub博客 : 什么是Copy-on-write技术 为准

前言

本文原创,著作权归WGrape所有,未经授权,严禁转载

阅读指南

阅读此篇文章,你能从中学到什么 ?

  • 理解写时复制技术
  • 写时复制的应用场景

一、介绍

在程序的内存优化技术中,有一种名为Copy-on-write(COW)的写时复制技术,它可用于避免不必要的内存拷贝,以提高内存的利用率。

二、理解

写时复制的思想是当多个调用者请求同一个资源时,所有调用者都会获取到一个指向这个资源的指针,直到某调用者对资源进行修改时,系统才会复制一份资源给此调用者。正是通过这种避免不必要的内存拷贝,减少了内存整体开销。

为了更透彻的理解这种技术,可以看如下程序,代码中定义了 str1 和 str2 这两个字符串,且第二个字符串一直和第一个内容一样,但直到程序退出结束,str2字符串的内容也未曾改变。

int main(){
    str1 = "hello world";
    str2 = str1;

    // ... ...

    return 0;
}

1、内存优化前

在未优化前,内存使用情况如下所示,两个字符串都占用了不同的内存空间。

2、内存优化后

在使用写时复制技术优化后,初始时两个字符串都共享同一个内存空间,只有当S2字符串出现写操作时,才会从内存中复制一个新的内存区域给S2使用。

也就是说只有当真正发生写操作时,才会有自己独享的可写内存,否则会使用共享的内存。

三、应用场景

1、数据结构

在一些常用的如树、数组、链表等数据结构中,我们都可以使用写时复制技术。

(1) 重复节点压缩

在如下链表中,第一个和第二个是完全一样的节点,如果在整个链表中大量存在这样重复的节点,会造成内存大量的浪费。

image

为了减小整个链表的内存大小,我们可以借鉴写时复制的设计,为链表增加一个count属性表示相同节点的数量,在程序中需要实际修改节点的时候,才把节点创建出来。

image

2、Linux多进程

在Linux进程实现中,进程fork出子进程时,会复制父进程的数据,这种复制方式就是Copy-on-write。子进程并不会完全拷贝出一个完整的内存副本,而是只有当子进程对数据进行修改时,才会进行复制。

如下图中,父进程P在fork出Q子进程后,两个进程会共用一个内存空间,当父进程P修改页面3的数据时,系统会拷贝出一个页面3的副本,这样父进程就会修改页面3的副本数据,而不会对子进程有影响,实现进程间数据的隔离。

截屏2022-11-24 22 23 09

3、Redis的BGSAVE

(1) 问题的复杂性

我们都知道BGSAVE对当前内存快照后,实现了磁盘的持久化存储。但是它并不是一个同步的操作命令,也就是说,在写入磁盘的过程中,Redis并未停止工作,反而还会存在对内存数据再次修改的可能。是不是想到这里才发现持久化操作是一个很复杂的事情 ?它应该如何实现 ?

(2) 探究问题真相

其实如果有了解过Redis底层原理,会发现Redis在实现BGSAVE命令时,使用了多进程的方式。为什么不是多线程呢 ?这背后原因就是在Redis对写时复制技术的应用,它巧妙的化解了内存数据快照时会被修改的复杂问题,下面会详细介绍下原因。

(3) BGSAVE原理

当执行BGSAVE命令时,Redis会fork出子进程。正是基于Linux天然的多进程写时复制机制,才可以保证在数据写入磁盘时,保持原有的内存大小,不至于内存占用量瞬间增大2倍。这样子进程可以正常的进行内存数据的落盘,只有当内存数据被修改时,Linux才会复制一块新的空间,供Redis读取并落盘使用。

(4) 为什么不是多线程

如果使用多线程机制,那么内存中的数据需要频繁加锁,不但大大降低了Redis的性能,而且系统复杂度过高。

所以Redis基于Linux多进程机制,大大简化了内存持久化的难度。通过Linux多进程天然的写时复制技术,实现了内存数据在写操作与读操作并发时的隔离,简化了系统的复杂度。

(5) 如何减少内存复制

一般地,在Redis多进程(如执行BGSAVE)的情况下,如果发生内存复制,会非常容易出现内存不足或耗尽的异常错误。为了减少内存复制,最根本的方法是减少写操作的发生,主要有以下优化手段。

  • 在Redis写操作低峰时期,才允许进行BGSAVE
  • rehash一定会发生写操作,所以Redis在多进程下会提高负载因子,减少rehash的出现,以减少写操作导致的内存复制