更多精彩内容,欢迎关注作者的微信公众号:码工笔记
字符串是程序中最基本的数据类型之一,它表示的是一个字符序列。 其概念很简单,但不同语言中的字符串实现都各不相同,而且很多大型项目中都会实现自己的字符串类型。
这种非标准化其实说明了它并不像看起来的那么简单。
本文主要学习一种比常见的字符串占用内存更小、访问更快的字符串实现------German String
。
这种
German String
结构是在Umbra项目(源自德国)中首次提出并使用的。
下面先看看最常见的两种字符串实现和其存在的问题:
一、C/C++的字符串实现
1、C语言中的字符串
C语言中,字符串(char *
)就是一段以\0
结尾的连续的字节序列,每个字节中存放一个字符。
这种简单模型有几个明显的问题:
-
如果字符串未以
\0
结尾,读写它就会踩到邻近的内存,导致安全性问题 -
要获取字符串长度需要遍历整个字符串
-
要拼接更多数据,需要重新申请整块内存,并拷贝所有数据到新地址,再释放旧内存
2、C++中的字符串
C++中的标准库中提供了std::string
类型,易用性和安全性比C语言的好很多。libc++
的std::strign
实现如下:
-
size
字段存放了字符串长度,ptr
指向的是真正存放字符串内容的地址,capacity
是当前已申请的内存buffer总大小; -
如果要拼接新数据,只要字符串总长度小于capacity,就可以直接拼接;
如果超出了capacity,std::string内部会处理申请新内存、拷贝数据、释放旧内存的逻辑;
-
string还支持短串优化:
如果字符串很短,string支持直接把字符串内容放到string的结构体中,而不用专门申请一个buffer再用指针访问它,capacity字段中有个bit用来标识当前string是否经过子短串优化。
短串优化听起来已经够好了,还能进一步优化吗?
答案是可以的。
不同场景下的典型字符串数据可能有不同的特点,如果能结合这些特点设计相应的结构,有可能取得更好的优化效果。
二、字符串数据的特点
CedarDB 的开发者在开发他们的数据库产品时,发现了线上使用的字符串数据有以下特点:
-
大部分字符串都很短:如省市名称、电话号码、常见的枚举值等;
-
大部分字符串在生成后不会改变:
libc++中为了优化不太会出现的append而预留的64bit的capacity字段就有点浪费了;
多线程读、写字符串需要加锁才能保证正确性,因此在可能的时候应该尽量使用只读字符串;
-
很多情况下,只需要读取字符串中的少数几个字符(如下列代码),每次都需要先读ptr,再读ptr的内容(两次访存)有点浪费:
csharp
//只取前缀就可以
select * from messages where starts_with(content, 'http');
//大部分字符串都不会以Gö开头
select sum(p.amount)
from purchases p, books b
where p.ISBN = b.ISBN and b.title = 'Gödel, Escher, Bach: An Eternal Golden Braid';
注意:libc++中string,对于非短串来说,就算只读取字符串的前几个字符,也需要两次访存(先读出ptr,再读ptr指向的内存)
三、German String的实现
1、神奇的16字节
German string将string对象设计为一个16字节(128-bit)的struct:
-
相比于libc++ string,它减少了64bit的capacity字段,占用内存更小
-
另外,限定其大小为16字节还有一个好处:它在作为方法参数进行传参时,大多机器的
Calling Convention
都会使用两个寄存器进行传参,而不是放到栈上,这也能大幅提升性能
以下代码实现了两个功能相同的compare方法,唯一区别是参数类型分别使用16字节的struct(data128_t)和std::string:
c#include <cstdint> #include <string> struct data128_t { uint64_t v[2]; }; void compare(data128_t, data128_t); void compare(std::string, std::string); void compareData128() { data128_t abc = {0x0300'0000'4142'4300, 0x0300'0000'0000'0000}; data128_t def = {0x0300'0000'4445'4600, 0x0300'0000'0000'0000}; compare(abc, def); } void compareStrings() { std::string abc = "abc"; std::string def = "def"; compare(move(abc), move(def)); }
通过Compiler Explorer看一下二者生成的汇编的区别:
scsscompareData128(): movabs rdx, 216172783259174400 movabs rcx, 216172782113783808 movabs rdi, 216172783208645376 movabs rsi, 216172782113783808 jmp compare(data128_t, data128_t)
lesscompareStrings(): push r15 mov eax, 25185 mov edx, 25956 push r14 push r13 push r12 push rbp push rbx sub rsp, 136 mov WORD PTR [rsp+16], ax lea r14, [rsp+96] lea r15, [rsp+64] mov WORD PTR [rsp+48], dx lea rbx, [rsp+16] mov rsi, r14 mov rdi, r15 mov BYTE PTR [rsp+50], 102 lea rbp, [rsp+48] lea r12, [rsp+112] mov BYTE PTR [rsp+51], 0 mov eax, DWORD PTR [rsp+48] lea r13, [rsp+80] mov BYTE PTR [rsp+18], 99 mov BYTE PTR [rsp+19], 0 mov DWORD PTR [rsp+112], eax mov eax, DWORD PTR [rsp+16] mov QWORD PTR [rsp+96], r12 mov QWORD PTR [rsp+104], 3 mov QWORD PTR [rsp+32], rbp mov QWORD PTR [rsp+40], 0 mov BYTE PTR [rsp+48], 0 mov QWORD PTR [rsp+64], r13 mov DWORD PTR [rsp+80], eax mov QWORD PTR [rsp+72], 3 mov QWORD PTR [rsp], rbx mov QWORD PTR [rsp+8], 0 mov BYTE PTR [rsp+16], 0 call compare(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >) mov rdi, QWORD PTR [rsp+64] cmp rdi, r13 je .L6 mov rax, QWORD PTR [rsp+80] lea rsi, [rax+1] call operator delete(void*, unsigned long) .L6: mov rdi, QWORD PTR [rsp+96] cmp rdi, r12 je .L7 mov rax, QWORD PTR [rsp+112] lea rsi, [rax+1] call operator delete(void*, unsigned long) .L7: mov rdi, QWORD PTR [rsp+32] cmp rdi, rbp je .L8 mov rax, QWORD PTR [rsp+48] lea rsi, [rax+1] call operator delete(void*, unsigned long) .L8: mov rdi, QWORD PTR [rsp] cmp rdi, rbx je .L5 mov rax, QWORD PTR [rsp+16] lea rsi, [rax+1] call operator delete(void*, unsigned long) .L5: add rsp, 136 pop rbx pop rbp pop r12 pop r13 pop r14 pop r15 ret mov rbx, rax jmp .L10 compareStrings() [clone .cold]:
可以看到使用data128_t类型时传参是直接使用寄存器,而且一共只用了4条指令就准备好了参数;
而std::string的版本在call compare之前则生成了非常多指令用来处理传参。
下面来具体看一下German string的内存布局,看它是如何高效利用这16字节的。
2、German string的内存布局
1)短串的内存布局:
如果字符串长度不大于12,可以直接存放到content字段中。
2)长串的内存布局:
长度大于12的串需要一个ptr指针来指向真正存放字符串内容的内存区域。
len字段
为了将struct限定在16字节(128-bit)之内,len字段我们限定只使用32-bit,也即最大长度只能是4G字节
prefix字段
在这个字段中我们存放了字符串的前4个字符,存储前缀可以提升字符串比较、排序等场景的性能,因为它可以少一次ptr访存
ptr字段
ptr指向存放字符串完整内容的内存buffer,buffer尾部没有多余的预留内存
- 字符串是只读的,因此访问它不需要加锁。
- 劣势:修改字符串效率降低,需要重新分配内存、拷贝等。但数据库的应用场景中,本来也很少就地修改字符串。
class字段
存放存储类型,创建字符串时指定,有以下三种:
-
temporary:类似C++中的std::string,RAII风格
构造German string对象时申请buffer、存储串内容到buffer中、buffer地址存到ptr字段中,生命周期结束时释放buffer;
-
persistent:类似C++中的常量字符串,不会销毁,永远可访问:
German string中所有的短串都是这种类型,因为数据就存储在string变量本身的struct中,只要拿到string变量就能正确访问数据;
长串也可以是persistent的,如ptr指向的是常量字符串,常量字符串在程序运行期永远不会被释放,所以只要拿到string变量就能访问数据;
-
transient:这种string中的ptr指向的是一块由外部管理的内存区域:
string内部并不管理或感知ptr指向的内存是否还有效,使用string的人需要自行保证ptr的有效性;
构造这种string开销很小,它不需要申请新内存和填数据,而是使用外部对象已经申请好并填好内容的内存;
Trasient类型的string在数据库中的应用场景
因为数据库管理的数据量一般较大,经常需要将内存中的数据换出到硬盘上,在使用时再换回内存中,如果是使用传统的string类型,换入过程会包括以下两步:
- 将字符串内容所在的
Page
加载到内存中- 构造一个
std::string
对象,string构造函数内部负责将字符串内容copy一份到它内部的buffer中这个过程中字符串数据被copy了两次,有点浪费,因为大部分情况下这些字符串我们只读一次就不会再用它了。
比如说考虑下面这个SQL语句:
csharpselect * from books where starts_with(title,'Tutorial')
我们想筛选出来所有书名以
Tutorial
开头的书。大部分book数据的title都不会命中where条件,也就是说在执行SQL的过程中,大部分的title数据我们都是只读一遍,以后就不会再用它了。这种情况就可以使用transient类型的German string:
Page加载进来后,将ptr指向Page中的内存地址(当前有效),就可以正常访问其数据了。构造这种transient string的成本极低(因为它不需要申请新内存,也不需要copy数据)。
后续我们把Page内容写出到磁盘上并释放其内存时,ptr指向的地址就无效了。string本身并不管理或感知ptr的有效性。使用者如果想过一会儿再次访问这种transient数据,需要在ptr有效时自行copy相应数据到自己管理的内存buffer上。