零零散散已经接触了很多有关并发编程的概念了,这篇博客主要做一个总结,梳理一下这些概念,以及从宏观的角度来看并发相关的问题是怎么产生的,又是怎么解决的。
看完这篇文章,可以继续浏览Java并发编程详解,看看这些宏观上的并发相关概念是怎么应用到Java中的。
1 并发与并行
在开始讨论前,我们先理清楚并发(concurrency)和并行(parallelism)分别是什么意思。
-
并发:多个任务可运行在可重叠的时间段内,强调可中断性。一个任务不必执行完全就可以切换到另外一个任务继续执行
-
并行:多个任务可运行在同一时间点内,强调独立性。多个任务的执行互不影响
以上参考了:what-is-the-difference-between-concurrency-and-parallelism
综上不难看出,并行其实是并发的子集,且并发不仅可以依靠多线程实现,异步回调,协程也能实现并发编程。 因为他们都能做到让当前执行流中所执行的一个任务还没执行完就切换至另一个任务,并在之后再切换回来。
1.1 同步与异步
同步和异步也是并发编程中常常提到的概念。我在之前的博客——非阻塞和异步有差别吗?阻塞、非阻塞、同步和异步概念总结有稍提到过异步的概念。
同步是指一个任务必须等待前一个任务执行完才能执行。
异步是指一个任务不必等待前一个任务执行完就能执行。
由此可以至少得出以下几个结论:
- 异步操作一定是并发的。
- 能够异步执行的任务也可以被写成同步执行,因为原本无关联可以异步执行的几个任务,也可以编写成一个一个按顺序执行。
关于同步和异步的详细探讨,可以参考这篇文章:彻底理解什么是同步和异步!
2 多线程的意义
最常听到的一句话就是“多线程能提高效率”,但是多线程为什么提高了效率呢,或者说多线程一定会提高效率吗?
这里得分情况讨论:
在单核处理器下: 多线程的意义主要是为了能并发处理多个任务。大部分情况下不能提高提高效率,因为上下文切换也是需要开销的,但有些时候也能起到提高效率的作用,譬如某个线程在进行IO操作了,那么这个时候切换线程很明显就提高了效率。
在多核处理器下: 由于有多个核心负责运行指令,多线程很明显就一下将效率提上去了。
上面的讨论其实就涉及到了多线程的数量和CPU核心数的关系,当一个任务是CPU密集型时,那么线程数为核心数 + 1更为合理,因为此时每个核心都会持续跑满运行,假如线程数不断变多,则CPU将浪费更多时间在线程切换上。相反当一个任务是IO密集型时,则可以考虑将线程数增多,譬如核心数 * 2,这是因为此时每个核心大都处于空闲状态,有充分时间执行下一个线程的任务。
这里推荐一篇关于多线程的文章:看完这篇还不懂高并发中的线程与线程池你来打我(内含20张图)
3 回调和协程
关于回调函数和协程的概念这里只做简单的介绍,想详细了解可以看以下文章:
在异步编程中,回调函数可以让异步操作执行完后继续执行我们之前的代码(即 传入的回调函数)。
按理来说,异步回调可以让我们很好地应对高并发场景了,但实际上,回调也并不是万金油。过分地使用回调函数会造成回调地狱(即 多层回调函数嵌套),异常捕获困难,以及代码难以编写等等问题。而协程将解决这些问题,以同步的方式编写异步代码。
协程是在用户态实现的任务调度,这意味着我们可以随意切换任务执行流而不用借助操作系统的帮忙,也就没有了上下文切换的开销。
关于回调函数和协程的对比,可以参考以下文章:
3.1 协程 vs 线程
看起来协程如此方便,不免会产生一个疑惑,即 为什么不直接用协程代替线程呢?
事实上协程诞生的比线程还早,如果协程真的那么无敌,那么我想也没线程啥事了,但实际上并非如此,线程还是被发明了出来。
其实思考协程实现的原理就不难发现,协程所带来的高效——没有上下文切换,正是建立在它始终运行在一个线程上所带来的。这意味着协程始终顶多只能切换任务的执行,而无法做到任务的并行,更详细的说,协程只能实现并发,而无法实现并行效果,这就是协程无法取代线程的根本原因。关于这里的探讨,详细可参考Quora上的一篇回答:What are coroutines? Does it run on different threads?
这也意味着协程只对IO密集型的任务有效率上的提升,因为对于CPU密集型这种实打实的需要并行的任务,协程将无能为力,毕竟怎么轮换任务,都还是在同一个线程上,也就是说还是在一个核心上。
另外还有一点值得是协程是协作式的,而线程是抢占式的。前者一般来说效率会更高一些,因为不需要定时的强制上下文切换,但也可能会带来某一任务一直垄断CPU的情况。
3.2 不同编程语言中的协程
上面所提到的协程概念其实是最一般的协程,实际上不同编程语言对协程的实现都不太一样。
3.2.1 编程语言的原生支持
需要明确的一点是,协程作为用户态实现的任务调度,其实即使没有编程语言的原生支持,我们自己也可以通过编写程序实现协程这一机制。
但原生支持将会带来更好的效果,包括但不限于:
- 简单易用。语言一般会通过规定关键字,推出官方库等操作使得协程的使用变得简单。
- 编译器优化。语言相关的编译器通常会对其相关特性做出优化。
- 标准化。通过语言规范规定,将会形成一个良好的生态圈,而不是形成各种不同的协程规范。
… …
3.2.2 JavaScript
在JavaScript中的Generator,看起来很符合协程的定义,但其实只能算是半协程,因为Generator只能将其执行权交给调用者。不过之后的Promise API很好地解决了这个问题。
3.2.3 Go
goroutine可以说是Go的一大特色。goroutine也和一般的协程不太一样。
Go并不将一个协程局限运行在创建它的线程中。这意味着多个goroutine可以运行在多个线程(OS thread)上,从而实现并行(Parallelism),而传统的协程则是通过让出(yield)执行权给其他同一线程中的协程,只能实现并发(Concurrency)。此外,值得注意一点的是,goroutine可以说就是Go程序的基本调度单位了,包括我们的程序入口(main function)也是运行在协程上,只不过这个协程比较特殊,当它终止时,其他所有协程也都会终止,在Go中我们只能创建goroutine而非thread,为此Go也有其独特的m:n调度模型,这里不再讨论。
Go的任务调度也有抢占式的特点。传统的协程是协作式的,试想一下假如我们有一个阻塞操作,比如IO操作,这个时候一般IO操作中也会封装让当前协程让出执行权(yield)的代码。到此还没有问题,但假如是一个运行很慢的操作,比如斐波那契数列的计算,这个时候Go调度器怎么知道是否要让该协程暂时出让执行权呢?前者IO操作尚且是一个不占用CPU资源的操作,但后者确实要一直使用CPU,假如计算斐波那契数列的协程不主动出让执行权,那么整个程序将会只执行这一个任务,这肯定是不行的,所以从Go 1.14开始,Go的调度器也采用了周期性抢占,当一个协程执行过久时,即使它没有主动出让(yield),Go的调度器也会强行抢占它让其他协程执行。
4 并发问题
上面提到多线程、协程等都有可能导致并发问题,即数据不一致。
数据不一致一般是由以下三种原因导致的(下面是基于多线程的角度分析的):
- 由于CPU的处理速度还是远远超过了内存的读写速度,现代CPU引入了缓存的概念。CPU一般都是将线程要读写的变量先拷贝到缓存,导致线程对变量的操作都是在缓存上的,无法直接操作主内存,这就造成了数据不一致。
这也是为什么不变类就不会有多线程问题,因为这个类的实例都是不会改变的,一旦你修改了这个变量,那么一定是返回一个新的被修改的变量,譬如String
类就没有多线程问题。 - 另外为了提高执行速度,编译器层面会在保证串行语义不变的情况下,修改我们代码的执行顺序,而CPU为了提高吞吐量,会同时进行多个指令周期。但在并行执行的情况下,就可能出现数据不一致,因为此时多个线程执行的各自的代码,编译器无法推断出这些语句之间的依赖性。
- 由于有些操作是基于一些变量的值才能进行的,这就有可能造成一个线程刚开始判断条件为真,准备执行一个操作,但在这个时候,另一个线程修改了变量条件,使得其为假了,现在如果前一个线程还执行这个操作的话就会发生数据不一致。
上述三点,正好对应了并发编程的三个核心特征:可见性、有序性、原子性。保证了这三个特性,我们就能保证数据一致性。
4.1 JavaScript中的并发问题
这里借JavaScript引申一下为什么只有异步编程也可能会导致并发问题。
我们知道JavaScript提供了异步编程的API,所以即使浏览器中JavaScript是单线程执行的,但是广义上来说还是会存在并发问题,因为异步操作破坏了由单线程带来的原子性。
考虑以下例子:
let x = 1;
async function incr() {
let y = x;
await delay(100);
x = y + 1;
}
假如这样执行,输出会小于4:
await Promise.all([incr(), incr(), incr()]);
console.log(x);
这样执行才会输出4:
await incr();
await incr();
await incr();
console.log(x);
这就是异步导致JavaScript的原子性被破坏的一个典型例子,需要我们自己有意识地去控制async
流或者实现锁来保证并发安全。
关于JavaScript实现锁,可以参考这篇文章:js 异步加锁
5 解决措施
5.1 可见性
上面提到由于CPU高速缓存的引入导致可见性并不能保证。为了解决这个问题,CPU制定了缓存一致性协议(比如MESI协议),引入内存屏障和其他等等措施。
这些都属于底层细节了,可以参考这个视频:jmm之mesi原理,深入浅出地讲解了MESI协议以及内存屏障这些概念。
5.1.1 Java中的可见性
我们不直接接触指令,与我们直接交互的是操作系统,自然在操作系统这个层面,它屏蔽了底层硬件细节,通过内存模型定义一系列规范来解决这个问题。同样,Java作为一个跨软硬平台的语言,自然提供了一个统一内存模型, 即JMM。
JMM其中很多细节我们没必要去了解,但happens-before原则十分关键,因为只要遵守这个规则,我们就可以保证数据的可见性。
有人可能会好奇,只要CPU每次修改缓存的时候立即同步到内存中不就行了,为什么要制定这么多约束规则,让我们只有在符合这些规则的情况下才保证数据一致性。这其实就是一种效率和准确性之间的平衡罢了。
5.2 有序性
对于Java来说,通过遵循上面提到的happens-before原则,我们可以保证在一定情况下的有序性。
如何协调多个线程代码的执行顺序,这一点只有我们设计程序时自己去保证。比如多线程之间可以通过wait/notify
机制来通信。
5.3 原子性
CPU层面:
一个操作在执行期间不能被打断,很明显这需要CPU级别的支持才行,CPU提供这样的指令。
譬如X86架构的芯片,有lock prefix的指令就能做到原子操作,这样的指令不仅无法被打断,而且会锁住总线,或者锁住缓存。
还有些芯片会提供关闭中断的指令,简单粗暴地达到无法被打断的效果。
操作系统层面:
而操作系统通过提供相应的API,使得我们能使用到这些原子操作。
对于Java而言,我们可以通过调用
Unsafe
类中的方法实现这些操作。当然这些方法都是native
修饰的。
参考链接:
6 锁
很明显,讲了这么多有关怎么保证并发安全的内容,但我一直没提到“锁”这个概念。
锁其实是并发编程中的一个抽象概念,底层实现中并没有锁这一概念,我们通过CPU提供的指令,构建出来锁这一概念及其API。因为锁更符合我们认知,用锁这一概念更便于我们分析、解决并发问题。
需要注意的是锁是锁住了共享资源,即 访问共享资源前需要检查该共享资源是否已被占用,而非锁住了线程,毕竟并发问题可以由多线程或者异步导致。
依照锁这一概念,我们又衍生出了很多类型的锁,这里简单介绍一下。
6.1 公平锁和非公平锁
公平锁是指按照多个线程按照锁的申请顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。好处是不会出现“线程饿死”的情况,即 每个线程最终都能获得到锁。
非公平锁是指多个线程加锁时立即获取锁,获取不到才进入队列排队。这样的好处是因为线程有概率直接获取到锁,减小了唤醒线程的开销,因为唤醒线程会涉及用户态到内核态的切换,这使得整体的吞吐效率更高。但缺点也很明显,就是有可能出现排队线程一直获取不到锁的情况,或者等很久才获取到锁。
6.2 独占锁和共享锁
独占锁也叫写锁,指的是如果线程获得了独占锁,那么其他线程将无法获得到任何其他锁。
共享锁也叫读锁,指的是如果线程获得了共享锁,其他线程只能继续获得共享锁,而不能添加独占锁了。
6.3 可重入锁和不可重入锁
可重入锁指的就是一个线程获得到锁后,可以继续加锁。而不可重入锁就不行,一个获得到锁的线程不能继续获得锁,只能先释放,再获得。
例如下面这段代码:
void foo(){
lock.lock();
bar();
}
void bar(){
lock.lock();
}
假设一个线程开始执行foo()
,如果lock
是不可重入锁的话,那么该线程将会在bar()
中发生死锁,因为此时该线程占有了该锁,没有释放,却还要企图获得该锁。
不可重入锁看起来很不符合逻辑,为什么一个线程获得到锁了,却不能继续加锁。其实这和底层实现有关,一般来说,锁的实现如果只考虑了简单的加锁和解锁状态(比如0和1),那么确实就不能实现可重入锁的功能。
要想实现可重入锁,还需要添加上别的逻辑(比如记录当前锁被加过几次)。
6.3.1 锁升级和锁降级
如果将可重入锁和读写锁的概念相结合,就有可能发生以下情况:
- 当一个线程持有写锁的情况下,虽然其他线程不能加读锁,但是线程自己是可以加读锁的,这个时候如果我们再释放写锁,那么其他线程也可以加读锁了,这就属于锁降级。
- 当一个线程加了读锁,这个时候如果再加写锁成功,那么就属于锁升级。
当然这些概念主要看具体实现的锁相关API支不支持,比如Java中的ReentrantReadWriteLock
是不支持的锁升级的。
6.4 lock-free和wait-free
利用锁这一概念,我们可以方便地处理并发问题。但高度抽象往往意味着不可定制化。
在锁的相关实现中,对于获取锁失败的线程,一般都是无脑挂起该线程,等到其他线程释放锁时,才会唤醒等待队列中的线程去获得锁。很明显,这样效率很低。假设我们现在有三个线程A、B、C,现在只有A线程获得到了锁,如果操作系统在分配CPU时间片时,没有分配到A,那么整个任务将会停滞不前。
所以如果想要追求高性能,就得自己去协调多个线程,以保证整个系统在任何时间片内,都能保持前进。这就是所谓的lock-free。比如以下代码:
AtomicInteger i = new AtomicInteger(1);
void add(){
i.getAndIncrement();
}
假设现在A、B、C三个线程轮流调用add()
,那么至少有一个线程CAS加一操作会成功。这就保证了一个任务中至少一个线程正在推进任务前进。
而如果不管系统调度到哪一个线程,每个线程都能继续执行,就实现了wait-free。wait-free相当于更为严格的lock-free。
需要注意的是自旋锁虽然也没有阻塞线程,说明A、B、C三个线程都能获得到时间片,但如果获得自旋锁的线程被挂起,那么其他线程都无法前进,所以自旋锁并不能算lock-free。
关于以上讨论,推荐去看这篇文章:如何在不加锁的情况下解决多线程问题?。这篇文章使用C语言去阐明lock-free/wait-free概念,会更清晰一点。