Go与并发

并发

简单的理解:并发通常指的是与一个或多个进程同时发生的过程,这也意味着所有这些进程都在同一时间取得进展。

并发中常见的问题

下面是一些在并发中常见到的问题:

竞争条件

当两个或多个操作必须按正确的顺序执行,而程序并未保证这个顺序,就会发生竞争条件,在大多数情况下,会数据中出现,其中一个并发操作尝读取一个变量,而在某个不确定的时间,另一个并发操作试图写入同一个变量。

  • Example.1:
1
2
3
4
5
6
7
var data int
go func(){
data++
}()
if data == 0{
fmt.Printf("the value is %v \n",data)
}
  • 通过 go 关键字开启了一个 goroutine 对 data 进行加加的操作,if 语句中对 data 进行了读取的操作这个程序的输出会是什么这段代码会有 3 中可能的结构:

    • 不打印任何东西,在这种情况下,第 3 行在第 5 行之前执行。
    • 打印 the value is 0 在这种情况下 第 5 行和第 6 行在第 3 行之前执行。
    • 打印 the value is 1 在这种情况下 第 5 行在第 3 行之前执行,但第 3 行在第 6 行之前执行。
  • 代码只是短短的几行,可是结果是很不确定的。在大多数情况下引入数据竞争原因是因为开发人员在用顺序性的思维来思考问题,他们假设,某一行的代码在另一个之前它就会先运行。goroutine 虽然在 if 语句之前,但是你无法保证它们的运行顺序。

  • 实际上,有些开发人员会使用休眠的语句,这样来保证能得到他们想要的结果。但是这种方法真的有效吗?

1
2
3
4
5
6
var data int
go func(){data++}()
time.sleep(1*time.Second)
if data ==0{
fmt.Printf("the value is $v\n",data)
}

思考一下数据竞争的问题是否得到了解决。虽然添加了 1 秒的休眠,但是以上三种情仍然有可能出现,只是可能性更小了,这种办法这只是概率上接近逻辑上的正确性,但是它永远不是逻辑正确。go 提供很多命令行工具方便我们对程序进行优化和调试,我们可以通过-race来检测数据竞争。go run -race main.go 你会看到是否存在数据竞争,以及在哪几行代码中存在冲突。

原子性

当某写东西被认为是原子的,或者具有原子性的时候,这意味着在它运行的环境中,它是不可分割或者不可中断的。那么思考一下如果一个原子操作长时间不停止又不能中断怎么办?这是个很严重的问题,所以内核只提供了对二进制和整数的原子操作。了解原子性第一件非常重要的事情就是上下文(context)。

它可能在某个上下文中是原子性的,而在另一个上下文中却不是,在你的进程上下文中进行原子操作在操作系统同的上下文可能就不是原子操作。在操作系统环境中的原子操机器环境中可能不是原子的,换句话说操作的原子性可以根据当前定义的范围而改变。

在考虑原子性时,经常第一件需要做的事就是定义上下文或范围,然后在思考这些操作是否是原子性的。一切都应当遵循这个原则。

说了一大堆概念,我们来看一下这个 不可分割 和 不可中断 是什么含义。它们意味这在你所定义的上下文中,原子的东西将被完整的运行。在这种情况下不会同时发生任何事情,

1
i++

我们看下 i++这个操作。很简单 i 的自增操作,它看起来可能是原子的,如果考虑到上下文的环境呢?那么拆分一下:

  • 检索 i 的值
  • 增加 i 的值
  • 存储 i 的值

以上 3 个操作构成了一个完整的 i++,这三个操作每一个都是原子的,但是它们三个结合了就可能不是。这取决于你的上下文,如果上下文 是一个没有并发的程序,那么该代码会串行的被执行执行过程中不会被中断也没有同时进行的,它就是一个原子的,如果上下文是一个 goroutine。它不会把 i 暴露给其他 goroutine 的话那么这个代码也是原子的。

