C++17循环内局部STL容器插入list时std::move与copy选型问题
问题背景
在如下C++17场景中存在性能优化选型疑问:代码最后一处emplace_back调用显然适合使用std::move,因为执行完成后局部变量idsLocal不再被使用。但循环内的emplace_back调用是否应该用std::move存在争议:传入std::move(idsLocal)可以避免容器拷贝开销,但有人担心move操作转移内存所有权后,后续迭代中idsLocal会丢失reserve预留的内存空间,触发多次动态重分配反而增加开销。
本次测试的固定入参为:limit值为1024,输入共5120个不同id。
最初的两种实现内存分配行为估算:
第一处emplace_back不使用move的场景: -> idsLocal执行reserve产生1次malloc -> 5次拷贝idsLocal到idsList产生5次malloc 第一处emplace_back使用move的场景: -> idsLocal执行reserve产生1次malloc -> 5次move idsLocal到idsList产生0次malloc(待验证) -> 首次move后idsLocal动态扩容产生40次malloc(每轮limit对应10次push_back触发扩容,共4轮,待验证)
核心疑问:如果内存分配开销较高,使用move的版本是否反而更慢?全场景使用std::move是否有性能收益?背后原理是什么?
测试原始代码
#include <list> #include <numeric> #include <vector> template < class T > auto divideIntoIdsList( size_t limit, const T &ids, std::list< T > &idsList ) { if ( ids.empty() ) return; T idsLocal; idsLocal.reserve( limit ); size_t iIdCount=0; for( typename T::const_iterator itIds = ids.begin(); itIds != ids.end(); ++itIds ) { idsLocal.push_back( *itIds ); if( ++iIdCount == limit ) { idsList.emplace_back( idsLocal ); // 是否使用std::move(idsLocal)? idsLocal.clear(); iIdCount=0; } } if( iIdCount > 0 ) idsList.emplace_back( idsLocal ); // 此处使用std::move收益明确 } int main() { using ids_t = std::vector< size_t >; ids_t ids( 5120 ); std::iota( std::begin( ids ), std::end( ids ), 0 ); std::list< ids_t > idslist; divideIntoIdsList( 1024, ids, idslist ); return 0; }
核心原理
首先明确两个所有主流STL实现(GCC libstdc++、Clang libc++、MSVC STL)都遵循的vector行为:
std::vector的移动构造是严格的常数时间操作,仅转移内部堆内存指针、大小、容量三个字段,不会做任何内存分配、元素拷贝操作。移动完成后,源vector处于合法但未指定状态,主流实现中移动后的源vectorsize()和capacity()均为0,原持有的堆内存完全归新构造的vector所有。vector::clear()仅会销毁容器内元素、将size()置为0,不会改变capacity,也不会释放已持有的堆内存——但如果vector本身已经因为move操作丢失了内存所有权(capacity为0),clear()也不可能凭空重新分配内存。
你之前的估算存在两个偏差:
- 「move后会产生40次malloc」的结论不准确:从空vector开始插入1024个元素,以主流实现2倍扩容因子计算,单轮插入仅会触发11次内存分配(容量从0逐步增长到1、2、4、8、16、32、64、128、256、512、1024),4轮合计44次分配,但每次扩容时旧内存会立刻被释放,且分配的内存块从小到大逐步增长,分配器对这种小内存块的分配效率远高于直接分配1024个元素的大块内存。
- 就算完全不做额外优化,直接在循环内使用move,总开销依然低于拷贝版本:拷贝版本每次插入都要分配1次1024元素大小的内存,再拷贝1024个元素,合计5次大块内存分配+5120次元素拷贝;而无额外优化的move版本,虽然有44次小内存分配,但总元素拷贝次数仅为4092次(每次扩容时拷贝旧元素的总量),且小内存分配的开销远低于大块内存分配,实际运行速度依然更快。
最优实现方案
完全不需要为了避免扩容而放弃move的收益,只需要在循环内move+clear之后补一行reserve(limit),就可以彻底消除后续扩容的开销,此时总开销为:
- 初始reserve:1次内存分配
- 5次move操作:0次内存分配、0次元素拷贝
- 4次循环内clear后的reserve:4次内存分配
总内存分配次数为5次,比不使用move的版本(6次分配)更少,且全程没有额外的元素拷贝开销,性能达到最优。
对应修改后的循环片段:
if( ++iIdCount == limit ) { idsList.emplace_back( std::move(idsLocal) ); idsLocal.clear(); idsLocal.reserve(limit); // 补这一行即可彻底避免后续扩容 iIdCount=0; }
最后一处emplace_back直接使用std::move(idsLocal)即可,不需要再做reserve,因为函数执行完后idsLocal会立刻被销毁。
最终结论
所有emplace_back调用处都应该使用std::move,只要在循环内move后补一次reserve,就能获得远高于拷贝版本的性能;就算忘记补reserve,move版本的性能也不会低于拷贝版本,不存在「move反而更慢」的情况。
内容的提问来源于stack exchange,提问作者SoulfreezerXP

