当前位置: 首页 > news >正文

COW(Copy-on-Write):开抄开抄,哎嘿,我装的

目录

COW 的核心工作机制

1. 正常拷贝 vs COW 拷贝

2. 触发分离的时机

3. 关键技术支撑

C++ 精简版 COW 手动实现

1. 设计思路

2. 代码实现步骤

3. 代码演示

4. 多线程安全考量

实现二:使用 std::shared_ptr 封装

1. 设计思路

2. 代码演示

典型应用场景

1. 操作系统进程管理

2. 虚拟化与容器

3. COW 文件系统的代表

COW 的代价与适用边界

1. 性能代价

2. 实现复杂度

3. 何时不该用 COW

结尾


COW(Copy-on-Write, 写时复制)一种将昂贵的复制操作推迟到真正需要写入时才执行的优化策略。

它是延迟计算思想在内存和资源管理领域的一种特化应用。两者都遵循“不到万不得已,不做昂贵之事”的原则。

COW 的核心工作机制

1. 正常拷贝 vs COW 拷贝

  • 正常拷贝:立即分配新内存,复制全部数据(时间复杂度 O(N))。

就像我手里有一本《C++ 从入门到入土》,你说你想看,好,我二话不说立马跑到打印店花钱给你打印一本,就算你在这本书上画个小王八,那都跟我没任何关系。

代价:花钱(内存)、花时间(CPU),就为了个万一我们用不着的改动。

  • COW 拷贝:仅复制指针和增加引用计数(时间复杂度 O(1)),内存中只有一份数据。

过会儿我手里又有一本《C++ 从入土到刨坟》,你又想看,这次我开窍了,用手机直接给你发一个共享链接,我们一起看一份 PDF。

这时候引用计数= 2,内存还是那一份。你翻页、查看内容(只读操作),我俩相安无事,速度快得飞起。

精髓:只要你不改,我们就是异父异母的亲兄弟。

2. 触发分离的时机

这是 COW 机制最坑爹的地方,分水岭就在于 const。

触发分离的信号:

当我们调用了一个非 const 成员函数时,比如 operator[](用作左值修改)、iterator begin() 且解引用修改,或者 .push_back()。

底层逻辑的内心独白是:

“哎呦喂,这小子想画个小王八?大事不好啦!这 PDF 是大家公用的,不能让他瞎涂。”

这时候就需要深度拷贝:

  1. 看计数器:if (ref_count > 1),这就有人共享

  2. 分配新内存: new char[capacity]。

  3. 复制原数据:memcpy 把老数据原封不动复制一份。

  4. 断舍离:把当前对象的指针指向新内存,老共享区的 ref_count--,新内存的 ref_count = 1。

  5. 在新副本上修改:你改你的,别人看别人的,世界和平。

3. 关键技术支撑

要想 COW 玩的转,就得保证这些玩意正常:

原子引用计数

  • 干嘛的:记录现在有几个对象在共享这块数据。

  • 必须是线程安全的原子操作,否则多线程下计数器崩了,就是内存泄漏或双重释放的主场了。

数据的私有封装

  • 结构长这样:[ RefCount (4字节) | Data (N字节) ]

  • 或者更骚的操作是侵入式(把计数塞进数据头里),亦或者非侵入式(std::shared_ptr 那种独立控制块)。

延迟分配策略

  • 绝不主动揽活,能拖就拖。

  • 如果我们只是预留空间就不必分离,只有真的往里 memset 写入时,发现这是共享的才进行分离。

C++ 精简版 COW 手动实现

理论完了,就得来点实操。

1. 设计思路

  • 引用计数:记录现在有几个人在用。

  • 数据:真正的字符数组。

  • 关键逻辑:谁想修改数据,先看看锁上显示是不是只有一个,如果不是,赶紧复制一份出来,不动公用的。

一个简单的数据结构示意图:

2. 代码实现步骤

  1. 定义内部存储结构Rep:包含引用计数和数组。

  2. 构造函数:分配内存时会多申请 4 字节放计数,指针指向数据区。

  3. 拷贝构造:不加锁,只加计数。把指针指过去,ref_count++。

  4. 写时复制分离函数detach():任何非 const 操作进来先喊它一声。

  5. 析构函数:ref_count--,如果归零了,与 Rep 一起回收。

3. 代码演示

