如果您可以选择用于应用程序的编程语言,您通常会选择您熟悉的并且能够以最短路径达到目标的语言。如果您需要高运行时速度,那么直接编译为机器代码的编程语言(如 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 性能越低,优化运行时代码就越有意义。例如,在移动设备上,它不仅可以改善响应行为,还可以延长电池寿命。
评论已关闭。