CXX11 ABI 与 STL string Copy-on-Write 机制

GCC 在 5.1 之后的 libstdc++ 中引入了支持 C++11 新标准的 ABI,用宏 _GLIBCXX_USE_CXX11_ABI 控制。 是否使用 CXX11 ABI 有两个主要区别,其一是 std::string 实现机制禁止 Copy-on-Write,其二是 std::list 获取大小的时间复杂度 [1]。

本文主要讨论 std::string 机制的变化和原因。

从 Copy-on-Write 到 eager-copy

Copy-on-Write (COW) 机制,学过 OS 的人应该都比较了解,这里不赘述了。

C++11 之前,对于 STL string 拷贝构造的实现方式没有做规定,一些编译器选择了 eager-copy 方式,即每构造一个新的 string,都重新分配一块新内存,直接将旧内存内容拷贝到新空间,如 clang 和 MSVC. 而 GCC 则是选择了 COW 或称 lazy-copy 机制,拷贝构造 string 时共享原 string 数据内存区域,通过引用计数维护共享,当有必要 unshare 时才分配新内存空间 [6,7]。

C++11 明确规定了 STL sting 禁止使用 COW 机制,从标准出发(21.4.1 p6),主要是 iterator/reference invalidation 方面的原因 [4]:

— as an argument to any standard library function taking a reference to non-const basic_string as an argument.

— Calling non-const member functions, except operator[], at, front, back, begin, rbegin, end, and rend.

标准指出,即使 non-const operator[] 也不应该导致迭代器失效,但 COW 机制下,对于一个被共享内存的 string,调用 non-const operator[] 一定会导致 unshare,而底层数据的修改(重新分配)将会无效化在这之前获取的迭代器或引用,这显然和 C++11 标准冲突。

因此,GCC 引入 CXX11 ABI,在 5.1 之后默认开启,将使用符合 C++11 标准的 eager-copy STL string.

下面这个经典例子可以更好地说明这个现象。

一个经典例子

这个例子的变体在 [4-7] 中均有出现,下面是笔者略加修改后实际测试过的代码。 需要用 GCC 编译,加编译选项 -D_GLIBCXX_USE_CXX11_ABI=0 禁用 CXX11 ABI,理论上将使用 COW 实现的 STL string. 但现实中,在 GCC <= 8.3.1 上测试成功,在 GCC >= 11 上并不会使用 COW 机制,测试时请注意版本。

首先,这段代码在 scope 中拷贝 s 构造了 s2(也可以想象成在另一个线程中,事实上真的放在另一个线程上也会有同样的效果), 随后调用 non-const operator[]s 不是 const 变量,s[0] 视为 non-const 版本),虽然事实上什么都没有修改, 但这个调用本身将导致 unshare,s 会指向一块新的内存。 这将导致之前获取的 s 的“迭代器” const char *p = s.data() 失效成为 dangling pointer,输出结果出现问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <cstdio>
#include <string>

int main() {
#if defined(__GNUC__)
printf("GCC %d.%d.%d\n", __GNUC__, __GNUC_MINOR__, __GNUC_PATCHLEVEL__);
#endif
std::string s = "str";
printf("s.data(): 0x%x\n", s.data());
const char *p = s.data();
// this can be in another thread
{
std::string s2(s);
printf("s.data(): 0x%x\n", s.data());
printf("s2.data(): 0x%x\n", s2.data());
s[0];
printf("s.data(): 0x%x\n", s.data());
printf("s2.data(): 0x%x\n", s2.data());
}
std::string a = "hello world";
printf("p: %s\n", p);
printf("s: %s\n", s.c_str());
return 0;
}
1
2
3
4
5
6
7
8
GCC 8.3.1
s.data(): 0x1dc3e88
s.data(): 0x1dc3e88
s2.data(): 0x1dc3e88
s.data(): 0x1dc3eb8
s2.data(): 0x1dc3e88
p: hello world
s: str
1
2
3
4
5
6
7
8
GCC 14.2.0
s.data(): 0x889b2438
s.data(): 0x889b2438
s2.data(): 0x889b2468
s.data(): 0x889b2438
s2.data(): 0x889b2468
p: str
s: str

