文章目录
Linux写时拷贝技术(copy-on-write)
在了解写时拷贝技术之前我们得先知道进程
进程的概念
进程的定义
肤浅的来说,加载到内存中的程序就叫做进程。但这并不完善。
运行中的系统存在大量的进程,操作系统该如何管理这些进程呢?仍然是先描述再组织。
进程在形成之初,操作系统就会为其创建进程控制块 PCB。进程控制块PCB用于描述进程,其中存储着进程的所有属性。
Linux 系统中,PCB 就是一个名为task_struct
的结构体。
Linux 查看进程的命令:
$ ps axj | head -1 && ps axj | grep 'myproc'
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
25486 29733 29733 25486 pts/0 29733 S+ 1003 0:00 ./myproc # 正在运行的myproc进程
启动程序的本质,就是在内存上创建进程。
进程和程序的区别
- 程序本质就是文件,是编译生成的二进制可执行文件。
- 程序被加载到内存中,系统自动为其创建PCB,以管理该进程。故进程是程序的代码数据与进程相关内核数据结构的总和。
操作系统在处理进程时, 不是直接操作程序而是读写PCB,因为PCB中含有程序的所有属性。也就是说,进程管理与进程对应的程序毫无关系,只与进程的PCB强相关。
PCB的内部构成
- 进程编号 —— 每个进程都有编号或称标识符,也就是 PID,具有唯一性用来区别于其他进程。
- 进程状态 —— 包括进程退出时的退出码、退出信号、任务状态等。
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
pid_t getppid(void);
程序是如何被加载变成进程的?
首先,程序通过编译器转换为目标代码,并由链接器与库函数链接,形成可执行文件。此时程序内部已分配逻辑地址,但尚未映射至物理内存。当程序被执行时,操作系统将其从存储介质加载至内存,并进行逻辑地址到物理地址的映射,这一映射关系存储于页表中。
在C/C++中,地址并不是内存物理上的地址,而是操作系统提供的虚拟地址。操作系统不允许直接读取物理内存。虚拟地址在不同进程中是相同的,但物理地址是不同的。例如,当使用fork
函数创建子进程时,父进程的栈空间中的变量并不会直接复制到子进程中,而是通过写时拷贝的机制。这意味着,当父进程或子进程尝试修改这些变量时,才会发生真正的复制操作,从而保证了父子进程间的数据独立性。
如果想要执行你书中(A程序)第8页的一段代码,那就由一个人,我们叫他管理员(操作系统),管理员把这一页程序复制到这本速读书(物理地址空间)的某个空白页上,比如第1页,同时记录这个映射关系(A程序的第8页放在了这本书的第1页),如果B程序也要执行第8页中的一段代码(虚拟地址相同),也需要由那个人把这一页复制到这本速读书的某个空白页上,比如第二页,并将映射关系记录在映射表上。这种情况就是虚拟地址相同,但被放在了不同的物理地址上。
写时复制(Copy-On-Write, COW)
写时复制(Copy-on-Write, COW)是一种优化策略。其主要思想是,当多个调用者(callers)请求相同资源(如内存或磁盘上的数据存储)时,他们会共享相同的指针指向同一资源。只有当某个调用者尝试修改资源内容时,系统才会为该调用者创建一份专用副本(private copy)。这种策略对其他调用者是透明的,只有在修改资源时才会创建副本,因此在调用者仅进行读取操作时可以共享同一份资源。
写时复制机制的原理
进程具有独立性,为了保证进程间数据独立,进程间不相互干扰,会有数据的写时拷贝,得到一张新的页表。在上面的程序中,变量的物理地址实际就不同了,因此数据也是不同的。
写时拷贝技术实际上是运用了一个 “引用计数” 的概念来实现的。在开辟的空间中多维护四个字节来存储引用计数。
有两种方法:
- 多开辟四个字节(pCount)的空间,用来记录有多少个指针指向这片空间。
- 在开辟空间的头部预留四个字节的空间来记录有多少个指针指向这片空间。
当我们多开辟一份空间时,让引用计数+1,如果有释放空间,那就让计数-1,但是此时不是真正的释放,是假释放,等到引用计数变为 0 时,才会真正的释放空间。如果有修改或写的操作,那么也让原空间的引用计数-1,并且真正开辟新的空间。
linux 下的 fork() 就是用的写时拷贝技术,引用计数不光在 string 这里用到,还有智能指针 shared_ptr 也用到了引用计数来解决拷贝问题。
写时拷贝的场景
虚拟内存管理中的写时复制
- 在虚拟内存管理中,页面通常被标记为只读。当某个进程尝试向内存写入数据时,内存管理单元(MMU)会抛出一个异常。内核在处理该异常时,会为该进程分配一份新的物理内存并复制数据到该内存中,然后重新执行写操作。
数据存储中的写时复制
- Linux等操作系统的文件管理系统使用了写时复制策略。
- 数据库服务器也通常采用写时复制策略,为用户提供快照(snapshot)。
软件应用中的写时复制
- C++标准程序库中的
std::string
类,在C++98/C++03标准中允许写时复制策略,但在C++11标准中为了提高并行性取消了这一策略。GCC从版本5开始,std::string
不再采用COW策略。
fork与COW
fork
是Linux系统中用于创建新进程的系统调用。在传统的fork
实现中,子进程会完全复制父进程的地址空间,包括数据段、堆、栈和只读的代码段。这种复制操作可能会消耗大量的时间和内存资源。
为了优化这一过程,现代操作系统引入了写时复制(Copy-On-Write, COW)策略。在fork
之后,子进程和父进程共享数据段、堆和代码段,但内核会将这些共享区域的访问权限设置为只读。如果任何一个进程尝试修改这些共享区域,内核会为该进程创建该区域的一个私有副本,这个过程称为“写时复制”。这样,只有在实际需要修改数据时,才会发生内存复制,从而节省了内存空间和复制时间。
vfork与fork
vfork
是另一种用于创建新进程的系统调用,它与fork
的主要区别在于,vfork
不会复制父进程的地址空间,而是让子进程直接在父进程的地址空间中运行,直到子进程调用exec
或exit
。这意味着子进程在调用exec
或exit
之前,对父进程地址空间中的任何修改都会影响到父进程。
vfork
的设计初衷是为了在子进程立即调用exec
执行新程序的情况下,避免不必要的地址空间复制。由于子进程不会访问父进程的地址空间,因此vfork
可以更高效地创建新进程。
然而,vfork
的使用需要非常小心,因为子进程和父进程共享地址空间,这可能导致数据竞争和不确定的行为。因此,vfork
通常在子进程调用exec
或exit
后立即返回,以避免潜在的问题。
vfork与fork的对比
fork
通过写时复制技术优化了进程创建过程,允许父进程和子进程共享地址空间,直到需要修改时才进行复制。vfork
则是在子进程立即调用exec
或exit
的情况下,避免复制父进程的地址空间,直接在父进程的地址空间中运行子进程。
两者都是创建新进程的方法,但fork
更为通用,而vfork
则是在特定场景下的一种优化手段。由于vfork
可能带来的风险,现代操作系统中fork
通常已经足够高效,而vfork
的使用则越来越少。