返回首页

Rust的Cow

Rust的Cow

在刷B站时偶然间刷到了Rust中的Cow的详解,观看后发现其能提高Web中部分场景的性能,在本网站尝试以后遂来和大家分享

Cow是什么?

Cow是Clone-on-Write(写时克隆)的缩写,是Rust标准库中一个非常精巧的智能指针,它的主要作用是在需要所有权移动或者修改时,才进行数据的克隆,读取情况下为借用,这样在保持灵活性的同时,避免不必要的内存分配和复制的开销。思想总结为读取时借用,写入时克隆。

底层结构

Cow 被定义为一个枚举(enum),它有两种状态:

pub enum Cow<'a, B> where B: 'a + ToOwned + ?Sized {
    Borrowed(&'a B), // 借用状态
    Owned(<B as ToOwned>::Owned), // 拥有状态
}
  • Borrowed(&'a B):此状态包含一个对类型 B 的不可变引用。它只是“借用”数据,不产生任何复制开销。
  • Owned(<B as ToOwned>::Owned):此状态包含数据的一份-owned(拥有所有权的)拷贝。

Cow 能够在这两种状态间切换,其关键依赖于 ToOwned trait。这个 trait 定义了如何从一个借用类型(如 &str)创建一个拥有所有权的类型(如 String)。

如何触发“克隆”

Cow 的行为通过两个关键方法来控制:

  1. to_mut(&mut self):这是触发“写时克隆”的核心方法。
    • 如果 Cow 当前是 Owned 状态,它直接返回内部数据的可变引用,无任何复制。
    • 如果 Cow 当前是 Borrowed 状态,它会调用 to_owned() 克隆数据,转变为 Owned 状态,然后返回新数据的可变引用。
  2. into_owned(self):此方法消费掉 Cow,并无论如何都返回一个-owned数据。
    • 如果是 Borrowed 状态,它会克隆数据。
    • 如果是 Owned 状态,它则直接移动(move)出数据,无额外克隆。

项目上的实际应用

以本网站为例子,主要改动的地方在RSS功能模块,RSS需要将部分特殊的符号进行转义,将文本能安全地嵌进 XML 而不被当成标记解析,此时需要对全串进行扫描,如果一处都不用替换,则可以返回原串不需要修改,否则需要修改数据,此时就需要克隆获得所有权。这个分支场景完美符合Cow。

接下来展示项目的优化路线

原代码:

/// 转义 XML 特殊字符。
fn escape_xml(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&apos;")
}

在这种写法下,str::replace 每次都返回一张全新的 String,链式调用意味着

  • 每调用一次 escape_xml,最多做 5 趟全串扫描、5 次堆分配(每层 replace 产出一张中间串,立刻被下一层丢弃);
  • 最亏的是干净字符串:明明一个字符都不用换,也要原样拷贝 5 遍、分配 5 张相同的串。

优化后的代码:

fn escape_xml(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&apos;"),
            _ => out.push(c),
        }
    }
    out
}

此时只需要1趟全串扫描,但必须进行1次堆分配,这对于干净字符串来说是没有必要的

使用Cow的代码:

fn escape_xml(s: &str) -> Cow<'_, str> {
    // None = 全串干净;Some(i) = 第一个特殊字符的字节位置
    match s.find(['&', '<', '>', '"', '\'']) {
        None => Cow::Borrowed(s),
        Some(i) => {
            let mut out = String::with_capacity(s.len());
            out.push_str(&s[..i]);      // 干净前缀整块 memcpy 过去
            for c in s[i..].chars() {   // 只从第一个特殊字符起逐字符转义
                match c {
                    '&' => out.push_str("&amp;"),
                    '<' => out.push_str("&lt;"),
                    '>' => out.push_str("&gt;"),
                    '"' => out.push_str("&quot;"),
                    '\'' => out.push_str("&apos;"),
                    _ => out.push(c),
                }
            }
            Cow::Owned(out)
        }
    }
}

此时也只需要全串扫描1趟,但是对于干净字符串不用进行额外的堆分配

对比

与Rust普通借用对比

  1. 内存占用

    • 普通借用 &str:在 64 位系统上占 16 字节(指针 8 字节 + 长度 8 字节)。
    • Cow<'_, str>:因为它是一个枚举,除了要存储指针和长度,还需要一个 1 字节的标记位(区分是 Borrowed 还是 Owned),并且由于内存对齐,它通常占 32 字节。

    影响:Cow 在栈上占用的空间是普通引用的 2 倍。如果在内存极度敏感的场景(如大量存储结构体中)或高频传输时,这会增加 CPU 缓存压力。

  2. 只读访问

