写在前面
Go语言最有魅力的一个方面就在于它内建的并发支持,Go的并发所涉及的内容化很多,预计会占用比较长的篇幅。等基本更新完成后会在此处放上所有文章的链接。
首先会从一些基本概念入手,之后会谈谈基本并发原语和go的并发原子操作,实际上就是尽量完整的剖析sync
这个包下的所有内容,会尽量附带着源码讲,这部分内容比较庞大但是我认为很值得。
接着是最重要的goroutine,以及channel、Context等内容,会提到一些进阶写法和容易错的点。
最后会尽量深入的讲一下底层的CSP理论、线程调度MPG模型等,从而对Go并发有更深入的理解。
参考资料来源于国内外的书籍、博客、课程、论坛等,主线参考鸟窝的Go并发编程实战课,在此基础上细化了知识点,并增加了自身对相关源码和实际使用的理解。
从并发谈起
什么是并发?
并发(Concurrent)指的是在一个时间间隔内可以处理多个任务;与之相对的概念是顺序(Sequential),即多个任务只能按顺序完成。
另一个容易混淆的概念是并行(Parallel),并行指的是在一个时刻内可以同时处理多个事件;与之相对的概念是串行(Serial),指的是物理上只能一个一个任务的执行,一个瞬间最多只能有一个任务在执行。
并发:宏观同时,微观轮换 并行:宏观微观都是同时进行
单核多线程:并发、串行 多核多线程:并发、并行
Erlang 之父 Joe Armstrong画的一张排队使用咖啡机的场景生动的表示了并发与并行的区别。
为什么要并发?
并发可以有效的利用多个CPU核心,从而大大提高程序的执行效率。
并发程序可以更好地处理复杂业务,进行多任务拆分、简化任务调度、同步执行任务。
当然并发也会引入额外的复杂度和风险,可能会带来不一致、死锁、饥饿、安全性等问题,而且并发程序难以调试,可能会出现诡异的结果。
并发方案
-
多进程:操作简单,进程之间由于资源不共享,不会有冲突的问题,但是开销很大,能开启的进程数少,所以一般不会使用该方式。
-
多线程:属于操作系统层面的并发,开销比多进程小但是比协程大,会存在数据冲突和锁的问题
-
非阻塞I/O:基于回调的异步非阻塞I/O
-
协程:用户态的轻量级线程,开销很小,Go采取了这种方式
并发导致的数据竞争
两个或多个goroutine在没有同步措施的情况下读写同一个共享资源时,会出现数据竞争的情况。无法知晓几个goroutine代码运行的先后顺序,从而导致可能产生多种结果。
举一个具体的例子:
|
|
由于开启了一个并发的协程,完全不知道1会在2之前还是2-3之间还是3之后执行,所以输出也不确定。
如何解决这一问题?
- 设置等待时间(不推荐)
- 加锁
- channel ...
操作的原子性
如果一件事是原子的,那么我们说它就是并发安全的,Go有单独的包sync/atomic
包含了语言支持的原子操作。
将在后面单独写一篇来介绍Go并发原子操作,这里暂时跳过。
死锁、活锁与饥饿
死锁
死锁是并发中一个绕不开的话题,操作系统中学过导致死锁的四个条件:
- 互斥条件
- 请求和保持条件
- 不剥夺条件
- 环路等待条件
并发编程的时候很容易出现死锁的问题,看到这种报错信息:
|
|
活锁
陷入活锁的程序并不像死锁一样陷入绝境,而是一直在进行,但是这些操作无法向前推进程序的状态。
一个例子就是网络发送数据包遇到冲突,都等待一段时间重发,结果由一起冲突。
这就像两个礼貌的司机在狭窄的桥上相遇,他们都互相礼让都调头更换了另一座桥,结果又在另一座桥上相遇。
饥饿
饥饿指的是一个可运行的进程尽管能继续执行,但是被调度器无限忽视,导致拿不到时间片来执行的情况。
并发进程无法获得执行工作所需的所有资源。往往是由于一些并发进程比较贪婪,每次都轮到贪婪进程优先获得资源,而其他的一些难以拿到资源的进程就会饥饿。
数据竞争的检测方法:race
多个goroutine同时操作共有的变量会发生各种意想不到的问题,可以通过
|
|
来查看可能的竞态检测。
就以前面的为例:
|
|
如果直接go run
,无法看出是否有数据竞争,但是通过go run -race
,就可以看到具体那几行出现了数据竞争:
|
|
就说了第10行goroutine7的data++
和第12行main goroutine的if data == 0
出现了数据竞争的情况。而且说明了goroutine7是第9行启动的。
一般会在测试的时候使用go -race
进行数据竞争的检测,从而调整代码,尽量避免数据竞争的发生。一般线上部署不会使用race
,因为会影响性能。
当然还有一个问题,因为是编译完之后才能通过具体地址检测的,即使某个地方可能会有冲突但是运行的时候没有表现出来,race也检测不到。