Go并发(一):并发的基本概念

写在前面

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
 3
 4
 5
 6
 7
 8
 9
10
data := 0
go func() {
// 1
  data++
}()
// 2
if data == 0{
// 3
  fmt.Println(data)
}

由于开启了一个并发的协程,完全不知道1会在2之前还是2-3之间还是3之后执行,所以输出也不确定。

如何解决这一问题?

  1. 设置等待时间(不推荐)
  2. 加锁
  3. channel ...

操作的原子性

如果一件事是原子的,那么我们说它就是并发安全的,Go有单独的包sync/atomic包含了语言支持的原子操作。

将在后面单独写一篇来介绍Go并发原子操作,这里暂时跳过。

死锁、活锁与饥饿

死锁

死锁是并发中一个绕不开的话题,操作系统中学过导致死锁的四个条件:

  1. 互斥条件
  2. 请求和保持条件
  3. 不剥夺条件
  4. 环路等待条件

并发编程的时候很容易出现死锁的问题,看到这种报错信息:

1
fatal error: all goroutines are asleep - deadlock!

活锁

陷入活锁的程序并不像死锁一样陷入绝境,而是一直在进行,但是这些操作无法向前推进程序的状态。

一个例子就是网络发送数据包遇到冲突,都等待一段时间重发,结果由一起冲突。

这就像两个礼貌的司机在狭窄的桥上相遇,他们都互相礼让都调头更换了另一座桥,结果又在另一座桥上相遇。

饥饿

饥饿指的是一个可运行的进程尽管能继续执行,但是被调度器无限忽视,导致拿不到时间片来执行的情况。

并发进程无法获得执行工作所需的所有资源。往往是由于一些并发进程比较贪婪,每次都轮到贪婪进程优先获得资源,而其他的一些难以拿到资源的进程就会饥饿。

数据竞争的检测方法:race

多个goroutine同时操作共有的变量会发生各种意想不到的问题,可以通过

1
go run -race xxx.go

来查看可能的竞态检测。

就以前面的为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package main

import (
	"fmt"
)

func main() {
	data := 0
	go func() {
		data++
	}()
	if data == 0{
		fmt.Println(data)
	}
}

如果直接go run,无法看出是否有数据竞争,但是通过go run -race,就可以看到具体那几行出现了数据竞争:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
==================
WARNING: DATA RACE
Write at 0x00c00012e058 by goroutine 7:
  main.main.func1()
      C:/Users/Administrator/Dropbox/GoProject/Concurrency/main.go:10 +0x5a

Previous read at 0x00c00012e058 by main goroutine:
  main.main()
      C:/Users/Administrator/Dropbox/GoProject/Concurrency/main.go:12 +0x92

Goroutine 7 (running) created at:
  main.main()
      C:/Users/Administrator/Dropbox/GoProject/Concurrency/main.go:9 +0x84
==================

就说了第10行goroutine7的data++和第12行main goroutine的if data == 0出现了数据竞争的情况。而且说明了goroutine7是第9行启动的。

一般会在测试的时候使用go -race进行数据竞争的检测,从而调整代码,尽量避免数据竞争的发生。一般线上部署不会使用race,因为会影响性能。

当然还有一个问题,因为是编译完之后才能通过具体地址检测的,即使某个地方可能会有冲突但是运行的时候没有表现出来,race也检测不到。