class CowString { private: // 这就是共享的控制块 + 数据块,粘在一起分配 struct Rep { std::atomic<int> ref_count; // 原子计数 char data[1]; // 占位用的,虽然未初始化,但在这里问题不大 static Rep* create(const char* str) { size_t len = strlen(str); // 分配内存:Rep 结构体大小 + 字符串长度 void* raw = operator new(sizeof(Rep) + len); Rep* rep = new(raw) Rep(); rep->ref_count = 1; // 初始为一 memcpy(rep->data, str, len + 1); return rep; } void destroy() { this->~Rep(); operator delete(this); } }; Rep* rep; // 指向共享数据区 public: // 构造函数 CowString(const char* str = "") : rep(Rep::create(str)) {} // 拷贝构造 CowString(const CowString& other) : rep(other.rep) { ++rep->ref_count; // 原子加一 } // 赋值操作 CowString& operator=(const CowString& other) { if (this != &other) { // 减引用 if (--rep->ref_count == 0) { rep->destroy(); } rep = other.rep; ++rep->ref_count; } return *this; } // 析构 ~CowString() { if (--rep->ref_count == 0) { rep->destroy(); } } // 写时复制分离函数 void detach() { if (rep->ref_count.load() == 1) { return; // 就一个,随便玩 } // 共享中,分配个新的 Rep* new_rep = Rep::create(rep->data); // 不要忘了还回去 if (--rep->ref_count == 0) { rep->destroy(); } rep = new_rep; } // 只读接口(const),绝不触发分离 const char* c_str() const { return rep->data; } size_t size() const { return strlen(rep->data); } char operator[](size_t pos) const { return rep->data[pos]; } // 只读 // 可写接口 char& operator[](size_t pos) { detach(); // 有人要修改,进行分离 return rep->data[pos]; } // 追加字符串也会改数据 void append(const char* suffix) { detach(); // 这里假设空间足够 strcat(rep->data, suffix); } };

使用示例:

// 测试 int main() { CowString s1("Hello"); CowString s2 = s1; // 共享中,没发生拷贝 std::cout << "s1: " << s1.c_str() << " s2: " << s2.c_str() << std::endl; s2[0] = 'J'; // 写操作,触发分离 std::cout << "After COW: s1 = " << s1.c_str() << ", s2 = " << s2.c_str() << std::endl; return 0; }

4. 多线程安全考量

在单线程中,上面代码没有什么大问题。但在多线程里,那就是哪哪都是问题。

主要有俩大坑:

  • 坑一:引用计数的竞争

如果不用 std::atomic<int>,两个线程同时拷贝同一个对象,都执行 ++ref_count,可能结果变成只加了 1 次。

后果就是内存泄漏(永远减不到 0)或提前释放容易引发悬垂指针,所以在上面的代码我们直接使用了 std::atomic<int>。

  • 坑二:那个经典的 Detach 竞态窗口

看这段危险代码:

// 线程 A 可能刚知道 ref_count > 1 然后准备新建,线程 B 此时析构了原对象导致 ref_count 变 0 void detach() { if (rep->ref_count.load() == 1) return; // 这里只是读,没锁 // 如果在这里发生线程切换,B 把 ref_count 减到 1 或者 0,那完蛋了 Rep* new_rep = Rep::create(rep->data); // ... }

这里我们要么整个互斥锁,简单粗暴,要么牺牲点头发上无锁编程。

这也是为什么现代 std::string 弃用 COW 转投 SSO,就是因为在多核时代,锁的开销已经大于直接复制 15 个字节的开销了。

实现二:使用 std::shared_ptr 封装

std::shared_ptr 自带线程安全的原子引用计数,还附赠自定义删除器功能。我们只需要动点歪脑筋,让它管理一个 std::string 的指针,再在修改前 fork 一下,完美。

1. 设计思路

  • 底层数据就是一个普通的 std::string,我们不直接碰它。

  • 用 shared_ptr<string> 把它包起来,拷贝构造就是指针赋值,引用计数自动 +1。

  • 任何想修改的动作,先判断 use_count() > 1,如果是,就深拷贝一份新的 string,再替换自己的 shared_ptr。

