Copy-On-Write技术
发布时间:

Copy-On-Write技术

Docker和Redis中都使用写时复制(Copy-On-Write, COW)技术来提高程序性能,那么COW是什么技术呢?这篇文章将深入探究一下COW技术

在阅读《The Docker Book》的时候遇到“写时复制”(Copy-On-Write)模型,感觉之前似乎没有看到过这个概念,于是打算深入探究一下COW技术的原理,并写下这篇笔记来记录一下我的学习过程。

大部分内容都是互联网上已有的文章,我不创造知识,我只是知识的搬运工。

什么是写时复制COW技术

写时复制简单的说就是指等到修改内存数据的时候才给子进程分配内存,在子进程只进行读操作的时候并不分配内存给子进程,而是共享父进程的内存空间。 详细说的话,在 Linux 系统中,调用 fork 系统调用创建子进程时,并不会把父进程所有占用的内存页复制一份,而是与父进程共用相同的内存页,而当子进程或者父进程对内存页进行修改时才会进行复制。也就是只有进程空间的某页内存的内容要发生变化时,才会将父进程的该页内存复制一份给子进程。

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

这些共享页面标记为写时复制,这意味着如果任何一个进程写入共享页面,那么就创建共享页面的副本,写时复制如图 1 所示,图中分别反映了修改页面 C 的前与后。

为什么需要COW

在Linux中,父进程调用fork函数创建一个子进程,传统的方法就是在创建子进程的时候就复制整个父进程的内存空间(包括数据段和堆栈)到子进程中,但是这个复制的过程是相当耗时的,如果子进程的大部分时间都是进行一些只读操作的话,那么复制父进程的内存完全没必要。这样会极大的增加创建子进程时所消耗的时间。

写入时复制是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这个过程对其他的调用者是透明的(transparently)。此作法的主要优点是如果调用者没有修改该资源,就不会有副本(private copy)被建立,因此多个调用者只是读取操作是可以共享同一份资源。

因此为了提高程序性能,减少创建子进程而导致的延迟,才有了写时复制技术。COW技术的优缺点总结如下:

优点:

  • COW技术可减少分配和复制大量资源时带来的瞬间延时。
  • COW技术可减少不必要的资源分配。比如fork进程时,并不是所有的页面都需要复制,父进程的代码段和只读数据段都不被允许修改,所以无需复制。

缺点:

  • 如果在fork()之后,父子进程都还需要继续进行写操作,那么会产生大量的分页错误(页异常中断page-fault),这样就得不偿失。

COW具体的实现原理

写时拷贝技术实际上是运用了一个 “引用计数” 的概念来实现的。在开辟的空间中多维护四个字节来存储引用计数。

  • 多开辟四个字节(pCount)的空间,用来记录有多少个指针指向这片空间
  • 在开辟空间的头部预留四个字节的空间来记录有多少个指针指向这片空间。

当我们使用fork多开辟一份空间时,让引用计数+1,如果有释放空间,那就让计数-1,但是此时不是真正的释放,是假释放,等到引用计数变为 0 时,才会真正的释放空间。如果有修改或写的操作,那么也让原空间的引用计数-1,并且真正开辟新的空间。 Linux中的fork函数用的就是写时复制技术。除了fork外,c++中string也是使用写时复制,下面以其为例来讲解使用引用技术来解决拷贝问题。c++源码使用的是在开辟空间头部预留4个字节的方案,这里为了方便理解,使用了第一种方案。

class String
{
public:
	//构造
	String(const char* str)
		:_str(new char[strlen(str) + 1])
		,_pCount(new int(1))
	{
		strcpy(_str, str);
	}
 
	//拷贝构造
	String(const String& s)
		:_str(s._str)
		,_pCount(s._pCount)
	{
		(*_pCount)++;
	}
 