为什么我们需要关心是否是原子的。因如果某个东西是院子的,那么也意味着它在并发环境中时安全的。大多数的语句不是原子的,更不用说函数,方法和程序了,如果原子是构成逻辑正确的关键。然而大多数语句又不是原子的那么我要如何调节?会在这个系列更新的后续文章中介绍。

内存访问同步

假设有一个数据竞争,两个并发进程试图访问相同的内存区域,它们访问的方式不是原子的,将上面的例子进行简单的修改

1
2
3
4
5
6
7
var data int
go func(){data++}()
if data == 0{
fmt.Prinf("the value is 0.")
}else{
fmt.Printf("the value is %v\n",data)
}

在 if 语句块中添加一个 else 分支,所以不管这个 data 值是多少我们总会得到一个输出。如果有一个数据竞争的存在,那么该程序的输出是不完全确定的。

实际上,程序中需要独占访问贡献资源的部分称为临界区,在这个例子中有三个临界区:

  • goroutine 增加 data 变量的值
  • if 语句检查 data 是否为 0
  • fmt.Prinf 语句 检索 data 的值并打印

有很多方法可以保护程序的临界区,解决这个问题的其中一个办法是在你的临界区之间内存访问做同步就是加锁,GO 中的 sync 包的互斥锁,不过这样的方法不是很建议来解决数据竞争问题。简单的演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var  mlock sync.Mutex
var value int
go func(){
mlock.Lock()
value++
mlock.Unlock()
}()
mlock.Lock()
if value == 0 {
fmt.Printf("the value is %v\n",value)
}else{
fmt.Printf("the value is %v\n",value)
}
mlock.Unlock()
  • 上面的代码解读,定义了一个变量类型为 sync.Mutex,其实就是定义了一个互斥锁。
  • 声明了一个 value 然后开启了一个 goroutine 对 value 进行自增的操作,这个自增操作之前我们添加了一把锁,在操作之后释放,这里的 goroutine 应该独占该内存的访问权。当锁释放之后宣布 goroutine 使用完了这段内存
  • 在 if 之前进行了加锁确保 if 语句独占 value 的内存访问权。
  • 当条件语句判断结束,这段语句独占内存结束。

在这个例子中,我们制作了一个约定,如果想要访问 value 这个变量的内存必须要调用 mlock.Lock(),当访问结束,必须显示的调用 Unlock(),在这 2 个语句之间的代码对变量 value 拥有独占访问权。这样就成功的内存进行了同步。

但是这样虽然可以使内存访问同步,解决了数据竞争,但并没有解决我们的竞争条件!这个程序的操作顺序仍然是不确定的,我们只是缩小了范围。在这个例子中 goroutine 和 ifelse 语句都有可能先执行。

这样看起来很简单,在我们使用 goroutine 并发编程的时候遇到临界区就可以加锁来解决内存区域访问同步的问题,但是有一些问题,它不会解决数据竞争或者逻辑正确性的问题,它也可能会造成维护和性能问题。在日常的开发中有些业务需求有时有时间的限制,通过加锁的方式同步对内存访问,需要所有的开发人员都要遵守这个约定加锁,如果某个员工忘记加锁要怎么办?后续将介绍一些相关的办法让我们避免加锁带来的种种问题。

加锁的内存访问同步会带来一些性能问题,每个临界区的访问之前我们都会调用 Lock()这就会拖慢我们的程序,每次执行这个操作,程序就会暂停一段时间。带来的 2 个问题

  • 我的临界区是否是频繁的进入和退出。
  • 我的临界区应该有多大。
    这对我们开发带来了问题。后续会介绍如何解决这个问题。

死锁,活锁和饥饿

死锁

