Rust 字符串驻留神器:Ustr 库深度解析
Rust 字符串驻留神器:Ustr 库深度解析
引言
在很多应用场景中,我们会反复使用相同的字符串作为标识符——比如 高频交易系统中的symbol、游戏引擎中的资源路径、编译器中的变量名、配置系统中的键名等等。这些字符串被创建了成千上万次,但本质上它们的内容是重复的。每次都分配新内存、每次比较都逐字节对比,这显然是一种浪费。
有没有一种方式,让相同内容的字符串在内存中只存一份,比较的时候只需要比指针就行?
答案就是 字符串驻留(String Interning),而 ustr 正是 Rust 生态中一个极其高效的字符串驻留库。
什么是 ustr?
ustr(Unique 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 asu64) asusize
}
这意味着不同字符串大概率落入不同的桶,多个线程同时操作缓存时不会互相阻塞。锁的实现选用了 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"] }
就这一行,开启高效字符串之旅。
夜雨聆风