当你只是读取 Cow 中的数据时(例如调用 .len()、.chars() 或直接 println!),底层会发生一次枚举状态匹配(match):

// 源码简化逻辑
match self {
    Cow::Borrowed(s) => s.len(),
    Cow::Owned(s) => s.len(),
}
  • 普通借用 &str:直接解引用指针,零额外指令。
  • Cow:每次读取都会执行一次分支判断(Branch)。

影响:现代 CPU 的分支预测器非常智能,如果代码路径长期处于 Borrowed 状态,这个开销几乎会被硬件“忽略”,单次操作在 纳秒级。但在**极高频循环(每秒百万次以上)**中,这个分支判断和更大的内存占用确实会导致性能比纯引用低几个百分点。

  1. 修改数据(最关键的区别)

    这里要分清楚比较对象:

    • 如果你手上已有 &mut T(可变借用):直接修改,零成本,Cow 比不过它。但问题是你必须拥有可变借用的权限。
    • 如果你手上只有 &T,但需要修改(最常见的场景):常规做法是 T::clone(&T)(防御性克隆),这会立即在堆上分配内存并复制全部数据(O(n) 耗时)。而 Cow 只有在第一次调用 .to_mut() 时才发生克隆。

    比较:假设一个 1MB 的字符串,在 100 次调用中,只有最后 1 次需要修改。

    • 常规做法:上来就克隆 1MB,总共复制了 100MB 数据。
    • 使用 Cow:前 99 次只是借用(零拷贝),最后 1 次克隆 1MB,总共只复制了 1MB 数据。

    在这种情况下,Cow 的枚举匹配开销(纳秒级)相比于内存拷贝开销(微秒/毫秒级)完全可以忽略不计,性能是碾压级的。

与Go的对比

Go中实现COW最典型的模式是组合使用 atomic.Value 和不可变的数据结构。

核心思想:

  1. 读操作:通过 atomic.Value 的 Load() 方法获取数据指针,整个过程无锁,性能极高。
  2. 写操作:完整复制一份当前数据,在副本上进行修改,最后通过 atomic.Value 的 Store() 方法原子地将指针指向新副本。

一个简单的示例:

import (
    "sync/atomic"
)

// CowMap 是一个写时复制的 map
type CowMap struct {
    data atomic.Value // 存储 map[string]string
}

// NewCowMap 创建一个新的 CowMap
func NewCowMap() *CowMap {
    cm := &CowMap{}
    cm.data.Store(make(map[string]string))
    return cm
}

// Get 读取操作,无锁
func (cm *CowMap) Get(key string) (string, bool) {
    m := cm.data.Load().(map[string]string)
    val, ok := m[key]
    return val, ok
}

// Set 写入操作,需要完整复制
func (cm *CowMap) Set(key, value string) {
    for {
        oldMap := cm.data.Load().(map[string]string)
        newMap := make(map[string]string)
        for k, v := range oldMap {
            newMap[k] = v
        }
        newMap[key] = value
        // CAS 操作:只有当前data指针仍指向oldMap时才更新,否则重试
        if cm.data.CompareAndSwap(oldMap, newMap) {
            return
        }
    }
}

其主要目的是在多线程并发读多写少的的情况下降低开销。Rust的Cow是无锁的,主要用于单线程

总结

Cow将“借用或拥有”这个模式与 Rust 独特的所有权系统结合得如此优雅且安全,这种体验在主流编程语言中确实罕见。

Rust能优雅实现COW,根本原因在于它拥有所有权(Ownership)+ 生命周期(Lifetime)+ 枚举(Enum) 三位一体的类型系统:

  1. 生命周期让它能安全地“借用”而不怕悬垂;
  2. 所有权让它能确定性地“拥有”并释放;
  3. 枚举让状态切换成为编译期可判定的逻辑。

这三者结合,使得Rust的Cow既不像C++那样需要程序员手动维护引用计数,也不像Go/Java那样依赖GC和全量拷贝。它把COW从“一种算法技巧”提升到了“一种语言原生模式”。

评论0

还没有评论,说点什么吧。