标准库 string 的 sso 优化对 LVector 插入影响的探究
钟老师让自己的做的一个课题研究,主题是标准库string的sso优化对LVector插入的影响
。
问题背景
LVector
在插入std::string
的时候遇到了问题,钟老师研究了一段时间,我接着他的成果继续探讨,将学到的内容总结在这里。
std::string的优化
标准库的std::string
其实是做了优化的,不同的编译器实现的细节可能不同,但是基本的大思路框架都是一样的。
注:以下都是理论上的思路分析,具体的底层代码请自行查阅资料。
基本内存模型
我们熟知的std::string
的内存模型大致是这样的
- 栈区当中存放容量
capacity
,大小size
和一根指向堆区数据区域的指针,三个分别占据8
字节,总共24
字节 - 堆区当中
data
是实际存放数据的地方,通过分配器分配出来的(默认使用std::allocator
,实际就是new
出来的),std::string
中的c_str()
方法获取的就是堆区这个数据区首地址

COW优化(现在不用)
COW
,即为Copy-On-Write
,写时拷贝。
提到这里,我首先想到了Linux
当中父子进程的”读时共享,写时拷贝“,父子进程在读的时候共享用户区的数据,例如先open()
一个文件,在fork()
,父子进程的文件描述符是同一个,具体可以表现为父进程读2
个字节,对于子进程的文件偏移指针也向后偏移2
个字节;这就是因为用户区的文件描述符表是读时共享的;当需要修改用户区的数据,比如一个变量,就会做拷贝操作了,这点毋庸置疑,这也是优化性能的一种策略。
参上,std::string
的COW
优化也是一个道理,具体如下图
- 在读取的时候共享
data
数据内存区域,当需要修改(写)的时候,str2
就做自己的拷贝

存在的问题
看到上面,可能会想,COW
这么好,为啥标题还是现在一般不用呢?
之所以不用,是因为这种机制在多线程当中可能会出现不可预期的乱七八糟的问题,具体自行查阅资料,这里不作阐述。
SSO优化
所以就有了SSO
优化,即Small String Optimization
,翻译过来就是短字符串优化。
那么为什么需要短字符串优化呢?看基本的内存模型

想象一下,如果我的字符串比较短,举个例子,小到8
个字节就能存下,那么是不是就不用存一个指针了,直接在栈区存储即可,还不用去堆区开辟空间,还不用考虑堆区内存释放的问题,岂不美哉?
我们再考虑一下,8
个字节的capacity
和8
个字节的size
最大能表示多少的数?2^63 - 1
,这也太大了吧,完全没必要,因此capacity
和size
也可以做进一步优化,注意不同编译器的实现不同,但是思路都是这样,能砍的就砍。当然不管如何,里面存放的pointer
是不会变的,因为std::string
中还有c_str()
接口,不能让功能变了。大概的优化模型如下,可以看到数据在栈区,这个时候指针指向自身内部的data
,合理,非常合理。

当字符串的长度变长,达到长字符串的标准(不同编译器的规定不一样),就会恢复一般的内存模型
关于SSO
优化的下的字符串的拷贝,由于内存模型中仍然存在指针,显然是一个深拷贝,可以写一个程序测试一下,顺便看一下std::string
的SSO
优化的表现
1 |
|
执行结果,可以发现完美验证了我们的分析
a
到b
经过了一次深拷贝,他们两个的数据区地址不同a、b
的本类地址和数据区地址非常相近,而c
离的很远
当然SSO
优化也存在一定问题,这就不是这个课题的重点了,请自行查阅资料。
在LVector中插入std::string
问题背景
测试LVector
的时候,发现prepend std::string
的时候程序崩溃,返回的值也不符合预期
具体分析
我们先不管这个LVector
是如何实现的,我们知道prepend
函数,肯定是调用insert
方法,所以去查insert
函数,以下只给出关键代码,其余都是针对代码的显而易见的分析(代码不是我写的,是钟老师写的,我只是阅读)
真正做插入的函数叫insertMultiple()
,里面有一些算法,不用管他,我们考虑insert
函数的逻辑,在目标处进行插入,然后需要把其他的数据前移或者后移,也就是说,需要做内存的移动或者拷贝,问题就处在这里,也就是代码中的moveMemory()
中
1 | template <typename T> |
可以发现,moveMemory
是直接调用的std::memmove
,直接把原内存给移动过去了,乍一看好像没什么毛病,搬运就搬运呗,但是注意,使用了std::memmove
,对象仅仅是换了一个位置,里面的数据什么都没变,现在把这个同std::string
的SSO
优化结合起来
- 从左边移动到右边,
capacity
、size
、data
都没有问题,关键在于这个指针,前面说过,里面的数据仅仅是换了一个位置,那指针指向的还是原先的地址啊,而原先的地址现在如何?不知道,可能被覆盖,可能被释放了,因此就会出现上面的问题

