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 |
|
1 | GCC 8.3.1 |
1 | GCC 14.2.0 |
事实上,STL string 的 operator[]
还提供了 const 版本 [2],我们将调用者 s
变成一个常量引用,就可以调用 const 版本。 这一调用不会导致 unshare,也不会导致 p
无效化。
1 |
|
1 | GCC 8.3.1 |
关于为什么放弃 COW 的进一步探讨
首先,从上面的例子可以看出,对于一个不用 const 约束的 string,也是默认的情况,类似 at
和 operator[]
这样直观上很可能是只读的操作,由于 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 |
|
如果 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)