乐于分享
好东西不私藏

Rust 字符串驻留神器:Ustr 库深度解析

Rust 字符串驻留神器:Ustr 库深度解析

Rust 字符串驻留神器:Ustr 库深度解析


引言

在很多应用场景中,我们会反复使用相同的字符串作为标识符——比如 高频交易系统中的symbol、游戏引擎中的资源路径、编译器中的变量名、配置系统中的键名等等。这些字符串被创建了成千上万次,但本质上它们的内容是重复的。每次都分配新内存、每次比较都逐字节对比,这显然是一种浪费。

有没有一种方式,让相同内容的字符串在内存中只存一份,比较的时候只需要比指针就行?

答案就是 字符串驻留(String Interning),而 ustr 正是 Rust 生态中一个极其高效的字符串驻留库。


什么是 ustr?

ustrUnique Str)是一个轻量级的句柄,代表全局字符串缓存中的一个静态、不可变条目。它的设计灵感来源于 OpenImageIO(OIIO)项目中的 ustring,但做了 Rust 风格的适配。

它的核心优势可以用四个字概括:快、省、稳、通

:字符串赋值和相等比较就是指针比较,O(1) 时间复杂度。哈希值是预计算好的,不需要每次重新算。

:相同内容的字符串在内存中只存储一份,无论你创建了多少个 Ustr 句柄。

:底层字符串永远不会被释放,Ustr 可以安全地跨线程传递,生命周期等同于 'static

:字符串末尾自带空字符终止符,可以直接传给 C 的 FFI 接口,省去了 CString 的转换开销。

当然,天下没有免费的午餐——所有被驻留的字符串永远不会被释放。但正如作者幽默地说的:*”《战争与和平》全文也就 3MB,所以大概率没问题。”*


快速上手

在 Cargo.toml 中添加依赖:

[dependencies]
ustr = { version = "1.1.0", features = ["serde"] }

基本用法

use ustr::{Ustr, ustr};

fnmain() {
// 两种创建方式:Ustr::from 和 ustr 函数
let u1 = Ustr::from("the quick brown fox");
let u2 = ustr("the quick brown fox");

// 相同内容只存一份,比较极其高效(指针比较)
assert_eq!(u1, u2);

// 拷贝只是复制一个指针,零开销
let u3 = u1;
assert_eq!(u2, u3);

// 可以方便地转为 &str 使用
let words: Vec<&str> = u1.as_str().split_whitespace().collect();
assert_eq!(words, ["the""quick""brown""fox"]);

// 查看缓存中有多少个唯一字符串
println!("缓存中的字符串数量: {}", ustr::num_entries());
}

直接传递给 C FFI

use ustr::ustr;

let u_fox = ustr("the quick brown fox");
// as_char_ptr() 返回带空终止符的 C 字符串指针
let len = unsafe { libc::strlen(u_fox.as_char_ptr()) };
assert_eq!(len, 19);

这也是 ustr 相比其他字符串驻留库的一个独特优势——无需额外转换即可与 C 代码交互。

高性能 HashMap

ustr 的哈希值是预计算好的,为了充分利用这一点,库提供了 UstrMap 和 UstrSet,它们内部使用了可以直接接受预计算哈希的 Hasher:

use ustr::{ustr, UstrMap};

letmut map: UstrMap<usize> = UstrMap::default();
let key = ustr("player_health");
map.insert(key, 100);

// 查找时也是 O(1),因为哈希不需要重新计算
assert_eq!(*map.get(&key).unwrap(), 100);

Serde 序列化支持

开启 serde feature 后,单个 Ustr 和整个缓存都可以序列化:

use ustr::{Ustr, ustr};

// 单个 Ustr 的序列化/反序列化
let u_ser = ustr("serde");
let json = serde_json::to_string(&u_ser).unwrap();
let u_de: Ustr = serde_json::from_str(&json).unwrap();
assert_eq!(u_ser, u_de);

整个缓存的序列化与恢复:

use ustr::{ustr, Ustr};

ustr("Send me to JSON and back");
let json = serde_json::to_string(ustr::cache()).unwrap();

// 反序列化会把字符串重新加载到全局缓存中
let _: ustr::DeserializedCache = serde_json::from_str(&json).unwrap();
assert_eq!(ustr::num_entries(), 1);

查询已有字符串

有时候你只想查询某个字符串是否已经被驻留过,而不想在不存在时创建它:

use ustr::{ustr, existing_ustr};

// 还没驻留过,返回 None
assert_eq!(existing_ustr("hello world!"), None);

let s1 = ustr("hello world!");

// 现在能找到了
let s2 = existing_ustr("hello world!");
assert_eq!(Some(s1), s2);

深入原理

理解了用法之后,我们来看看 ustr 为什么能这么快。

核心数据结构:一个指针打天下

Ustr 的定义极其简洁:

#[repr(transparent)]
pubstructUstr {
    char_ptr: NonNull<u8>,
}

整个 Ustr 就是一个指针,大小等同于 *const u8(8 字节,64 位系统)。#[repr(transparent)] 保证了它的内存布局与裸指针完全一致,这对 FFI 友好度至关重要。

这个指针指向的不仅仅是字符串内容。在字符串数据的前面,还存储了一个 StringCacheEntry,其中包含了字符串的长度和预计算的哈希值:

内存布局:
┌─────────────────────────┬──────────────────────┬──────┐
│   StringCacheEntry      │   字符串字节数据       │ \0   │
│  (hash + len)           │                      │      │
└─────────────────────────┴──────────────────────┴──────┘
                          ↑
                     char_ptr 指向这里

获取长度和哈希值只需要向前偏移一个结构体的大小:

