对于线程安全的理解
对于21世纪的人类,已经不满足于单核并行的模式;提高效率,增效降本才是我们向往的。程序设计中选择什么样的数据结构体是解决某个问题的关键。如果多线程使用到的数据结构体要满足并发,则涉及到数据的线程安全问题。
- 一种解决办法是选择单独的互斥元或外部锁来使数据结构体在某一时间段独占,且释放后没有残留。
- 另一种就是设计可以多线程同时访问的数据结构体。明显,后者在大部分场景是效率较高的。
多核时代,程序员们为了系统运行效率做了很多事情。并发、多线程是其中绕不开的一个话题,有了多线程,随之而来的就是线程之间的同步,临界区的出现,然后就是锁的使用。程序员随之发现锁的开销较大,于是有了缩短临界区话题,尽可能的让临界区变得更小一点。但是总归临界区的缩小是有限度的,也就是有天花板的。因此我们开始探索原子操作,无锁化编程。于是为了功能正常的情况下,还要保证良好的效率,本文探讨原子操作的背后,内存的组织形式,编译器、cpu 的执行顺序,语言为 c++ 语言。
c++11 标准提出了内存模型,而在 c++11 之前,c++ 本身没有多线程的概念,c++ 使用者使用的是操作系统为我们提供的多线程、原子操作。那时的编译器和处理器认为系统中只有一个执行流。但在多线程之后,编码变难了,开发者编写的代码和最终运行的代码之间往往存在较大的差异,而运行的结果与开发者预期的一致,只是表现而已。
那么产生差异的原因主要来自于如下三个方面:
原子操作:是一个不可分割的操作,从系统中的任何一个线程中,你都无法观察到完成了一半的这种操作,它要么做完了,要么没有做完。如果读取对象值的载入操作是原子的,并且所有对该对象的修改也都是原子的,那么这个载入操作所获得得要么是对象的初始值,要么是被修改者修改后的值。
CAS 的意思:是 Compare & Set
或者 Compare & Swap
。整个过程是原子的。现代几乎所有的CPU指令都支持 CAS 的原子操作,X86 下对应的是 CMPXCHG 汇编指令。
在 c++ 的 atomic 中关于 CAS 的实现有两种:
1 | template< class T > |
lockfree.queue(c++ boost)实现。
C++语言本身没有提供线程安全的容器,而高质量的 boost 库中有实现线程安全队列,而且还是无锁的实现,以下代码基于 boost 库 1.78.0 版本。
我只保留了主要的代码逻辑,方便理解代码含义。
queue 采用链表为底层实现方式,包括头节点 head 和尾节点 tail。通过预先分配一个不存储数据的傀儡节点,可以少掉很多边界条件的判断,保证队列中总是至少会有一个节点,将在头尾的两个节点访问分开。对于一个空队列,head 和 tail 都指向这个傀儡节点,而不是 null。
1 | class queue { |
BlockingQueue 和 ConcurrentLinkedQueue (java)实现
Java提供的线程安全的 Queue 可以分为阻塞队列和非阻塞队列,其中阻塞队列的典型例子是 BlockingQueue,非阻塞队列的典型例子是 ConcurrentLinkedQueue。以下的代码基于 openjdk version "11.0.8"
BlockingQueue 是一个接口,具体的实现有很多,如 ArrayBlockingQueue、DelayQueue、LinkedBlockingDeque、LinkedBlockingQueue、PriorityBlockQueue、SynchronousQueue。就拿 ArrayBlockingQueue 来看吧
ArrayBlockingQueue 提供了很多 API 供我们使用,在插入数据和获取数据我只选择了两个具有代表性的 API 进行分析,其余相似的 API 都是换汤不换药,基本的逻辑思想都是一致的。下面代码只罗列出比较重要的部分。
1 | public class ArrayBlockingQueue<E> extends AbstractQueue<E> |
不一定,对于 CAS 实现的硬件级的互斥,其单次操作性能比相同条件下的应用层的较为高效,但当多个线程并发时,硬件级的互斥引入的代价与应用层的锁争用同样令人惋惜。因此如果纯粹希望通过使用 CAS 无锁算法及相关数据结构而带来程序性能的大量提升是不可能的,硬件级原子操作使应用层操作变慢,而且无法再度优化。相反通过对有锁多线程程序的良好设计,可以使程序性能没有任何下降,可以实现高度的并发性。
但是我们也要看到应用层无锁的好处,比如不需要程序员再去考虑死锁、优先级反转等棘手的问题,因此在对应用程序不太复杂,而对性能要求稍高时,可以采用有锁多线程。而程序较为复杂,性能要求满足使用的情况下,可以使用应用级无锁算法。
推荐 C++ 实现的无锁线程安全队列项目:https://github.com/cameron314/concurrentqueue
此项目是比较优秀的多生产者、多消费者的无锁并发队列的工业级别的实现。
首先明确go语言的设计模块:不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存。这样在我看来让 go 语言代码更加整洁。因此 go 语言中 Goroutine 之间会通过 Channel 传递数据。基于go 1.15 版本,Channel 的实现。
chan 的底层数据结构如下:
1 | type hchan struct { |
chan 使用 make 关键字创建,可以带缓冲区的异步 Channel 和不带缓冲区的同步 Channel。这里对创建过程不做赘述。基本上分为三种情况: