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,伪共享的议题比较复杂,欢迎留意评论!

相关文章
|
1天前
|
算法 C语言 C++
【c++丨STL】list的使用
本文介绍了STL容器`list`的使用方法及其主要功能。`list`是一种双向链表结构,适用于频繁的插入和删除操作。文章详细讲解了`list`的构造函数、析构函数、赋值重载、迭代器、容量接口、元素访问接口、增删查改操作以及一些特有的操作接口如`splice`、`remove_if`、`unique`、`merge`、`sort`和`reverse`。通过示例代码,读者可以更好地理解如何使用这些接口。最后,作者总结了`list`的特点和适用场景,并预告了后续关于`list`模拟实现的文章。
19 7
|
13天前
|
安全 Java 开发者
Spring容器中的bean是线程安全的吗?
Spring容器中的bean默认为单例模式,多线程环境下若操作共享成员变量,易引发线程安全问题。Spring未对单例bean做线程安全处理,需开发者自行解决。通常,Spring bean(如Controller、Service、Dao)无状态变化,故多为线程安全。若涉及线程安全问题,可通过编码或设置bean作用域为prototype解决。
26 1
|
19天前
|
存储 编译器 C语言
【c++丨STL】vector的使用
本文介绍了C++ STL中的`vector`容器,包括其基本概念、主要接口及其使用方法。`vector`是一种动态数组,能够根据需要自动调整大小,提供了丰富的操作接口,如增删查改等。文章详细解释了`vector`的构造函数、赋值运算符、容量接口、迭代器接口、元素访问接口以及一些常用的增删操作函数。最后,还展示了如何使用`vector`创建字符串数组,体现了`vector`在实际编程中的灵活性和实用性。
45 4
|
20天前
|
C语言 C++ 容器
【c++丨STL】string模拟实现(附源码)
本文详细介绍了如何模拟实现C++ STL中的`string`类,包括其构造函数、拷贝构造、赋值重载、析构函数等基本功能,以及字符串的插入、删除、查找、比较等操作。文章还展示了如何实现输入输出流操作符,使自定义的`string`类能够方便地与`cin`和`cout`配合使用。通过这些实现,读者不仅能加深对`string`类的理解,还能提升对C++编程技巧的掌握。
45 5
|
20天前
|
存储 编译器 C语言
【c++丨STL】string类的使用
本文介绍了C++中`string`类的基本概念及其主要接口。`string`类在C++标准库中扮演着重要角色,它提供了比C语言中字符串处理函数更丰富、安全和便捷的功能。文章详细讲解了`string`类的构造函数、赋值运算符、容量管理接口、元素访问及遍历方法、字符串修改操作、字符串运算接口、常量成员和非成员函数等内容。通过实例演示了如何使用这些接口进行字符串的创建、修改、查找和比较等操作,帮助读者更好地理解和掌握`string`类的应用。
30 2
|
25天前
|
存储 安全 Java
Java多线程编程中的并发容器:深入解析与实战应用####
在本文中,我们将探讨Java多线程编程中的一个核心话题——并发容器。不同于传统单一线程环境下的数据结构,并发容器专为多线程场景设计,确保数据访问的线程安全性和高效性。我们将从基础概念出发,逐步深入到`java.util.concurrent`包下的核心并发容器实现,如`ConcurrentHashMap`、`CopyOnWriteArrayList`以及`BlockingQueue`等,通过实例代码演示其使用方法,并分析它们背后的设计原理与适用场景。无论你是Java并发编程的初学者还是希望深化理解的开发者,本文都将为你提供有价值的见解与实践指导。 --- ####
|
26天前
|
存储 算法 Linux
【c++】STL简介
本文介绍了C++标准模板库(STL)的基本概念、组成部分及学习方法,强调了STL在提高编程效率和代码复用性方面的重要性。文章详细解析了STL的六大组件:容器、算法、迭代器、仿函数、配接器和空间配置器,并提出了学习STL的三个层次,旨在帮助读者深入理解和掌握STL。
42 0
|
5天前
|
存储 编译器 C语言
【c++丨STL】vector模拟实现
本文深入探讨了 `vector` 的底层实现原理,并尝试模拟实现其结构及常用接口。首先介绍了 `vector` 的底层是动态顺序表,使用三个迭代器(指针)来维护数组,分别为 `start`、`finish` 和 `end_of_storage`。接着详细讲解了如何实现 `vector` 的各种构造函数、析构函数、容量接口、迭代器接口、插入和删除操作等。最后提供了完整的模拟实现代码,帮助读者更好地理解和掌握 `vector` 的实现细节。
17 0
|
1月前
|
缓存 安全 C++
C++无锁队列:解锁多线程编程新境界
【10月更文挑战第27天】
43 7
|
1月前
|
消息中间件 存储 安全