字符串驻留 —— String Interning(Symbol)
在文件解析等任务中,经常会遇到高频的字符串比对操作,例如编译器的词法分析器(Lexer),以及像 ProtoBufferText 这类文件的解析。前者存在大量的关键词,而 DSL文件中也有许多重复的字段名
在这些场景中,如果直接使用 String 类型,因为数以万计的字符串克隆操作会让性能急剧下降。&str 类型倒是不错,文件流的生命周期足够长,引用访问速度也很快,不过字符串的显著缺陷是,它的比对需要逐字节比对,例如解析Token,假如有100种Token,这个O(n)的比对最坏的情况下要执行100次
enum Token {
Fn,
Let,
Match,
Ident,
// ..
}
fn classify_token(token: &str) -> Token {
match token {
"fn" => Token::Fn,
"let" => Token::Let,
"match" => Token::Match,
// ... many other tokens
}
}
在这种场景下,使用更多的是 Symbol 类型。以下都代码均为Rustc源代码中使用的Symbol,其位于compiler/rustc_span目录下
Symbol是一个线程局部(thread_local)变量,其数据类型为 Map<&'static str, u32>
以下是 Rust 编译器(rustc)所使用的 Symbol 数据类型的定义:
pub struct Symbol(SymbolIndex);
其中的SymbolIndex是一个u32的自增id
下面是 Symbol 的主要方法:
impl Symbol {
pub const fn new(n: u32) -> Self {
Symbol(SymbolIndex::from_u32(n))
}
pub fn intern(string: &str) -> Self {
with_session_globals(|session_globals|
session_globals.symbol_interner.intern(string
))
}
pub fn as_str(&self) -> &str {
with_session_globals(|session_globals| unsafe {
std::mem::transmute::<&str, &str>(
session_globals.symbol_interner.get(*self)
)
})
}
}
这里的 with_session_globals 函数实际上就是 SESSION_GLOBALS.with,其类型和定义如下代码,SESSION_GLOBALS使用的是scoped_thread_local,避免资源泄露
pub struct SessionGlobals {
symbol_interner: symbol::Interner,
span_interner: Lock<span_encoding::SpanInterner>,
metavar_spans: MetavarSpansMap,
hygiene_data: Lock<hygiene::HygieneData>,
source_map: Option<Arc<SourceMap>>,
}
pub fn with_session_globals<R, F>(f: F) -> R
where
F: FnOnce(&SessionGlobals) -> R,
{
SESSION_GLOBALS.with(f)
}
scoped_tls::scoped_thread_local!(static SESSION_GLOBALS: SessionGlobals);
其中 Interner 结构体的定义如下:
pub(crate) struct Interner(Lock<InternerInner>);
struct InternerInner {
arena: DroplessArena,
strings: FxIndexSet<&'static str>,
}
Rust 编译器的 Interner 使用的是 DroplessArena:
pub struct DroplessArena {
start: Cell<*mut u8>,
end: Cell<*mut u8>,
chunks: RefCell<Vec<ArenaChunk>>,
}
DroplessArena 的内存分配只需移动指针,就像寄存器 rbp 和 rsp 的操作一样。而且正如其名称所示,其中存储的数据不会被释放,生命周期为 'static,不会产生内存碎片,内存分配速度显著快于系统内存分配。strings 是一个 indexmap::IndexSet,indexmap 可以保持元素的插入顺序,Symbol 直接使用这个插入顺序作为 id,hash算法是目前Rust中最快的 FxHasher
下面是 intern 方法的具体实现:
impl Interner {
#[inline]
fn intern(&self, string: &str) -> Symbol {
let mut inner = self.0.lock();
// 绝大多数情况下在这一行返回
if let Some(idx) = inner.strings.get_index_of(string) {
return Symbol::new(idx as u32);
}
let string: &str = inner.arena.alloc_str(string);
let string: &'static str = unsafe { &*(string as *const str) };
let (idx, is_new) = inner.strings.insert_full(string);
debug_assert!(is_new);
Symbol::new(idx as u32)
}
}
在文件解析场景中,例如以下代码:
fn classify_token_with_symbol(token: &str) -> Token {
match SYMBOL.intern(token) {
sym::fn_ => Token::Fn,
sym::let_ => Token::Let,
sym::match_ => Token::Match,
// ... many other tokens
}
}
首次遇到字符串,需要计算哈希值、处理哈希冲突(几乎不可能遇到)并插入值;后续遇到相同字符串时,只需计算哈希值,然后返回 Symbol 内部的 u32,每个分支的匹配只需比对这个 u32 值即可
由于关键字或者字段名通常是我们已知的,所以可以在最开始Intern所有这些字符串
SmartString —— 栈存储小字符串
在 64 位平台上,标准的 String 类型 24 字节的空间,其中指针(ptr)、长度(len)和容量(cap)各占 8 字节,它们的数据类型均为 usize
当字符串长度不超过 23 字节时,SmartString 会将字符串的 u8 数组直接存储在栈上。此时,剩余的 1 字节(共 8 位)被巧妙利用。其中 1 位作为标志位,用于区分字符串是存储在栈上(InlineString)还是堆上(BoxedString);剩下的 7 位则用于存储字符串的长度信息。由于栈上字符串的最大长度为 23,而 2 ^ 7 = 128,足以存储长度信息
BoxedString的layout就是ptr、len、cap这三个usize类型,它没有标志位,那么如何判断呢?
内存分配器会对指针进行对齐,一般是按照 2 的幂次方(如 2、4、8 字节等)对齐,这就保证了指针的最低位为 0,那么只要InlineString的标志位设置为1,根据标志位的奇偶来判断就好了
在处理小字符串的场景中,clone 操作转化为了栈上的复制(Copy)操作,不仅大幅提升性能,还减轻了开发者在处理 clone 操作时的心智负担
但需要注意的是,即便只是 24 字节的栈上复制操作,其性能也低于&str
基于类似的思路,还有smallvec这个著名的crate,从2.0版本,它的实现使用了Const Generic特性,有机会再说