C++ STL容器如何解决线程安全的问题?

本文涉及的产品
容器镜像服务 ACR,镜像仓库100个 不限时长
简介: 众所周知,STL容器不是线程安全的。对于vector,即使写方(生产者)是单线程写入,但是并发读的时候,由于潜在的内存重新申请和对象复制问题,会导致读方(消费者)的迭代器失效。实际表现也就是招致了core dump。另外一种情况,如果是多个写方,并发的push_back(),也会导致core dump。

众所周知,STL容器不是线程安全的。对于vector,即使写方(生产者)是单线程写入,但是并发读的时候,由于潜在的内存重新申请和对象复制问题,会导致读方(消费者)的迭代器失效。实际表现也就是招致了core dump。另外一种情况,如果是多个写方,并发的push_back(),也会导致core dump。


解法一


加锁是一种解决方案,比如互斥锁std::mutex。但是加std::mutex确实性能较差。对于多读少写的场景可以用读写锁(也叫共享独占锁)来缓解。比如C++17引入了std::shared_mutex 。更多锁的种类可以阅读我之前写的这篇文章:


如何理解互斥锁、条件变量、读写锁以及自旋锁?


当然本文的目的自然不是自我重复再次介绍一次锁的使用,请继续阅读解法二!


解法二


更多的时候,其实可以通过固定vector的大小,避免动态扩容(无push_back)来做到lock-free!


即在开始并发读写之前(比如初始化)的时候,给vector设置好大小。


struct Data {
...
};
vector<Data> v;
v.resize(1000);


注意是resize(),不是reserve()


可能大家平时用reserve()比较多,顾名思义,reserve就是预留内存。为的是避免内存重新申请以及容器内对象的拷贝。说白了,reserve()是给push_back()准备的!

而resize除了预留内存以外,还会调用容器元素的构造函数,不仅分配了N个对象的内存,还会构造N个对象。从这个层面上来说,resize()在时间效率上是比reserve()低的。但是在多线程的场景下,用resize再合适不过。


你可以resize好N个对象,多线程不管是读还是写,都是通过容器的下标访问operator[]来访问元素,不要push_back()新元素。所谓的『写操作』在这里不是插入新元素,而是修改旧元素。


如果N的最大个数是可以预期的就直接设置就好,如果没办法预期就再把vector搞成ring buffer(环形队列)来缓解压力。


可以给元素类加上成员变量标记当前的读写状态、是否被消费等等。


当然,你会说,如果B,C,D,E,F这个5个线程是等价的,要不停消费vector中的元素,会造成重复消费不?


当然会。你可以把队列头的下标定义成原子变量(std::atomic),尽管原子变量也需要做线程同步,但是比一般的锁开销要小很多啦。


如果你想连原子变量也不用,有没有办法呢?有啊。那就给B,C,D,E,F分配不同的消费队列啊。比如当前有5个读线程,那么每个线程就消费下标对5取模之后的某个固定结果的下标。比如:


  • B消费:0、5、10、15、……


  • C消费:1、6、11、16、……


  • D消费:2、7、12、17、……


  • E消费:3、8、13、18、……


  • F消费:4、9、14、19、……


每个读线程各自维护自己当前消费的最新下标。


这样做有啥问题没?也有,就是可能会导致不同的线程繁忙和等待的情况差异巨大:忙的忙死,闲的闲死。具体场景具体分析,总之,无论如何要控制住。不要让一个任务hang住整个线程。


vector是顺序容器,STL中还有一类关联容器其线程安全问题也不容小觑。比如map、unordered_map。


我们可能会有这样一种场景:在并发环境下,收集一些Key-Value,存储在某一个公共的容器中。这里也谈一下不用锁的方案,当然做不到放之四海皆准。它有一些限制条件,只能看是否满足你的需要了。


当有多个写线程对情况下,并发地插入 map/unordered_map都会引发core dump。对此,在某些场景下也可以避免加锁:如果全量的key有办法在并发之前就能拿到的,那么就对这个map,提前做一下insert。并发环境中如果只是修改value,而不是插入新key就不会core dump!不过如果你没办法保证多个写线程不会同时修改同一个key的value,那么可能存在value的覆盖。无法保证这点时,还是需要加锁。不过可以对key采取某种hash策略转成整型,然后进行分段加锁,减少一点锁冲突的概率,或者用一下CAS的策略。


