“更小&更快”的字符串——German String

更多精彩内容,欢迎关注作者的微信公众号:码工笔记

字符串是程序中最基本的数据类型之一,它表示的是一个字符序列。 其概念很简单,但不同语言中的字符串实现都各不相同,而且很多大型项目中都会实现自己的字符串类型。

这种非标准化其实说明了它并不像看起来的那么简单。

本文主要学习一种比常见的字符串占用内存更小、访问更快的字符串实现------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看一下二者生成的汇编的区别:

scss 复制代码
compareData128():
        movabs  rdx, 216172783259174400
        movabs  rcx, 216172782113783808
        movabs  rdi, 216172783208645376
        movabs  rsi, 216172782113783808
        jmp     compare(data128_t, data128_t)
less 复制代码
compareStrings():
        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语句:

csharp 复制代码
select * 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上。

参考资料

  1. cedardb.com/blog/german...
  2. cedardb.com/blog/string...
相关推荐
_乐无7 小时前
Unity 性能优化方案
unity·性能优化·游戏引擎
2402_8575893615 小时前
Spring Boot编程训练系统:实战开发技巧
数据库·spring boot·性能优化
爱搞技术的猫猫16 小时前
实现API接口的自动化
大数据·运维·数据库·性能优化·自动化·产品经理·1024程序员节
EterNity_TiMe_21 小时前
【论文复现】STM32设计的物联网智能鱼缸
stm32·单片机·嵌入式硬件·物联网·学习·性能优化
saturday-yh1 天前
性能优化、安全
前端·面试·性能优化
青云交1 天前
大数据新视界 -- 大数据大厂之 Impala 性能优化:基于数据特征的存储格式选择(上)(19/30)
大数据·性能优化·金融数据·impala·存储格式选择·数据特征·社交媒体数据
数据智能老司机2 天前
Rust原子和锁——Rust 并发基础
性能优化·rust·编程语言
2401_857026232 天前
Spring Boot编程训练系统:性能优化实践
spring boot·后端·性能优化
数据智能老司机2 天前
Rust中的异步编程——创建我们自己的Fiber
性能优化·rust·编程语言
青云交3 天前
大数据新视界 -- 大数据大厂之 Impala 性能优化:为企业决策加速的核心力量(下)(14/30)
大数据·性能优化·impala·查询优化·企业决策·数据整合·系统融合