事实上,STL string 的 operator[] 还提供了 const 版本 [2],我们将调用者 s 变成一个常量引用,就可以调用 const 版本。 这一调用不会导致 unshare,也不会导致 p 无效化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <cstdio>
#include <string>

int main() {
#if defined(__GNUC__)
printf("GCC %d.%d.%d\n", __GNUC__, __GNUC_MINOR__, __GNUC_PATCHLEVEL__);
#endif
std::string s = "str";
printf("s.data(): 0x%x\n", s.data());
const char *p = s.data();
// this can be in another thread
{
std::string s2(s);
printf("s.data(): 0x%x\n", s.data());
printf("s2.data(): 0x%x\n", s2.data());
// explicitly invoke operator[](...) const
((const std::string &)s)[0];
printf("s.data(): 0x%x\n", s.data());
printf("s2.data(): 0x%x\n", s2.data());
}
std::string a = "hello world";
printf("p: %s\n", p);
printf("s: %s\n", s.c_str());
return 0;
}
1
2
3
4
5
6
7
8
GCC 8.3.1
s.data(): 0x1f00e88
s.data(): 0x1f00e88
s2.data(): 0x1f00e88
s.data(): 0x1f00e88
s2.data(): 0x1f00e88
p: str
s: str

关于为什么放弃 COW 的进一步探讨

首先,从上面的例子可以看出,对于一个不用 const 约束的 string,也是默认的情况,类似 atoperator[] 这样直观上很可能是只读的操作,由于 C++ 设计上这些函数的非 const 版本支持修改,因此编译器最安全的做法是对这类调用都执行 unshare,而且唯一合理的做法是为调用这些函数的 string 对象进行重分配。

类似上面的例子,s 先被构造,且获取了其迭代器/指针 p,之后又用 s 构造了 s2,但之后再次对 s 调用一个直觉上只读但事实上导致 unshare 的函数,就会无效化已获取的 s 的迭代器。这种行为本身是反直觉的。 因此,C++11 重新规定,即使 non-const operator[] 也不应该导致迭代器失效。

其次,在多线程视角下,这可能会带来性能负担。比如,上面的例子若将大括号 scope 放在另一个线程 2 中,进一步,假设初始线程 1 只访问 s,线程 2 只访问 s2,这本该是逻辑上独立、从并行角度看互不冲突的情况。 但由于 COW 机制,任一线程第一次 unshare 操作都会触法重新分配,而这些线程实质上在共享 s 初始分配的内存区域,因此这种重新分配必须伴随原子操作和加锁。 如果有大量线程共享同一个 string 的内存空间,任何线程内的第一次 unshare 操作都会非常昂贵,并引入原子操作的其他副作用,如阻止乱序执行、cache invalidation 等。

还有一个比较重要的多线程场景,如多个线程同时用 non-const operator[] 操作同一个 string 的不同区段,也就是一般情况下我们期望的 "container thread safety" [3]:

1
2
3
4
#pragma omp parallel for
for( size_t i = 0, n = v.size(); i < n; ++i ) {
v[i] = 0;
}

如果 v 是 COW 机制的 STL string,则必须在 #pragma 之前对 v 做必要的 unshare,如 v[0],否则某个线程第一次实际调用 operator[] 导致 unshare 将使此前所有线程已经取得的 v 的迭代器无效。

对于 GCC 这样采用 COW 的编译器,C++11 的规定导致必须放弃 COW 以满足迭代器有效性要求。 这就走向了 eager-copy 机制,目前编译器在这种机制下常用的优化是小字符串优化(small string optimization, SSO),比如将很短的字符串直接放在栈空间上(本质是直接放在 string 结构体内),而非将 data 单独分配在堆内存上,这里就不具体展开了 [5]。

References

[1] Dual ABI - The GNU C++ Library

[2] std::basic_string<CharT,Traits,Allocator>::operator[] - C++ reference

[3] Concurrency Modifications to Basic String

[4] Legality of COW std::string implementation in C++11

[5] Unexpected C++ String Modification Caused by COW (Copy-On-Write)

[6] GCC编译宏_GLIBCXX_USE_CXX11_ABI背景分析和实现原理

[7] std::string的Copy-on-Write:不如想象中美好