  • 删除器?不需要,shared_ptr 默认 delete 很完美。

我们利用了 shared_ptr 对控制块的原子管理,直接规避了手写 RefCount 时多线程下的竞态噩梦。

现在我们只负责写业务,标准库负责给我们兜底。

2. 代码演示

class CowStringShared { private: // 用一个 shared_ptr 管着一个普通的 string std::shared_ptr<std::string> data_ptr; // 分离函数 void detach() { // 如果指针非空,且不止一个人在用,就复制一份出来 if (data_ptr && data_ptr.use_count() > 1) { data_ptr = std::make_shared<std::string>(*data_ptr); } else if (!data_ptr) { // 如果是空壳,也造一个空字符串,方便后续操作 data_ptr = std::make_shared<std::string>(); } } public: // 构造函数 CowStringShared(const char* str = "") : data_ptr(std::make_shared<std::string>(str)) {} // 拷贝构造:shared_ptr 的拷贝构造会让引用计数 +1,完美 CowStringShared(const CowStringShared&) = default; // 赋值操作:默认的 shared_ptr 赋值会正确处理旧资源释放和新资源引用 CowStringShared& operator=(const CowStringShared&) = default; // 析构:shared_ptr 自动搞定,我们啥也不用管,真好 ~CowStringShared() = default; // 只读接口 const char* c_str() const { return data_ptr ? data_ptr->c_str() : ""; } size_t size() const { return data_ptr ? data_ptr->size() : 0; } // 只读访问字符 char operator[](size_t pos) const { return (*data_ptr)[pos]; } // 可写接口 char& operator[](size_t pos) { detach(); return (*data_ptr)[pos]; } void append(const std::string& suffix) { detach(); *data_ptr += suffix; } // 为了方便输出,加个友元 friend std::ostream& operator<<(std::ostream& os, const CowStringShared& s) { os << (s.data_ptr ? *s.data_ptr : ""); return os; } int get_usecount() { return data_ptr.use_count(); } };

使用示例:

// 测试 int main() { CowStringShared s1("Hello"); CowStringShared s2 = s1; // 引用计数现在是 2,内存共享 std::cout << "s1: " << s1 << ", s2: " << s2 << std::endl; std::cout << "引用计数: " << s1.get_usecount() << std::endl; // 输出 2 s2[0] = 'J'; // 写操作,触发分离 std::cout << "修改后 s1: " << s1 << ", s2: " << s2 << std::endl; std::cout << "s1 引用计数: " << s1.get_usecount() << ", s2 引用计数: " << s2.get_usecount() << std::endl; // s1 引用计数变为 1,s2 引用计数也是 1,各自独立 return 0; }

为了直观,我们把两种实现进行对比:

维度手搓版本shared_ptr 封装版
代码量需要管理内存分配/释放、原子计数几乎没有内存管理代码
多线程安全需要非常小心地设计原子操作shared_ptr 的引用计数是原子操作,基础保障有了
灵活性可以精确控制内存布局(如 Rep 与数据连续)数据是独立的 std::string,多一次间接访问

用 shared_ptr 造 COW,虽然思想是旧的,但接口是新的,好用就完事了( ◜◡‾)。

典型应用场景

来整点好玩的,看看 COW 是怎么在某些地方撑起半边天(撑地嘿嘿)。

1. 操作系统进程管理

这是 COW 最经典的地方,没有之一。

假如我们在 Linux 终端里敲下一个命令,shell 调用 fork() 创建子进程。按照最朴素的逻辑,操作系统应该把父进程的内存完完整整复制一份给子进程——几十 MB 甚至几 GB,想想就肉疼。更要命的是,大多数程序 fork() 之后立刻 exec() 加载新程序,刚才复制的内存全被丢弃。

这时候 COW 就要站出来了:

  • fork() 时,内核只复制页表,让父子进程的虚拟地址指向同一块物理内存。

  • 同时把父子两边的页表项都标记为只读。

  • 只要两边都只是读数据,大家共用同一份物理页,相安无事。

  • 一旦某个进程试图写入,CPU 触发缺页异常,内核捕获后:分配新的物理页 → 复制原内容 → 修改页表映射 → 标记可写。

一张父子进程共享物理页的简图:

2. 虚拟化与容器

如果说 fork 是 COW 在内存里的首秀,那 Docker 就是把 COW 搬到了磁盘上。

问题:假设一个 Ubuntu 基础镜像 200MB,我们基于它跑 10 个容器,如果每个容器都复制一份完整的文件系统,那就是 2GB。更别提镜像本身还有多层(基础层 → 依赖层 → 应用层),每层都要存。

COW 解法

Docker 镜像由多层只读层叠加而成,容器启动时在最上面加一个可写层(容器层),所有容器共享底下的只读层。

