如果您可以选择用于应用程序的编程语言,您通常会选择您熟悉的并且能够以最短路径达到目标的语言。如果您需要高运行时速度,那么直接编译为机器代码的编程语言(如 C++)是您的最佳选择。
在现代应用程序中,内存地址的来回跳转、循环以及(有时是不必要的)数据区域复制会消耗大量的机器代码。在本文中,我将重点介绍 C++ 的 move 语义,它可以让您避免不必要的复制过程。即使您不是程序员,您仍然可以使用 valgrind 堆分析器 massif 分析内存分配。
代码:rvalues 和 lvalues
在 C++ 编程中,您必须处理 lvalues 和 rvalues。在下面的示例中,a 在赋值运算符 = 的左侧,因此它是 lvalue。赋值 5 在右侧,因此它是 rvalue。
a = 5;
当编译此行代码时,lvalue 被解释为之后可以更改的符号地址。rvalue 是一个纯粹的硬编码值。后续代码无法访问它,因为 rvalues 没有地址。如果您可以确定表达式的地址,或者编译器允许,则它是 lvalue。也就是说,如果一个表达式在行尾的分号后仍然存在,那么它是 lvalue;否则,它是 rvalue。
lvalue 可以出现在左侧和右侧。rvalue 只能出现在右侧。
a = 5;
b = a;
自 C++11 以来,移动语义和处理右值引用的可能性已融入标准。
右值引用用双 & 符号 && 标记。它们允许将 lvalue 解释为 rvalue。尤其是在创建对象时,使用 rvalue 引用可以提高性能,我们将在示例代码中看到这一点。
网上有很多文章介绍了语法和正确用法。Chromium 项目文档 对此主题做了很好的介绍。在本文中,我们将使用一个示例来揭示对性能的实际影响。
git clone https://github.com/hANSIc99/optimizing_cpp_sample.git默认行为
假设您有一个名为 MyObject 的类,它在其构造函数中接受另一个类 MyType 作为参数。为了创建 MyObject 的实例,必须预先创建 MyType 的实例。
在代码中,它看起来像这样(main.cpp 第 16 行)
MyType<double> type_1(container);
MyObject<MyType<double> > object_1(type_1);MyType 是一个模板类,在其构造函数中需要一个双精度向量(您无需关注此属性)。MyObject 也是一个模板类,在其构造函数中接受 MyType (type_1) 的实例。
使用 MyObject
- 调用
MyType的构造函数来创建实例 (type_1) - 调用
MyType的复制构造函数(创建type_1的副本) - 调用
MyObject的构造函数(将type_1的副本作为参数) - (… 使用 object_1 做一些工作 … )
- 调用
type_1的析构函数 - 调用
object_1的析构函数,这将导致它调用type_1副本的析构函数
如果 MyType 的实例 (type_1) 仅用于创建 MyObject 的实例 (object_1),则会执行许多不必要的代码。
您可以自己尝试:更改目录并调用 make
cd optimizing_cpp_sample
make CFLAGS=-DOPT1编译完成后,调用示例程序
./memory_sample您现在应该看到不同构造函数类型的跟踪消息
MyType::MyType() contructor called
MyType::MyType(const MyType&) copy constructor called
MyObject::MyObject(const T& mytype) constructor called
MyType::m_data contains 32767 elements
MyType::~MyType() destructor called
MyType::~MyType() destructor called优化示例
清除二进制文件,使用不同的参数调用 make,然后再次运行程序
make clean
make CFLAGS=-DOPT2
./memory_sample这次,构造函数的跟踪消息看起来有点不同
MyType::MyType() contructor called
MyType::MyType(MyType&& other) move constructor called
MyObject::MyObject(T&& mytype) constructor with move called
MyType::~MyType() destructor called
MyType::m_data contains 32767 elements
MyType::~MyType() destructor called它没有调用 MyType 复制构造函数,而是调用了 move 构造函数,并且 MyType 析构函数只被调用一次。正如您将在下面看到的,这对运行时性能有积极影响。
查看代码中的 main.cpp 第 23 行。它显示 object_2 是直接使用应该传递给 MyType 构造函数的参数创建的
MyObject<MyType<double> > object_2(container);就是这样:只需一行代码即可使用 MyType 构造函数的参数调用 MyObject 的构造函数。编译器检测到无需在内存中保留 MyType 的实例。它没有创建另一个副本,而是将 object_2 内部指向 MyType 实例的内部指针设置为先前创建的实例。
谨慎移动
使用 move 语义需要一定的谨慎。再次运行程序
make clean
make CFLAGS=-DOPT3
./memory_sample程序立即崩溃
MyType::MyType() contructor called.
MyType::MyType(MyType&& other) move constructor called
MyType::m_data contains 3 elements
Segmentation fault (core dumped)发生了什么?打开 main.cpp 并移动到第 27 行
void case_3(){
MyType<double> type_3({1.2, 3.4, 5.6});
MyObject<MyType<double> > object_3(std::move(type_3));
object_3.m_mytype.print();
/* Dangerous: std::move destroys the object */
type_3.print();
} 这强制使用 move 构造函数,通过在传递 type_3 作为参数时使用 std::move 来初始化 object_3(第 30 行)。
将 type_3 移动到 object_3 后,您不能再引用 type_3(因为您已经移动了它)。该对象已被销毁,无法再次使用。
衡量性能影响
您可能已经注意到输出中显示了执行时间。虽然您在单次调用中看不到任何效果(如上面的示例),但当您在无限循环中运行代码时,会有明显的改进。
不使用移动构造函数
make clean
make CFLAGS=-DOPT4
./memory_sample在我的系统上,执行平均需要 170 毫秒
Average time: 170ms - Last execution took: 160um
Average time: 170ms - Last execution took: 179um
Average time: 170ms - Last execution took: 162um使用移动构造函数
make clean
make CFLAGS=-DOPT5
./memory_sample这次,执行平均需要 143 毫秒
Average time: 143ms - Last execution took: 142um
Average time: 143ms - Last execution took: 138um
Average time: 143ms - Last execution took: 142um这个(构建的)示例实现了运行时减少 16%。请注意,编译器优化已关闭 (-O0)。使用更高程度的优化 (-O3),运行时减少将不那么显着 (11%)。
分析内存分配
在基于 Linux 的系统上分析内存分配的首选工具是 valgrind,或者更准确地说,是 堆分析器 massif。在本例中,执行复制构造函数会导致堆内存分配。
在第一个示例中运行 massif
$ make clean
$ make CFLAGS=-DOPT1
$ valgrind --tool=massif ./memory_sample这将输出一个名为 massif.out 的文件,带有尾随进程 ID。可以使用 ms_print 读取此文件
ms_print massif.out.4781它打印出内存分配随时间变化的图表
(Stephan Avenwedde, CC BY-SA 4.0)
此图表显示内存分配随时间增加。这与实现相符。ms_print 的输出还包括有关哪些代码行导致内存分配的信息。我不会详细介绍如何读取输出,因为有 优秀的文档 可用。
结论
无论您使用哪种编程语言,减少复制操作(在本例中会导致堆内存分配)都是提高对性能至关重要的应用程序的运行时执行速度的好方法。当使用 C++ 时,您需要一些先决条件来应用这些优化
- C++11,因为
move语义仅自该版本起可用 move构造函数/move赋值运算符的可用性(请参阅 规则 5)
在现代 x86-64 CPU 上,堆内存分配非常快,如果没有精确的测量,您不会注意到应用程序是否已优化。您的 CPU 性能越低,优化运行时代码就越有意义。例如,在移动设备上,它不仅可以改善响应行为,还可以延长电池寿命。

评论已关闭。