![4aa89fe335a4baea506d24c215d91f49.png](https://i-blog.csdnimg.cn/blog_migrate/09f09c3ff0b70f19ca682f37160b0a00.jpeg)
0 写在前面
上一篇文章高并发编程--多处理器编程中的一致性问题,介绍了多处理器编程中一致性方面的问题,也提到了并发编程中的同步问题。本篇文章针对多处理器编程中的同步问题(synchronization)也做一些总结。
上篇文章已经比较详细的把多处理器一致性问题总结了一下,看过上一篇文章,理解synchronization问题应该就相对容易一些。文章中如果存在问题,请各位帮忙提出,我也会及时修改。
本文的讨论主要针对SMP架构,主要涉及的内容主要有以下几个方面:
- synchronization的一些基本概念
- 为什么锁可以保证只有一个线程可以进入临界区
- 为什么临界区内的执行结果对于后续的执行体都是可见的
- 常见的并发同步机制
1 什么情况会需要synchronization
并发编程如果各自处理自己的任务,互不干涉,那是比较理想的状况,但现实中通常我们需要在各个执行体之间进行一些通信或者资源共享,那么这个时候就需要保证对于共享资源操作的安全性,而synchronization的引入就是为了解决这个问题。
synchronization发生在一个执行体与另外的执行体有交互依赖的场景。通常有两种交互需要synchronization:competition和cooperation[3]。
- competition
这种场景经常是发生在多个执行体需要对共享资源执行指令,但是必须只能同时有一个执行体执行指令。比较典型的场景,多个线程执行计数,对一个共享的全局计数变量执行自加操作,那么这个自加操作指令就是会存在competition,需要synchronization工具保证正确性和安全性。
- cooperation
这种场景发生在多个执行体需要依赖对方的执行结果的时候。很典型的场景就是生产者消费者模型,(a)生产者生产data item,消费者消费data item,(b)且每个data item只能被消费一次。那么为了保证上述两个特性,就需要一种在生产者和消费者之间的synchronization工具来保证生产者与消费者之间的cooperation。
无论是competition还是cooperation一般都会涉及到共享资源的操作,为了保护共享资源通常使用锁,关于锁有两个基本问题下面会分别讨论。
这里还有一些基本的概念:
- critical section
操作共享资源的代码片段称之为临界区,这个临界区就是我们在并发编程中需要保护的地方。
- race condition
多个执行体(线程或进程)同时进入临界区的时候,同时进行共享资源修改,这时候就产生了race condition
- indeterminate
因为多个执行体同时进入临界区,可能带来的执行结果是不能预料的,所以对于一个并发的执行来说,其结果是indeterminate的。
- mutual exclusive
Mutex的命名也是来源于这个词,它代表着一种互斥机制,用来保证只有一个线程可以进入临界区,对于临界区的操作保证其不存在race condition并且执行结果是deterministric。
所以这样看来,锁的基本任务就是mutal exclusive[5]。
2 为什么锁能够保证只有一个线程进入临界区
在涉及到并发编程中共享资源保护的时候我们首先想到的是利用锁,锁可以保证在多并发环境下只有一个线程可以进入临界区。但是我们可能经常会忽略的问题是,我们所使用的锁其实也是一个共享资源,为什么这个共享资源就没有并发问题呢?
这个问题其实是针对锁的正确性保证问题的另一种描述。锁要解决的问题上面描述过了,就是mutex exclusive,那么一个锁是否正确的解决了这个问题,就是看他是不是真正做到了保证只有一个执行体进入临界区。
从内部实现来看,无论是mutex还是spinlock,都是依赖于TAS(test and set)/ CAS (compare_and_swap) / CAE (compare_and_exchange)的原子性[1]。TAS的原子性保证在CPU执行这个指令的时候只有成功和失败两个状态,那么就可以保证多个并发的线程在置位mutex的时候只有一个线程可以置位成功,置位成功的线程便可进入临界区。
所以,保证只有一个线程进入临界区的真正原因在于OS对TAS的原子性保证。
3 锁如何保证memory order
锁的首先是保证了只有一个执行体可以同时进入临界区,他还有一个任务就是保证memory order。memory order的概念在上篇文章中已经描述过。
在一致性保证中提到过,CPU和编译器都会为了提高性能进行一系列的优化,这里包括乱序执行、指令预取等等。通常我们在临界区外都是直接使用共享变量的执行结果,并没有显示的指定memory_order,为什么我们能够保证我们所使用的共享资源是最新的?答案很显而易见,就是锁保证了这一点。
mutex的lock和unlock同时具备了acquire和release语意。
就如上篇文章总结的一样,lock acquire保证了在其上面的code在memory order这条执行线上一定在其上面被执行,unlock release保证了在其下面的code,在memory order执行线上一定在其下面执行,这也就保证了临界区的共享操作执行一定是在lock于unlock之间执行的。但是lock与unlock之间的共享资源操作同样可能会存在乱序,不过这已经不会成为问题,因为进入临界区后只有一个线程在执行共享资源的操作,所以是安全的。
借用登博的一张图解释一下吧[2]:
![5f5f4c6a4cf557b2b6679fb7c004035c.png](https://i-blog.csdnimg.cn/blog_migrate/775794c2a0f0959aa41c8c6a277d9c30.jpeg)
4 常见的synchronization工具
回想一下我们在并发编程中通常会用到哪些synchronization工具,mutex、spinlock、readwritelock、condition variable、Semaphores、join、future等等。
这些synchronization工具可以按照行为分为两类,在抢锁的时候如果没抢到,这时候有两种状态:spinning和blocking。spin是指不停地去查询状态,占用了CPU,blocking是指在没有抢到锁的时候切换出去,然后当前执行体处于blocking状态。
这里简单介绍mutex、spinlock、condition variable三种synchronization工具,因为这三种比较典型,spinlock是spin类型的synchronization,mutex和condition variable是一种blocking类型的synchronization,且condition variable是一种monitor 类型的bocking synchronization工具。
这里并不介绍这些工具的使用方法,cppreference里很全了,这里针对这些synchronization的一些基本原理做一些简单的总结。
4.1 spinlock
spinlock前面说过是一种spin类型的lock,在抢不到锁的时候会保持spin状态,spin状态会消耗CPU资源,但这并非一定是一件不好的事情,在临界区运行时间非常短且不会出现block的场景下,spinlock就比较合适,因为如果不在用户态spin,而是进入内核等待,内核的切换也会带来一定的时间开销,假设临界区执行时间是1us,内核切换是4us,那么陷入内核就有点得不偿失了。
spinlock是纯用户态锁,当然也是基于TAS的原子性来保证只有一个线程会进入临界区。用户可以自己实现spinlock,只是要注意两个点:1. 保证进入临界区的原子性,2. 保证lock的acquire语意和unlock的release语意。
spinlock比较适合的场景:临界区内执行时间较短,不会出现block。比如单纯的查表操作可以使用spinlock。
4.2 mutex
- why mutex
spinlock的特点就是用户态完成同步操作,但是对于临界区很长或者会存在block的场景下,spinlock显然是一种浪费CPU的行为,因此对于那种临界区可能会block或者花很久事件的场景来说,需要让那些没有抢到锁的线程放弃掉CPU,主动进入睡眠。因此就需要mutex,mutex可以使没有抢到锁的线程放弃CPU进入睡眠。
既然要放弃CPU,且需要唤醒,就需要OS的支持了,内核态会将sleep的的线程切换出去。Linux内核通过futex提供了mutex所需要的陷入内核,放弃CPU的功能。
- futex
futex是linux从2.5.7开始支持的特性[6],futex提供了完整的线程间同步机制,他由用户态和内核态两部分组成,在非竞争状态下,是在用户态运行的,当遇到竞争的时候,没抢到锁会陷入内核。futex提供了两个主要的接口,futex_wait和futex_wake。futex有一个内核对象waiting queue,用于提供队列和调度交互。具体futex的原理可以参考论文[Fuss, Futexes and Furwocks: Fast Userlevel Locking in Linux ]和内核的源码。
- mutex使用场景
在划分锁的时候,有时候会以用户态和内核态来区分,mutex通常被划分为内核态的锁,所以会被认为性能比较差,因为他牵涉到了内核的上下文切换,上面已经提到,mutex只会在竞争的时候陷入内核,通常情况下mutex并不会造成性能影响,常规的在没有竞争情况下mutex lock和unlock的时间大概是在ns级别的且只是在用户态切换,所以在竞争不激烈的情况下mutex不会是瓶颈。
mutex比较适合的场景:临界区的耗时相对较长,或者用户不希望当前线程在抢锁失败的时候仍然占用CPU的时候
4.3 condition variable
条件变量是一种monitor synchronization工具,通常用于cooperation场景,比较典型的应用场景是在producer和consumer。condition variable与mutex都是一种blocking类型的synchronization,也就是说condition variable在没有满足条件的时候也会陷入内核睡眠,前提是他先抢占了锁。因为也需要陷入内核睡眠,因此很容易想到,condition variable在内核中也是基于futex实现。
Condition variables provide synchronization primitives used to block a thread until notified by some other thread that some condition is met or until a system time is reached. [8]
条件变量通常需要与一把锁联合使用,来提供synchronization功能[7],condition variable提供wait方法,在线程抢到锁的时候如果不满足响应的条件调用wait的时候,会执行两个操作:release lock,陷入内核等待。同时condition variable提供了notify方法,notify方法会将wait的thread从内核唤醒,调用wait的线程在唤醒的时候会重新acquire lock。
reference
- How does a mutex work? What does it cost?
- 并发编程系列之一:锁的意义
- ConcurrentProgramming.Algorithms,Principles,andFoundations(Springer,2013)
- The Art of Multiprocessor Programming
- lock
- Fuss, Futexes and Furwocks: Fast Userlevel Locking in Linux
- c++ concurrency in action
- C++ ISO IEC 14882