C STL 容器如何解决线程安全的问题?
扫描二维码
随时随地手机看文章
push_back()
,也会导致core dump。解法一
加锁是一种解决方案,比如互斥锁std::mutex
。但是加std::mutex
确实性能较差。对于多读少写的场景可以用读写锁(也叫共享独占锁)来缓解。比如C 17引入了std::shared_mutex
。更多锁的种类可以阅读之前写的这篇文章:如何理解互斥锁、条件变量、读写锁以及自旋锁?当然本文的目的自然不是自我重复再次介绍一次锁的使用,请继续阅读解法二!解法二
更多的时候,其实可以通过固定vector的大小,避免动态扩容(无push_back)来做到lock-free!即在开始并发读写之前(比如初始化)的时候,给vector设置好大小。struct Data {
...
};
vector 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、……
分段加锁
,减少一点锁冲突的概率,或者用一下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,伪共享的议题比较复杂,欢迎留意评论!- EOF -