死锁程序是所并发进程彼此等待的程序,这种情况下如果没有外界的干预程序无法恢复。如何理解死锁通过一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Value struct{
mu sync.Mutex
value int
}
var wg sync.WaitGroup
printSum := func(v1,v2 *Value){
defer wg.Done()
v1.mu.Lock()
defer v1.mu.Unlock()
time.Sleep(2*time.Second)
v2.mu.Lock()
defer v2.mu.Unlock()
fmt.Printf("sum=%v\n",v1.value+v2.value)
}
var a,b Value
wg.Add(2)
go printSum(&a,&b)
go printSum(&b,&a)
wg.Wait()
  • 将一个匿名函数赋给了变量 prinSum 在 go 中函数式一等公民,你可以将它赋给变量。
  • 在这个匿名函数中我们进入临界区传入了一个值v1.value+v2.value,在完成打印后 defer 退出临界区。
  • 我们在 v1 的加锁后进入休眠来模拟一些工作(并触发了死锁)
  • go run main.go 试着运行一下。看下输出
1
fatal error: all goroutines are asleep - deadlock!

没错触发了死锁。通过一个图来阐述整个运行的过程,框表示函数,水平线表示调用函数,竖线表示函数的生存时间。

image

创建了 2 个 goroutine 第一个 goroutine 调用 printSum 锁定 a 试图去锁定 b,在此期间,第二个 goroutine 锁定 b 试图去锁定 a,这 2 个 goroutine 都无限期的等待着。同样的这里存在了一个数据竞争,这不是一个完美的死锁。一个逻辑上完美的死锁需要正确的同步。

出现死锁有几个必要的条件,这些条件被称为 Coffman 条件:

  • 相互排除 并发进程同时拥有资源的独占权
  • 等待条件 并发进程必须同时拥有一个资源,并等待额外的资源
  • 没有抢占 并发进程拥有的资源只能被该进程释放即满足这个条件
  • 循环等待 一个进程 p1 必须等待一系列其他并发进程 p2,这些并发进程 P2 也在等待进程 p1。造成了循环等待

实际上我们并不能保证 goroutine 的顺序,或者需要多长时间启动,但是一个 goroutine 可以在另外一个 goroutine 开始之前获得和释放锁,从而避免死锁这是有道理的。

来看下这个 printSum 是否满足以上 4 个条件

  • printSum 函数确实需要 a 和 b 的独占权,所以满足相互排除
  • 因为 printSum 持有 a 或 b 并正在等待另一个,满足等待条件
  • 我们没有任何办法让我们的 goroutine 被抢占
  • 第一个 goroutine 正在等待第二次的调用,反过来也是。

所以 printSum 满足了以上 4 个条件。死锁!规避 Conffman 条件可以有效的帮助我们绕开死锁。但是日常开发中有些条件很难去推理很难去绕开 Coffman 条件。所以如何做到 确定并发安全性

活锁

活锁是正在主动执行并发操作的程序,但是这些操作无法向前推进程序的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
cadence := sync.NewCond(&sync.Mutex{})
go func() {
for range time.Tick(1 * time.Millisecond) {
cadence.Broadcast()
}
}()
takeStep := func() {
cadence.L.Lock()
cadence.Wait()
cadence.L.Unlock()
}
tryDir := func(dirName string, dir *int32, out *bytes.Buffer) bool { //1
fmt.Fprintf(out, "%v", dirName)
atomic.AddInt32(dir, 1) //2
takeStep() //3
if atomic.LoadInt32(dir) == 1 {
fmt.Fprintf(out, ".Success!")
return true
}
takeStep()
atomic.AddInt32(dir, -1) //4
return false
}
var left, right int32
tryLeft := func(out *bytes.Buffer) bool { return tryDir("left", &left, out) }
tryRight := func(out *bytes.Buffer) bool { return tryDir("right", &right, out) }

1 tryDir 允许一个人尝试向一个方向移动,并返回是否成功,dir 每个方向都表示为试图朝这个方向移动的人数
2 首先我们宣布将要向这个方向移动一个距离,atomic 是 go 的原子操作包
3 为了演示活锁,每个人都必须以相同的速度或节奏移动,takeStep 模拟所有对象之间的一个不变的节奏
4 这个理的人一直到它们不能向这个方向走而放弃,我们通过把这个方向减 1 来表示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
walk := func(walking *sync.WaitGroup, name string) {
var out bytes.Buffer
defer func() { fmt.Println(out.String()) }()
defer walking.Done()
fmt.Fprintf(&out, "%v is trying to scoot:", name)
for i := 0; i < 5; i++ {
if tryLeft(&out) || tryRight(&out) {
return
}
}
fmt.Fprintf(&out, "\n%v tosses her hands up in exasperation!", name)
}
var peopleInHallway sync.WaitGroup
peopleInHallway.Add(2)
go walk(&peopleInHallway, "Alice")
go walk(&peopleInHallway, "Barbara")
peopleInHallway.Wait()
  • 对尝试次数进行了人为限制,以便此程序能结束。
  • 首先这个人会试图向左走,如果失败了会向右走。
  • 这个变量为程序提供了一个等待知道两个人都能通过或放弃的方式
1
2
3
4
Barbara is trying to scoot:leftrightleftrightleftrightleftrightleftright
Barbara tosses her hands up in exasperation!
Alice is trying to scoot:leftrightleftrightleftrightleftrightleftright
Alice tosses her hands up in exasperation!

你可以看到,Alice 和 Barbar 在最终退出之前持续竞争。这个例子演示了使用活锁十分常见的原因。两个或两个以上的并发进程试图在没有协调的情况下防止死锁。活锁势必死锁更复杂,它看起来程序在工作,如果一个活锁程序在你的机器上运行,你可以通过查看 cpu 利用率来确定它是否在处理某些逻辑。活锁是一组被称为 “饥饿” 的更大问题子集

饥饿

饥饿是在任何情况下,并发进程都无法获得执行工作所需的所有资源

当我们讨论活锁时,每个 goroutine 的资源是一个共享锁。活锁是与饥饿无关的,因为在活锁中,所有并发进程都是相同的,并且没有完成工作,更广泛的说,饥饿通常意味着一个或多个塔兰的并发进程,它们不公平的组织一个或多个并发进程。或者组织全部并发进程。
通过一个例子了解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
var wg sync.WaitGroup
var sharedLock sync.Mutex
const runtime = 1 * time.Second
greedyWorker := func() {
defer wg.Done()
var count int
for begin := time.Now(); time.Since(begin) <= runtime; {
sharedLock.Lock()
time.Sleep(3 * time.Nanosecond)
sharedLock.Unlock()
count++
}
fmt.Printf("Greedy worker was able to execute %v work loops\n", count)
}
politeWorker := func() {
defer wg.Done()
var count int
for begin := time.Now(); time.Since(begin) <= runtime; {
sharedLock.Lock()
time.Sleep(1 * time.Nanosecond)
sharedLock.Unlock()

sharedLock.Lock()
time.Sleep(1 * time.Nanosecond)
sharedLock.Unlock()

sharedLock.Lock()
time.Sleep(1 * time.Nanosecond)
sharedLock.Unlock()
count++
}
fmt.Printf("Polite worker was able to execute %v work loops\n", count)
}
wg.Add(2)
go greedyWorker()
go politeWorker()
wg.Wait()

程序的输出如下

1
2
Polite worker was able to execute 381403 work loops
Greedy worker was able to execute 752818 work loops

Greedy 的 worker 会贪婪的抢占共享锁,已完成整个工作循环,而 Polite 的 worker 则试图只在需要是锁定,两种 worker 都在做同样多的模拟工作,睡眠时间为 3 纳秒,但是你可以看到同样的时间里 Greedy 的 worker 的工作量是 Polite 的两倍多。

所以饥饿会导致程序表现不佳或者不正确,这个实力演示了低效的场景,我们还应该考虑到来自于外部过程的饥饿,饥饿同样应用于 cpu,内存,文件句柄,数据库连接。

1
2