
前言std::vector是 C 里最常用的容器也是多线程事故最集中的地方。典型现象包括程序偶发崩溃、堆损坏heap corruption、double free、迭代器指向已释放内存、数据莫名其妙丢失。这些症状看起来像「内存错误」根源却往往是两个线程同时修改了同一个 vector触发了未定义行为。最常见的误解有三个一是认为reserve()之后push_back()就安全了因为「不会扩容」二是认为多个线程写不同下标互不干扰三是认为「只有一个线程写、其他线程读」没问题。前两个忽略了size本身的更新和标准对容器并发访问的限制第三个则直接构成数据竞争。本文先解释 vector 的内存布局与失效规则再拆解三类典型错误场景最后给出互斥保护、分片合并、只读快照等可落地的方案。所有结论以 C17 标准语义为准。一、vector 的内存布局与失效规则std::vector通常由三个指针描述起始地址、结束地址size 末尾和容量末尾capacity 末尾。size()是已构造元素个数capacity()是不重新分配前提下能容纳的元素个数。当size() capacity()再push_back时vector 会申请一块更大的连续内存把已有元素移动或拷贝过去然后释放旧内存。这一步「搬家和释放旧家」正是多线程事故的核心其他线程手里的指针、引用、迭代器全部指向已经释放的旧缓冲区。按标准规定扩容后所有指针、引用、迭代器都失效即使没有扩容push_back也会使尾后迭代器end()失效。操作是否可能重新分配失效范围push_back/emplace_back容量不足时会扩容时全部失效否则仅end()insert可能插入点及之后扩容时全部erase不会删除点及之后reserve(n)n 大于当前容量会全部resize增大且超容量会全部clear不会全部size 归零shrink_to_fit可能全部标准对并发访问到底保证了什么标准库容器的通用约定是多个线程可以同时读同一个容器只要没有线程在修改它修改不同元素在大多数容器上被允许并发进行但std::vectorbool是明确的例外因为它按位压缩存储多个「元素」可能共享同一个机器字。任何会改变容器结构size、capacity、重新分配的操作都要求独占访问。这条规则很容易被读成「写不同下标没问题」但实践中有两个陷阱。第一写不同下标虽然不构成容器层面的数据竞争但如果这些元素在逻辑上相关比如同时更新一个计数器和一条记录仍然需要同步。第二vectorbool的代理引用会让「不同下标」落在同一字节上并发写就是数据竞争。至于「一个线程写、多个线程读」只要写操作与读操作之间没有同步就是数据竞争属于 UB。标准不会因为「读」而豁免。所谓「读线程看到旧值也没关系」只是你的一厢情愿编译器可以把读提到循环外也可能读到撕裂的size与指针组合。二、三类典型错误场景场景一多线程并发push_back。两个线程同时执行v.push_back(x)可能同时观察到size capacity各自申请新缓冲区互相覆盖指针最终一块内存被释放两次。即使reserve过两个线程仍会竞争修改同一个size计数器和同一段尾元素内存。场景二遍历期间被其他线程修改。一个线程用迭代器或范围 for 遍历另一个线程push_back触发扩容遍历线程手里的迭代器立即悬垂。这类问题在压力测试里可能几天才出现一次。场景三把data()或元素地址交给另一个线程。例如把v[0]传给工作线程主线程随后push_back导致扩容工作线程写入的就是已释放内存。// 适用于 C17 —— 错误示范仅用于说明问题#include thread#include vectorstd::vectorint g_data;void bad_writer(int base) {for (int i 0; i 1000; i) {g_data.push_back(base i); // 多线程同时执行数据竞争UB}}三、方案一用互斥量保护所有访问最直接也最不容易出错的做法把 vector 和一把互斥量封装在同一个类里所有读写都走成员函数。注意读操作也必须加锁。// 适用于 C17#include mutex#include vectorclass IntStore {public:void append(int value) {std::lock_guardstd::mutex lk(m_);data_.push_back(value);}void append_range(const std::vectorint values) {std::lock_guardstd::mutex lk(m_);data_.insert(data_.end(), values.begin(), values.end());}std::vectorint snapshot() const {std::lock_guardstd::mutex lk(m_);return data_; // 拷出一份调用方在锁外随意使用}std::size_t size() const {std::lock_guardstd::mutex lk(m_);return data_.size();}private:mutable std::mutex m_;std::vectorint data_;};snapshot()返回的是副本调用方拿到的迭代器不会因为后续append而失效。代价是一次 O(n) 拷贝如果数据量大且读多写少可以改成std::shared_ptrconst std::vectorint配合std::shared_mutex让读者共享同一份不可变快照。锁的粒度要合理可以在锁外准备好要插入的局部 vector再一次性insert避免在锁内做耗时计算。但不要为了「减少锁时间」而把size()的读取放到锁外那正是竞争点。四、方案二分片收集最后合并如果每个线程本来就在产生独立数据最好的办法是让它们各写各的局部 vector最后单线程合并。这样整个热路径没有锁也没有共享写。// 适用于 C17#include cstddef#include thread#include vectorstd::vectorint parallel_collect(int workers, int per_worker) {std::vectorstd::vectorint parts(static_caststd::size_t(workers));std::vectorstd::thread pool;pool.reserve(static_caststd::size_t(workers));for (int w 0; w workers; w) {pool.emplace_back([parts, w, per_worker] {auto local parts[static_caststd::size_t(w)];local.reserve(static_caststd::size_t(per_worker));for (int i 0; i per_worker; i) {local.push_back(w * per_worker i);}});}for (auto t : pool) t.join();std::size_t total 0;for (const auto p : parts) total p.size();std::vectorint merged;merged.reserve(total);for (const auto p : parts) {merged.insert(merged.end(), p.begin(), p.end());}return merged;}关键点是每个线程只访问parts[w]这一个已经构造好的std::vector对象彼此不共享元素parts本身的大小在启动线程前就固定线程运行期间不做任何会改变parts结构的操作。合并阶段所有线程已join不存在并发。方案三固定容量 原子下标当元素类型平凡、数量上限已知、只追加不删除时可以预分配固定大小并原子地领取下标// 适用于 C17#include atomic#include cstddef#include vectorclass FixedBuffer {public:explicit FixedBuffer(std::size_t capacity) : data_(capacity) {}// 返回写入位置的下标容量耗尽返回 capacity()std::size_t claim() {std::size_t idx next_.fetch_add(1, std::memory_order_relaxed);return idx data_.size() ? idx : data_.size();}int at(std::size_t idx) { return data_[idx]; }std::size_t capacity() const { return data_.size(); }private:std::vectorint data_; // 构造后不再改变大小std::atomicstd::size_t next_{0};};这里data_的结构永远不变构造时定好大小之后只写元素所以不存在扩容导致指针失效的问题每个线程通过fetch_add拿到唯一下标写的是不同元素。前提是元素类型不是bool且写入完成后要有适当的同步例如用release/acquire或额外的完成标志才能让读者安全读取。若需要知道「哪些位置已经写好」还得再加一层状态数组或计数器。常见坑点❌ 认为v.reserve(10000)后多线程push_back就安全。✅reserve只消除重新分配size更新和尾元素写入仍然竞争要么加锁要么每线程独立容器。❌ 一个线程遍历for (int x : v)另一个线程push_back。✅ 遍历前在锁内拷贝快照或整个遍历期间持锁或改用不可变快照。❌ 把v.data()或v[i]交给工作线程长期保存。✅ 保证交接期间容器不被修改否则改用std::shared_ptrconst std::vectorT这类生命周期稳定的载体。❌ 多线程写std::vectorbool的不同下标以为互不影响。✅vectorbool按位存储不同下标可能共享字节改用std::vectorchar或std::arraybool, N。❌ 只给push_back加锁size()、empty()、范围 for 直接读。✅ 所有访问统一走同一把锁读也是访问。❌ 在持锁期间回调用户代码或做文件 I/O导致锁被长时间占用甚至重入死锁。✅ 锁内只做容器操作把耗时逻辑移到锁外。❌ 用全局 vector 配合后台线程程序退出时静态对象先析构、线程后访问。✅ 退出前显式停止并join所有线程不要让线程活过容器生命周期。❌ 用erase删除元素的同时其他线程持有该元素引用。✅ 删除会使删除点及之后的引用/迭代器失效先同步再让持有者释放引用。总结方案并发写读的性能额外内存适用场景互斥量封装串行化需加锁或拷贝无通用优先选择读写锁 不可变快照写时新建快照读者并发一份快照读多写少、数据量大分片收集后合并各线程独立合并后单线程读与元素总数同阶批量并行计算固定容量 原子下标不同下标并行需额外同步标志预分配满容量数量上限已知的追加场景std::vector本身不提供任何线程安全保证标准只承诺「不修改时可并发读」以及「不同元素可并发修改vectorbool除外」。真正要记住的是扩容会让所有指针、引用、迭代器失效而失效的时机无法从另一个线程安全地观测。把共享 vector 封装起来、明确所有权或者干脆让每个线程拥有自己的容器比事后排查堆损坏要省事得多。