问题解决
分析了问题的来源,那解决问题就好办了,比如可以为std::string
做特化,让他在这里使用拷贝的策略,这样能解决问题
- 注意:这里的拷贝和前面移动导致的类似浅拷贝不一样,这里的拷贝是通过分配器构造,实际上调用的是
std::string
的拷贝构造函数,不管std::string
是哪种优化方式,深拷贝他是必然做的,也就是说那根pointer
就指向的是自身的data
而不是之前的了,这样就是对的
1 | template <> |
继续思考
那么问题来了,如果有一个自定义类型,里面含有std::string
,那又该怎么办呢?
例如下面的测试
1 | struct DataT |
我第一反应想到的就是能不能用c++
通过某种手段判断一个类当中是否含有指定类型例如std::string
的成员变量,但由于水平不够,或者因为本来就不太好使,这条路走不通
所以就只能从刚才提到的moveMemory()
入手了,既然直接移动不好,那我干脆改成拷贝不行吗?当然不好,白白多了很多次拷贝,这是不可接受的,那有没有办法将二者结合起来呢?你别说,还真有。
参考了Qt
的部分实现,Qt
中封装了一个叫QTypeInfoQuery
的类,里面有一个变量isRelocatable
,这个东西可以用来判断类能否平凡可复制,顾名思义,像std::string
显然不能平凡可复制,因为SSO
的优化,平凡复制的话指针指向的地方是原来的,显然不行,说白了就是类似浅拷贝,因此这里做了判断,如果不行就拷贝,可以就移动

关于std::string
堆内存那个模型,是满足平凡可复制条件的,画个图如下理解
std::memmove
不会触发类的析构函数,因此堆内存还在,不会被释放,因此就做到了完美迁移,同时避免了不必要的栈内存和堆内存的拷贝,提升了效率

在查看了QTypeInfoQuery
的isRelocatable
的来源之后,我发现调用的是标准库的一个type_traits
,叫is_trivially_copyable
,可以判断是否可以平凡可复制,平凡可复制的含义见上
这里写了一个程序测试
1 |
|
执行结果
- 前两个类由于具有
std::string
(第二个类套娃,也算),返回0
,不可平凡可复制 - 第三个类,全是一些
int
,显然可以 - 第四个类,堆内存,这个设计和一般的
std::string
是一样的,返回的是0
好,现在问题来了,我们刚才说借助type_traits
来进行判断,好决定是通过移动还是通过拷贝,代码甚至我都写好了
1 | template <typename T> |
我们还是考虑刚才的堆内存模型,经过判断之后是走拷贝这一条路,但是没必要啊,std::memmove
不会激活对象的析构函数,我把所有数据移动到另一个地方,这个指针还是指向堆区的这一块内存,也就是说,实际上这个内存模型是平凡可复制的,这也是钟老师最初的想法(我研究了这么久才到大佬的初步想法,e
了),这一点优化,就导致了最开始的问题,但是我们必须要有这种思维。
那么就没有解决方案了吗?
其实是有的,可以在这个判断之前再加上一层判断,例如某个类就是这种堆内存模型,经过分析他其实是可以平凡可复制的,那么我通过某种手段,例如宏,在那个类当中提供一种注册的方式,我保证这个类的行为是平凡可复制的,执行到这里之后先执行这个判断,如果ok
,那直接走移动的道路,目前这样来看比较合理。这也是钟老师目前总结之后的研发需求。