fnas_string_cache_entry(&self) -> &StringCacheEntry {
unsafe { &*(self.char_ptr.as_ptr().cast::<StringCacheEntry>().sub(1)) }
}

pubfnlen(&self) -> usize {
self.as_string_cache_entry().len
}

pubfnprecomputed_hash(&self) -> u64 {
self.as_string_cache_entry().hash
}

这种设计非常巧妙:char_ptr 直接指向字符串内容,可以零开销转为 &str 或 C 字符串;而元数据就藏在指针前面,一次指针偏移即可拿到。

分桶并发缓存

全局字符串缓存使用了分桶(Sharding)策略来降低锁竞争:

pubstructBins(pub(crate) [Mutex<StringCache>; NUM_BINS]);

缓存被分成了 NUM_BINS 个桶,每个桶有独立的互斥锁。插入或查找时,先对字符串计算哈希,再根据哈希的高位选择桶:

fnwhichbin(hash: u64) -> usize {
    ((hash >> TOP_SHIFT asu64) % NUM_BINS asu64asusize
}

这意味着不同字符串大概率落入不同的桶,多个线程同时操作缓存时不会互相阻塞。锁的实现选用了 parking_lot::Mutex,相比标准库的 Mutex 更轻量、更快。

字符串创建的完整流程

来看 Ustr::from 的实现:

pubfnfrom(string: &str) -> Ustr {
// 1. 用 ahash 计算哈希值
let hash = {
letmut hasher = ahash::AHasher::default();
        hasher.write(string.as_bytes());
        hasher.finish()
    };
// 2. 根据哈希选择桶,加锁
letmut sc = STRING_CACHE.0[whichbin(hash)].lock();
// 3. 在桶内插入(如果已存在则直接返回已有指针)
    Ustr {
        char_ptr: unsafe {
            NonNull::new_unchecked(sc.insert(string, hash) as *mut _)
        },
    }
}

整个过程分三步:计算哈希、选桶加锁、插入或查找。如果字符串已经在缓存中,insert 方法会直接返回已有的指针,不会分配新内存。

为什么相等比较是指针比较?

因为 PartialEq 的实现直接比较的是内部的指针:

#[derive(Copy, Clone, PartialEq)]
#[repr(transparent)]
pubstructUstr {
    char_ptr: NonNull<u8>,
}

由于 derive(PartialEq),两个 Ustr 相等当且仅当它们的 char_ptr 相等。而字符串驻留机制保证了相同内容的字符串只有一个地址,因此指针相等就等价于内容相等。这是一个 O(1) 操作,不受字符串长度影响。

为什么哈希这么快?

Hash 的实现直接使用了预计算的哈希值:

impl Hash for Ustr {
fnhash<H: Hasher>(&self, state: &mut H) {
self.precomputed_hash().hash(state);
    }
}

哈希值在字符串第一次被驻留时就已经计算好并存储在 StringCacheEntry 中,之后每次取哈希都只是读一个 u64,完全不需要遍历字符串内容。这就是为什么用 UstrMap 做查找能如此高效。

Bump Allocator:只分配不释放

ustr 内部使用了 Bump Allocator(碰撞分配器)来管理字符串的存储。这种分配器的特点是分配极快(只需要移动一个指针),但不支持单独释放某个分配——这恰恰契合了字符串驻留”只增不减”的语义。

当一个 Bump Allocator 的空间用完时,会分配一块新的更大的内存,旧的块被保存在 old_allocs 列表中。这保证了所有已驻留字符串的指针永远有效。

线程安全

Ustr 实现了 Send 和 Sync

unsafeimplSendfor Ustr {}
unsafeimplSyncfor Ustr {}

这是安全的,因为 Ustr 指向的字符串是不可变的,且永远不会被释放。你可以放心地在线程间传递 Ustr,不需要任何同步原语。


适用场景与限制

非常适合的场景

ustr 特别适合那些”少量唯一字符串,大量重复引用”的场景:交易系统中的币种、游戏引擎中的资源标识符、编译器/解释器中的符号表、配置系统中的键名、日志系统中的标签字段等等。在这些场景中,字符串的创建远少于比较和复制,ustr 能够把赋值降为指针拷贝,把比较降为指针比较,把哈希降为读取一个 u64

不太适合的场景

如果你的程序会持续产生海量不重复的短命字符串,ustr 就不那么合适了,因为这些字符串会永久占据内存。同样,如果你的主要操作是字符串拼接、切片等”加工”操作而非比较和查找,那么标准的 String 可能更合适。


性能直觉

为了帮助你建立直觉,这里做一个简单的对比总结。

标准 String 的相等比较需要 O(n) 时间逐字节对比,Ustr 只需要 O(1) 比较一个指针。标准 String 的哈希需要 O(n) 遍历所有字节,Ustr 只需要 O(1) 读取预存值。标准 String 的赋值需要堆分配加内容拷贝,Ustr 只是拷贝一个 8 字节的指针。标准 String 占 24 字节(指针 + 长度 + 容量),Ustr 只占 8 字节。


总结

ustr 是一个设计精巧的字符串驻留库,它通过全局缓存、分桶锁、Bump Allocator 和预计算哈希等技术,将字符串的比较、拷贝和哈希操作都降到了 O(1)。它的 API 简洁直观,与 Rust 的类型系统和 FFI 生态都能完美融合。

如果你的项目中有大量重复字符串的比较和查找场景,不妨试试 ustr,它可能会给你带来意想不到的性能提升。

[dependencies]
ustr = { version = "1.1.0", features = ["serde"] }

就这一行,开启高效字符串之旅。

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » Rust 字符串驻留神器:Ustr 库深度解析

猜你喜欢

  • 暂无文章