STL 容器的线程安全性——从数据竞争到引用失效的三层陷阱

讨论「STL 容器线程安全」时,若先对齐一条分层链,就不容易把不同层次的问题搅在一起:标准只承诺数据竞争层的底线(多读安全、有写必同步)→ 但加了锁也只消除了竞争,生命周期层的「引用 / 指针 / 迭代器失效」依旧存在 → 而失效与否,最终落在内存模型层:容器是连续存储还是节点式,决定了一次
erase会不会让别人手里的地址作废。三层从上到下是「能不能并发访问」→「访问的地址还在不在」→「为什么在 / 不在」,对齐在哪一层再争,才不会鸡同鸭讲。
1. 一条链:从「标准只保证什么」到「为什么会悬指针」
历史与条款可以从简;要紧的是每一层解决了什么、又遗留了什么,下一层才必然出现。
1.1 数据竞争层:标准给出的底线
C++ 标准对容器线程安全的承诺极其有限,可以压成两句话:
- 多个线程同时只读同一容器是安全的;
- 只要有一个线程在写,所有访问(包括读)都必须由使用者外部同步。
C++17 [res.on.data.races] 进一步明确:同一容器上的不同成员函数调用相互冲突,除非全是只读;begin()、end()、size()、empty() 这些看似无害的操作,与写并发时同样构成数据竞争;而不同容器实例之间互不影响。
标准刻意不在容器里塞任何 mutex。原因是锁的粒度只能由使用者决定——库无法预知你是想保护单次操作、一段事务、还是整个数据结构,强行内建锁只会在所有场景都不合适。
1.2 生命周期层:加了锁,问题没完
矛盾:给每次容器操作都套上 mutex,数据竞争确实消失了——但这只保证「同一时刻没有两条执行流踩同一块内存」,并不保证你上一次操作拿到的地址,到下一次操作时还指向同一个对象。
// 线程 A:取元素地址
lock();
auto* ptr = &container.back(); // 跨出临界区后还想用
unlock();
ptr->done = true; // ← 这个地址此刻还有效吗?
// 线程 B:删了中间某个元素
lock();
container.erase(it); // 可能让 ptr 悬挂
unlock();两条线程各自加锁,没有数据竞争——但 ptr 指向的对象可能已被 erase 搬走或析构。这比数据竞争更难查:通常不崩溃、ASan 也未必报,只表现为「写进去了却没人读到」,逻辑陷入死等。
问题本质是锁的生命周期与指针的生命周期不匹配:锁保护单次操作,指针却跨越了多次操作。要补这个洞,得下沉到容器的内存模型。
1.3 内存模型层:失效与否由布局决定
为什么有的容器搬家、有的不搬,取决于元素是「紧密排在一起」还是「各自独立分配」。这一层才是真正决定上一层「地址还在不在」的根,下一节展开。
2. 内存模型决定地址稳定性
容器操作是否让元素地址变化,是由内存布局模型直接推出的结论,不是实现的随意选择:连续存储天然不稳定,节点式天然稳定,
deque与unordered_*各有一块「条件稳定」的灰色地带。
| 容器 | 内存模型 | 中间 erase / 插入是否搬家 | 元素地址稳定性 |
|---|---|---|---|
std::vector | 连续数组 | 是(任何位置增删 / 扩容都可能) | 最差 |
std::string | 连续数组 (SSO) | 是 | 最差 |
std::deque | 分段数组 | 中间 erase 搬家;两端 push/pop 稳定 | 部分 |
std::list | 双向链表节点 | 否——只摘除目标节点 | 稳定 |
std::forward_list | 单向链表节点 | 否 | 稳定 |
std::map / set | 红黑树节点 | 否 | 稳定 |
std::unordered_map / set | 哈希桶 + 链表 | 日常稳定,rehash 时全失效 | 有条件稳定 |
把它分成三类来记,比逐个背接口可靠:
- 连续容器(
vector、string):元素紧密排列,中间增删必须移动后续元素来维持连续性,扩容更是整体搬迁——地址几乎不可托付。 - 节点式容器(
list、map、set):每个元素独立堆分配,增删只改相邻节点的指针链接,元素地址恒定。 - 两块灰色地带:
deque分块存储,两端操作不动已有元素,但中间erase会把删除点之后的元素逐个 move 上来填坑;unordered_*日常地址稳定,可一旦负载因子超阈值触发 rehash,所有元素重新散列,引用 / 指针 / 迭代器全部失效。
一句话:「会不会搬家」不是 API 细节,而是内存模型的必然推论——这正是上一节那个悬指针 Bug 的根因所在。
3. 一个真实案例:deque → list 的最小修复
当工作线程长期持有指向容器元素的裸指针,而另一条线程在容器中间 erase 已完成的元素,
deque的「中间删除搬家」会让指针悬挂;换成list是改动最小、又恰好命中根因的修复。
场景还原(基于某分布式存储引擎的 lookup 路径):主线程把 &lookup_pending_.back() 交给每个 lookup bthread,bthread 之后靠这个裸指针写回 done / ret;主线程的轮询逻辑一旦发现某请求 done,就把它从容器中间 erase 掉。
- 同一个「并发写 + 中间删除」的模式,
deque搬家造成悬指针,list因为只摘节点而安全。 - Bug 的表象是
Next()死循环、请求永远「没完成」,但根因在第 2 节那张表的一格:deque中间erase不保证地址稳定。
为什么是 list,而不是别的修法:
| 方案 | 命中根因的方式 | 代价 |
|---|---|---|
deque → list | 节点地址恒定,裸指针永远有效 | 遍历缓存不友好;每节点多 prev/next 指针 |
deque<unique_ptr<T>> | 稳的是 unique_ptr 持有的堆对象 | 多一层堆分配 + 解引用,生命周期更绕 |
| 加锁 + 复制出元素 | 不持有元素地址,绕开整个问题 | 锁粒度大、复制开销 |
| 无锁队列 | FIFO 场景性能最优 | 不支持中间删除,本例用不了 |
list 在这里是 drop-in 替换:用到的 emplace_back / back / erase / size / empty / 遍历 / clear 它全支持,业务 .cc 一行不用动。代价(局部性差)在「并发上限 4 个、容器极小」的前提下可忽略——选型成立的前提是这个上下文,换个高频遍历的场景结论就不同。
4. 判别模式:什么时候必须查地址稳定性
风险只在一个特定交叉点出现:一条执行流持有元素地址,另一条执行流可能改动容器结构。三个条件同时成立才危险,打破任意一个即安全。
- 跨操作持有元素地址——不限裸指针,
std::reference_wrapper、std::observer_ptr、缓存下来的迭代器都算。 - 容器结构会被并发修改——
insert/erase/push/pop/reserve/ 触发rehash都算;纯读不算。 - 持有的是元素内部地址而非容器顶层句柄——
&vec[i]、&list.front()、&map[k]属于元素地址;容器对象本身的地址不会因内容变化而变。
三者同时满足才有雷。常见落点:
- 生产者 / 消费者队列:消费者取走元素后,生产者旧指针可能失效 → 存
unique_ptr或消费端复制值。 - 回调注册表:回调持有上下文指针,注册表
erase时回调可能还在跑 → 用节点式容器,或erase前先等回调结束。 - 并发读 + 偶尔写:读端缓存了迭代器,写端
erase同一元素 → 即使读写锁保护,迭代器仍会失效 → 读端改为复制值,不留迭代器。
5. 选型判据
不是「
list优于deque」,而是「跨执行流持有元素地址」这一约束一旦成立,地址稳定性就压过局部性,成为首要指标;约束不成立时,连续容器几乎总是更优。
元素地址是否会被别的执行流跨操作持有?
├─ 否 → 容器类型无所谓,按性能 / 局部性选(多数场景 vector/deque 更好)
└─ 是 → 在你实际做的那个操作下,地址是否稳定?
├─ 稳定 → 直接用
└─ 不稳定 → 三选一:
1. 换节点式容器(list / map / set)
2. 容器存智能指针,间接持有对象
3. 改设计:不持有元素地址,改为复制值或下标索引6. 小结
- 数据竞争层:标准只保证「多读安全、有写必同步」,且刻意不内建锁——粒度交给使用者。
- 生命周期层:加锁只消除竞争,消不掉引用 / 指针 / 迭代器失效;锁护的是单次操作,指针却跨多次操作。
- 内存模型层:搬不搬家是布局的必然——连续容器不稳、节点式稳、
deque中间删与unordered_*rehash 是灰色地带。 - 判据:仅当「跨执行流持有元素地址 + 容器结构被改」同时成立才需担心,此时地址稳定性优先;否则按性能选。
一句话说全:谈 STL 容器线程安全,先分清是在争「能不能并发访问」「访问的地址还在不在」还是「为什么在 / 不在」——三层对齐了,加锁与换容器才不会各修各的、修错了层。
版权所有
版权归属:Pray0