概念
“写入时复制(英语:Copy-on-write,简称COW)是一种计算机 [程序设计]领域的优化策略。其核心思想是,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是 [透明]的。此做法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy) 被创建,因此多个调用者只是读取操作时可以共享同一份资源。”
COW 已有很多应用,比如在Linux 等的文件管理系统也使用了写时复制策略。
应用
Linux fork()
当通过 fork()
来创建一个子进程时,操作系统需要将父进程虚拟内存空间中的大部分内容全部复制到子进程中(主要是数据段、堆、栈;代码段共享)。这个操作不仅非常耗时,而且会浪费大量物理内存。特别是如果程序在进程复制后立刻使用 exec
加载新程序,那么负面效应会更严重,相当于之前进行的复制操作是完全多余的。
因此引入了写时复制技术。内核不会复制进程的整个地址空间,而是只复制其页表,fork
之后的父子进程的地址空间指向同样的物理内存页。
但是不同进程的内存空间应当是私有的。假如所有进程都只读取其内存页,那么就可以继续共享物理内存中的同一个副本;然而只要有一个进程试图写入共享区域的某个页面,那么就会为这个进程创建该页面的一个新副本。
如果是 fork()+exec() 的话,子进程被创建后就立即执行一个 executable,父进程内存中的数据对子进程而言没有意义——即父进程的页根本不会被子进程写入。在这种情况下可以完全避免复制,而是直接为子进程分配地址空间,如下图所示。
写时复制技术将内存页的复制延迟到第一次写入时,更重要的是,在很多情况下不需要复制。这节省了大量时间,充分使用了稀有的物理内存。
虚拟内存管理中的写时复制
虚拟内存管理中,一般把共享访问的页面标记为只读,当一个 task 试图向内存中写入数据时,内存管理单元(MMU)抛出一个异常,内核处理该异常时为该 task 分配一份物理内存并复制数据到此内存,重新向 MMU 发出执行该 task 的写操作
这里顺便了解一下 Linux 的内存管理
Linux 内存管理
为了充分利用和管理系统内存资源,Linux 采用虚拟内存
管理技术,在现代计算机系统中对物理内存做了一层抽象。
它为每一个进程都提供一块连续的私有地址空间,在 32 位模式下,每一块虚拟地址空间大小为 4GB。
Linux 采用虚拟内存管理技术,利用虚拟内存技术让每个进程都有4GB
互不干涉的虚拟地址空间。
进程初始化分配和操作的都是基于这个「虚拟地址」,只有当进程需要实际访问内存资源的时候才会建立虚拟地址和物理地址的映射,调入物理内存页。
“这个原理其实和现在的某某网盘一样。假如你的网盘空间是1TB
,真以为就一口气给了你这么大空间吗?都是在你往里面放东西的时候才给你分配空间,你放多少就分多少实际空间给你,但你看起来就像拥有1TB
空间一样。”
进程(执行的程序)占用的用户空间按照 访问属性一致的地址空间存放在一起
的原则,划分成 5
个不同的内存区域。访问属性指的是“可读、可写、可执行等。
- 代码段
代码段是用来存放可执行文件的操作指令,可执行程序在内存中的镜像。代码段需要防止在运行时被非法修改,所以只准许读取操作,它是不可写的。 - 数据段
数据段用来存放可执行文件中已初始化全局变量,换句话说就是存放程序静态分配的变量和全局变量。 - BSS 段
BSS
段包含了程序中未初始化的全局变量,在内存中bss
段全部置零。 - 堆
heap
堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用 malloc 等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用 free 等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减) - 栈
stack
栈是用户存放程序临时创建的局部变量,也就是函数中定义的变量(但不包括static
声明的变量,static 意味着在数据段中存放变量)。除此以外,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放回栈中。由于栈的先进先出特点,所以栈特别方便用来保存/恢复调用现场。从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。
可以在 linux 下用size
命令查看编译后程序的各个内存区域大小:
# size /usr/local/sbin/sshd text data bss dec hex filename 1924532 12412 426896 2363840 2411c0 /usr/local/sbin/sshd
内核地址空间划分
在 x86 32
位系统里,Linux 内核地址空间是指虚拟地址从 0xC0000000
开始到 0xFFFFFFFF
为止的高端内存地址空间,总计 1G
的容量, 包括了内核镜像、物理页面表、驱动程序等运行在内核空间
直接映射区
直接映射区 Direct Memory Region
:从内核空间起始地址开始,最大896M
的内核空间地址区间,为直接内存映射区。
直接映射区的 896MB 的「线性地址」直接与「物理地址」的前896MB
进行映射,也就是说线性地址和分配的物理地址都是连续的。内核地址空间的线性地址0xC0000001
所对应的物理地址为0x00000001
,它们之间相差一个偏移量PAGE_OFFSET = 0xC0000000
该区域的线性地址和物理地址存在线性转换关系「线性地址 = PAGE_OFFSET
+ 物理地址」也可以用 virt_to_phys()
函数将内核虚拟空间中的线性地址转化为物理地址。
高端内存线性地址空间
内核空间线性地址从 896M 到 1G的区间,容量 128MB 的地址区间是高端内存线性地址空间,为什么叫高端内存线性地址空间?下面给你解释一下:
前面已经说过,内核空间的总大小 1GB,从内核空间起始地址开始的 896MB 的线性地址可以直接映射到物理地址大小为 896MB 的地址区间。
退一万步,即使内核空间的 1GB 线性地址都映射到物理地址,那也最多只能寻址 1GB 大小的物理内存地址范围。
内核空间拿出了最后的 128M 地址区间,划分成下面三个高端内存映射区,以达到对整个物理地址范围的寻址。而在 64 位的系统上就不存在这样的问题了,因为可用的线性地址空间远大于可安装的内存。
动态内存映射区
vmalloc Region
该区域由内核函数vmalloc
来分配,特点是:线性空间连续,但是对应的物理地址空间不一定连续。vmalloc
分配的线性地址所对应的物理页可能处于低端内存,也可能处于高端内存。
永久内存映射区
Persistent Kernel Mapping Region
该区域可访问高端内存。访问方法是使用 alloc_page (_GFP_HIGHMEM)
分配高端内存页或者使用kmap
函数将分配到的高端内存映射到该区域。
固定映射区
Fixing kernel Mapping Region
该区域和 4G 的顶端只有 4k 的隔离带,其每个地址项都服务于特定的用途,如 ACPI_BASE
等。
用户空间内存数据结构
虚拟地址的好处
- 避免用户直接访问物理内存地址,防止一些破坏性操作,保护操作系统
- 每个进程都被分配了 4GB 的虚拟内存,用户程序可使用比实际物理内存更大的地址空间
系统处理流程
系统将虚拟内存分割成一块块固定大小的虚拟页(Virtual Page),同样的,物理内存也会被分割成物理页(Physical Page),当进程访问内存时,CPU 通过内存管理单元(MMU)根据页表(Page Table)将虚拟地址翻译成物理地址,最终取到内存数据。这样在每个进程内部都像是独享整个主存。
当 CPU 拿到一个虚拟地址希望访问内存的时候,将其分为虚拟页框号和偏移两个部分,先拿着虚拟页框号查 TLB,TLB 命中就直接将物理页框号和偏移拼接起来得到物理地址。在拿着物理地址进行访存。访存的时候也是先看缓存汇总是否有,没有的话再访问下一级存储器。如果 TLB 没有命中的话,就利用CR3
寄存器(存储当前进程的一级页表基址)逐级地查页表。
当初始化一个进程的时候,Linux 系统通过将虚拟地址空间和一个磁盘上的对象相关联来初始化这个进程的虚拟地址空间,这个过程称之为内存映射。
可执行文件存储在磁盘中,其中有虚拟内存中的各个段的数据,比如代码段,数据段等。比如代码段,它在程序执行的过程中应该是不变的,而且在内存中的样子和在磁盘中是一样的,所以是如何加载到内存中的呢。
Linux 将内存的不同区域映射成下面两种磁盘文件中的一种:
- Linux 文件系统的常规文件。比如可执行文件。文件的某一部分被划分为页大小的块,每一块包含一个虚拟地址页的初始内容。当某一块不足一页的时候,用零进行填充。但是操作系统并不会在一开始就将所有的内容真的放到内存中,而是 CPU 第一次访问发生了缺页的时候,才由缺页中断将这一页调入物理内存。(当进程在申请的内存的时候,linux 内核其实只分配一块虚拟内存地址,并没有分配实际的物理内存,相当于操作系统只给进程这一块地址的使用权。只有当程序真正使用这块内存时,会产生一个缺页异常,这时内核去真正为进程分配物理页,并建立对应的页表,从而将虚拟内存和物理内存建立一个映射关系,这样可以做到充分利用到物理内存。)
- 匿名文件。虚拟内存的一片区域也可以映射到由内核创建的一个匿名文件,如堆栈部分和未初始化的全局变量,在可实行文件中并没有实体,这些会映射到匿名文件。当 CPU 访问这些区域的时候,内核找到一个物理页,将它清空,然后更新进程的页表。这个过程没有发生磁盘到主存中间的数据交互。但是需要注意,在
C++
堆申请的内存不一定都是 0,因为C++
内部实现了堆内存管理,可能申请的内存并不是操作系统新分配的,而是之前分配了返回了,但是被C++
内存管理部分保留了,这次申请又直接返回给了用户。
在上面两种情况下,虚拟页被初始化之后,它会在交换空间和主存中进行换入换出。交换空间的大小限制了当前正在运行的进程的虚拟页的最大数量。交换空间的大小可以在按照操作系统的时候进行设置。
内存映射与进程间共享对象 (CopyOnWrite)
不同的进程可以共享对象。比如代码段是只读的,运行同一个可执行文件的进程可以共享虚拟内存的代码段,这样可以节省物理内存。还有进程间通信的共享内存机制。这些都可以在虚拟内存映射这个层次来实现。可以将不同进程的虚拟页映射到同一个物理页框,从而实现不同进程之间的内存共享。
同时为了节省物理内存,可以使用copy-on-write
技术,来实现进程私有的地址空间共享。初始时刻让多个进程共享一个物理内存页,然后当有某一个进程对这个页进行写的时候,触发copy-on-write
机制,将这个物理页进行复制,这样就实现了私有化。
Buddy(伙伴)分配算法
Linux
内核引入了伙伴系统算法(Buddy system),什么意思呢?就是把相同大小的页框块用链表串起来,页框块就像手拉手的好伙伴,也是这个算法名字的由来。
具体的,所有的空闲页框分组为 11 个块链表,每个块链表分别包含大小为 1,2,4,8,16,32,64,128,256,512 和 1024 个连续页框的页框块。最大可以申请 1024 个连续页框,对应 4MB 大小的连续内存。
因为任何正整数都可以由 2^n
的和组成,所以总能找到合适大小的内存块分配出去,减少了外部碎片产生 。
slab 分配器
看到这里你可能会想,有了伙伴系统这下总可以管理好物理内存了吧?不,还不够,否则就没有 slab 分配器什么事了。
那什么是 slab 分配器呢?
一般来说,内核对象的生命周期是这样的:分配内存-初始化-释放内存,内核中有大量的小对象,比如文件描述结构对象、任务描述结构对象,如果按照伙伴系统按页分配和释放内存,对小对象频繁的执行「分配内存-初始化-释放内存」会非常消耗性能。
伙伴系统分配出去的内存还是以页框为单位,而对于内核的很多场景都是分配小片内存,远用不到一页内存大小的空间。slab
分配器,「通过将内存按使用对象不同再划分成不同大小的空间」,应用于内核对象的缓存。
伙伴系统和 slab 不是二选一的关系,slab
内存分配器是对伙伴分配算法的补充。
mmap
mmap 是 POSIX 规范接口中用来处理内存映射的一个系统调用,它本身的使用场景非常多:
- 可以用来申请大块内存
- 可以用来申请共享内存
- 也可以将文件或设备直接映射到内存中
进程可以像访问普通内存一样访问被映射的文件,在实际开发过程使用场景非常多
在 LINUX 中我们可以使用 mmap 用来在进程虚拟内存地址空间中分配地址空间,创建和物理内存的映射关系。
mmap 是将一个文件直接映射到进程的地址空间,进程可以像操作内存一样去读写磁盘上的文件内容,而不需要再调用 read/write 等系统调用。
int main(int argc, char **argv) { char *filename = "/tmp/foo.data"; struct stat stat; int fd = open(filename, O_RDWR, 0); fstat(fd, &stat); void *bufp = mmap(NULL, stat.st_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); memcpy(bufp, "Linuxdd", 7); munmap(bufp, stat.st_size); close(fd); return 0; }
在 mmap 之后,并没有将文件内容加载到物理页上,只是在虚拟内存中分配了地址空间。当进程在访问这段地址时,通过查找页表,发现虚拟内存对应的页没有在物理内存中缓存,则产生"缺页",由内核的缺页异常处理程序处理,将文件对应内容,以页为单位 (4096) 加载到物理内存,注意是只加载缺页,但也会受操作系统一些调度策略影响,加载的比所需的多。
所处空间
一个进程的虚拟空间有多个部分组成,mmap 的文件所处的内存空间在内存映射段中。
mmap 和 read/write 的区别
read 的系统调用的流程大概如下图所示:
a) 用户进程发起 read 操作;
b) 内核会做一些基本的 page cache 判断,从磁盘中读取数据到 kernel buffer 中;
c) 然后内核将 buffer 的数据再拷贝至用户态的 user buffer;
d) 唤醒用户进程继续执行;
而 mmap 的流程如下图所示
内核直接将内存暴露给用户态,用户态对内存的修改也直接反映到内核态,少了一次的内核态至用户态的内存拷贝,速度上会有一定的提升
mmap 的优点有很多,相比传统的 read/write 等 I/O 方式,直接将虚拟地址的区域映射到文件,没有任何数据拷贝的操作,当发现有缺页时,通过映射关系将磁盘的数据加载到内存,用户态程序直接可见,提高了文件读取的效率。对索引数据这种大文件的读取、cache、换页等操作直接交由操作系统去调度,间接减少了用户程序的复杂度,并提高了运行效率。
优缺点
优点如下:
- 对文件的读取操作跨过了页缓存,减少了数据的拷贝次数,用内存读写取代 I/O 读写,提高了文件读取效率。
- 实现了用户空间和内核空间的高效交互方式。两空间的各自修改操作可以直接反映在映射的区域内,从而被对方空间及时捕捉。
- 提供进程间共享内存及相互通信的方式。不管是父子进程还是无亲缘关系的进程,都可以将自身用户空间映射到同一个文件或匿名映射到同一片区域。从而通过各自对映射区域的改动,达到进程间通信和进程间共享的目的。同时,如果进程 A 和进程 B 都映射了区域 C,当 A 第一次读取 C 时通过缺页从磁盘复制文件页到内存中;但当 B 再读 C 的相同页面时,虽然也会产生缺页异常,但是不再需要从磁盘中复制文件过来,而可直接使用已经保存在内存中的文件数据。
- 可用于实现高效的大规模数据传输。内存空间不足,是制约大数据操作的一个方面,解决方案往往是借助硬盘空间协助操作,补充内存的不足。但是进一步会造成大量的文件 I/O 操作,极大影响效率。这个问题可以通过 mmap 映射很好地解决。换句话说,但凡是需要用磁盘空间代替内存的时候,mmap 都可以发挥其功效。
缺点如下:
- 文件如果很小,是小于 4096 字节的,比如 10 字节,由于内存的最小粒度是页,而进程虚拟地址空间和内存的映射也是以页为单位。虽然被映射的文件只有 10 字节,但是对应到进程虚拟地址区域的大小需要满足整页大小,因此 mmap 函数执行后,实际映射到虚拟内存区域的是 4096 个字节,11~4096 的字节部分用零填充。因此如果连续 mmap 小文件,会浪费内存空间。
- 对变长文件不适合,文件无法完成拓展,因为 mmap 到内存的时候,你所能够操作的范围就确定了。
- 如果更新文件的操作很多,会触发大量的脏页回写及由此引发的随机 IO 上。所以在随机写很多的情况下,mmap 方式在效率上不一定会比带缓冲区的一般写快
Linux 等的文件管理系统使用了写时复制策略
ZFS
、BTRFS
两种写时复制文件系统,写时复制文件系统采用了日志式技术。
ZFS
ZFS
文件系统的英文名称为 Zettabyte File System
, 也叫动态文件系统(Dynamic File System
), 是第一个 128
位文件系统。最初是由 Sun
公司为 Solaris 10
操作系统开发的文件系统。作为 OpenSolaris
开源计划的一部分,ZFS
于 2005 年 11 月发布,被 Sun
称为是终极文件系统,经历了 10
年的活跃开发。而最新的开发将全面开放,并重新命名为 OpenZFS
。
利用写时拷贝使 ZFS
的快照和事物功能的实现变得更简单和自然,快照功能更灵活。缺点是,COW
使碎片化问题更加严重,对于顺序写生成的大文件,如果以后随机的对其中的一部分进行了更改,那么这个文件在硬盘上的物理地址就变得不再连续,未来的顺序读会变得性能比较差。
BTRFS
BTRFS
(通常念成 Butter FS
),由 Oracle
于 2007 年宣布并进行中的 COW
(copy-on-write
式)文件系统。目标是取代 Linux ext3
文件系统,改善 ext3
的限制,特别是单一文件大小的限制,总文件系统大小限制以及加入文件校验和特性。加入 ext3/4
未支持的一些功能,例如可写的磁盘快照 (snapshots
),以及支持递归的快照 (snapshots of snapshots
),内建磁盘阵列(RAID
)支持,支持子卷 (Subvolumes
) 的概念,允许在线调整文件系统大小。
首先是扩展性 (scalability
) 相关的特性,btrfs
最重要的设计目标是应对大型机器对文件系统的扩展性要求。Extent
、B-Tree
和动态 inode
创建等特性保证了 btrfs
在大型机器上仍有卓越的表现,其整体性能而不会随着系统容量的增加而降低。其次是数据一致性 (data integrity
) 相关的特性。系统面临不可预料的硬件故障,Btrfs
采用 COW
事务技术来保证文件系统的一致性。btrfs
还支持 checksum
,避免了 silent corrupt
的出现。而传统文件系统则无法做到这一点。第三是和多设备管理相关的特性。Btrfs
支持创建快照 (snapshot
),和克隆 (clone
) 。btrfs
还能够方便地管理多个物理设备,使得传统的卷管理软件变得多余。最后是其他难以归类的特性。这些特性都是比较先进的技术,能够显著提高文件系统的时间/空间性能,包括延迟分配,小文件的存储优化,目录索引等。
数据库一般采用了写时复制策略,为用户提供一份 snapshot
MySQL MVCC
多版本并发控制(MVCC) 在一定程度上实现了读写并发,它只在 可重复读(REPEATABLE READ) 和 提交读(READ COMMITTED) 两个隔离级别下工作。其他两个隔离级别都和 MVCC 不兼容,因为 未提交读(READ UNCOMMITTED),总是读取最新的数据行,而不是符合当前事务版本的数据行。而 可串行化(SERIALIZABLE) 则会对所有读取的行都加锁。
行锁,并发,事务回滚等多种特性都和 MVCC 相关。MVCC 实现的核心思路就是 Copy On Write
Java 中的写时复制应用
j.u.c 包中支持写时复制的线程安全的集合:CopyOnWriteArrayList、CopyOnWriteArraySet,与 fail-fast 的容器相比,fail-safe 的 COW 容器固然安全了很多,但是由于每次写都要复制整个数组,时间和空间的开销都更高,因此只适合读多写少的情景。在写入时,为了保证效率,也应尽量做批量插入或删除,而不是单条操作。并且它的正本和副本有可能不同步,因此无法保证读取的是最新数据,只能保证最终一致性。
Redis
Redis 在生成 RDB 快照文件时不会终止对外服务
Redis 重启后可以恢复数据。比如 RDB,是保存某个瞬间 Redis 的数据库快照。执行 bgsave 命令,Redis 就会保存一个 dump.rdb 文件,这个文件记录了这个瞬间整个数据库的所有数据。Redis 厉害的地方就是,在保存的同时,Redis 还能处理命令。那么有一个很有趣的问题——Redis 是怎么保证 dump.rdb 中数据的一致性的?Redis 一边在修改数据库,一边在把数据库保存到文件,就不担心脏读脏写问题吗?
Redis 有一个主进程,在写数据,这时候有一个命令过来了,说要把数据持久化到磁盘。我们知道 redis 的 worker 是单线程的,如果要持久化这个行为也放在单线程里,那么如果需要持久化数据特别多,将会影响用户的使用。所以单开(fork)一个进程(子进程)专门来做持久化的操作。
至于实现原理,是这样的:fork() 之后,kernel 把父进程中所有的内存页的权限都设为 read-only,然后子进程的地址空间指向父进程。当父子进程都只读内存时,相安无事。当其中某个进程写内存时,CPU 硬件检测到内存页是 read-only 的,于是触发页异常中断(page-fault),陷入 kernel 的一个中断例程。中断例程中,kernel 就会把触发的异常的页复制一份,于是父子进程各自持有独立的一份。
是父进程持有原品、子进程持有复制品,还是反之?
谁修改内存,谁就持有复制品
kernel 进行复制的单位是一个内存页吗?
copy 的大小是一个页大小