  • 读文件:从上往下找,找到就直接用。

  • 写文件:如果文件来自下面的只读层,先复制到可写层,再修改。这个操作叫 copy_up。

3. COW 文件系统的代表

传统文件系统修改文件是原地覆盖:新数据直接写回原来的磁盘位置。万一写一半断电咋办?文件可能变成一坨不可描述的东西。

COW 文件系统换个思路:永远不覆盖原数据。每次写操作,先把数据写到新的磁盘块,然后原子性地更新指针指向新块。

两大代表:

  • ZFS(Zettabyte File System):它把文件系统和卷管理整合在一起,靠 COW 实现了快照、克隆、端到端校验和、自修复等一堆高级功能。创建快照几乎瞬间完成,因为只需复制根节点指针。

  • Btrfs(B-tree File System):同样基于 COW,支持子卷、快照、压缩、RAID 等。快照是子卷级别的轻量克隆,创建快照等于新建一个和原子卷共享数据的子卷。

你看,这些场景它们都有共同点:

  1. 共享优先:能不复制就不复制,大家先共用同一份资源。

  2. 只读万岁:只要只读,万事大吉。

  3. 写时分离:一旦有人要改,立即复制一份私有的,绝不污染公共资源。

  4. 引用计数:记录有多少人还在用,等没人用了再真正释放。

COW 的代价与适用边界

前面的内容凸显了 COW 那么多的优点,你一看,“哎呀,COW 真好用,以后所有的项目我都要用 COW”。

现在是时候泼盆冷水了,这玩意儿要是没缺点,C++11 也不会狠心把 std::string 的 COW 给“优化”掉。

1. 性能代价

COW 最大的陷阱在于:把复制的成本从拷贝时转移到了第一次写入时。

表面上看:拷贝构造函数快得像闪电,O(1) 时间,内存纹丝不动。

实际上:我们欠了一笔技术债,出来混迟早要还(´~`)。

首次写入的情况:

  • 触发分离判断:if (ref_count > 1)(原子读,还行)

  • 分配新内存:new char[size](可能触发系统调用,慢)

  • 完整深拷贝:memcpy(把我们以为省掉的复制工作原样补上)

  • 更新引用计数:原子减操作 + 可能的旧内存释放

假设我们要把一个大字符串传给 1000 个线程,每个线程都只读,使用 COW 完美,内存只用一份。但如果其中 999 个线程都只是读,只有一个线程手贱,在某个犄角旮旯写了一个字符。

结果是什么?

那一个线程的写入延迟 = 一次完整的深拷贝 + 内存分配 + 可能的锁竞争。
原本可以在拷贝时平摊的成本,现在被集中到了那个倒霉线程的写操作上。

使用体验:99% 的操作飞快,突然一个操作卡成 PPT,哇,爆率真的很高哎,爆出了传说中的延迟毛刺

更糟糕的是,如果有多个线程同时触发写入(比如它们各自想追加日志),每个线程都会争先恐后地执行 detach(),结果就是:

  • 内存分配器被瞬间打爆

  • 引用计数的原子操作在多个 CPU 核心间乒乓缓存

  • 原本一份数据被复制成 N 份,内存占用反而爆炸

因此 COW 在实时系统或低延迟场景下,这种不确定性是致命的。

2. 实现复杂度

手搓 COW 写对了是艺术,写错了是事故。

在多线程环境下,简单的 ++ref_count 用默认的 memory_order_seq_cst 没问题,但如果我们想继续优化性能使用更宽松的内存序,哪天没操作好容易完蛋。

并且在 C++11 引入移动语义后,很多场景下浅拷贝 + 转移所有权比 COW 更高效。因为移动操作直接窃取资源,连引用计数的开销都省了。

COW 的共享机制反而阻碍了移动,因为移动要求源对象立刻失效,而 COW 的共享数据不能轻易被窃取。

3. 何时不该用 COW

一、高频小对象

对于只有几十字节的小字符串,COW 是纯纯的负优化。

  • 引用计数的 4~8 字节开销可能比数据本身还大。

  • 原子操作的 CPU 开销比直接 memcpy 几个字节还高。

  • 这正是 C++11 std::string 转向 SSO 的根本原因,短字符串直接塞进对象内部,连堆分配都省了。

二、多线程高并发写入

如果我们的程序是典型的生产者-消费者模型,多个线程频繁修改共享数据,COW 会变成灾难:

  • 分离次数激增,内存分配压力山大。

  • 引用计数的原子操作在多核间砰砰砰,导致缓存失效。

  • 每个线程最终都会拥有一份独立副本,内存占用不降反升。

说了些不该用的地方,那 COW 到底适合什么场景?

  1. 大对象:数据体积远大于引用计数开销。

  2. 读多写少:共享远多于修改,分离是稀有事件。

  3. 拷贝频繁:对象经常被按值传递、返回、存入容器。

结尾

COW 的人生信条就是:能蹭就蹭,蹭不了再买。

我们请他喝果茶,他不要,说“没必要,你先喝,喝不完剩下的给我就行”。

我们要改个文案,他不动,说“先这么用着吧,真要改我再复制一份”。

哎,COW 就是这么的无所谓,但是到了多线程薅他羊毛的时候,他就会急眼:“你改我也改?那我到底听谁的?得,老子不伺候了,你们一人一份自己玩去!”

这就是为啥后来 string 不和他玩了,受不了他这磨磨唧唧的性格,转头找了短小精悍的 SSO。

http://www.jsqmd.com/news/723631/

相关文章:

  • Golang goroutine泄漏怎么排查_Golang协程泄漏排查教程【实战】
  • 认证榜单:2026年AI搜索行业GEO优化公司推荐与选型指南
  • 工控人出差必带的 10 样东西,少一样都麻烦
  • 告别传统天线:用紧耦合阵列(TCA)实现超宽带通信的保姆级原理拆解
  • FPGA并行计算与硬件加速实战解析
  • SAM2S:手术视频语义长期跟踪分割技术解析
  • 【2024边缘AI落地关键突破】:.NET 9原生支持TinyML推理+轻量服务网格,仅需128MB RAM即可部署
  • CMOS Ising机器在文本摘要中的高效优化应用
  • 【GraphWorX32】忘记最高权限密码解决方法(9.20)
  • MemOS:内存优先计算范式解析与应用实践
  • 别再到处找PDK了!手把手教你用ADS自带的DemoKit设计10GHz切比雪夫滤波器(附完整工程)
  • Spring Cloud 2027 云原生支持:构建现代化云应用
  • 自动化工作流:全平台社交媒体评论区数据采集与关键词筛选系统
  • 蓝桥杯单片机省赛避坑指南:从DS18B20到IIC,手把手拆解2021年真题的编程逻辑
  • 如何快速掌握w64devkit:Windows平台便携式C/C++开发套件终极指南
  • 南充婚姻家事法律服务现状及专业机构解析:南充保险理赔律师事务所,南充公司法务律师事务所,优选推荐! - 优质品牌商家
  • 查看单元测试用例覆盖率新姿势:IDEA 集成 JaCoCo
  • 从‘跑字典’到‘跑掩码’:John the Ripper 增量与掩码模式详解,搞定那些有规律的‘强密码’
  • 从Overleaf回迁本地:TexStudio搭配TexLive 2024的深度配置与效率提升指南
  • 2026年4月中央空调回收口碑推荐榜单 - 优质品牌商家
  • Scratch游戏物理引擎入门:用“描边法”和“二次检测”搞定坦克碰撞与反弹
  • SCALE技术:视觉-语言-动作模型的自适应优化方案
  • Android蓝牙开发踩坑记:用GATT连接经典蓝牙(EDR)的正确姿势,别再传那个参数了!
  • AutoAgents:多智能体协作如何重塑AI驱动的软件开发流程
  • Koodo Reader 2.3.2:跨平台电子书管理系统的架构解析与实战应用
  • GEO管理系统有哪些功能?一篇讲透企业必用核心能力
  • 代码—开发平台
  • Nature | Anthropic:蒸的不止数据,还有 “灵魂”
  • “Burst编译通过≠真正加速”:深度解析DOTS 2.0中[CompileAsManaged]误用、float4x4矩阵未向量化、JobHandle依赖环导致的性能归零现象
  • 2026年3月盐酸生产厂家口碑推荐,液碱/精制盐酸/次氯酸纳/食品级盐酸/工业合成盐酸,盐酸源头厂家哪家好 - 品牌推荐师