共计 4918 个字符,预计需要花费 13 分钟才能阅读完成。
旧文章,遗记了发到 segmentfault: 原文
本文记录一次排查工作,顺便提供对于 C++ RVO 的一句话总结。
根本问题
问题引发
在我的 message-queue 开发过程中,有这么样的代码:
inline std::optional<T> pop_front() {lock l(_m);
_cv.wait(l, [this] {return _abort || !_data.empty(); });
if (_abort) return {}; // std::nullopt;
auto r = std::move(_data.back());
_data.pop_back();
return r;
}
它的用意足够简略,就是从 std::deque _data
中弹出一个队尾元素。只是因为队列可能为空,所以有一个阻塞式的条件变量来期待队列中有有效值(前三行)。
依照直觉,我牵强附会地写完了这段代码。
随即我编写了一个测试片段:
void test_mq() {
hicc::pool::threaded_message_queue<hicc::debug::X> xmq;
hicc::debug::X x1("aa");
{xmq.emplace_back(std::move(x1));
hicc_debug("xmq.emplace_back(std::move(x1)) DONE. AND, xmq.pop_front() ...");
std::optional<hicc::debug::X> vv = xmq.pop_front();
hicc_debug("vv (%p):'%s'", (void *) &vv, vv.value().c_str());
}
hicc_debug("x1 (%p):'%s'", (void *) &x1, x1.c_str());
}
hicc::debug::X
是一个专门用来调试 RVO,In-place construction,Copy Elision 等等个性的工具类,它平平无奇,只不过是在若干地位埋点冰打印 stdout 文字而已,这能够让咱们直观察看到哪些行为实际上产生了。
namespace hicc::debug {
class X {
std::string _str;
void _ct(const char *leading) {printf("- %s: X[ptr=%p].str: %p,'%s'\n", leading, (void *) this, (void *) _str.c_str(), _str.c_str());
}
public:
X() {_ct("ctor()");
}
~X() {_ct("dtor");
}
X(std::string &&s)
: _str(std::move(s)) {_ct("ctor(s)");
}
X(std::string const &s)
: _str(s) {_ct("ctor(s(const&))");
}
X &operator=(std::string &&s) {_str = std::move(s);
_ct("operator=(&&s)");
return (*this);
}
X &operator=(std::string const &s) {
_str = s;
_ct("operator=(const&s)");
return (*this);
}
const char *c_str() const { return _str.c_str(); }
operator const char *() const { return _str.c_str(); }
};
} // namespace hicc::debug
但我实现的很简略,所以有时候要配合 gdb 调试和断点,以便实质性地确认一行输入到底和代码中的哪一行互相对应。
问题实况
好的,下面的测试片段跑起来,后果如下:
- ctor(s): X[ptr=0x7ffeeb08a690].str: 0x7ffeeb08a691, 'aa'
07/16/21 07:49:18 [debug]: xmq.emplace_back(std::move(x1)) DONE. AND, xmq.pop_front() ... /Users/hz/hzw/cc/hicc-cxx/tests/pool.cc:24 (void test_mq())
- dtor: X[ptr=0x621000001500].str: 0x621000001501, 'aa'
- dtor: X[ptr=0x7ffeeb08a460].str: 0x7ffeeb08a461, 'aa'
07/16/21 07:49:18 [debug]: vv (0x7ffeeb08a750): 'aa' /Users/hz/hzw/cc/hicc-cxx/tests/pool.cc:26 (void test_mq())
- dtor: X[ptr=0x7ffeeb08a750].str: 0x7ffeeb08a751, 'aa'
07/16/21 07:49:18 [debug]: x1 (0x7ffeeb08a690): 'aa' /Users/hz/hzw/cc/hicc-cxx/tests/pool.cc:28 (void test_mq())
- dtor: X[ptr=0x7ffeeb08a690].str: 0x7ffeeb08a691, 'aa'
看起来 dtor 十分多。
我急躁分别,ctor(s) 这代表着 test_mq()
的 L4,没有问题;emplace_back()
没有多余的 dtor,达到了我的原始构想,没有问题;
L3 和 L4 的 dtor,有点怪,我只能通过调试器来确认:L3 是由 pop_front()
的 L7 产生的,_data.pop_back()
析构了一个队尾元素,所以打印了该元素的 dtor(因为 X class 没有对外部的 str 进行 swap 优化,所以 std::move(X x) 不能残缺地达成挪动语义,体现进去就是 str 的值不会在挪动语义之后被清零)。而 L4 的 dtor 产生在 pop_front()
退出点,由 return r
隐式结构的 std::optional<X>
长期对象的析构所带出。
再之后,L6 的 dtor 是 test_mq()
中的 vv 析构所带出;L8 的 dtor 是第一个 ctor(s) 的对应析构调用。这两个 dtor 没有问题。
所以看起来 dtor 多是在测试代码上,外围库只有一个问题:L4 的 dtor 是预料外的产生。
刨根问底
RVO
通过实况的剖析,咱们晓得问题出自于 std::optional<T>
做函数返回值时没有正确地 RVO。
这相当不迷信。
康康:
std::string build(){
std::string ret;
ret += "2";
ret += "3";
return ret;
}
int main(){std::cout << build() << '\n';
}
RVO 的个性让 L2 的 ret 不用析构间接被返回给了 caller。
这是个很失常的编译器优化能力,也是 C++11 以来的标准约定,怎么可能在 optional 上就 XJBG 了呢。
所以我试图改写代码来找到起因。
解决
无趣的过程略略略。
最终可能顺利 RVO 的实现是这样的:
inline std::optional<T> pop_front() {
std::optional<T> ret;
lock l(_m);
_cv.wait(l, [this] {return _abort || !_data.empty(); });
if (_abort) return ret; // std::nullopt;
ret.template emplace(std::move(_data.back()));
_data.pop_back();
return ret;
}
用这个新实现跑测试片段,失去:
- ctor(s): X[ptr=0x7ffee3d86690].str: 0x7ffee3d86691, 'aa'
07/16/21 07:51:43 [debug]: xmq.emplace_back(std::move(x1)) DONE. AND, xmq.pop_front() ... /Users/hz/hzw/cc/hicc-cxx/tests/pool.cc:24 (void test_mq())
- dtor: X[ptr=0x621000001500].str: 0x621000001501, 'aa'
07/16/21 07:51:43 [debug]: vv (0x7ffee3d86750): 'aa' /Users/hz/hzw/cc/hicc-cxx/tests/pool.cc:26 (void test_mq())
- dtor: X[ptr=0x7ffee3d86750].str: 0x7ffee3d86751, 'aa'
07/16/21 07:51:43 [debug]: x1 (0x7ffee3d86690): 'aa' /Users/hz/hzw/cc/hicc-cxx/tests/pool.cc:28 (void test_mq())
- dtor: X[ptr=0x7ffee3d86690].str: 0x7ffee3d86691, 'aa'
能够看到,本来的 L4 dtor 被解决掉了,也就是说 std::optional 返回值被正确地 RVO 了。通过调试器查看 ret 的地址指针以及 vv 的地址指针可能确认这一点。
原因
比照前后两个实现,两者的区别在于咱们最初应用了一个函数体作用域内的惟一的 std::optional<T>
变量 ret,在 clang 编译器中,它会将 ret 标记为辨认到 RVO 变量的状态。而原有的实现采纳了隐式构造方法,编译器未能正确辨认到 RVO 变量。
当咱们采纳多个变量时,编译器也有可能不能正确 RVO:
inline std::optional<T> pop_front_bad() {lock l(_m);
_cv.wait(l, [this] {return _abort || !_data.empty(); });
if (_abort) return {}; // std::nullopt;
std::optional<T> ret{std::move(_data.back()) };
_data.pop_back();
return ret;
}
上述例子中,除了 ret 还有隐式返回:return {}
,所以同样不能正确地 RVO。
小结
限于精力,不想再做其余编译器的行为探讨了。
这里是以 std::optional
返回值产生的问题来探讨 RVO 的。之所以正好使它,是因为 optional 最容易产生 RVO 失败的状况。咱们采纳 std::optional
作为返回值时,总是在面临这样的场景:失常时咱们返回有效值,异样时咱们冀望能返回一个空值。而空值不是 {}
就是 std::nullopt
,完满地挑战了编译器的死角。
C++ Spec
那么,对于 RVO (Return Value Optimization) 来说,C++ 标准上的要求大抵上是这样的:
非强制的复制 / 挪动 (C++11 起)操作打消
下列环境下,答应但不要求编译器省略类对象的复制和挪动 (C++11 起)结构,即便复制 / 挪动 (C++11 起)构造函数和析构函数领有可察看副作用。间接将对象结构到它们原本要复制 / 挪动到的存储中。这是一项优化:即便进行了优化而不调用复制 / 挪动 (C++11 起)构造函数,它依然必须存在且可拜访(如同齐全未产生优化),否则程序非良构:
- return 语句中,当操作数是领有主动存储期的非 volatile 对象的名字,其并非函数形参或 catch 子句形参,且其具备与函数返回类型雷同的类类型(疏忽 cv 限定)时。这种复制打消的变体被称为 NRVO,“具名返回值优化 (named return value optimization)”。
在对象的初始化中,当源对象是无名长期量且与指标对象具备雷同类型(疏忽 cv 限定)时。当无名长期量为 return 语句的操作数时,称这种复制打消的变体为 RVO,“返回值优化 (return value optimization)”。 (C++17 前) 返回值优化是强制要求的,而不再被当做复制打消;见上文。 复制打消 – cppreference.com
很简单是不是。
我看的脑子爆炸。
不过,简略一句话:平安的 RVO(或者具名的 NRVO)是在函数中提前申明返回值变量,并在任何返回点都返回它。