另外对于unordered_map,在单写多读的多线程场景下,会不会有问题呢?也可能有。gcc 4.7.2的unordered_map实现曾被爆出有这个问题。原因的新插入的元素,触发了rehash,让其他线程在unordered_map中查找的过程之中,出现了core dump。见:


https://stackoverflow.com/questions/16353334/segv-in-gccs-stdunordered-map


我不确定clang以及后续的gcc版本是否还有此问题。应该在不添加任何额外同步代码的情况下,无法解决。


容器并发前初始化与伪共享的争议


本文内容我曾经在知乎上写过,有网友评论:解法二会有false sharing(伪共享)的问题。


这里我简单回应一下,谈论伪共享,要考虑具体的场景。的确某些时候伪共享会带来性能损失,但是要和并行化带来的性能提升来比较,孰高孰低。如果并行提升的性能足够多,是足以弥补这点伪共享的损失的。


比如我要进行远程IO,我有N个key要查询redis,把他们的结果存储到一个vector中,这个vector的写入操作在IO的异步回调函数中。在不加任何额外处理的情况下,极大概率会导致vector的core dump。而如果vector初始化一下,则无需在回调函数中加锁,就能保证安全。这时候并行IO本身带来的性能提升,远远大于可能的伪共享带来损失。


这里为什么说可能呢?因为伪共享的触发没你想象的这么简单。如何成功模拟出一次伪共享带来性能损失的例子?你可以写程序自测一下,并不容易……甚至你改一下优化级别,改成O2,测试表现都很不一样。


一般网络上谈论伪共享时所举的例子,并不是一个vector中多个元素之间并行读写触发了伪共享。而是vector的元素类型是一个对象,对象中有2个数据字段a和b,在多线程分别更新同一个元素的a和b字段的时候,导致了伪共享。比如一个线程更新vector中每个元素的a字段,另外一个线程更新vector中每个元素的b字段。


Anyway,伪共享的议题比较复杂,欢迎留意评论!