	//赋值运算符重载
	String& operator=(const String& s)
	{
		if(_str != s._str)
		{
			if(--(*_pCount) == 0)
			{
				delete[] _str;
				delete _pCount;
			}
			_str = s._str;
			_pCount = s._pCount;
			(*_pCount)++;
		}
		return *this;
	}
 
	~String()
	{
		if(--(*_pCount) == 0)
		{
			delete[] _str;
			delete _pCount;
		}
	}
 
	char& operator[](size_t pos)
	{
		if(*_pCount > 1)
		{
			char* newstr = new char[strlen(_str) + 1];
			strcpy(newstr, _str);
			--(*_pCount);
			_str = newstr;
			_pCount = new int(1);
		}
		return _str[pos];
	}
 
	const char* c_str()
	{
		return _str;
	}
private:
	char* _str;
	int* _pCount;
};

从这段代码上可以看出,如果使用赋值运算符进行拷贝的话,其实就是直接将原始的内存地址复制过去,并且将引用计数加一。但是重载[]运算符的时候,如果引用计数大于1,则此时会发生真正的拷贝,拷贝原始的数据到一个新的地址,并且将引用计数减一。

string str1 = "hello world";
string str2 = str1;

此时str1和str2的地址是相同的。

str1[1]='q';
str2[1]='w';

此时由于发生了写操作,str1的地址发生了变化,而str2的地址还是原来的。

COW技术的应用

  1. 虚拟内存管理中的写时复制

一般把这种被共享访问的页面标记为只读。当一个task试图向内存中写入数据时,内存管理单元(MMU)抛出一个异常,内核处理该异常时为该task分配一份物理内存并复制数据到此内存,重新向MMU发出执行该task的写操作。

  1. Docker的镜像和容器

这也是我为什么会深入探究COW技术的初衷

Docker的镜像就是一个分层的只读文件系统,如下图所示: 然后容器就是在上面一同了一层可写层, 镜像可以从别的镜像或者容器中诞生.就像Linux的父子进程一样. 分层就类似于父子进程共享的地址空间. 当我们需要个性化的修改数据时, 我们并没有修改原来的只读数据, 而是在我们新创建的可写层中进行写入。 对于从同一个镜像创建的不同容器而言,除了最上层的可修改层不同,下面所有的层都是只读不可修改的,所以所有容器共享这一部分。而每个容器所做的操作只影响它自己的那个可修改层。所以这种技术保障了Docker的高效。

关于这部分,可以继续深入理解Docker的底层实现。

这样的设计使得Docker的镜像非常地方便使用, 同时本地的镜像也可以共用分层的文件。从镜像中创建容器和从容器中创建镜像都变得简单和易用。省去了大量的时间和资源消耗。

  1. Java中的CopyOnWriteArrayList,CopyOnWriteArraySet

其原理和Linux中的写时复制一样: 在多线程环境,凡是读操作都没有进行加锁操作,而写操作都会在锁的保护下复制一份数据,在复制的数据上做修改,修改完后,再把底层数组的引用指向修改后的新数组。

  1. redis中的COW技术

这是《Redis的设计与实现》中的

执行BGSAVE命令或者BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)来优化子进程的使用效率,所以在子进程存在期间,服务器会提高负载因子的阈值,从而避免在子进程存在期间进行哈希表扩展操作,避免不必要的内存写入操作,最大限度地节约内存。

结合上面对COW技术的理解,那么Redis在持久化的过程中提高负载因子的阈值的原因如下了:

  • Redis在持久化时,如果是采用BGSAVE命令或者BGREWRITEAOF的方式,那Redis会fork出一个子进程来读取数据,从而写到磁盘中。
  • 总体来看,Redis还是读操作比较多。如果子进程存在期间,发生了大量的写操作,那可能就会出现很多的分页错误(页异常中断page-fault),这样就得耗费不少性能在复制上。
  • 而在rehash阶段上,写操作是无法避免的。所以Redis在fork出子进程之后,将负载因子阈值提高,尽量减少写操作,避免不必要的内存写入操作,最大限度地节约内存。