目录
打赏
0
0
0
0
5
分享
相关文章
【c++丨STL】基于红黑树模拟实现set和map(附源码)
本文基于红黑树的实现,模拟了STL中的`set`和`map`容器。通过封装同一棵红黑树并进行适配修改,实现了两种容器的功能。主要步骤包括:1) 修改红黑树节点结构以支持不同数据类型;2) 使用仿函数适配键值比较逻辑;3) 实现双向迭代器支持遍历操作;4) 封装`insert`、`find`等接口,并为`map`实现`operator[]`。最终,通过测试代码验证了功能的正确性。此实现减少了代码冗余,展示了模板与仿函数的强大灵活性。
59 2
【c++丨STL】map/multimap的使用
本文详细介绍了STL关联式容器中的`map`和`multimap`的使用方法。`map`基于红黑树实现,内部元素按键自动升序排列,存储键值对,支持通过键访问或修改值;而`multimap`允许存在重复键。文章从构造函数、迭代器、容量接口、元素访问接口、增删操作到其他操作接口全面解析了`map`的功能,并通过实例演示了如何用`map`统计字符串数组中各元素的出现次数。最后对比了`map`与`set`的区别,强调了`map`在处理键值关系时的优势。
140 73
【c++丨STL】set/multiset的使用
本文深入解析了STL中的`set`和`multiset`容器,二者均为关联式容器,底层基于红黑树实现。`set`支持唯一性元素存储并自动排序,适用于高效查找场景;`multiset`允许重复元素。两者均具备O(logN)的插入、删除与查找复杂度。文章详细介绍了构造函数、迭代器、容量接口、增删操作(如`insert`、`erase`)、查找统计(如`find`、`count`)及`multiset`特有的区间操作(如`lower_bound`、`upper_bound`、`equal_range`)。最后预告了`map`容器的学习,其作为键值对存储的关联式容器,同样基于红黑树,具有高效操作特性。
69 3
C++ 容器全面剖析:掌握 STL 的奥秘,从入门到高效编程
C++ 标准模板库(STL)提供了一组功能强大的容器类,用于存储和操作数据集合。不同的容器具有独特的特性和应用场景,因此选择合适的容器对于程序的性能和代码的可读性至关重要。对于刚接触 C++ 的开发者来说,了解这些容器的基础知识以及它们的特点是迈向高效编程的重要一步。本文将详细介绍 C++ 常用的容器,包括序列容器(`std::vector`、`std::array`、`std::list`、`std::deque`)、关联容器(`std::set`、`std::map`)和无序容器(`std::unordered_set`、`std::unordered_map`),全面解析它们的特点、用法
C++ 容器全面剖析:掌握 STL 的奥秘,从入门到高效编程
【c++丨STL】priority_queue(优先级队列)的使用与模拟实现
本文介绍了STL中的容器适配器`priority_queue`(优先级队列)。`priority_queue`根据严格的弱排序标准设计,确保其第一个元素始终是最大元素。它底层使用堆结构实现,支持大堆和小堆,默认为大堆。常用操作包括构造函数、`empty`、`size`、`top`、`push`、`pop`和`swap`等。我们还模拟实现了`priority_queue`,通过仿函数控制堆的类型,并调用封装容器的接口实现功能。最后,感谢大家的支持与关注。
107 1
|
3月前
|
【c++丨STL】stack和queue的使用及模拟实现
本文介绍了STL中的两个重要容器适配器:栈(stack)和队列(queue)。容器适配器是在已有容器基础上添加新特性或功能的结构,如栈基于顺序表或链表限制操作实现。文章详细讲解了stack和queue的主要成员函数(empty、size、top/front/back、push/pop、swap),并提供了使用示例和模拟实现代码。通过这些内容,读者可以更好地理解这两种数据结构的工作原理及其实现方法。最后,作者鼓励读者点赞支持。 总结:本文深入浅出地讲解了STL中stack和queue的使用方法及其模拟实现,帮助读者掌握这两种容器适配器的特性和应用场景。
88 21
深入浅出 C++ STL:解锁高效编程的秘密武器
C++ 标准模板库(STL)是现代 C++ 的核心部分之一,为开发者提供了丰富的预定义数据结构和算法,极大地提升了编程效率和代码的可读性。理解和掌握 STL 对于 C++ 开发者来说至关重要。以下是对 STL 的详细介绍,涵盖其基础知识、发展历史、核心组件、重要性和学习方法。
【c++丨STL】list模拟实现(附源码)
本文介绍了如何模拟实现C++中的`list`容器。`list`底层采用双向带头循环链表结构,相较于`vector`和`string`更为复杂。文章首先回顾了`list`的基本结构和常用接口,然后详细讲解了节点、迭代器及容器的实现过程。 最终,通过这些步骤,我们成功模拟实现了`list`容器的功能。文章最后提供了完整的代码实现,并简要总结了实现过程中的关键点。 如果你对双向链表或`list`的底层实现感兴趣,建议先掌握相关基础知识后再阅读本文,以便更好地理解内容。
84 1
【c++丨STL】list的使用
本文介绍了STL容器`list`的使用方法及其主要功能。`list`是一种双向链表结构,适用于频繁的插入和删除操作。文章详细讲解了`list`的构造函数、析构函数、赋值重载、迭代器、容量接口、元素访问接口、增删查改操作以及一些特有的操作接口如`splice`、`remove_if`、`unique`、`merge`、`sort`和`reverse`。通过示例代码,读者可以更好地理解如何使用这些接口。最后,作者总结了`list`的特点和适用场景,并预告了后续关于`list`模拟实现的文章。
129 7
Spring容器中的bean是线程安全的吗?
Spring容器中的bean默认为单例模式,多线程环境下若操作共享成员变量,易引发线程安全问题。Spring未对单例bean做线程安全处理,需开发者自行解决。通常,Spring bean(如Controller、Service、Dao)无状态变化,故多为线程安全。若涉及线程安全问题,可通过编码或设置bean作用域为prototype解决。
71 1